Spaces:
Running on Zero
Running on Zero
| # M22 — Mobile Native Client | |
| **Spec version:** v1.0 (Phase 2) | |
| **Depends on:** M01 (identity), M15 (relay tier for push and NAT traversal), M16 (tokens for app auth), M23 (E2E for chat), X06 (WebSocket for live updates), the entire Phase 1 bus protocol as a wire client | |
| **Depended on by:** end users on iOS/Android — first non-web HearthNet surface | |
| --- | |
| ## 1. Responsibility | |
| Native mobile application that: | |
| - Onboards into a community (scan invite QR or paste invite blob) | |
| - Stores keys in the device's secure enclave (iOS Keychain, Android Keystore) | |
| - Receives push notifications via the relay tier (M15) | |
| - Calls the community's anchor(s) via the bus protocol over HTTP/SSE or WebSocket | |
| - Provides UI for chat (1:1 + group), marketplace, ask (LLM), community feed | |
| - Operates fully when the user's anchor is reachable; degrades gracefully when not | |
| This module specifies **the contract between the mobile client and the rest of HearthNet**. The actual Flutter codebase lives in a separate repo (`/mobile-native`) and is not Python. | |
| --- | |
| ## 2. File layout | |
| ``` | |
| mobile-native/ # separate Flutter project | |
| ├── pubspec.yaml | |
| ├── README.md | |
| ├── lib/ | |
| │ ├── main.dart | |
| │ ├── onboarding/ # invite scan, key gen | |
| │ ├── identity/ # key storage, signing | |
| │ │ ├── secure_storage.dart | |
| │ │ └── signing.dart | |
| │ ├── bus/ # protocol client | |
| │ │ ├── http_client.dart | |
| │ │ ├── ws_client.dart | |
| │ │ └── sse_client.dart | |
| │ ├── crypto/ # E2E using cryptography_flutter / libsodium bindings | |
| │ │ ├── x25519.dart | |
| │ │ ├── ratchet.dart | |
| │ │ └── envelope.dart | |
| │ ├── push/ # APNs / FCM hookup | |
| │ │ └── subscriber.dart | |
| │ ├── ui/ # screens | |
| │ │ ├── chat.dart | |
| │ │ ├── marketplace.dart | |
| │ │ ├── ask.dart | |
| │ │ └── community.dart | |
| │ └── settings/ | |
| ├── ios/ | |
| ├── android/ | |
| └── tests/ | |
| hearthnet/mobile/ # Python-side helper (in the main package) | |
| ├── __init__.py | |
| ├── invite.py # mobile-targeted invite QR generation | |
| └── push_authority.py # bus-side service for mobile push token registry | |
| ``` | |
| The Python `hearthnet/mobile/` package contains the anchor-side helpers used by the existing community. The Flutter code is its own world; this spec governs the wire contract it must implement. | |
| --- | |
| ## 3. Onboarding flow | |
| ``` | |
| User installs HearthNet app | |
| ↓ | |
| App: "Scan invite QR or paste invite link" | |
| ↓ | |
| On scan: parse hnvite:// blob (Phase 1 §13) | |
| ↓ | |
| App generates Ed25519 keypair via libsodium binding | |
| Persists private key in iOS Keychain (kSecAttrAccessibleAfterFirstUnlock) | |
| or Android Keystore (KeyProperties.AUTH_REQUIRED if biometric set) | |
| ↓ | |
| App calls bus.call("onboard.complete", input={ | |
| "invite_token": "...", | |
| "node_id_full": "<our new ed25519 pub>", | |
| "device_class": "mobile", | |
| "display_name": "<user-entered>", | |
| "platform": "ios|android", | |
| }) | |
| ↓ | |
| Anchor processes invite (M13), emits node.joined event | |
| ↓ | |
| App fetches community manifest (signed); pins it | |
| ↓ | |
| App registers for push: | |
| - obtain APNs/FCM device token from OS | |
| - bus.call("relay.push.register", input={device_token, platform}) | |
| ↓ | |
| App publishes E2E prekey bundle (e2e.prekeys.published event) | |
| ↓ | |
| Ready to use | |
| ``` | |
| --- | |
| ## 4. Bus protocol on mobile | |
| ### 4.1 Same wire as desktop | |
| The mobile client speaks the same HTTP/SSE/WebSocket protocol as Phase 1 anchors. It is a **client** that calls into anchors/services in its community; it does not host capabilities itself (mostly — see §4.4). | |
| ### 4.2 Endpoint selection | |
| The mobile client maintains an ordered list of endpoints: | |
| 1. **Cached anchor endpoints** from last successful manifest fetch | |
| 2. **Local network discovery** (mDNS via [Bonjour iOS / NSD Android]) — only when on Wi-Fi | |
| 3. **Relay tier** (M15) — for NAT traversal when on cellular | |
| 4. **DHT lookup** (X05) — last resort | |
| Per call, the client tries endpoints in order; first success wins. Persistent failures bubble up as `relay_unreachable` or `network_unreachable`. | |
| ### 4.3 Reconnection | |
| WebSocket connections are persistent for tool-call loops and live pubsub. On disconnect, the client reconnects with exponential backoff (1s, 2s, 4s, ..., capped at 60s). Reconnect re-subscribes to all active topics. | |
| ### 4.4 Mobile-as-callable | |
| In Phase 2, the mobile client does NOT register capabilities back into the community. It's purely a caller. Phase 3 may allow simple things (`market.list` from local cache while offline). | |
| ### 4.5 Token-bearer mode | |
| For background-fetch (when the app is suspended), the OS may run a brief task. Background tasks use a **bearer token** from M16 (issued at onboarding, refreshed on each app foreground). The token has scope: | |
| ```json | |
| { | |
| "capabilities": ["chat.fetch","marketplace.list"], | |
| "rate_limit_per_minute": 30 | |
| } | |
| ``` | |
| This avoids needing the user's biometric to unlock the private key for background polling. | |
| --- | |
| ## 5. Push notifications | |
| ### 5.1 What triggers a push | |
| When the following events occur in the community, anchors send a push to relevant subscribed mobile devices: | |
| - `chat.message.sent` where recipient is the mobile user | |
| - `chat.thread.message.sent` where mobile user is a thread member | |
| - `marketplace.post.created` where post matches user's subscribed categories | |
| - `community.alert` (broadcast emergency alert) | |
| - `node.joined` (subscribed users only) | |
| ### 5.2 Push payload shape | |
| Per [M15 §5.4](M15-relay-tier.md), the payload is minimal: | |
| ```json | |
| { | |
| "event_type": "chat.message.sent", | |
| "sender_short": "7H4G-...", | |
| "preview": "Hallo Jana, ich bring..." // optional cleartext preview if not E2E | |
| } | |
| ``` | |
| For E2E messages, the preview is absent and the app must fetch + decrypt on open. | |
| ### 5.3 iOS vs Android specifics | |
| **iOS:** APNs payload with `aps.alert.title` and `aps.alert.body`. Background mode enabled to fetch on receive (`content-available: 1`). | |
| **Android:** FCM `data` message (not `notification` — we control display). Handled by the app's `FirebaseMessagingService`. | |
| ### 5.4 Quiet hours | |
| User-configurable. Push silenced 22:00–07:00 by default; emergency alerts override. | |
| ### 5.5 Mute and per-thread settings | |
| Per-thread mute, per-category marketplace silence. Stored in `mobile.preferences` event (self-only, encrypted-at-rest on device). | |
| --- | |
| ## 6. Secure key storage | |
| ### 6.1 iOS — Keychain Services | |
| ```dart | |
| // lib/identity/secure_storage.dart (sketch) | |
| const _accessibility = 'kSecAttrAccessibleAfterFirstUnlock'; | |
| Future<void> storePrivateKey(String label, Uint8List bytes) async { | |
| await KeychainAccess.setData( | |
| label: label, | |
| data: bytes, | |
| accessibility: _accessibility, | |
| accessControl: AccessControl.userPresence, // biometric on key use | |
| ); | |
| } | |
| ``` | |
| ### 6.2 Android — Keystore | |
| Hardware-backed if available (StrongBox on modern Pixels), else TEE. | |
| ```dart | |
| final cipher = await CryptographyFlutter.aesGcm( | |
| keyId: 'hearthnet_identity_v1', | |
| requireAuth: AuthMethod.biometric, | |
| ); | |
| ``` | |
| ### 6.3 Backup | |
| Private keys are NEVER backed up via iCloud / Google Backup. App-level backup uses an encrypted export blob (user-chosen passphrase) that the user is expected to save out-of-band (e.g. password manager, written down). | |
| `config.mobile.cloud_backup_allowed = false` enforced. | |
| ### 6.4 Lost device | |
| If the user loses the device, they: | |
| 1. Wait out the device's session — eventually messages stop delivering | |
| 2. From another device, call `node.revoke` on this NodeID (anchor co-signs) | |
| 3. The revoked NodeID is then blacklisted; the lost phone, even if recovered, can't authenticate | |
| This is identical to the Phase 1 node revocation flow. | |
| --- | |
| ## 7. UI surface (mobile) | |
| Mirrors the web UI ([M08](../../modules/M08-ui.md)) but native: | |
| | Tab | Content | | |
| |-----|---------| | |
| | **Chat** | 1:1 conversations + group threads. Live updates via WS. Voice notes optional (record → STT → send transcript + audio attachment). | | |
| | **Market** | List + post; image attachments via camera/gallery. | | |
| | **Ask** | LLM chat. Tool-augmented mode available. Voice input button. | | |
| | **Community** | Member list, recent events, federation peers. | | |
| | **Settings** | Push prefs, language, backup, advanced. | | |
| All UI is plain Material/Cupertino — no exotic frameworks. Matches Christof's preference for boring, durable tech. | |
| --- | |
| ## 8. Configuration | |
| Mobile-side (lives in app): | |
| ```dart | |
| const config = { | |
| 'community_id': '<from invite>', | |
| 'anchor_endpoints': [/* from manifest */], | |
| 'relay_url': 'https://relay.hearthnet.de', | |
| 'push_enabled': true, | |
| 'quiet_hours': {'start': '22:00', 'end': '07:00'}, | |
| 'background_fetch_minutes': 15, | |
| }; | |
| ``` | |
| Anchor-side (Python, for the push_authority service): | |
| ```python | |
| config.mobile.push_enabled = True | |
| config.mobile.push_categories_marketplace = ["essentials","emergency"] | |
| config.mobile.push_quiet_hours_default = ("22:00","07:00") | |
| ``` | |
| --- | |
| ## 9. Errors | |
| | Condition | UI presentation | | |
| |-----------|-----------------| | |
| | No network | Offline banner; queued sends | | |
| | Anchor unreachable | "Your community anchor is offline. Retrying..." | | |
| | Relay unreachable | Falls back to direct; warns if all fail | | |
| | Token expired | Silent refresh; only surface if refresh fails | | |
| | Push delivery failed | No UI; logged for diagnostics | | |
| | Manifest signature mismatch | Hard block; re-onboard required | | |
| --- | |
| ## 10. Tests | |
| ### Flutter side | |
| - Widget tests for each tab | |
| - Integration test for onboarding flow with a mock anchor | |
| - E2E test using a real anchor in CI (Linux runner running hearthnet) | |
| ### Anchor-side Python | |
| - `test_push_subscription_recorded` | |
| - `test_push_dispatch_on_chat_message_sent` | |
| - `test_quiet_hours_silences_non_emergency` | |
| - `test_revocation_revokes_mobile_session` | |
| ### Manual | |
| - iOS + Android smoke tests on physical devices | |
| - Background-fetch verified across 30-minute suspensions | |
| --- | |
| ## 11. Cross-references | |
| | What | Where | | |
| |------|-------| | |
| | Bus protocol | [Phase 1 CAP §5](../../CAPABILITY_CONTRACT.md) | | |
| | Push relay tier | [M15 §5.4](M15-relay-tier.md) | | |
| | Token-bearer auth | [M16 §5.5](M16-tokens.md) | | |
| | E2E chat | [M23](M23-e2e-encryption.md) | | |
| | WebSocket | [X06](../cross-cutting/X06-websocket.md) | | |
| | Invite blobs | [Phase 1 CAP §13](../../CAPABILITY_CONTRACT.md) | | |
| | Web UI (mirror) | [M08](../../modules/M08-ui.md) | | |
| --- | |
| ## 12. Open questions | |
| 1. **Flutter vs React Native vs native (Swift + Kotlin).** Choosing Flutter for shared codebase and Christof's stated preference for boring/durable stacks. Reconsider if Flutter's keychain support is shaky. | |
| 2. **End-to-end encryption library in Flutter.** Need a libsodium binding that matches the Python side bit-exactly. `flutter_sodium` is well-maintained; verify on both platforms. | |
| 3. **Background fetch reliability.** iOS throttles aggressively. We accept "best effort"; push is the real delivery mechanism. | |
| 4. **Offline mode depth.** Mobile-only LLM (small Phi-3 / Gemma 2B) is Phase 3. | |
| 5. **Web push for PWA.** Could the same flow target a PWA (no native app)? Yes, with FCM web push; documented but not built in Phase 2. | |
| 6. **Family-share licence.** Christof might want to ship the iOS app to family members under his account; App Store policy permits this within Family Sharing. | |