Skip to content

feat: target Android 17 and ask for local network access - #6977

Merged
mahibi merged 2 commits into
masterfrom
feat/target-sdk-37
Nov 12, 2026
Merged

mahibi merged 2 commits into
masterfrom
feat/target-sdk-37

Conversation

@AndyScherzinger

@AndyScherzinger AndyScherzinger commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

Raises targetSdk from 36 to 37 (lint OldTargetApi, 14 → 13) and adds what Android 17 needs for that: the local network permission. Without it, a server on the LAN is unreachable: requests time out after 45 s with no hint about a permission (verified on an Android 17 emulator).

📡 Local network permission (ACCESS_LOCAL_NETWORK)

Asked only on Android 17+, and only where it matters:

  • Server selection (typed URL and login QR code): when the server is a .local name or resolves to a private, link-local, CGNAT or unique local address. The resolved address is checked, not just the URL, so LAN host names are covered.
  • Conversation list start, for the current account: covers existing accounts after the update and servers that only resolve locally at home (split DNS). Checked when the list is created, not again when it's recreated (e.g. after a rotation).
  • First call: lets peers on the same network connect directly. Calls still work through TURN without it, so a denial isn't asked again.
  • Call start, when the signaling, STUN or TURN server of the call is on the local network: asked even after a denial, since the call can't reach them without it.

When it's denied, a dialog explains why it's needed and offers to ask again, or to open the app settings once the system no longer shows its prompt (after two denials). Dismissing it on server selection shows an error instead of running into the timeout. Chrome asks for its own Nearby devices permission during browser login for a LAN server; there's deliberately no warning for that for now.

Servers besides Nextcloud: the conversation list also checks the account's high-performance backend (HPB) signaling server, which the chat connects to. A call checks the signaling, STUN and TURN servers from its signaling settings before connecting to them. So an HPB or TURN server on the LAN behind a public Nextcloud URL is covered. Not covered: a server reached over a VPN (e.g. Tailscale's 100.64.0.0/10) needs no permission, but is still asked for, because only the address is checked.

