Spaces:
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:
- Cached anchor endpoints from last successful manifest fetch
- Local network discovery (mDNS via [Bonjour iOS / NSD Android]) β only when on Wi-Fi
- Relay tier (M15) β for NAT traversal when on cellular
- 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:
{
"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.sentwhere recipient is the mobile userchat.thread.message.sentwhere mobile user is a thread membermarketplace.post.createdwhere post matches user's subscribed categoriescommunity.alert(broadcast emergency alert)node.joined(subscribed users only)
5.2 Push payload shape
Per M15 Β§5.4, the payload is minimal:
{
"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
// 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.
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:
- Wait out the device's session β eventually messages stop delivering
- From another device, call
node.revokeon this NodeID (anchor co-signs) - 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) 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):
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):
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_recordedtest_push_dispatch_on_chat_message_senttest_quiet_hours_silences_non_emergencytest_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 |
| Push relay tier | M15 Β§5.4 |
| Token-bearer auth | M16 Β§5.5 |
| E2E chat | M23 |
| WebSocket | X06 |
| Invite blobs | Phase 1 CAP Β§13 |
| Web UI (mirror) | M08 |
12. Open questions
- 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.
- End-to-end encryption library in Flutter. Need a libsodium binding that matches the Python side bit-exactly.
flutter_sodiumis well-maintained; verify on both platforms. - Background fetch reliability. iOS throttles aggressively. We accept "best effort"; push is the real delivery mechanism.
- Offline mode depth. Mobile-only LLM (small Phi-3 / Gemma 2B) is Phase 3.
- 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.
- 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.