Your home's energy, on the phone.
Two native apps. The iPhone app is pure Swift in appleApp/. The Android app
is Jetpack Compose on Kotlin Multiplatform (shared/, androidApp/). Both
carry pairing, passkeys, Noise, the relay and the session. The box at home is
the record. This app is a cached projection of it. The cloud is blind.
Not a wrap of the web app. Same protocol,
same QR, same relay, same RP ID (app.ftw.energy).
Follow FTW's shared vision and roadmap. The target experience needs few routine choices, honest live feedback and reliable daily charging. The native status below is a dated record and its verification gates still apply; the vision does not declare new native features shipped.
Sourceful develops this app. PRs are welcome, preferably based on issues. Share a short Markdown proposal or a focused fix with relevant test evidence. See CONTRIBUTING.md.
iPhone (appleApp/) Android
SwiftUI app ── widgets Compose
│ │ │
▼ ▼ ▼
FTWKit ──► FTWShared ◄── App Group shared (KMP)
session, cache records, enrollment, vault,
Noise, vault formatters Noise IK, frames,
│ session, relay
└──────────────────┬──────────────────────┘
▼
wss://relay.ftw.energy (encrypted)
│
▼
FTW box
Two taps: scan the QR on the box, Face ID / biometrics, the house.
M1 is pairing, a lean Now, a car view and price widgets. This is its foundation, taken from the Swift draft in #5 and cut down to that scope. iOS 26 is the minimum. iPhone only.
In the app
- Pair: camera QR, a link from outside shown before it is trusted (also a pairing QR scanned with the iOS camera, as a universal link), and the live demo against a simulated box. In the simulator, which has no camera, a picture of the QR from Photos takes the same path.
- Now: one sentence, the energy flow, the price card with the cheapest two hours, today's totals and the fuse. The freshness band above it keeps carrier and source state apart.
- A price widget that reads the App Group cache and nothing else.
- The app writes
prices-<site>andcar-<site>into the App Group after each read, with where each reading came from and when. - The session closes when the app goes to the background and opens again on return, without a passkey.
- A relay that turns the phone away with 4429 shows "FTW is busy" ("FTW är upptaget") instead of an error.
Proven
| Check | Result |
|---|---|
swift test in appleApp/FTWKit (Xcode 27, Swift 6.4) |
94 tests green, 1 live test skipped |
| Cross-implementation vectors from the web app and the box | Noise, frames, rendezvous handles and the vault copy match |
ContractTests against protocol/registry.yaml |
Scopes, ops, capabilities, modes, errors, states and frozen field ids match |
| App and widget for the iOS Simulator, signed ad hoc | Builds; both carry the App Group entitlement |
| Demo in the iOS Simulator (iPhone 17, iOS 26.5) | Launches and streams |
| A picture of a pairing QR, picked from Photos in that simulator | Read and paired; the home then dials the relay |
Next in M1
The car view (level presets 20/40/60/80 and ±5 through loadpoint.soc.set,
the goal through the schedule PUT, Charge now, Pause and Back to the plan),
the four price widget families, then tests and screenshots.
Manual steps before a phone build
The team is L66M368PZ6, set once in appleApp/FTW.xcconfig for the app and
the widget, with automatic signing. Nothing for signing is in the repository.
Someone in that team still has to:
- Register the App IDs
energy.ftw.appandenergy.ftw.app.widgets, the App Groupgroup.energy.ftw.appon both, and Associated Domains on the app. Signing in to Xcode with the team and building for a phone does this through automatic signing; the developer portal works too. - Publish
apple-app-site-associationonapp.ftw.energywithL66M368PZ6.energy.ftw.appunderwebcredentialsand/punderapplinks(in ftw-webapp). Passkeys and the camera QR link need it.
Simulator builds sign ad hoc and need no Apple account.
Not in M1 (do not start these next)
Plan, History, Box admin, escrow and recovery, spoken box codes, macOS, LAN, WebRTC, push and the store listing.
V1 is Pair + Now. That is on main as of 2026-08-22. Not a wrap of the web
app. Not Flutter, not React Native. Android's KMP core stays as it is until
Android is back in scope.
In the app today
- Scan or paste a v2 pairing QR (
https://app.ftw.energy/p#v2.…). - One passkey prompt at enroll. RP ID
app.ftw.energy. PRF saltftw.prf.v1.vault. - Noise_IK_25519_ChaChaPoly_SHA256 to the box through
wss://relay.ftw.energy. - Now shows headline plus grid / solar / battery / house from frozen field ids.
- Vault, site and last readings live in Android EncryptedSharedPreferences. Cold start paints from cache, then reconnects without Face ID. Forget wipes the store.
Proven here
| Check | Result |
|---|---|
./gradlew :shared:jvmTest |
Green |
Live box e2e (127.0.0.1:18080 + production relay) |
hello_ok + snapshot, phase streaming |
Android emulator FTW_Phone (API 35 ARM64) |
APK installed, Pair shown twice |
Passkey PRF cannot run on the JVM. Live e2e uses a local wrapping key for the ceremony and the real Noise / relay / box path. The Android emulator has no camera feed — paste the pairing link.
Not v1 on Android (do not start these next)
Energy, History, Plan, EV, commands, escrow restore, spoken codes, LAN, WebRTC, push, Play listing.
Known holes
srcStateshould follow the Now fields'srcIdin the dict, not every driver on the site.- Wrap key is raw PRF bytes, not the web app's HKDF. An Android vault will not open in the PWA, and the other way around. The iPhone app uses the web app's HKDF.
PasskeyHost.enrollfrom Kotlin still blocks. The UIs call the async ceremony and skip that path.- Field ids in
Explanation.ktare still hand-written; they should come fromprotocol/registry.yaml.
iPhone, on a Mac with Xcode 27:
cd appleApp/FTWKit && swift testAndroid, JDK 21:
export JAVA_HOME="$(brew --prefix openjdk@21)/libexec/openjdk.jdk/Contents/Home"
./gradlew :shared:jvmTestThe Android suite covers enrollment URLs, Cacophony Noise IK, hello/sub CBOR matching the box's interop hex, 512-byte lane 0 frames, the vault, and a session that turns hello_ok + snap into Now state.
Start a box (make dev in srcfl/ftw, or
go run ./cmd/ftw with app_link.enabled: true). The box joins
wss://relay.ftw.energy — that origin is fixed on the box, so the client
meets it there.
export FTW_LIVE_BOX=127.0.0.1:8080 # or 18080 if the API port moved
export FTW_LIVE_RELAY=wss://relay.ftw.energy
./scripts/e2e-ftw.shThe test mints POST /api/app-link/pairing with {"role":"owner"}, runs the
shipped connectToSite path (Noise IK, pairing code in handshake message 1,
prologue ftw.session.v1: + box static), and asserts hello_ok plus a
snapshot that includes the frozen field ids.
iPhone: open appleApp/FTW.xcodeproj in Xcode 27 and run the FTW scheme on
a simulator. The demo on the pairing screen runs without a box, a passkey or
a network; -FTWDemo YES opens it at launch in a debug build. From the
command line:
cd appleApp
xcodebuild build -project FTW.xcodeproj -scheme FTW -destination 'generic/platform=iOS Simulator'Simulator builds sign ad hoc (FTW.xcconfig), so the App Group entitlement
travels with the app and its widget.
Android: ./gradlew :androidApp:assembleDebug (minSdk 28). Pair uses CameraX
- ML Kit for the QR. Passkeys go through Credential Manager.
Emulator (AVD FTW_Phone, API 35 ARM64 Google APIs):
export ANDROID_HOME="$HOME/Android/sdk"
"$ANDROID_HOME/emulator/emulator" -avd FTW_Phone
adb install -r androidApp/build/outputs/apk/debug/androidApp-debug.apk
adb shell am start -n energy.ftw.app/.MainActivityRP ID app.ftw.energy. PRF salt ftw.prf.v1.vault. Reading uses a local
wrapping copy so Now paints without a passkey prompt.
| Path | What |
|---|---|
appleApp/FTWKit/Sources/FTWKit |
Swift: identity, crypto, protocol, relay, session, screen state. The app alone links it. |
appleApp/FTWKit/Sources/FTWShared |
Sendable values for the app and its extensions: the App Group cache and the price and freshness formatters |
appleApp/FTWKit/Sources/FTWDemo |
The simulated box, for the demo, App Review and previews |
appleApp/FTW/ |
SwiftUI for iPhone, Keychain, passkeys, camera |
appleApp/FTWWidgets/ |
WidgetKit extension. Reads the cache; no key, no relay. |
appleApp/FTW.xcconfig |
Settings app and widget share: the App Group, versions, signing |
shared/ |
KMP for Android: identity, crypto, protocol, relay, session |
androidApp/ |
Compose UI |
protocol/registry.yaml |
Names shared with the box |
scripts/e2e-ftw.sh |
Live box e2e (Android shared logic) |
The Xcode project uses folders that Xcode keeps in step with the disk. A new
file under FTW/, FTWWidgets/ or a package target needs no change to
project.pbxproj; only a new target does.
AGPL-3.0-only with the Energyplan combination permission and an app store distribution permission. See LICENSE, LICENSING.md and NOTICE. Earlier Apache-licensed versions retain their earlier grants.