Verified on the Android 17 emulator (gplay build of this branch, RESTRICT_LOCAL_NETWORK enforced, server on 10.0.2.2):

  • The permission is needed for plain HTTP to the server, not only for mDNS/NSD: a TCP request made under the app's UID (run-as, app in the foreground) to 10.0.2.2:8081 times out after 8 s with the permission revoked and returns 200 OK with it granted. A request to the public 1.1.1.1:80 succeeds either way.
  • Existing LAN account: the system prompt appears when the conversation list opens. Denying it shows the dialog ("Set permissions" asks again); after the second denial the button turns into "Open settings", which opens the app's settings page. Allowing it loads the list from the server.
  • Server selection with a LAN address and the permission denied: the dialog appears at once instead of the 45 s timeout; Dismiss shows "Allow local network access to connect to this server."
  • Server selection with a public server (cloud.nextcloud.com) and the permission denied: no prompt, browser login opens.
  • QR code path: not run on the emulator. The server is read from the QR code by ServerSelectionActivity.serverFromQr, unit-tested for both QR formats (nc://login/, nc://onetime-login/) and any element order, and then goes through the same check as a typed URL.
  • Not exercised: the first-call prompt (needs an account on a public server; with a LAN server the conversation list has already asked).

🔍 Rest of the Android 17 changes

Checked against the codebase and on an Android 17 emulator (API 37, generic and gplay builds at target 37); no code change needed:

  • Background audio hardening: voice messages auto-advance in the background, calls, picture-in-picture and voice recording keep audio focus.
  • Background activity launch: incoming call full-screen intent on the lock screen and Answer from the heads-up notification work.
  • ContactsContract column removal: contact sync still finds and updates linked contacts.
  • Static final reflection and MessageQueue: Robolectric 4.17 supports SDK 37; the full gplay unit test suite passes (1128 tests); no runtime reflection or native-load errors on device.
  • Certificate Transparency on by default: a public HTTPS server works through the custom trust manager.
  • gplay: Firebase token registration and push delivery (app in background and killed) work.

🖼️ Screenshots

Android's own "Nearby devices" permission prompt over the conversation list The app's dialog after the first denial, with "Set permissions" The same dialog after the second denial, with "Open settings" Server selection with a local network server and the permission denied, showing the error text
t2 t3 t5 t9

🚧 TODO

  • Screenshots of the permission prompt and the denial dialog
  • On a real Android 17 device: Bluetooth / wired headset switches during a call in the background
  • LAN peer-to-peer call with and without the permission
  • Self-signed and user-CA servers (Certificate Transparency)
  • Background blur in a video call on a gplay build (MediaPipe native libraries; the emulator ran out of memory)
  • connectedAndroidTest on an API 37 emulator (gplay debug, 51 tests passed; LoginIT and ShareUtilsIT are @Ignored in the code)
  • Rebase and recount the lint score after chore(res): move the adaptive launcher icon to mipmap-anydpi (lint 14 → 13) #6975 (both lower it by one)

🏁 Checklist

  • ⛑️ Tests (unit and/or integration) are included or not needed
  • 🔖 Capability is checked or not needed
  • 🔙 Backport requests are created or not needed: /backport to stable-xx.x
  • 📅 Milestone is set
  • 🌸 PR title is meaningful (if it should be in the changelog: is it meaningful to users?)

🤖 AI (if applicable)

  • The content of this PR was partly or fully generated using AI

@AndyScherzinger AndyScherzinger added this to the 26.0.0 milestone Oct 28, 2026
@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

📱 QA build

Download app-qa-debug.apk
QR code Open the QR code for this download
Commit 796e2a0
Version 6977
Available until 7 days after this build

The QA build installs alongside a released Nextcloud app, so you can keep
using your existing install while testing.

Downloading the file requires a GitHub account, so open this link on the
device you want to test on, or transfer the APK to it.

@AndyScherzinger
AndyScherzinger force-pushed the feat/target-sdk-37 branch 2 times, most recently from 4bb0c96 to 07697ce Compare October 29, 2026 07:17
@AndyScherzinger
AndyScherzinger marked this pull request as ready for review October 30, 2026 14:16
@AndyScherzinger AndyScherzinger added the 3. to review Waiting for reviews label Oct 30, 2026
@AndyScherzinger
AndyScherzinger force-pushed the feat/target-sdk-37 branch 2 times, most recently from 262e31b to 1c87541 Compare November 4, 2026 14:51
@mahibi

mahibi commented Nov 5, 2026

Copy link
Copy Markdown
Collaborator

from claude (did not have a look myself yet):

  1. The callback is lost if the activity is recreated (LocalNetworkPermission.kt:41). onDone is only kept in memory. If the screen rotates, or the app      
     process is killed, while the system prompt is open, the result goes to a new instance where onDone == null and nothing happens. In                      
     ServerSelectionActivity, showserverEntryProgressBar() has already run (ServerSelectionActivity.kt:239), so the spinner keeps spinning until the user    
     taps again. One fix: save a "pending server URL" in SavedStateHandle or onSaveInstanceState and run the check again when the result arrives.            
  2. The conversation list keeps asking after a denial (ConversationsListActivity.kt:604). onPostCreate runs on every cold start and every rotation. Once the
     user has denied twice, the system no longer shows its prompt, but showDeniedDialog still opens the "Settings" dialog every time. That's arguably fine   
     when the server is only reachable locally, but a rotation shouldn't bring the dialog back. Two options: only check when savedInstanceState == null, or  
     remember that the user dismissed the dialog.                                                                                                            
  3. Two checks can run at once. dispose() cancels the old RxJava status query but not a coroutine that is still in ensure. If the user taps "connect" twice 
     while DNS is still resolving, the permission is requested twice and the second callback replaces the first. Keeping the Job and cancelling it, or       
     ignoring a call while a request is pending, would fix this.                                                                                             
  4. In CallActivity, a permanently denied permission is requested on every call. If the user denied it twice from the conversation list,                    
     shouldShowRequestPermissionRationale returns false again. The call screen then adds the permission on every call, and the system silently denies it each
     time. Nothing breaks; it's just an extra request on each call. The comment saying "ask once" isn't quite accurate in this case.                         
  5. Some servers aren't covered. Only the account's baseUrl is checked. A self-hosted HPB, signaling or TURN server on the LAN behind a public Nextcloud URL
     won't trigger the prompt. That's an edge case, but it may be worth a note in the PR.                                                                    
  6. Small points:                                                                                                                                           
     - The message says the permission appears as "Nearby devices". Check this against the final Android 17 permission group before shipping.                
     - isLocalServer("https://nextcloud.local.") with a trailing dot isn't detected as local. Negligible.                                                    
     - In ConversationsListActivity, the new property is wedged between currentUser and snackbarHostState with an odd blank line.                            
     - Switching accounts inside the activity doesn't run the check again for the new account.                                                               
                                                                                                                                                             
  None of these block the branch. I'd fix 1 and 2 before merging; the rest are polish.

@AndyScherzinger

Copy link
Copy Markdown
Member Author

Thanks @mahibi, I checked each point against the code. The fixes are folded into a15c1c7 (feat(network): ask for local network access on Android 17), and the branch is rebased onto current master.

  1. Callback lost on recreation: fixed. The result now goes to a handler the activity registers in the LocalNetworkPermission constructor instead of a callback per request, so a recreated activity receives it. Server selection keeps a pending QR code in onSaveInstanceState and re-reads the typed address from the restored field. Server selection is locked to portrait, so rotation doesn't trigger this there, but a process death or other configuration changes still did.
  2. List asks again after rotation: fixed. The check runs only when the list is created (savedInstanceState == null). It still runs on each cold start while the permission is denied for a local server, because nothing on the list loads without it.
  3. Two checks at once: fixed. ensure() is ignored while a request is pending, from resolving the server until the user answers.
  4. Call screen after a permanent denial: valid, but it causes no visible behaviour. The system answers the request without a prompt, and prepareCall() still runs. I corrected the comment rather than storing a separate "asked" flag.
  5. HPB / signaling / TURN on the LAN: valid limitation, now noted in the PR description. The first-call prompt partly covers it, since it doesn't depend on the server's address.
  6. Small points:
    • "Nearby devices": checked on the Android 17 emulator (CP41.260831.007). The system prompt reads "find, connect to, and determine the relative position of nearby devices", and the app settings list the permission under Nearby devices (screenshots in the description).
    • Trailing dot: fixed, the host is trimmed, with a test for https://nextcloud.local./index.php.
    • Blank line: fixed. The property sits next to currentUser again without the extra blank line.
    • Account switch: no change needed. Switching accounts relaunches the list (relaunchForAccount starts a new instance and finishes the old one), so the check runs for the new account.

Checks: full gplay unit suite (1341 tests, 0 failures), ktlint, detekt, and lint (still 13 warnings).

@AndyScherzinger

Copy link
Copy Markdown
Member Author

Point 5 is now fully covered, in 35b6502 (feat(network): ask for local network access on Android 17):

  • Conversation list: also checks the account's stored HPB signaling server (externalSignalingServer), which the chat's websocket connects to.
  • Call: after fetching the signaling settings, checks the signaling server and every STUN and TURN URL before connecting to them. It asks even after an earlier denial, since the call can't reach those servers without it. The call continues either way once the user has answered.
  • STUN and TURN URLs (turn:host:3478?transport=udp, turns:[fd00::7]:5349) have no //, so their host is parsed separately; there are new tests for that.

Checks: unit tests (1343, 0 failures), ktlint, detekt, lint (still 13 warnings). The branch is rebased on current master.

@mahibi

mahibi commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator

taking over

Apps targeting API 37 need ACCESS_LOCAL_NETWORK to reach the local
network. Without it, a request to a server on the LAN never reaches
it and times out after 45 s with no hint about a permission.

Ask for it where it matters, and only on Android 17 and later:
- on server selection, and for a scanned login QR code, when the
  server is a .local name or resolves to a private, link-local, CGNAT
  or unique local address
- when the conversation list opens, for the current account, so
  existing accounts and servers that only resolve locally at home
  are covered too
- on the first call, so peers on the same network can connect
  directly; calls still work through TURN without it, so a denial is
  not asked again

When the permission is denied, a dialog explains why it's needed and
offers to ask again, or to open the app settings once the system no
longer shows its prompt.

While the app targets API 36 the permission is granted implicitly
through INTERNET, so nothing is asked before the targetSdk bump.

Assisted-by: Claude Code:claude-opus-5-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
Verified on an Android 17 emulator with generic and gplay builds:
local network access, voice messages and calls in the background,
picture-in-picture, incoming calls on the lock screen and from the
heads-up notification, push delivery, contact sync, Certificate
Transparency against a public server, and the Robolectric tests on
SDK 37.

Assisted-by: Claude Code:claude-opus-5-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
@mahibi
mahibi force-pushed the feat/target-sdk-37 branch from 8b97998 to 796e2a0 Compare November 12, 2026 14:30
@mahibi

mahibi commented Nov 12, 2026

Copy link
Copy Markdown
Collaborator

I was not able to test as currently i have problems to run Android Studio Emulators.
As Andy tested it i suggest to merge and wait for feedback of real android 17 device users in alpha/beta channel.

@github-actions

Copy link
Copy Markdown
Contributor

Codacy

Lint

TypemasterPR
Warnings1716
Errors00

SpotBugs

CategoryBaseNew
Bad practice77
Correctness88
Dodgy code4040
Internationalization33
Malicious code vulnerability33
Performance77
Security1111
Total7979

@mahibi
mahibi merged commit 09f6f09 into master Nov 12, 2026
20 checks passed
@mahibi
mahibi deleted the feat/target-sdk-37 branch November 12, 2026 17:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

3. to review Waiting for reviews AI assisted

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants