Android app

The Android client talks to your own Mycorrhizal server — it is not a standalone app and ships no default or hosted backend. Before installing it, have a server running; the client refuses to talk to a server below the client/server compatibility floor (see client/server compatibility), which moved to v1.0.0 at the 1.0 major (issue #1170). The app requires Android 8.0 (API 26) or later.

Install channels

The same source tree is built into three distribution variants, one per channel (the why is ADR 0022). Each channel signs its build with a different key, and Android refuses to update an installed app with an artifact signed by another key — so install from the channel you intend to stay on.

Channel Build Reminder push Where to get it
GitHub Releases, updated by Obtainium obtainium FCM fast path, WorkManager polling fallback the project’s Releases page
F-Droid foss — no Firebase/Google Play Services WorkManager polling only the official F-Droid client (submission in progress, issue #1133)
Google Play play — no call/SMS capture FCM fast path, WorkManager polling fallback Play listing in progress (issue #1201)

The obtainium and play builds include Firebase Cloud Messaging (FCM) as a latency fast-path; it is optional at runtime and does nothing without a server-side Firebase configuration. Every channel delivers reminders through the WorkManager polling workers, so the foss (F-Droid) build — which carries no Firebase or Google Play Services class at all — loses only the fast path, not reminders.

The play build omits the opt-in call/SMS activity-logging feature entirely, because Google Play restricts READ_SMS/RECEIVE_SMS/READ_CALL_LOG to an app that is the user’s default dialer or SMS handler (issue #1200). The obtainium and foss builds keep it.

Switching channels

All three variants deliberately share applicationId = com.mycorrhizal.crm, but each store signs with its own key (the project’s release keystore for Obtainium, F-Droid’s key for F-Droid, and Google Play App Signing for Play). Android therefore treats them as different apps for update purposes:

Switching channels requires uninstalling the old app and installing the new one. This is inherent to publishing one application through multiple stores; it is not a bug.

Your contacts and history live on your server, so a reinstall re-downloads them after you sign in. Only the app’s on-device settings and offline mirror are local; the app is safe to uninstall if you can sign in again.

Server profiles

The app keeps a list of server profiles, exactly one active at a time (ADR 0028 Decision 1). A profile is a named server: a label you choose and its URL. Each profile’s sign-in credential is stored separately, so switching between servers — a home instance and a work instance, or re-pointing after a domain move — does not sign you out of the other.

Manage them under Settings → Account → Servers:

  • Add server — a label and a URL, then sign in for that server. A new profile has no saved session, so the app lands on the sign-in screen for it.
  • Switch — tap a profile. This is a full switch: the offline mirror is a per-profile cache and is wiped, so one server’s cached data never appears under another. If the call/SMS outbox holds interactions that have not synced yet, the app names the count and asks before discarding them.
  • Rename — change a profile’s label.
  • Remove — revoke the profile’s saved sign-in and forget it on this device.

An upgrade preserves your existing server and sign-in: the first launch after updating migrates them into a single Remote profile, with no re-login.

Local (on-device) profiles

A local profile runs the real Mycorrhizal backend inside the app — no server at all (ADR 0028 Decision 2). The app starts the embedded server lazily on the first request, stores its data under its own app-private directory (filesDir/local-server/), and talks to it over a private Unix socket. The CRM data product is the same code the server runs, so a local profile has the same dashboard, cadence, duplicates and exports a remote one does.

A local profile is storage only (ADR 0028 Decision 2, amended 2026-09-29): it has no network or multi-user surface. Immich, Paperless, Seafile, Nextcloud, GeoPulse, calendar and contact subscriptions, the Monica import and live Meerkat fetch, notification channels, admin/user management, webhooks, API tokens and DAV are not available on-device, and their Settings entries are hidden. Reminders are not pushed to external channels. File-based imports and exports work as usual.

Constraints:

  • arm64-v8a devices only. The embedded server is built for, and shipped on, arm64-v8a alone; on any other ABI the “Use on this device only” entry is hidden. (The pure-Go SQLite stack’s x86_64 syscalls are blocked by Android’s seccomp filter, and GOOS=android links internally only for arm64.)
  • The local store is the only copy. It is not backed by any server and is not included in Android’s cloud backup (allowBackup=false). Removing the app, or Settings → Account → Servers → (local) → Delete local data, permanently deletes it — hence the typed confirmation.
  • Backup and attach are user-initiated. “Export account bundle to file” writes a full-fidelity JSON backup through the system file picker; a local profile can later be attached to a remote server by importing that bundle (one-time move, never a live two-way sync). See ADR 0028 Decisions 3 and 5.

Moving a local profile to a server

Settings → Account → Servers, on the active local profile, offers Move this data to a server (ADR 0028 Decision 3, one-time — never a live sync). The wizard:

  1. asks you to add or pick a Remote server and sign in to it (2FA supported) — without switching to it, so the local profile stays active and writable;
  2. exports the account bundle from the on-device server, in memory (nothing is written to disk);
  3. uploads it to the remote’s mycorrhizal import source under an Idempotency-Key;
  4. shows the remote’s preview with per-contact add / merge / skip — the same review screen the VCF import uses — plus a count of anything the bundle can’t carry over exactly;
  5. on confirm, applies it (and drops the remote’s finished import session, which would otherwise count against its per-user cap), then switches to the remote profile;
  6. marks the local profile a read-only archive: still browsable, every write is refused before it leaves the app (with a “read-only archive” message), and a persistent Delete archived local data action stays on its row. Nothing is deleted automatically.

Size limit. The bundle is one JSON document held in memory, and the import accepts at most 64 MiB. The export enforces the same limit: if the account is too large (typically many contacts with photos), the Export step (and “Export account bundle to file”) fails immediately with a message giving the bundle’s size and the limit — before anything is uploaded — and the local profile is untouched. Remove contacts or photos you no longer need, or use the CSV/vCard export instead. The server also reports the limit in an X-Mycorrhizal-Bundle-Max-Bytes response header on every successful export. Streamed/chunked bundles are out of scope.

Backing out, an error, or the app being killed at any step before the switch leaves the local profile active and writable; re-running is safe because the server’s import ledger keys on the bundle’s stable IDs, so contacts already moved are not created twice. Nothing ever flows from the remote back to the local profile.

The profile’s own “Me” contact is merged into the “Me” contact the destination already has (restoring a backup into a fresh local profile, or attaching to a server that has its own Me) rather than creating a second “Me”; the bundle records which contact is Me (self_contact_uid), and a bundle without it imports every contact as new.

The entry point (“Use on this device only”, on the sign-in screen and in Settings → Account → Servers, below the profile list so it stays reachable however many profiles exist) is offered in every distribution variant. A local profile holds the only copy of its data, so the app pairs it with the user-initiated account-bundle backup described above.

Mycorrhizal can be opened from another app — a launcher shortcut, an automation tool, a notes app — or from a notification, through a mycorrhizal:// link. A link can only navigate: it never changes your data, and it carries an opaque id, never a name, number, note, or token (ADR 0029, issue #384). The worst a forged link can do is show you a screen of your own data you could have opened yourself.

A link opens nothing until you unlock the app. It is held until sign-in, the server-compatibility check, and the app lock (ADR 0014) have all completed. It is then dropped — not opened later, in a different account — if you sign out, switch server profiles (including an active-profile switch), or the link has already waited more than 10 minutes. Deep-link URIs are never logged.

The complete set of routes (ADR 0029 §2):

Route Link Opens
Home / dashboard mycorrhizal://home the dashboard
Contact mycorrhizal://contacts/{id} that contact’s page
Contact activity timeline mycorrhizal://contacts/{id}/activities that contact’s activity timeline
Search mycorrhizal://search?q={query} the contact list, with {query} filled into search
Circle mycorrhizal://circles/{id} that circle
Tag mycorrhizal://tags/{id} that tag
Household mycorrhizal://households/{id} that household

A link always opens the screen it names with the argument it carries: a search link filters the contact list even if Contacts is already open, and a contact link opens that contact, never the one you looked at before.

Anything else — an unknown route, a malformed or out-of-range id, an extra path segment — opens nothing: the app shows whatever it would have shown without the link. The same routes also work as paths on your own server (/contacts/{id}, /search?q=…, …), which is the form a Web Push notification taps into; where the web has no matching screen it opens the route’s nearest parent (a tag opens /circles?tab=tags, a circle opens /circles).

Log activities from location history (GeoPulse)

With a GeoPulse connection configured (Settings → Integrations → GeoPulse: server URL and a write-only API token, Test connection, Disconnect; the token must be re-entered on first connect and whenever the server’s origin changes), the Activities screen’s top bar has Log from location history. Pick a date and Look up: each recorded stay shows its place, local start time, duration, city/country, the file names of photos found nearby (or a “could not be checked” note), or Already logged. The result count is announced to screen readers, and each stay’s Log activity control is labelled with its place and time. Tapping it opens the normal activity form pre-filled with the location, the stay’s own local date and its external_ref; you choose the contacts and save (the server dedupes a stay that was already logged). Nothing is stored until you save. Both entry points are hidden on a local (embedded) profile, which has no outbound integrations.

Editing the same record from two places

Edits and deletes of contacts, notes, activities, life events and reminders are checked against the record’s revision (ADR 0008, ADR 0009). The app sends the revision the record had when you opened it as an If-Match header; if the web app or another device changed the record in the meantime, the server refuses the write (412 PRECONDITION_FAILED) and nothing is saved or deleted. There is no automatic merge. The app shows a “Changed elsewhere” prompt:

  • Load latest version — discard your edit and reload the server’s copy (for a delete, reload the list so the row shows the current record; you can then delete it again).
  • Keep editing (edit forms) / Not now (deletes and the life-event list) — dismiss the prompt. On an edit form your typed changes stay on screen so you can copy them out first; saving again without loading the latest version is refused again until the revision is refreshed.

These writes are online-only: the app’s offline queue (call/SMS capture, replayed as new activities) only ever creates records, so it never sends If-Match and cannot collide this way.

Contact timeline

A contact’s page shows the 5 most recent events of its merged timeline (activities, notes, reminders, life events, gifts and external activities such as Immich photos). View all opens a full-screen timeline explorer that pages through the contact’s timeline with the same type and recency filters as the web app and a Load more button. The explorer needs a connection to the server (it is not kept in the offline mirror); on a local profile it reads the on-device server.

Contact map

Map in the navigation drawer (or rail) plots every contact address that has coordinates, on the tile style your server is configured with. Tap a pin to see the contact’s name and address and open the contact; the top-bar toggle switches to a plain list of the same points (the map canvas itself cannot be traversed with TalkBack). If the server holds more than 5000 plottable addresses the screen shows the first 5000 and a notice saying so.

In a contact’s address editor, Coordinates (latitude, longitude) takes a pair such as 51.5007, -0.1246, and Find coordinates looks up the address text in the editor through the geocoder your server operator enabled (GEOCODER_PROVIDER). It works on a new or just-edited address; the result is only stored when you save the contact, so Discard leaves the server untouched. Each address also has a Sensitivity picker (Normal, Private, Secret): private and secret addresses are withheld from sync, exports and shares and are never sent to the geocoder (Find coordinates is disabled with a reason, and updates as soon as you change the picker). The lookup is not available in a local (on-device) profile, where it is disabled and you enter coordinates by hand. Private and secret addresses still appear on your own map. How sensitivity, geocoding and the operator settings work is in Contact map.

About and build details

Settings → About lists the app’s Version, Version code, Build type (build type and distribution flavor, for example release / obtainium, plus any application-id suffix), and, when known, the Commit and Built date. Server version shows the version (and commit) of the server you are signed in to, with the build date beneath it when the server reports one; if the server lookup fails it shows a dash and the rest of Settings is unaffected. Copy build details puts all of this on the clipboard as plain text for a bug report.

Tablets and landscape phones

The permanent navigation rail starts at the Medium width class (600–839dp), as Material 3 recommends, and continues at Expanded; the two-pane contacts list and detail use the same window. A phone held in landscape is wide but short, so the rail would not leave vertical room for its items: the permanent rail and two-pane layout therefore still require at least Medium height, and a landscape phone keeps the modal navigation drawer, which scrolls as one list so every destination stays reachable.

The rail shows the seven People destinations (Dashboard, Contacts, Circles, Tags, Households, Network, Map) and a More item; More opens the full grouped drawer, so Interactions, Data & admin and Settings stay one tap away without making the rail scroll or truncate its labels. The rail is painted clear of the status bar (its top inset is filled with the primary bar colour, matching the status icons) and the selected item uses the Material 3 indicator pill.

At Medium and Expanded a single-pane screen — everything except the two-pane contacts split — is capped at 840dp and centred instead of stretching across the window. At Expanded the Dashboard additionally lays its sections out in two columns.

The navigation drawer (and, at Medium/Expanded widths, the rail) is built from one destination registry, android/app/src/main/kotlin/com/mycorrhizal/crm/NavDestinations.kt (issues #1758, #1759, #1757):

  • People: Dashboard, Contacts, Circles, Tags, Households, Network, Map. Interactions: Activities, Unassigned notes, Occasions, Contact shares. Data & admin: Data, Import contacts, Audit log. Settings is pinned below the scrolling list.
  • The current destination is the selected item (announced by TalkBack); the header shows the signed-in user and the server host and opens Servers. Every destination opens with the Menu icon, and back from one returns to the Dashboard. A drawer label is always the destination’s screen title (pinned in every locale by NavDestinationsTest). At Medium/Expanded the rail renders the People group plus More (issue #1776); More is selected while the current route lives in one of the groups behind it.
  • Unassigned notes lists only notes that have no contact, so it is named for that set; New note creates one.
  • Settings is a short index of groups, each a sub-screen: Account (session, Servers, Change password, Two-factor authentication, Passkeys, API tokens), Appearance (language, date format, theme), Privacy & security (App lock, Block screenshots), Contacts & fields, Tracking & notifications, Integrations, Administration (admin accounts only), About, and Log out. App lock is one switch: turning it on also enrols biometric sign-in on servers that support it, and turning it off revokes that enrolment on the server first (if the revoke fails, for example offline, the lock stays on). A biometric sign-in set up from the post-login prompt while the lock is off has its own Turn off biometric sign-in button there.

Design system

The conventions every Android screen follows (issue #1760). They apply to all five locales (en, de, es, fr, it); a new screen that breaks one is a bug, and the rules marked “enforced” are pinned by a test. The web app is held to the same written rules (UX audit; its empty states are tracked in issue #1779).

  • Sentence case for every heading, label, button and menu item: capitalise the first word and proper nouns only (“Overdue relationships”, “Change password”, “API tokens”), never “Overdue Relationships”. German keeps every noun capitalised because the language requires it; the other locales follow the same sentence-case rule as English. Enforced for en, es, fr and it by SentenceCaseStringsTest (android/core/ui/src/test/kotlin/com/mycorrhizal/crm/ui/SentenceCaseStringsTest.kt), which fails on a short string with a Title Case word that is not a listed proper noun. A proper noun the test does not know (a new integration name) is added to its properNouns set with the change that introduces it. Approved exception: the English settings_api_tokens_create_title is “Create API Token” (issue #2166), listed in the test’s exceptions map; other locales stay sentence case.
  • Empty states use the one shared EmptyState composable in core/ui (StateComponents.kt): an optional icon, a title of the form No <things> yet with no trailing period, at most one sentence of body saying what the list is for, and, wherever the screen can create the thing, the primary create action as a button (actionLabel + onAction). A filtered “no results” state and a fatal error show the title alone: a create button would not fix them. Do not hand-roll an empty Text.
  • One add affordance per list. A screen’s primary create action is the floating action button (BrandFab); its empty state repeats the same action. Do not also put a top-bar + or an inline “Add” button beside it. A create action that only makes sense in the empty state (a contact has at most one cadence policy) still uses the FAB. An in-flight upload or save is shown on the FAB itself.
  • Row actions. A list row’s secondary actions (rename, delete, …) go behind one overflow menu (RowOverflowMenu, a ⋮ button) rather than a pair of icon buttons per row. Its TalkBack label names the row (“More actions for Family”) so no two rows announce the same thing. A row with exactly one secondary action may show it inline. Swipe-to-act is allowed only with an undo.
  • Disabled controls say what enables them. A button that is disabled until some input is present shows a short helper line under it, and the same sentence as its TalkBack state description: “Enter a server URL to use SSO”, “Enter your new password and confirm it to continue”. A dialog’s confirm button that waits for a required text field is the one standard exception: the field itself is already marked required.

  • Dialog or full-screen form (issue #1773). Any picker, including a fixed-list dropdown (LabelledDropdown), means a full-screen form. A create/edit form that has a picker, a contact search, more than about four typed (text) fields, or a body that scrolls is a full-screen form built on FormScaffold (EntityFormScreen for the timeline entities) with the pinned Save/Create button: note, activity, reminder, gift, life event, preference, relationship and mark discussed. There is no recorded exception: the preference form (category, level and sensitivity dropdowns) and the relationship form (contact search plus type and sensitivity dropdowns) were dialogs before the maintainer’s 2026-10-20 decision and are full screen now. An AlertDialog is only for a short, picker-free interaction: a confirmation (delete, discard, reject) or a form of a few typed fields that fits without scrolling at the default font size and has no picker (the conversation agenda item: content and a reference URL). Dialogs on other screens that predate this rule and still carry a picker are converted as those screens are next touched, not by exception.
    • A full-screen entity form keeps the list underneath where it was (the list’s scroll state is hoisted above the form/list switch), and the open form and its typed input survive rotation and process death (rememberSaveable, or the ViewModel’s SavedStateHandle for mark discussed). System back and the top-bar arrow leave the form and discard what was typed, the same as Cancel did in the dialogs; the “Discard changes?” guard is #1751’s, and EntityFormScreen is the one place it plugs into for the timeline entities.
  • Pickers are labelled dropdowns. A choice among a fixed list is a LabelledDropdown (core/ui): a read-only outlined field with a visible label and a trailing arrow, never a bare button showing only the current value. TalkBack hears “

Passkeys

The app can use a passkey as your second sign-in step and lets you add, list and remove passkeys (Settings → Account → Passkeys), through the platform’s Credential Manager. Your passkey’s private key stays in the Android credential provider (for example Google Password Manager), never in the app. The design and its limits are recorded in ADR 0034.

Passkeys appear in the app only when all of these hold:

  • you are signed in to a remote server (the on-device local profile has no domain, so never);
  • that server’s operator has turned on native Android passkeys — the server then reports the webauthn_android capability on /health (see WEBAUTHN_ANDROID_ENABLED in the configuration reference); this only works on a publicly reachable HTTPS instance whose domain Google can fetch /.well-known/assetlinks.json from;
  • the device can run Credential Manager: Android 14 or newer, or — on Android 8–13 — the Play Store and GitHub (Obtainium) builds, which bundle the Google Password Manager provider. The F-Droid build has no Google Play services dependency, so below Android 14 it has no passkeys.

When any of these is missing your account is never a dead end. Sign-in falls back to your authenticator-app code or a recovery code, and a passkey-only account is told to use a recovery code and why: “this server isn’t set up for passkeys on Android”, “this device has no passkey provider”, or “passkeys aren’t available on this app for this server”. Cancelling the system passkey prompt is silent. Removing a passkey needs a live second-factor proof — an authenticator or recovery code, or an assertion from a different passkey (offered only when you have another). Accounts that sign in through an identity provider (OIDC) cannot add passkeys, same as the web app. When your first passkey is your account’s first second factor, the app shows your recovery codes once. Regenerating recovery codes (Settings > Two-factor authentication) works with a code or, when a passkey can answer on this device, Verify with a passkey instead — so a passkey-only account can rotate its set.

TLS

The app trusts only system certificate authorities — there is no “import a self-signed certificate” path. A self-hosted server must present a certificate that chains to an OS-trusted CA (for example a Let’s Encrypt certificate), or the app will refuse the connection. See client/server compatibility and the Android network-security posture in MASVS-L1.

Verifying a build

An APK downloaded from the GitHub Releases page can be checked against its build provenance and signature — see Verifying release artifacts. F-Droid and Google Play verify the artifacts they distribute themselves; the release verification page also records which key signs each channel.


Back to top

Mycorrhizal CRM - Self-hosted personal contact management

This site uses Just the Docs, a documentation theme for Jekyll.