Add local network permission handling for private CalDAV servers - #146
Merged
Conversation
Android 17 (API 37) gates local network access behind the android.permission.ACCESS_LOCAL_NETWORK runtime permission; below that it came for free with INTERNET. Since targetSdk is 37, adding a CalDAV account on a LAN address silently failed: denied TCP connects do not fail fast, they time out, so the app looked broken rather than blocked. Reproduced against Radicale on a phone where targetSdk 36 connects and 37 times out. Introduce the repo's first runtime-permission API, kept generic so further permissions only need a new AppPermission constant and a branch in the Android actual: - PermissionRequester (expect/actual) with status/request/openAppSettings, following the rememberX() shape used by ImagePicker. Android implements it for real; iOS can only open settings, since its own prompt is raised implicitly on first connection and cannot be queried; Desktop and Web report NOT_APPLICABLE. - isPrivateNetworkHost() classifies RFC1918, link-local, ULA, loopback, .local and single-label hosts, so people syncing with a hosted provider never see a "nearby devices" prompt. In the add-account sheet, both routes to OnAddPrincipal now funnel through one submit() that requests the permission first when the host is private, and the server field shows whether access is granted with a button to review it in system settings. Rediscovery and sync are deliberately not gated: they require an account added earlier, so that flow has already been through this. Also declare NSLocalNetworkUsageDescription in the three iOS Info.plists. iOS already prompts and works, but the key supplies the purpose string shown in that prompt and is effectively mandatory on iOS 18+. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019HgjhLvQ2D1St6oxS6nx8T
Requesting the local network permission now ends the tap: the account is not added behind the OS dialog, the user taps Add account again once they have answered it. Resuming for them meant holding the credentials in a second piece of state and deciding what to do on a refusal - and the people who reach this are pointing the app at a server on their own network, so tapping again is no burden. The permission callback only updates the indicator now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019HgjhLvQ2D1St6oxS6nx8T
Adding an account now always runs discovery, permission or not, so both paths to OnAddPrincipal go back to exactly what they are on main and the sheet's behaviour change is just the notice and its button. Someone pointing the app at a LAN server can read the timeout and act on the notice sitting right above the button. The manage button now picks its action from the status: DENIED is the one state the OS may still prompt for, so it asks there; GRANTED can only be revoked in settings, and UNKNOWN is iOS, which has nothing to ask through, so both open settings. Android cannot tell "never asked" from "refused" - both read DENIED - so this prompts on the first-run case and does nothing visible after a permanent denial. Accepted to keep the sheet at one piece of permission state and no gate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019HgjhLvQ2D1St6oxS6nx8T
It was only ever read there. With it moved, AddPrincipalBottomSheet is byte-identical to main again and the whole feature lives in the one composable that draws it. Dropped the LaunchedEffect(host) re-read along the way: the grant is app-wide, not per-host, so changing the server field cannot change it. The ON_RESUME observer is synced up to the current lifecycle state when it is added, so it covers both the first read and the return from the settings page on its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019HgjhLvQ2D1St6oxS6nx8T
It already passes, via the no-dot rule rather than a case of its own, which makes it easy to drop by accident: tightening that rule later would silently disagree with 127.0.0.1 and ::1, which are private explicitly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019HgjhLvQ2D1St6oxS6nx8T
…not focused anymore. Also allow credentials to have no username and password to allow server communication without authentication.
Android stops offering the permission dialog once the user has refused twice. From then on launch() returns denied immediately without showing anything - logcat says "No requestable permission in the request." - so the manage button looked dead in exactly the state where the user most wants it to work. A rationale the system will no longer show is how that state announces itself, so after a denied result with shouldShowRequestPermissionRationale false, open the settings page instead, where the grant can still be changed. Where the Activity cannot be resolved nothing changes. One deliberate imprecision: dismissing the dialog with Back rather than answering it looks the same from here, so that also lands in settings. Telling the two apart needs timing guesswork or a persisted "already asked" flag, and neither is worth it for a button whose whole job is to get the user to where they can change the grant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019HgjhLvQ2D1St6oxS6nx8T
…quester Reading permission state and opening the settings page both work off an application context, so PlatformPermissionChecker now joins the other platform services in Koin and reads like them. What stays composable is only what has to be: on Android the prompt goes through an ActivityResultLauncher, which must be registered against the Activity before it reaches STARTED and dies with it, so a singleton holding the Application can never own one. With the rest moved out, that constraint is what the remaining file is about, which is easier to see than it was when one type mixed both. AddAccountScreen takes the checker as a defaulted parameter, matching how AddPrincipalBottomSheet takes its variant, so the preview can supply a stub instead of needing a Koin graph. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019HgjhLvQ2D1St6oxS6nx8T
… username and server is inferred.
It is the single guard every WebDAV request checks before attaching a
basicAuth header, so both directions matter: too eager and the app sends
Basic base64(":") to servers that would have answered anonymously, too shy
and it drops credentials the user entered.
Covers both present, neither, either one alone, whitespace-only treated as
absent (isNotBlank, not isNotEmpty), and a password with surrounding spaces
still counting as real.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HgjhLvQ2D1St6oxS6nx8T
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.
Summary
Android 17 (API 37) moved local network access behind the
android.permission.ACCESS_LOCAL_NETWORKruntime permission; below that it came for free withINTERNET. SincetargetSdkis 37, adding a CalDAV account on a LAN address silently failed — denied TCP connects don't fail fast, they time out, so the app looked broken rather than blocked.Reproduced against a self-hosted Radicale: the phone's browser reached
http://192.168.1.21:5232fine while the app timed out after 10s, and on the same devicetargetSdk36 connected where 37 did not.What changes for the user
Adding an account always runs discovery, permission or not. Nothing is gated or held back: someone pointing the app at a LAN server can read the timeout and act on it.
What's new is a notice under the server field, shown only when the entered host is on a private network and only while that field isn't being typed in, saying whether local network access is granted, plus a Manage permission button:
Once Android has refused twice it stops showing the dialog and
launch()returns denied immediately without prompting. The button detects that (no rationale will be shown) and opens the settings page instead, so it never looks dead.Implementation
Two types, split by what each one needs.
PermissionChecker(status,openAppSettings) works off an application context, soPlatformPermissionCheckeris injected through Koin like the other platform services.PermissionRequester(request) can't be: on Android the prompt goes through anActivityResultLauncher, which must be registered against the Activity before it reaches STARTED and is torn down with it — so it stays a@Composable expect fun rememberPermissionRequester, the shaperememberImagePickerandrememberFilePickeralready use for the same reason.Per platform — Android does the real check and prompt; iOS reports
UNKNOWNand can only open settings, since its own prompt is raised implicitly on the first LAN connection and can't be queried; Desktop and Web reportNOT_APPLICABLEand show no permission UI at all.The Android side references the permission as a string literal and the version gate as the numeric
37rather than aBuild.VERSION_CODESconstant — Google's own documentation sample names the wrong one, and a misnamed constant compiles into a check that never fires.isPrivateNetworkHost(LocalNetworkAddress.kt) decides when any of this is worth mentioning: IPv410/8,172.16/12,192.168/16,169.254/16,127/8; IPv6fc00::/7,fe80::/10,::1and IPv4-mapped forms;.localand single-label hostnames includinglocalhost. It matches on the literal host, so a DNS name pointed at a private range isn't recognised — resolving would need a lookup before the UI could render, and the cost is a missed hint, not a broken connection. Covered byLocalNetworkAddressTest, including the boundaries that must stay public (172.15.x,172.32.x,192.169.x).Credentials without a username and password.
Credentialsno longer requires them, so read-only collections on servers that don't authenticate can be subscribed to. This was needed to read the host offcredentialsas the single source of truth, and is a genuine feature in its own right. All 17basicAuthcall sites are now guarded byhasUsernameAndPassword()— sendingBasic base64(":")would make some servers reject requests they'd otherwise serve anonymously. The server field stays labelled optional; such servers are rare enough that trying is expected to be enough.Declarations:
ACCESS_LOCAL_NETWORKin the three Android manifests, andNSLocalNetworkUsageDescriptionin the three iOSInfo.plistfiles. iOS already prompted and worked without the key — it supplies the purpose string shown inside that prompt, is effectively mandatory on iOS 18+, and is required for App Store review.Testing
Verified on device: the Android 17 phone that reproduced the timeout now connects after granting, and an anonymous server works. CI (
:shared:allTestspluslintDebugon all three app modules) is green.Not covered by tests:
hasUsernameAndPassword()and the permission state machine are verified by reading and on-device use rather than by CI. The iOS target is not built by this workflow, soPermissionChecker.ios.ktis only checked by compiling the shared framework locally.Known limitations