Skip to content

Add local network permission handling for private CalDAV servers - #146

Merged
patrickunterwegs merged 18 commits into
mainfrom
claude/cool-franklin-3qsh69
Sep 12, 2026
Merged

Add local network permission handling for private CalDAV servers#146
patrickunterwegs merged 18 commits into
mainfrom
claude/cool-franklin-3qsh69

Conversation

@patrickunterwegs

@patrickunterwegs patrickunterwegs commented Sep 11, 2026

Copy link
Copy Markdown
Member

Summary

Android 17 (API 37) moved 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 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:5232 fine while the app timed out after 10s, and on the same device targetSdk 36 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:

  • Not granted — asks the OS, since that's the one state it may still be willing to prompt for.
  • Granted — opens app settings, the only place a grant can be revoked.
  • Unknown (iOS) — opens app settings; iOS has nothing to ask through.

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, so PlatformPermissionChecker is injected through Koin like the other platform services. PermissionRequester (request) can't be: on Android the prompt goes through an ActivityResultLauncher, 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 shape rememberImagePicker and rememberFilePicker already use for the same reason.

Per platform — Android does the real check and prompt; iOS reports UNKNOWN and can only open settings, since its own prompt is raised implicitly on the first LAN connection and can't be queried; Desktop and Web report NOT_APPLICABLE and show no permission UI at all.

The Android side references the permission as a string literal and the version gate as the numeric 37 rather than a Build.VERSION_CODES constant — 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: IPv4 10/8, 172.16/12, 192.168/16, 169.254/16, 127/8; IPv6 fc00::/7, fe80::/10, ::1 and IPv4-mapped forms; .local and single-label hostnames including localhost. 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 by LocalNetworkAddressTest, including the boundaries that must stay public (172.15.x, 172.32.x, 192.169.x).

Credentials without a username and password. Credentials no longer requires them, so read-only collections on servers that don't authenticate can be subscribed to. This was needed to read the host off credentials as the single source of truth, and is a genuine feature in its own right. All 17 basicAuth call sites are now guarded by hasUsernameAndPassword() — sending Basic 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_NETWORK in the three Android manifests, and NSLocalNetworkUsageDescription in the three iOS Info.plist files. 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:allTests plus lintDebug on 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, so PermissionChecker.ios.kt is only checked by compiling the shared framework locally.

Known limitations

  • On an anonymous account, "update password" is silently ineffective — the rebuilt credentials keep the blank username, and that sheet has no username field. Accepted for now.
  • After a permanent denial, dismissing the permission dialog with Back is indistinguishable from it never being shown, so that also lands in app settings.

claude and others added 18 commits September 11, 2026 19:33
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
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
@patrickunterwegs
patrickunterwegs merged commit f4198fd into main Sep 12, 2026
1 check passed
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