Skip to content

fix: gate local-network access for Android 17 (Local Network Protections) - #147

Merged
emir-hasanbegovic merged 5 commits into
mainfrom
fix/android17-local-network-permission
Jul 22, 2026
Merged

fix: gate local-network access for Android 17 (Local Network Protections)#147
emir-hasanbegovic merged 5 commits into
mainfrom
fix/android17-local-network-permission

Conversation

@emir-hasanbegovic

@emir-hasanbegovic emir-hasanbegovic commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

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:

  • Discovery (mDNS via NsdManager) fell back to the OS per-device picker.
  • Auto-reconnect (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 NsdManager FLAG_SHOW_PICKER path, 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

  • Declare ACCESS_LOCAL_NETWORK in the manifest.
  • New LocalNetworkAccess helper: one place for the API-37 gate + permission string; a no-op on older OSes (implicit LAN access via INTERNET). sdkInt is injectable for tests.

Request at each entry point that first needs the network

  • MainActivity (launcher): on first resume, if a remembered satellite exists and the permission is missing, prompt up front — so returning users reconnect from the home screen instead of failing silently. Grant → autoReconnectAll(); deny → a notification pointing to Connections.
  • ConnectionsActivity: request on the first scan when entering; deny → a persistent banner that opens app settings; self-heals on return once granted.
  • 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; 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

  • Translations for the 3 new strings across bs/de/es/fr/pt-rBR (MissingTranslation is a lint error in this project).

Resulting flow (Android 17+)

  • Returning user w/ remembered satellite: open app → one-time "Allow Dish to find and connect to devices on your local network?" on the home screen → grant → auto-reconnects. Deny → notification → Connections.
  • New user: Setup → Satellite card → prompt → scan/pair as before.
  • Connections: prompts on entry for the scan; no more OS device-picker.
  • One app-global permission: grant once anywhere, granted everywhere; no per-screen re-prompts.
  • Older OSes: unaffected — the gate short-circuits.

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 — an AUTO_RECONNECT is refused before any handshake or socket when local-network access is missing.

Verification

Local, all green: compileDebugKotlin, ktlintCheck, detekt, lintDebug, testDebugUnitTest.

⚠️ Enforcement is device-side on Android 17+; older OSes short-circuit. Not yet exercised on a live API-37 device — the launch-time prompt, deny→notification, and grant-from-settings paths are worth a manual smoke test on an emulator/device before merge.

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.
@emir-hasanbegovic emir-hasanbegovic changed the title fix: restore satellite discovery on Android 17 (request ACCESS_LOCAL_NETWORK) fix: gate local-network access for Android 17 (Local Network Protections) Jul 22, 2026
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
emir-hasanbegovic force-pushed the fix/android17-local-network-permission branch from 10c6f7a to fb12abe Compare July 22, 2026 12:23
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.
@emir-hasanbegovic
emir-hasanbegovic merged commit 8840105 into main Jul 22, 2026
9 checks passed
@emir-hasanbegovic
emir-hasanbegovic deleted the fix/android17-local-network-permission branch July 22, 2026 13:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants