add mobile DAV provisioning handoff doc
One-tap calendar/contacts setup for the iOS and Android apps: the server contract that already exists, and specs for the two platform mechanisms that don't yet — a signed .mobileconfig for iOS and a DAVx5 intent for Android. Payload keys, intent identifiers and install flows are researched against Apple's device-management reference and davx5-ose source rather than recalled; the doc marks what is verified against a running deployment and what is still specification. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,557 @@
|
||||
# One-tap calendar & contacts setup — handoff for the mobile developers
|
||||
|
||||
**Audience:** whoever builds the Officer iOS and Android apps.
|
||||
**Goal:** the user opens the Officer app, taps one button, and their phone's native Calendar and
|
||||
Contacts apps start syncing with their Officer server. No typing a server URL. No typing a password.
|
||||
|
||||
**Status of this document:** the server side described in §1 is built, deployed and verified against a
|
||||
real iPhone. Everything in §3 (iOS) and §4 (Android) is a specification for work that has **not** been
|
||||
written yet — the payload keys, intent names and install flows are researched and cited, not
|
||||
implemented. Where something is unverified I say so explicitly rather than rounding it up to fact.
|
||||
|
||||
---
|
||||
|
||||
## 0. The honest summary, before you read 4000 words
|
||||
|
||||
There is no "one button" on either platform, if "one button" means silent. Neither iOS nor Android
|
||||
lets a normal app create a system calendar/contacts account without the user seeing something.
|
||||
|
||||
What is achievable:
|
||||
|
||||
| | Best case | Taps after your button |
|
||||
| ----------- | -------------------------------------------------------------------- | ----------------------------------------------------------- |
|
||||
| **iOS** | Serve a signed `.mobileconfig`, open it in Safari | ~5 (Allow → Settings → Install → passcode → Install → Done) |
|
||||
| **Android** | Fire an explicit Intent into DAVx⁵ with URL + credentials pre-filled | ~3 (Next → pick collections → Create) |
|
||||
|
||||
Both are a large improvement over the status quo, which is the user hand-typing a URL, an email and a
|
||||
32-character generated password into a settings screen, on a phone keyboard, twice (once for
|
||||
calendar, once for contacts). That is what we are eliminating.
|
||||
|
||||
Read §5 before you ship either one — the credential handling is the part that can actually hurt.
|
||||
|
||||
---
|
||||
|
||||
## 1. The server contract (built, live, verified)
|
||||
|
||||
Base URL: the Officer instance's `PUBLIC_URL`. On the reference deployment that is
|
||||
`https://officer.pastilhas.dev`. **Do not hardcode it** — the app already knows its server; use the
|
||||
same base it uses for the REST API.
|
||||
|
||||
### 1.1 Two doors, deliberately different
|
||||
|
||||
Officer exposes DAV twice, and confusing them is the fastest way to waste a day:
|
||||
|
||||
| Path | Auth | Who uses it |
|
||||
| ------------------------ | ---------------------------- | -------------------------------------------------- |
|
||||
| `/dav/*` | HTTP Basic, on every request | Phones, Thunderbird, any CalDAV/CardDAV client |
|
||||
| `/api/caldav/_officer/*` | Platform session JWT | Officer's own web UI (returns JSON, not iCalendar) |
|
||||
| `/api/dav/passwords` | Platform session JWT | Managing the credentials the first row uses |
|
||||
|
||||
`/dav` is mounted **top level**, outside the auth middleware, because a CalDAV client cannot hold a
|
||||
30-day JWT, cannot refresh one, and cannot answer a passkey challenge. It authenticates with Basic on
|
||||
literally every request. See `src/servers/api/dav/sync-router.ts` for the implementation and the
|
||||
reasoning.
|
||||
|
||||
The mobile app itself talks to the **third** row — the JSON door — to mint the credential. It never
|
||||
speaks DAV.
|
||||
|
||||
### 1.2 The credential model
|
||||
|
||||
- **Username** = the account's email address. (Officer is single-user; there is exactly one.)
|
||||
- **Password** = a **DAV app password**, not the login password.
|
||||
|
||||
DAV app passwords are argon2-hashed at rest, scoped to `/dav` and nothing else, and **the plaintext is
|
||||
returned exactly once, at creation, and is not readable again by anything**. There is deliberately no
|
||||
"show it to me again" endpoint. If it is lost, revoke the row and mint another — that is cheaper than
|
||||
any design where the secret can be read back.
|
||||
|
||||
This matters for your flow: **you cannot build a "re-send my config" button that reuses an existing
|
||||
password.** Every provisioning action mints a new one.
|
||||
|
||||
### 1.3 App-password API
|
||||
|
||||
All four are behind the normal session auth — same `Authorization: Bearer <jwt>` your app already
|
||||
sends. Source: `src/servers/api/dav/router.ts`.
|
||||
|
||||
```
|
||||
GET /api/dav/passwords
|
||||
→ { passwords: [{ id, label, createdAt, lastUsedAt, revokedAt }, ...] }
|
||||
|
||||
POST /api/dav/passwords body: { label: string } (required, non-empty)
|
||||
→ { entry: {...}, password: "<plaintext, once>", username: "<owner email>" }
|
||||
|
||||
POST /api/dav/passwords/:id/revoke → { ok: true }
|
||||
DELETE /api/dav/passwords/:id → { ok: true }
|
||||
```
|
||||
|
||||
**Label it with the device.** `"Pixel 9"`, `"iPhone 15 — Officer app"`. The label is the only thing
|
||||
that lets the owner revoke the right one later from the web UI
|
||||
(Settings → Integrations → DAV app passwords). A label of `"mobile"` on four devices is useless.
|
||||
|
||||
### 1.4 URLs the clients need
|
||||
|
||||
```
|
||||
Base / discovery root https://<host>/dav/
|
||||
Principal https://<host>/dav/<userId>/
|
||||
A collection https://<host>/dav/<userId>/<collection>/
|
||||
```
|
||||
|
||||
`<userId>` is the numeric account id — `1` on a single-user instance, which every Officer instance is.
|
||||
Collections on the reference deployment are `personal`, `work` (calendars) and `contacts` (address
|
||||
book), but **these are user-created and you must not hardcode them.** Discover them:
|
||||
|
||||
- **For your own UI** (e.g. showing the user what will be synced): `GET /api/caldav/_officer/collections`
|
||||
returns `[{ path, displayName, kind: 'calendar' | 'addressbook', color }]`.
|
||||
- **For the clients**: don't. Give DAVx⁵ and iOS the _base_ URL and let them do RFC 6764 discovery.
|
||||
That is what it is for.
|
||||
|
||||
### 1.5 Autodiscovery — works, with one caveat
|
||||
|
||||
```
|
||||
GET|PROPFIND /.well-known/caldav → 301 → /dav/
|
||||
GET|PROPFIND /.well-known/carddav → 301 → /dav/
|
||||
OPTIONS /dav/ → DAV: 1, 2, 3, calendar-access, addressbook, extended-mkcol
|
||||
```
|
||||
|
||||
Registered with `.all`, not `.get` — RFC 6764 §6 has the client probe the well-known URI with the
|
||||
method it actually intends to use, and iOS sends `PROPFIND`. (These were GET-only until 2026-08-03 and
|
||||
answered 404 to every real client while looking perfectly healthy in a browser. Test with the method
|
||||
the client uses.)
|
||||
|
||||
CORS is deliberately **not** applied to `/dav` or the well-known paths, because Hono's `cors()`
|
||||
answers every `OPTIONS` itself as a preflight — 204, no `DAV:` header — and never reaches the route
|
||||
beneath. iOS refuses an account whose server does not advertise `calendar-access`, and reports the
|
||||
failure as _"Cannot connect using SSL"_, which is a message about TLS for a problem that has nothing to
|
||||
do with TLS. Don't reintroduce it.
|
||||
|
||||
### 1.6 The iOS account-merge trap (verified the hard way)
|
||||
|
||||
**iOS keys accounts by `server + username`.** If a CalDAV account already exists for
|
||||
`officer.example.com` / `owner@example.com`, adding a _CardDAV_ account with the same pair is silently
|
||||
folded into the existing calendar account — no error, no new account, contacts simply never appear.
|
||||
|
||||
Manual workaround is to give the CardDAV account the explicit collection URL
|
||||
(`https://host/dav/1/contacts/`) so the pair differs. **A configuration profile sidesteps this
|
||||
entirely** — one profile containing both a CalDAV and a CardDAV payload creates both accounts
|
||||
correctly. That is a real argument for the profile approach over any "deep link into Settings" idea.
|
||||
|
||||
### 1.7 Debugging
|
||||
|
||||
Every proxied DAV request is logged server-side:
|
||||
|
||||
```
|
||||
pm2 logs officer | grep '\[dav\]'
|
||||
[dav] PROPFIND /dav/1/contacts/ -> 207 (user=1, ua=iOS/18.5 (22F76) accountsd/1.0)
|
||||
```
|
||||
|
||||
Failed auth logs a warning (`no such user X` / `bad app password for X`). A request with no
|
||||
credentials at all is not logged — that is the normal opening move of every client. If you are
|
||||
debugging a phone that "won't connect", ask for this log first; the `ua` string tells you which
|
||||
daemon on the phone is (or is not) talking.
|
||||
|
||||
---
|
||||
|
||||
## 2. What you're building, in both apps
|
||||
|
||||
A screen — call it **Sync calendar & contacts** — that:
|
||||
|
||||
1. Explains in one sentence what will happen.
|
||||
2. Has one primary button.
|
||||
3. On tap: mints an app password labelled with the device, hands it to the platform mechanism, and
|
||||
walks the user through the residual taps with real screenshots.
|
||||
4. Afterwards, shows sync status and a **Remove / revoke** action.
|
||||
|
||||
Point 4 is not optional. You are creating a long-lived credential on the user's device; the app that
|
||||
created it is the only reasonable place to destroy it.
|
||||
|
||||
---
|
||||
|
||||
## 3. iOS — configuration profile
|
||||
|
||||
### 3.1 Mechanism
|
||||
|
||||
Apple's configuration profile format (`.mobileconfig`) is a signed XML plist. Two payload types matter,
|
||||
and Apple's own documentation confirms **both allow manual (non-MDM) installation on iOS, with no
|
||||
supervision and no user-approved MDM required**:
|
||||
|
||||
- `com.apple.caldav.account` — https://developer.apple.com/documentation/devicemanagement/caldav
|
||||
- `com.apple.carddav.account` — https://developer.apple.com/documentation/devicemanagement/carddav
|
||||
|
||||
### 3.2 Exact payload keys
|
||||
|
||||
Verified against Apple's device-management reference. Note `HostName`, not `AccountHostName`;
|
||||
capital `N` in `HostName`; lowercase `n` in `Username`.
|
||||
|
||||
**CalDAV** (`com.apple.caldav.account`):
|
||||
|
||||
| Key | Type | Required |
|
||||
| -------------------------- | ------- | ----------------------------------------------------- |
|
||||
| `CalDAVHostName` | string | **yes** |
|
||||
| `CalDAVUsername` | string | required for non-interactive install |
|
||||
| `CalDAVPassword` | string | no ("only use this in encrypted profiles" — see §5.2) |
|
||||
| `CalDAVPort` | integer | no |
|
||||
| `CalDAVUseSSL` | boolean | no, defaults `true` |
|
||||
| `CalDAVPrincipalURL` | string | no — "the base URL to the user's calendar" |
|
||||
| `CalDAVAccountDescription` | string | no — this is the name the user sees |
|
||||
|
||||
**CardDAV** (`com.apple.carddav.account`): identical shape — `CardDAVHostName` (required),
|
||||
`CardDAVUsername`, `CardDAVPassword`, `CardDAVPort`, `CardDAVUseSSL`, `CardDAVPrincipalURL`,
|
||||
`CardDAVAccountDescription`.
|
||||
|
||||
Top level: `PayloadType` must be the literal string `Configuration`; `PayloadVersion` must be integer
|
||||
`1` (it is the _format_ version, not yours); `PayloadIdentifier` (reverse-DNS — this is what decides
|
||||
replace-vs-add on reinstall); `PayloadUUID`; `PayloadContent` (array). Each entry in `PayloadContent`
|
||||
carries its own `PayloadType`/`PayloadVersion`/`PayloadIdentifier`/`PayloadUUID`, and its identifier
|
||||
must be unique within the profile.
|
||||
|
||||
### 3.3 Template
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PayloadType</key> <string>Configuration</string>
|
||||
<key>PayloadVersion</key> <integer>1</integer>
|
||||
<key>PayloadIdentifier</key> <string>dev.officer.dav</string>
|
||||
<key>PayloadUUID</key> <string><!-- fresh UUID per issued profile --></string>
|
||||
<key>PayloadDisplayName</key> <string>Officer Calendar & Contacts</string>
|
||||
<key>PayloadDescription</key> <string>Adds your Officer calendars and address book.</string>
|
||||
<key>PayloadOrganization</key> <string>Officer</string>
|
||||
<key>PayloadScope</key> <string>User</string>
|
||||
<key>PayloadRemovalDisallowed</key> <false/>
|
||||
<key>PayloadContent</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>PayloadType</key> <string>com.apple.caldav.account</string>
|
||||
<key>PayloadVersion</key> <integer>1</integer>
|
||||
<key>PayloadIdentifier</key> <string>dev.officer.dav.caldav</string>
|
||||
<key>PayloadUUID</key> <string><!-- fresh UUID --></string>
|
||||
<key>PayloadDisplayName</key><string>Officer Calendar</string>
|
||||
<key>CalDAVAccountDescription</key><string>Officer</string>
|
||||
<key>CalDAVHostName</key> <string>officer.example.com</string>
|
||||
<key>CalDAVPort</key> <integer>443</integer>
|
||||
<key>CalDAVUseSSL</key> <true/>
|
||||
<key>CalDAVUsername</key> <string>owner@example.com</string>
|
||||
<key>CalDAVPassword</key> <string><!-- minted app password --></string>
|
||||
<key>CalDAVPrincipalURL</key><string>/dav/1/</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>PayloadType</key> <string>com.apple.carddav.account</string>
|
||||
<key>PayloadVersion</key> <integer>1</integer>
|
||||
<key>PayloadIdentifier</key> <string>dev.officer.dav.carddav</string>
|
||||
<key>PayloadUUID</key> <string><!-- fresh UUID --></string>
|
||||
<key>PayloadDisplayName</key><string>Officer Contacts</string>
|
||||
<key>CardDAVAccountDescription</key><string>Officer</string>
|
||||
<key>CardDAVHostName</key> <string>officer.example.com</string>
|
||||
<key>CardDAVPort</key> <integer>443</integer>
|
||||
<key>CardDAVUseSSL</key> <true/>
|
||||
<key>CardDAVUsername</key> <string>owner@example.com</string>
|
||||
<key>CardDAVPassword</key> <string><!-- minted app password --></string>
|
||||
<key>CardDAVPrincipalURL</key><string>/dav/1/</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
`PayloadUUID`s must be freshly generated per issued profile. `PayloadIdentifier` should stay **stable**
|
||||
— reinstalling with the same identifier replaces the previous profile rather than stacking a duplicate
|
||||
account, which is what you want when the user re-runs setup.
|
||||
|
||||
### 3.4 Generate it server-side, not in the app
|
||||
|
||||
The profile embeds a password that only the server can mint. Build it on the server. Suggested new
|
||||
endpoint — **this does not exist yet; it is yours (or ours) to write**:
|
||||
|
||||
```
|
||||
POST /api/dav/provision/ios body: { deviceLabel: string }
|
||||
behind userMiddleware
|
||||
1. mints an app password labelled deviceLabel
|
||||
2. renders the plist
|
||||
3. signs it
|
||||
4. stores it against a single-use, short-lived token
|
||||
→ { url: "https://<host>/dav/provision/<opaque-token>.mobileconfig", expiresAt }
|
||||
|
||||
GET /dav/provision/<token>.mobileconfig
|
||||
no session auth (Safari has none) — the token IS the auth
|
||||
single use, ≤5 min TTL, deleted on first fetch
|
||||
Content-Type: application/x-apple-aspen-config
|
||||
```
|
||||
|
||||
The `Content-Type` is mandatory — iOS identifies a profile by MIME type, and serving it as
|
||||
`application/octet-stream` or `text/xml` gets you a downloaded file the OS ignores.
|
||||
|
||||
The single-use short-TTL token is not paranoia: **a profile URL that stays valid is a password that
|
||||
stays valid**, sitting on a public HTTPS endpoint with no authentication.
|
||||
|
||||
### 3.5 Signing
|
||||
|
||||
Apple's requirement: "place the XML property list in a DER-encoded, CMS Signed Data structure."
|
||||
|
||||
```bash
|
||||
openssl smime -sign \
|
||||
-in profile.mobileconfig \
|
||||
-out profile-signed.mobileconfig \
|
||||
-signer /etc/letsencrypt/live/<domain>/cert.pem \
|
||||
-inkey /etc/letsencrypt/live/<domain>/privkey.pem \
|
||||
-certfile /etc/letsencrypt/live/<domain>/chain.pem \
|
||||
-outform der -nodetach -md sha256
|
||||
```
|
||||
|
||||
`-outform der` and `-nodetach` are both load-bearing: the default output is S/MIME (base64 + MIME
|
||||
headers), which iOS will not parse, and without `-nodetach` the file contains a signature with no
|
||||
embedded profile. `-certfile` matters because iOS ships roots, not intermediates.
|
||||
|
||||
**On using the Let's Encrypt TLS cert to sign:** iOS checks only that the signing certificate chains
|
||||
to a trusted root and has not expired. It does **not** check EKU/key usage, and the certificate CN does
|
||||
not have to match the host serving the profile. Multiple practitioner sources report LE-signed profiles
|
||||
showing as verified; Apple publishes no requirements document for profile-signing certificates at all,
|
||||
so this is well-corroborated practice rather than documented policy. Two real risks:
|
||||
|
||||
1. **Use an RSA cert (`--key-type rsa`).** There is an unresolved report of ECDSA P-256-signed profiles
|
||||
showing "Unverified" where a byte-identical RSA one shows verified. Unconfirmed, but the RSA path is
|
||||
the one that is definitively known to work.
|
||||
2. **90-day expiry.** There is no timestamping; iOS evaluates the signing cert at install time. You
|
||||
must re-sign on every certificate renewal, or new installs show "Not Verified". Also: Apple rejects
|
||||
a _replacement_ profile signed with a different identity, so plan the renewal, don't improvise it.
|
||||
|
||||
Unsigned works too — the install flow is identical, the user just sees a red **Not Signed**. Ship
|
||||
signed.
|
||||
|
||||
### 3.6 The install flow — and its hard limits
|
||||
|
||||
**Since iOS 12.2 a profile cannot self-install from a web page.** Apple: _"When you download a
|
||||
configuration profile from a website or an email message in iOS 12.2 or later […] you need to go to
|
||||
the Settings app to install it."_ There is no way around this without MDM enrollment, which is not
|
||||
appropriate here.
|
||||
|
||||
**Third-party browsers and in-app web views do not work.** `Linking.openURL()` → Safari. Do **not** use
|
||||
`WKWebView` or an in-app browser; the profile will download and nothing will happen.
|
||||
|
||||
Sequence to walk the user through, with screenshots in your UI:
|
||||
|
||||
1. Your button → `Linking.openURL(profileUrl)` → Safari opens.
|
||||
2. Safari alert asking permission to download a configuration profile → **Allow**.
|
||||
_(Exact button labels are not in any Apple document — Safari's strings are closed. Screenshot from a
|
||||
real device rather than quoting me.)_
|
||||
3. **"Profile Downloaded"** sheet → Close.
|
||||
4. User opens **Settings**. At the top of the root list: **Profile Downloaded** → tap.
|
||||
5. **Install** (top right) → device passcode → **Install** → **Install** → **Done**.
|
||||
|
||||
Hard constraints, all Apple-documented:
|
||||
|
||||
- **8-minute window.** "If a profile is not installed within 8 minutes of downloading it, it is
|
||||
automatically deleted." Your instructions screen must not be a wall of text the user reads slowly.
|
||||
- **One at a time.** Only one downloaded-but-uninstalled profile can exist; a second download replaces
|
||||
the first.
|
||||
- **Lockdown Mode blocks profile installation entirely.** The user must disable it, install, re-enable.
|
||||
- **Stolen Device Protection (iOS 17.3+)** requires disabling — or being in a familiar location —
|
||||
before installing a configuration profile.
|
||||
|
||||
Detect success by probing whether the account exists (see §6) rather than by anything Safari returns —
|
||||
you get no callback.
|
||||
|
||||
Later profiles are viewable and removable at **Settings → General → VPN & Device Management**.
|
||||
|
||||
---
|
||||
|
||||
## 4. Android — DAVx⁵ intent
|
||||
|
||||
### 4.1 There is no `.mobileconfig` equivalent, and there structurally cannot be
|
||||
|
||||
AOSP ships **no system DAV client at all** — only the Calendar and Contacts _providers_ plus the
|
||||
sync-adapter framework. There is no built-in account type for a configuration payload to target.
|
||||
|
||||
You also cannot create a DAVx⁵ account programmatically. `AccountManager.addAccountExplicitly` requires
|
||||
a signature match with the authenticator that owns the account type, enforced server-side in
|
||||
`AccountManagerService`. You can create _your own_ account type; you can never inject someone else's.
|
||||
|
||||
`Settings.ACTION_ADD_ACCOUNT` only opens a filtered picker — no URL, no credentials.
|
||||
|
||||
Android Enterprise managed configuration would work, but: DAVx⁵'s free/OSE build has **zero**
|
||||
managed-config support (no `app_restrictions.xml`, no `RestrictionsManager` anywhere in the tree), the
|
||||
managed build is a separate paid app (`com.davdroid.managed`, licence-gated, ~€2/user/month with a
|
||||
5-user minimum), and Google's Android Management API explicitly prohibits _"solutions developed and
|
||||
used exclusively for first party in-house applications"_ — which is precisely this. Not a path.
|
||||
|
||||
**So: DAVx⁵'s documented intent API is the answer.** It is genuinely good.
|
||||
|
||||
### 4.2 The intent
|
||||
|
||||
Documented at https://manual.davx5.com/integration.html. Identifiers below were confirmed against
|
||||
`bitfireAT/davx5-ose@main` source, not just the manual.
|
||||
|
||||
- **applicationId:** `at.bitfire.davdroid` (same for F-Droid/OSE and Play builds). The Kotlin namespace
|
||||
is `com.davx5.ose` — that is **not** a package name, don't use it.
|
||||
- **Activity:** `at.bitfire.davdroid.ui.setup.LoginActivity`, `exported="true"`.
|
||||
- **Extras** (all optional Strings): `"url"`, `"username"`, `"password"`.
|
||||
- **URI schemes** on its VIEW/BROWSABLE filter: `caldav`, `caldavs`, `carddav`, `carddavs`, `davx5`.
|
||||
`caldavs`/`carddavs`/`davx5` are rewritten to `https`; `caldav`/`carddav` to `http`.
|
||||
- **Account types:** `bitfire.at.davdroid` and `at.bitfire.davdroid.address_book`.
|
||||
|
||||
**Gotcha the official manual gets wrong.** `StandardLoginTypesProvider.intentToInitialLoginType()`
|
||||
decides whether to skip the login-type chooser by inspecting **`intent.data` only** — it never reads the
|
||||
`url` extra. An extras-only intent (exactly the manual's own snippet) logs _"Did not understand login
|
||||
intent"_ and shows the type-picker first. **Set both `data` and the extras.** Extras win over any
|
||||
userinfo in the URI.
|
||||
|
||||
Also: the manual documents a `davPath` extra. **No such constant exists in current `main`.** Ignore it.
|
||||
|
||||
```kotlin
|
||||
Intent().apply {
|
||||
setClassName("at.bitfire.davdroid", "at.bitfire.davdroid.ui.setup.LoginActivity")
|
||||
data = Uri.parse("https://officer.example.com/") // drives skipLoginTypePage
|
||||
putExtra("username", email)
|
||||
putExtra("password", appPassword)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
```
|
||||
|
||||
The bare root URL is enough — our `.well-known` redirects handle discovery, and DAVx⁵'s own Radicale
|
||||
documentation recommends `/` as the base URL for Radicale v3, which is what Officer runs.
|
||||
|
||||
### 4.3 React Native specifics
|
||||
|
||||
`Linking.sendIntent(action, extras)` **cannot set a component/class name**, and
|
||||
`react-native-send-intent`'s `openApp()` uses `getLaunchIntentForPackage`, which lands on
|
||||
`AccountsActivity`, not `LoginActivity`. So:
|
||||
|
||||
- **Zero native code:** `Linking.openURL('davx5://user@officer.example.com/')` — matches the implicit
|
||||
filter. Requires a `<queries>` entry for `canOpenURL` on Android 11+:
|
||||
```xml
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="davx5" />
|
||||
</intent>
|
||||
</queries>
|
||||
```
|
||||
**But the password ends up in a URI**, which is worse for logging and clipboard exposure.
|
||||
- **Recommended: a ~20-line native module** that builds the explicit intent above, so the password
|
||||
travels as an Intent extra. Do this one.
|
||||
|
||||
### 4.4 What the user still does
|
||||
|
||||
`LoginActivity` always renders its Compose UI. Best case the user taps through: login detail (pre-filled)
|
||||
→ Next → resource detection → account name → **Create Account**. ~3 taps.
|
||||
|
||||
There is **no `setResult`** — the manual says not to rely on the activity result, and that is accurate.
|
||||
Detect completion with an `OnAccountsUpdateListener` on account type `bitfire.at.davdroid`, or by
|
||||
polling `AccountManager.getAccountsByType()` on resume.
|
||||
|
||||
### 4.5 DAVx⁵ not installed
|
||||
|
||||
Check with `PackageManager` / `canOpenURL`. If absent, send the user to Play
|
||||
(`market://details?id=at.bitfire.davdroid`) or F-Droid, and tell them which one they're getting — the
|
||||
Play build is paid (~€6 one-off), the F-Droid build is free and identical. **Say this in the UI.**
|
||||
Handing someone a paywall they didn't expect is a bad first experience of a self-hosted product.
|
||||
|
||||
### 4.6 Alternative: build sync into the Officer app itself
|
||||
|
||||
Viable, and worth costing before committing to the DAVx⁵ dependency. You would implement an
|
||||
`AbstractAccountAuthenticator` + `AbstractThreadedSyncAdapter` under your own account type, writing
|
||||
into `CalendarContract` / `ContactsContract` with `caller_is_syncadapter=true`. Libraries DAVx⁵ itself
|
||||
uses: `com.github.bitfireAT:dav4jvm` (the standalone Kotlin WebDAV/CalDAV protocol layer — the piece
|
||||
worth reusing), `org.mnode.ical4j:ical4j`, `com.googlecode.ez-vcard:ez-vcard`. Copy their dependency
|
||||
pins: ical4j pulls Apache Commons versions that need a newer Java than a typical `minSdk`, so they
|
||||
force `commons-codec` 1.17.1 and `commons-lang3` 3.15.0 as `strictly`.
|
||||
|
||||
**None of this is expressible from JavaScript.** Manifest components and `res/xml` cannot be registered
|
||||
at runtime, and `onPerformSync` runs with no JS bridge attached. It is an all-native subproject.
|
||||
Permissions: `READ/WRITE_CALENDAR` and `READ/WRITE_CONTACTS` are dangerous/runtime with **no
|
||||
sync-adapter exemption**; `READ/WRITE_SYNC_SETTINGS` are normal. `AUTHENTICATE_ACCOUNTS`,
|
||||
`MANAGE_ACCOUNTS` and `USE_CREDENTIALS` are marked _removed_ in AOSP and are no-ops since API 23 —
|
||||
several Android training docs still show them; those docs are stale.
|
||||
|
||||
Recommendation: ship the DAVx⁵ intent first. It is days, not months. Revisit only if the dependency
|
||||
becomes a real problem.
|
||||
|
||||
---
|
||||
|
||||
## 5. Security requirements — read this one
|
||||
|
||||
### 5.1 Non-negotiable
|
||||
|
||||
1. **Mint a fresh app password per provisioning action.** Never reuse, never cache. The plaintext is
|
||||
unrecoverable by design; there is nothing to reuse anyway.
|
||||
2. **Label with the device name.** Revocation is only usable if the labels are distinguishable.
|
||||
3. **HTTPS only, always.** `CalDAVUseSSL`/`CardDAVUseSSL` true, `https://` scheme in the Android URI.
|
||||
4. **The iOS profile URL must be single-use and short-lived** (≤5 minutes, deleted on first fetch). It
|
||||
is an unauthenticated public URL containing a live password.
|
||||
5. **Do not persist the plaintext password in the app.** Hand it to the platform mechanism and drop it.
|
||||
Do not put it in `AsyncStorage`, do not log it, do not include it in crash reports.
|
||||
6. **Do not put it in a URI if you can avoid it** (the Android native-module argument in §4.3).
|
||||
7. **Provide revocation in the app.** `GET /api/dav/passwords` to list, `POST /:id/revoke` to kill.
|
||||
|
||||
### 5.2 A tradeoff to state out loud
|
||||
|
||||
Apple documents `CalDAVPassword`/`CardDAVPassword` as _"only use this in encrypted profiles"_. We are
|
||||
embedding a password in a **signed but unencrypted** profile served over HTTPS. That is a deliberate
|
||||
deviation from the documented path, mitigated by TLS, the single-use token and the ≤5-minute TTL —
|
||||
and by the fact that the credential is scoped to `/dav` and grants nothing but calendar and contacts.
|
||||
|
||||
Profile encryption requires knowing the target device's certificate in advance, which we don't have
|
||||
outside MDM. If this tradeoff is unacceptable to whoever reviews it, the fallback is to omit the
|
||||
password key entirely — iOS then prompts for it at install time, and the user pastes it. That is one
|
||||
extra step and still far better than typing the URL.
|
||||
|
||||
---
|
||||
|
||||
## 6. Verifying it actually worked
|
||||
|
||||
You get no success callback on either platform. Options, best first:
|
||||
|
||||
1. **Server-side.** After provisioning, poll for `lastUsedAt` becoming non-null on the app-password
|
||||
entry via `GET /api/dav/passwords`. Unambiguous: a device authenticated with it. This is the
|
||||
cleanest signal and works identically on both platforms. Verified: `verifyDavAppPassword` writes
|
||||
`lastUsedAt` on every successful auth, throttled to at most once a minute (a syncing phone hits it
|
||||
constantly). So allow up to ~60s before treating a null as failure.
|
||||
2. **Android:** `AccountManager.getAccountsByType("bitfire.at.davdroid")`.
|
||||
3. **iOS:** query `EKEventStore` / `CNContactStore` for a source matching the account description.
|
||||
Requires calendar/contacts permission, which you may not want to ask for. Weakest option.
|
||||
4. **Manual, for support:** `pm2 logs officer | grep '\[dav\]'` on the server — the `ua` string names
|
||||
the exact daemon that connected.
|
||||
|
||||
---
|
||||
|
||||
## 7. Suggested order of work
|
||||
|
||||
1. Server: `POST /api/dav/provision/ios` + the token-gated `.mobileconfig` endpoint (§3.4), signing
|
||||
wired into the certbot renewal hook (§3.5). This is the only new backend work either platform needs.
|
||||
2. Android: the native intent module + the setup screen. Smallest, ships first, and it validates the
|
||||
whole shape of the feature.
|
||||
3. iOS: the setup screen with real device screenshots for every step in §3.6.
|
||||
4. Both: sync status + revoke.
|
||||
|
||||
## 8. Open questions for whoever picks this up
|
||||
|
||||
- Should the setup screen let the user choose _which_ collections sync, or sync everything? DAVx⁵ asks
|
||||
anyway; iOS does not. Asking twice on Android is worse than not asking at all.
|
||||
- What happens on re-provisioning? A stable iOS `PayloadIdentifier` replaces the profile cleanly, but
|
||||
the old app password stays live unless you revoke it. Suggest: revoke the previous
|
||||
same-labelled password when minting a new one, and say so in the UI.
|
||||
- Multi-device: nothing prevents it, and the labels make it manageable. Worth a list view in the app
|
||||
showing every device with a live DAV password.
|
||||
|
||||
## 9. Sources
|
||||
|
||||
Apple: [CalDAV payload](https://developer.apple.com/documentation/devicemanagement/caldav) ·
|
||||
[CardDAV payload](https://developer.apple.com/documentation/devicemanagement/carddav) ·
|
||||
[Install a configuration profile (HT209435 / 102400)](https://support.apple.com/en-us/102400) ·
|
||||
[Lockdown Mode](https://support.apple.com/en-us/105120) ·
|
||||
[Device management profiles](https://support.apple.com/guide/deployment/depc0aadd3fe/web)
|
||||
|
||||
DAVx⁵: [integration manual](https://manual.davx5.com/integration.html) ·
|
||||
[source](https://github.com/bitfireAT/davx5-ose)
|
||||
|
||||
Android: [`setApplicationRestrictions`](https://developer.android.com/reference/android/app/admin/DevicePolicyManager) ·
|
||||
[AMI permissible usage](https://developers.google.com/android/management/permissible-usage)
|
||||
|
||||
Officer: `src/servers/api/dav/sync-router.ts` (the DAV door), `src/servers/api/dav/router.ts`
|
||||
(app passwords), `src/servers/hono.ts` (CORS gating + well-known), `docs/nextcloud-replacement.md`.
|
||||
|
||||
---
|
||||
|
||||
_Written 2026-08-04. §1 is verified against a running deployment and a real iPhone. §3 and §4 are
|
||||
researched specifications, not shipped code — verify against a device before trusting any tap count._
|
||||
Reference in New Issue
Block a user