There are three different Android 17 blog posts on this site already. One covers config-change restarts, background audio hardening, and the large-screen adaptivity deadline. Another covers the new APIs — Handoff, Contact Picker, PQC signing. A third covers the Eclipsa HDR video story. None of them touch what I'm writing about today, because this particular cluster of changes didn't get its own I/O session or a flashy post. They showed up in the behavior change docs, and they're the ones that are quietly going to cause production incidents for apps that haven't tested against API 37.
Seven changes. All security- or privacy-motivated. Several of them will silently break things that have worked fine for years — reflection patterns, LAN networking, native library loading, TLS assumptions. Let me go through each one with enough depth to actually tell you what to do about it.
1. ACCESS_LOCAL_NETWORK: The New Runtime Permission for LAN
This is the one most likely to cause visible regressions in production for a broad swath of apps. Android 17 introduces a new runtime permission, ACCESS_LOCAL_NETWORK, that you must hold before your app can discover or connect to devices on the local network — smart home devices, Chromecast receivers, network printers, Bluetooth LE peripherals advertised over LAN, anything you reach via direct socket on the same Wi-Fi subnet.
The permission lives under the NEARBY_DEVICES permission group, so it joins BLUETOOTH_SCAN, BLUETOOTH_CONNECT, and UWB_RANGING there. It was optional in Android 16 for early testing; on API 37+ it is mandatory.
<!-- AndroidManifest.xml -->
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
Declaring the manifest entry is not enough. You need to go through the standard runtime permission request flow, and users can revoke it at any time:
// Check before any local network operation
if (ContextCompat.checkSelfPermission(
context, Manifest.permission.ACCESS_LOCAL_NETWORK
) != PackageManager.PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(
activity,
arrayOf(Manifest.permission.ACCESS_LOCAL_NETWORK),
REQUEST_CODE_LOCAL_NETWORK
)
return
}
// Permission granted — safe to open socket or start discovery
val socket = Socket("192.168.1.10", 8080)
The apps that will feel this immediately: anything doing multicast DNS (mDNS) for service discovery, anything casting to a local device, any direct TCP/UDP socket to an IP in the 192.168.x.x range, and any app using NSD Manager or a similar discovery API without the system UI mediating the connection. HailUp, for example — its local streaming mode that connects directly to a device on the same subnet — would need this permission on Android 17 before it can even open a socket.
Check your dependencies too. If you use a casting SDK, a smart home SDK, or a P2P file-transfer library, check whether the SDK itself declares and requests this permission. If it doesn't, the connection will silently fail or throw a SecurityException on API 37+ without any obvious log indicating why.
There's an alternative for apps that connect to devices the user explicitly picks: the system now provides a mediated device picker that doesn't require the permission. For use cases where the user taps a Chromecast icon and the system picker appears, you can avoid the permission entirely. But for apps that enumerate the network themselves — a smart home app that scans for lights, an audio app that lists receivers — there's no way around it.
2. Static Final Fields Are Now Unmodifiable via Reflection
Modifying static final fields through reflection has always been undefined behavior in Java, but it worked on Android for long enough that a non-trivial number of libraries and testing patterns rely on it. Android 17 closes that door permanently for apps targeting API 37+.
Attempting to call field.set(null, newValue) on a static final field now throws IllegalAccessException. Attempting to do the same through JNI — SetStaticLongField() on a final field — causes a crash rather than throwing.
// This crashes on API 37+ targeting
val field = SomeClass::class.java.getDeclaredField("STATIC_FINAL_CONSTANT")
field.isAccessible = true
field.set(null, newValue) // IllegalAccessException on API 37+
The places I've seen this pattern most often in the wild: mock injection in unit tests (swapping a singleton registered as a final field), SDK initialization tricks where a library modifies its own configuration field after the class is loaded, and a handful of "inject a logger" patterns where a final logger field gets replaced with a test double.
The fix depends on where the pattern lives. In test code, switch to proper dependency injection — if you're using Hilt and you're on Hilt 1.4.0 stable, the ViewModel factory changes make this cleaner than it used to be. In production code, any pattern that requires mutating a final field at runtime is architecturally broken and should be refactored rather than worked around. In third-party SDKs, file a bug and test against API 37 now so you find out before your users do.
3. ECH and Certificate Transparency: TLS Gets Stricter by Default
Two TLS changes shipped in this release, and they interact in ways worth understanding together.
Encrypted Client Hello (ECH) is now enabled by default. ECH encrypts the Server Name Indication (SNI) field in the TLS handshake — the field that previously leaked which hostname you were connecting to even on HTTPS connections. For most apps, this is completely transparent. If the server supports ECH, the platform uses it. If it doesn't, the platform falls back to ECH GREASE (sending a fake encrypted extension) and completes the standard handshake.
Where this can cause problems: network inspection tools and proxies that rely on reading the SNI in plaintext, corporate MDM solutions that filter traffic by hostname via SNI, and any backend infrastructure doing TLS termination with strict SNI matching that rejects the ECH extension. If you're seeing unexpected connection failures on API 37 that you can't reproduce with certificate pinning disabled, ECH interaction with your backend or proxy layer is the first thing to investigate.
You can control ECH behavior per-domain via the Network Security Configuration:
<!-- network_security_config.xml -->
<network-security-config>
<domain-config cleartextTrafficPermitted="false">
<domain includeSubdomains="true">api.example.com</domain>
<domain-encryption echEnabled="false" />
</domain-config>
</network-security-config>
Certificate Transparency (CT) enforcement is the second change. CT was opt-in in Android 16; in Android 17 it is enforced by default for all TLS connections. CT requires that the server's certificate be logged to a publicly auditable log before the platform will accept it. This is standard for certificates issued by major CAs since 2018, but you can run into failures with:
- Self-signed certificates in internal tooling or staging environments
- Older certificates from certain CAs that weren't CT-logging everything before it became standard
- Certificates issued by private CAs used in enterprise environments
For internal/staging environments, add the certificate authority to your debug Network Security Config. For production, verify your cert is CT-logged by checking it with Google's Transparency Report or a similar tool — if your CA issued it in the last few years, it almost certainly is, but worth confirming before you target API 37.
4. Native Dynamic Code Loading: Libraries Must Be Read-Only
If your app or any of its native dependencies loads a shared library at runtime via System.load() or the JNI equivalent, that library now must be located on a read-only filesystem path. If it's writable — loaded from the app's cache directory, from a path downloaded at runtime, from an extracted directory that isn't marked read-only — the system throws UnsatisfiedLinkError.
This change targets a real attack vector: an attacker gains write access to the directory where your app's dynamically-loaded .so lives, replaces it with malicious code, and your app loads it on next startup. Making read-only paths the only acceptable source for runtime-loaded native code closes that substitution window.
If you're doing this at all, stop. The Android security team's recommendation is to avoid dynamic native code loading entirely. Load .so files from the APK's native library directory (set up by the installer as read-only) rather than extracting them to writable storage at runtime. This also simplifies Play Store compliance — the store has been flagging apps that download native code for separate review for a few years now.
The common pattern this breaks: some plugin architectures and "hot patching" systems that download native libraries over the network and load them without going through an update. If that's in your dependency tree, not just your own code, run adb shell logcat | grep UnsatisfiedLinkError against an API 37 device with your full production build to find offenders.
5. Contacts Provider 2: PII Columns Removed from the Data View
Three columns that used to be available when querying ContactsContract.Data are gone on API 37+: ACCOUNT_NAME, ACCOUNT_TYPE, and ACCOUNT_TYPE_AND_DATA_SET. Queries that select these columns return null; projections that rely on them won't crash but won't return data.
These columns exist on ContactsContract.RawContacts and are still accessible there. If your app reads account information alongside contact data, you need to join on RAW_CONTACT_ID:
// Old pattern (returns null for account columns on API 37+)
val cursor = contentResolver.query(
ContactsContract.Data.CONTENT_URI,
arrayOf(
ContactsContract.Data.DISPLAY_NAME,
ContactsContract.Data.ACCOUNT_NAME, // null on API 37+
ContactsContract.Data.ACCOUNT_TYPE // null on API 37+
),
null, null, null
)
// New pattern: query RawContacts directly for account info
val rawCursor = contentResolver.query(
ContactsContract.RawContacts.CONTENT_URI,
arrayOf(
ContactsContract.RawContacts._ID,
ContactsContract.RawContacts.ACCOUNT_NAME,
ContactsContract.RawContacts.ACCOUNT_TYPE
),
"${ContactsContract.RawContacts.CONTACT_ID} = ?",
arrayOf(contactId.toString()),
null
)
This matters most for apps that display "synced from Google" or "synced from Exchange" labels next to a contact, or that use account type to decide whether to show a contact at all. If you're not showing account information in your UI, you probably don't query these columns and this doesn't affect you.
6. Activity Background Launch Restrictions Extend to IntentSender
Background Activity Launch (BAL) restrictions have tightened with every major Android release for the past several years. The Android 17 version extends those restrictions to IntentSender. If your app sends a PendingIntent that another app or the system uses to launch an Activity, and that launch happens while your app is in the background, it now needs to use the granular BAL control flags rather than the old blanket MODE_BACKGROUND_ACTIVITY_START_ALLOWED.
// Old approach (deprecated, restricted on API 37+)
val options = ActivityOptions.makeBasic()
options.pendingIntentBackgroundActivityStartMode =
MODE_BACKGROUND_ACTIVITY_START_ALLOWED
// New approach: use fine-grained control
val options = ActivityOptions.makeBasic()
options.pendingIntentBackgroundActivityStartMode =
MODE_BACKGROUND_ACTIVITY_START_ALLOW_IF_VISIBLE
The ALLOW_IF_VISIBLE mode allows the launch only if your app is currently visible to the user — either in the foreground or recently visible. Other modes give you finer control if your specific use case needs it. The intent is to prevent notification-driven Activity launches that hijack the user's screen from apps that have no visible context.
This typically affects notification action intents and any system-service callbacks that launch your Activity. Test these flows explicitly on API 37: tap your notification action with your app in background, trigger your alarm/reminder flow, tap a widget that opens a specific screen — anywhere you use a PendingIntent to start an Activity.
7. Bluetooth RFCOMM Sockets: EOF Instead of IOException
This is the smallest change in the list, but it's a classic "works fine for ten years then breaks on the new OS" issue. When a Bluetooth RFCOMM socket closes — either because the remote device disconnected or the connection was terminated — InputStream.read() now returns -1 to signal end-of-stream, conforming to the standard Java IO contract. Previously it threw IOException.
// Pattern that breaks on Android 17 if socket closes cleanly
try {
val buffer = ByteArray(1024)
while (true) {
val bytes = inputStream.read(buffer) // used to throw IOException on disconnect
processData(buffer, bytes)
}
} catch (e: IOException) {
handleDisconnect() // no longer triggered by clean close on API 37+
}
// Correct pattern that handles both old and new behavior
val buffer = ByteArray(1024)
var bytes: Int
while (inputStream.read(buffer).also { bytes = it } != -1) {
processData(buffer, bytes)
}
handleDisconnect() // reached when read() returns -1
If you ship an app that talks to Bluetooth hardware — a fitness device, a payment terminal, a custom IoT peripheral — test the clean disconnect path on Android 17. Unclean disconnects (power off, signal loss) still produce IOException. It's only a clean socket close that now returns -1 instead.
How to Audit Your App Before Targeting API 37
The uncomfortable reality is that several of these changes — particularly the reflection ban and the Contacts Provider PII removal — can come from third-party SDKs you don't control. A systematic audit matters more than just scanning your own code.
- Install a API 37 emulator image and run your full app with
targetSdkVersion 37in a debug build. Don't wait for the final production bump — the earlier you find breakage the cheaper it is. - Enable strict mode for reflection in debug builds:
StrictMode.setVmPolicy(VmPolicy.Builder().detectAll().penaltyLog().build())will surface reflective access violations before they become crashes. - Check each SDK in your dependency tree against its changelog for API 37 compatibility notes. Casting SDKs (Google Cast, Airplay bridges), smart home SDKs, and any SDK that claims to support "plugin loading" or "hot updates" are the highest-risk categories.
- Test every flow that touches local networking — explicitly request
ACCESS_LOCAL_NETWORKand verify the permission is in your manifest before those tests, or you'll get misleading results.
On production timing: Most of these changes bite when targetSdkVersion reaches 37, not on install. You have until Google mandates API 37 targeting for Play Store updates to fix them — but Google's track record suggests the deadline for new apps will arrive faster than you expect it to.
The pattern across all seven of these changes is the same story Android tells every major release: behaviors that were technically always incorrect are having their loopholes closed, and the platform is moving toward a model where the security properties are guaranteed rather than advisory. The apps that test early against new APIs find a refactoring task. The apps that don't find a production incident. Five years of shipping Android apps has convinced me the gap between those two outcomes is almost always just whether you set up a CI lane against preview builds, which takes a few hours to do once.
No comments yet. Be the first to leave one!