Memories
Android/iOS client for Memories. The Flutter UI calls the shared Rust service
through crates/ffi/memories.h; it does not implement a separate storage or
cryptography protocol.
Choose a mode
On first launch, including the first launch after upgrading from a version with no mode preference, choose where you want to work:
| Mode | Data location | What you need |
|---|---|---|
| Standalone | Encrypted library on this phone; works offline | Create a local library or unlock the existing one |
| Center | Your self-hosted server's hosted store | Server origin such as https://center.example.com and a member token |
| SaaS | The hosted store at https://memories.ivjn.us |
A member token for that service; the URL is fixed |
The app's Center mode means a server running with hosted storage enabled
(memories-center --data DIR). It is different from a ticket-only authorization
Center, which distributes signed tickets for local devices. Center and SaaS use
the same hosted client; they differ in endpoint selection and saved connection
profile. Neither requires creating or unlocking a local library first.
First connection
- Select a mode. The app remembers the choice for the next launch.
- For Standalone, create or unlock your library. For Center, enter the server's HTTPS origin without an API path; for SaaS, keep the fixed service address.
- In either hosted mode, enter the member token supplied by your service operator and choose Connect. The member's role and scope determine what it can do; use a member token for content work, not an operator admin token.
- After connecting, open Hosted knowledge to browse, search, view, edit or delete knowledge. You can also review requests or open Paired devices. The server address, current vault and granted scope remain visible.
Hosted content is encrypted at rest with an operator-held password, but the operator can read it. The app displays Server can read. Device-to-device sealed secrets have a separate end-to-end protection boundary.
Switch, disconnect and reconnect
Use Switch mode in the main toolbar before or after login. The dialog marks the current mode and explains the effect; select another mode to switch, or Cancel to keep the session. Finish or leave an open editor first; leaving an unsaved draft asks for confirmation.
| Action | Result |
|---|---|
| Switch mode | Locks the local library, stops sharing, closes the active hosted connection and clears visible content; saves the new mode |
| Return to Standalone | Reopens the local library screen in its locked state; existing data remains |
| Enter Center or SaaS again | Restores that mode's saved connection when available |
| Disconnect | Removes the current profile's saved token and closes its session; the URL and other mode's profile remain |
| Background the app | Locks local access, clears hosted sessions and dismisses sensitive pages and drafts |
| Return from the background | Use Reconnect saved session on the hosted page, or enter a token and connect again |
Switching does not upload, copy, merge or delete library data. Disconnecting does not revoke the token on the server. Center and SaaS keep their URL/token pairs in separate platform secure-storage entries. On upgrade, the old hosted connection is imported into the matching mode only; see configuration and migration.
Available features
| Workflow | Standalone | Center / SaaS |
|---|---|---|
| Local library creation, backup and restore | Available | Use the hosted service; no local setup is needed |
| Browse, search, view, edit and delete knowledge | Available after unlock | Hosted knowledge, with actions restricted by token scope |
| Create knowledge cards | Local full-page editor | Hosted full-page editor with the fields accepted by the hosted API |
| Review hosted authorization requests | Switch to Center or SaaS | Pending/history tabs, approve/reject confirmation and refresh |
| Device pairing | Local device/member approval workflow | Hosted pairing, sealed-secret and device controls, subject to permissions |
| Relay sharing and cloud connections | Available through local library controls | Not exposed on the hosted page |
Hosted creation omits tags, source and relationships; editing an existing ordinary card supports these fields and preserves other metadata. Ordinary memories use the text editor. Structured profiles, protected cards, type templates and E2E cards are view-only in this general editor. Both editors retain a draft when saving fails; drafts are not persisted across app locking or backgrounding. See content editing and authorization.
Connection troubleshooting
The app validates tokens through /v1/session before saving a connection, even
for write-only accounts. Upgrade self-hosted servers together with the app.
Read-only accounts can browse without mutation controls; write-only accounts
can create without loading the library or type list. The server authorizes every
operation again. Search excludes private content, credentials and files; clear
the query to return to the knowledge list. Expired/revoked sessions, insufficient
permissions and unavailable servers have localized error feedback. A 401 requires
disconnecting and connecting with a valid token.
- Cannot connect: check the complete HTTPS origin, network, certificate and
token. A self-hosted Center must enable hosted storage; a ticket-only service
cannot supply this screen.
localhoston a phone is the phone itself. - Permission denied: check the token's vault, role, scope and expiry with the service operator. Selecting another mode does not grant access.
- No pending requests: refresh and check the requester/vault/approver routing. The history tab is not a complete server audit log.
- Session closed after backgrounding: reconnect explicitly. Unsaved drafts have been dismissed as part of locking and cannot be restored.
- Native service could not start: use a build containing the matching Rust library. Hosted modes also use FFI and require the native library.
Development
Build native libraries first with scripts/build-android.ps1 or
scripts/build-ios.sh from the repository root. See the root
README for toolchain requirements and signing instructions.
Use Flutter 3.44.2, matching CI. From this directory:
flutter pub get
flutter gen-l10n
dart format --output=none --set-exit-if-changed lib test integration_test
flutter analyze
flutter test
flutter test integration_test/native_smoke_test.dart -d DEVICE_ID
flutter run -d DEVICE_ID
Widget tests use a fake backend to exercise lock-screen lifecycle behavior, mode selection/switching, hosted-session isolation, cards, localization and secret-reference flows. Clipboard tests mock Flutter's platform channel; they do not verify delivery to an OS clipboard. The integration test loads the real native library and creates a disposable database on the device. Normal runs use the platform application-support directory and a Keychain/Keystore-protected local factor. Use test data during development; see SECURITY.md for the current trust boundaries.
Document secrets and clipboard
In Standalone mode, open a memory/card menu and choose Protect secret in document to replace an exact value in its current body with a named reference. Confirm the history warning, enter the reference name and hidden secret, then authorize when prompted. The Rust service stores the credential and replaces the body in one transaction.
Choose Copy secret on a document, protected card or credential to copy its current value after confirmation and authorization. Documents first show a reference picker. OTP cards copy the seed; their separate OTP action generates a one-time code. Values are not displayed, and late responses after backgrounding or locking are discarded. A completed clipboard copy can persist after locking.
Ordinary edits preserve reference mappings. Old document versions and existing backups retain their previous contents. See the wiki secrets guide for reference rules, updates, compatibility and clipboard limits. Rebuild the native library together with the Flutter app when adding these operations.
Localization
Use the language icon in the app bar to select English, Simplified Chinese, Japanese, German, French, or the system default. The preference is stored separately from the encrypted library and is available while locked. Switching languages preserves the current library session.
Translations live in lib/l10n/app_*.arb; flutter gen-l10n produces the ignored
lib/l10n/generated/ directory. Material and Cupertino controls use Flutter's
localization delegates. Follow the localization guide
when adding messages or languages.
Share session (relay RPC host)
In Standalone mode, while unlocked and in the foreground, the overflow menu
offers Share session.
It joins wss://…/v1/rpc/{room}/a with a high-entropy token so a desktop CLI can
attach as slot b (MEMORIES_DAEMON_RELAY / MEMORIES_DAEMON_ROOM /
MEMORIES_DAEMON_TOKEN). Backgrounding or locking stops the session. There is no
HTTP/WS listen on mobile for MVP; the native FFI denylist still applies. See
remote daemon.
Biometric unlock and sensitive actions
In the unlocked Standalone library, use the overflow menu →
Unlock with biometrics / device lock (off by default; turning
it on requires one system check). lib/biometric.dart wraps local_auth
(BiometricPrompt on Android with device PIN/pattern/password fallback; Face ID /
Touch ID on iOS, NSFaceIDUsageDescription in Info.plist). MainActivity
extends FlutterFragmentActivity as local_auth requires.
- The library password for quick unlock is stored with
AndroidOptions.biometric(resetOnError: false): an Android Keystore AES key withsetUserAuthenticationRequired(true)andsetInvalidatedByBiometricEnrollment(true). On iOS the Keychain item usesKeychainAccessibility.passcode. - Gated: unlocking the library, copying/revealing credential plaintext, using protected cards / OTP, approving pairing and Hosted Center requests, seal and re-seal.
- When enrollment changes (
KeyPermanentlyInvalidatedException) the app tells the user, re-verifies with the device credential, discards only the stale quick-unlock copy, and re-creates the key after one password unlock. The library is never wiped. - No biometrics/device credential, or toggle off: the existing password flow.
- Tests mock
DeviceAuth/UnlockSecretStore(test/biometric_test.dart).
Content editor
lib/content_editor.dart is shared by new memories, credentials and Hosted
Center entries: file import (title from filename), monospace mode for
.env-like content (auto-detected or toggled), and a content-first name that
is inferred from the filename or first non-empty line and only prompted for on
save when still empty. Handwriting (pen/eraser/undo/clear, ML Kit Digital
Ink) and OCR (camera/photos/file, ML Kit text recognition) run on-device; see
handwriting and OCR. Widget tests inject ScanServices fakes.
Memories and knowledge cards stay open until saving succeeds. A failed write keeps the draft available for retry; leaving with changes asks for confirmation. Drafts are held in memory only and are still dismissed by background or idle locking. File import asks before replacing existing text. Memories expose their private/ordinary setting before saving, and attachment retries skip files already saved during the current edit.
Knowledge cards use a full-page editor with a persistent save action, custom fields, tags, sources and relationships. Edit content opens the shared text editor for file import, handwriting and OCR. Hosted creation exposes only the metadata accepted by its existing mobile API.
Authorization on mobile
The Center and SaaS pages separate pending requests from history. Review the requester, vault, access scope, duration and full reason before confirming an approval or rejection. Approval still uses the existing device-authentication gate, and the requester must claim the grant separately. Errors preserve the request for retry; pull to refresh or use the refresh button.
The Center request API does not return a complete decision history. The history tab combines decisions made during the current connection with approved requests returned by the Center. These local records are cleared when leaving the page. Failed new connections are closed without replacing the saved profile.
Local member management shows only actions permitted by the displayed roles. Confirmations identify the member and the old/new access. The Rust service rechecks the current policy; UI filtering does not grant any authority. Owner membership cannot be changed through ordinary role actions, and only Owners see ownership transfer.
Brand
Logo source: assets/brand/logo.svg (+ logo-mono.svg, adaptive
logo-foreground.svg). Icons and splash images were rendered with
rsvg-convert; re-run the same sizes when the logo changes.