fix: gate local-network access for Android 17 (Local Network Protections) - #147
Merged
Merged
Conversation
Android 17 (API 37, our target/compile SDK) enforces Local Network Protections: mDNS/UDP/raw-socket access to the LAN is blocked until the user grants ACCESS_LOCAL_NETWORK. The app neither declared nor requested it, so the OS interrupted a satellite scan with its own "Choose a device to connect" picker -- and only addresses picked there would work, so manual-add and remembered-satellite reconnect could still fail. Take the blanket-permission path rather than the NsdManager FLAG_SHOW_PICKER path: discovery is only one of several LAN operations (UDP discovery + input stream, direct connect by IP, manual add, reconnect), and the picker would only bless picker-obtained addresses. - Declare ACCESS_LOCAL_NETWORK (manifest). - LocalNetworkAccess: one place for the API-37 gate + permission string; a no-op on older OSes (implicit LAN access via INTERNET). - ConnectionsActivity: request on the first scan when entering the screen; on denial show a persistent banner that routes to app settings; re-check and self-heal on return. - SetupConnectionActivity: request before the scan via a pending-resume callback (mirrors onDiscoverableResult) so a pre-grant blocked scan can't shadow the real one through the manager's single-flight guard; denial surfaces the setup error dialog. - Translate the two banner strings + one setup string across bs/de/es/fr/pt-rBR (MissingTranslation is a lint error here). Verified locally: compileDebugKotlin, ktlintCheck, detekt, lintDebug, testDebugUnitTest.
Follow-up to the Android 17 local-network fix. - Add LocalNetworkAccessTest (JUnit4 + MockK): enforcement boundary at API 37, pre-enforcement OS granted without touching the runtime permission, and granted/denied reflected on an enforcing OS. - Make isEnforced/isGranted take an injectable sdkInt (default Build.VERSION.SDK_INT) so the boundary is testable without Robolectric, matching PairingApproval's injectable-Random seam. Callers unchanged via the default. - Trim the comments added by the previous commit to the repo's terse WHY-not-WHAT style (#78). Verified: testDebugUnitTest (5/5 new pass), ktlintCheck, detekt.
Discovery wasn't the only local-network path. autoReconnectAll() fires satellite sockets from a process-lifecycle observer (and the main-screen reconnect), off any Activity — so on Android 17 a returning user's remembered satellite reconnected before the permission was ever requested and failed silently with EPERM (no picker, no prompt). - ConnectionCoordinator.autoReconnectAll(): skip the satellite connect loop until ACCESS_LOCAL_NETWORK is granted (BT reconnect unaffected). Kills the silent EPERM; a lifecycle observer can't itself request a permission. - MainActivity: request the permission on first resume when a remembered satellite exists, so returning users are prompted up front and reconnect from the home screen; on grant → autoReconnectAll, on deny a notification points to Connections. - Test the new gate + thread the injected Context through the hub test builder. Streaming on the overlays is transitively covered: no grant → no connection → nothing to stream. Verified: testDebugUnitTest (40 hub tests incl. the new gate), ktlintCheck, detekt, lintDebug.
Moves the local-network gate from ConnectionCoordinator.autoReconnectAll() into SatelliteConnectionManager.connect(). The manager already injects @ApplicationContext, so gating there needs no extra constructor param — which had pushed the coordinator to 10 params and required @Suppress("LongParameterList"). connect() is also the more complete choke point: it covers every non-user-initiated path (autoReconnectAll, the setup auto-reconnect, and RETRY_AFTER_DEATH retries), not just the coordinator's loop. User-initiated connects are unaffected. The gate test moves with it; existing AUTO_RECONNECT tests still pass (JVM SDK_INT=0 → not enforced → granted). Verified: testDebugUnitTest, ktlintCheck, detekt, lintDebug.
emir-hasanbegovic
force-pushed
the
fix/android17-local-network-permission
branch
from
July 22, 2026 12:23
10c6f7a to
fb12abe
Compare
Drop the narration/what comments this branch added and shorten the rest to one line each, keeping only non-obvious rationale: why request before the scan (single-flight race), why prompt in MainActivity (the reconnect observer can't), and the user-initiated gate condition.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
On Android 17 (API 37 — our
targetSdk/compileSdk) the app couldn't reach satellites: scanning popped a system "Choose a device to connect" picker, and a returning user's remembered satellite silently failed to reconnect. This restores normal connectivity behind a single one-time permission prompt.Why
Android 17 enforces Local Network Protections: mDNS/UDP/raw-socket LAN access is blocked until the user grants
ACCESS_LOCAL_NETWORK. The app neither declared nor requested it, so:NsdManager) fell back to the OS per-device picker.autoReconnectAll(), fired from a process-lifecycle observer on app foreground) and any direct socket connect failed with EPERM — no picker, no prompt, silent.We take the blanket-permission path rather than the
NsdManagerFLAG_SHOW_PICKERpath, because local-network access is spread across discovery, UDP streaming, direct connect-by-IP, manual add, and reconnect; the picker would only bless picker-obtained addresses.How
Core
ACCESS_LOCAL_NETWORKin the manifest.LocalNetworkAccesshelper: one place for the API-37 gate + permission string; a no-op on older OSes (implicit LAN access viaINTERNET).sdkIntis injectable for tests.Request at each entry point that first needs the network
autoReconnectAll(); deny → a notification pointing to Connections.onDiscoverableResult), so a pre-grant blocked scan can't shadow the real one through the manager's single-flight guard; deny → the setup error dialog.Gate the automatic path
SatelliteConnectionManager.connect(): refuse any non-user-initiated connect (auto-reconnect + death-retries) until the permission is granted — a lifecycle observer can't itself request a permission, so this kills the silent EPERM until an Activity has prompted. Streaming on the overlays is covered transitively: no grant → no connection → nothing to stream.i18n
MissingTranslationis a lint error in this project).Resulting flow (Android 17+)
Tests
LocalNetworkAccessTest(JUnit4 + MockK): enforcement boundary at API 37, pre-enforcement granted without touching the permission, granted/denied on an enforcing OS.SatelliteConnectionManagerTest: new case — anAUTO_RECONNECTis refused before any handshake or socket when local-network access is missing.Verification
Local, all green:
compileDebugKotlin,ktlintCheck,detekt,lintDebug,testDebugUnitTest.