# 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 ` 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: "", username: "" } 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:///dav/ Principal https:///dav// A collection https:///dav/// ``` `` 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 PayloadType Configuration PayloadVersion 1 PayloadIdentifier dev.officer.dav PayloadUUID PayloadDisplayName Officer Calendar & Contacts PayloadDescription Adds your Officer calendars and address book. PayloadOrganization Officer PayloadScope User PayloadRemovalDisallowed PayloadContent PayloadType com.apple.caldav.account PayloadVersion 1 PayloadIdentifier dev.officer.dav.caldav PayloadUUID PayloadDisplayNameOfficer Calendar CalDAVAccountDescriptionOfficer CalDAVHostName officer.example.com CalDAVPort 443 CalDAVUseSSL CalDAVUsername owner@example.com CalDAVPassword CalDAVPrincipalURL/dav/1/ PayloadType com.apple.carddav.account PayloadVersion 1 PayloadIdentifier dev.officer.dav.carddav PayloadUUID PayloadDisplayNameOfficer Contacts CardDAVAccountDescriptionOfficer CardDAVHostName officer.example.com CardDAVPort 443 CardDAVUseSSL CardDAVUsername owner@example.com CardDAVPassword CardDAVPrincipalURL/dav/1/ ``` `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:///dav/provision/.mobileconfig", expiresAt } GET /dav/provision/.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//cert.pem \ -inkey /etc/letsencrypt/live//privkey.pem \ -certfile /etc/letsencrypt/live//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 `` entry for `canOpenURL` on Android 11+: ```xml ``` **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._