Spaces:
Running on Zero
Running on Zero
File size: 11,564 Bytes
70650b7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 | # 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.
|