From 98f6c5c26cafdbe37f84c25e16fbc92763dcdd70 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Wed, 5 Aug 2026 09:20:02 +0200 Subject: [PATCH] Sync the phone's alarm through Firebase, mirroring to GitHub Completes the cutover started on the PC side: phone_app was still pinned to crdt_sync_dart-v0.2.0, so the Flutter half was writing only to GitHub even though _sync.py had already moved. pushWith() now takes a RemoteStore instead of a GitHubClient, so one call serves Firebase, GitHub, or the MirrorStore that writes both; syncBackend() picks which, and returns the bare GitHub client when this device has not been set up for Firebase -- the normal cutover state, and also the rollback. The remote path is byte-identical on both backends, so only the transport differs and the PC's parse is untouched. Settings gained the Firebase section that actually calls saveAccount(). Its absence was the bug found in diet-guard: without it openFirebase() always returned null, so the app was a Firebase no-op on real hardware no matter what the tests said. Signing in happens immediately on Connect, so a wrong password surfaces on-screen rather than as a silent failure at push time. The three UI problems kuhy hit in diet-guard are avoided here rather than repeated: GitHub is demoted into "Advanced (GitHub mirror)" instead of sitting beside Firebase as a peer choice, the connected account renders as read-only text with a Disconnect button rather than an empty-looking form, and the status card reports the Firebase state it is actually reading. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017FH32BgGQbQLDstBhR26Xa --- phone_app/lib/screens/settings_screen.dart | 216 +++++++++++++++--- .../lib/services/alarm_sync_service.dart | 39 ++-- phone_app/lib/services/firebase_backend.dart | 105 +++++++++ phone_app/pubspec.lock | 6 +- phone_app/pubspec.yaml | 2 +- 5 files changed, 317 insertions(+), 51 deletions(-) create mode 100644 phone_app/lib/services/firebase_backend.dart diff --git a/phone_app/lib/screens/settings_screen.dart b/phone_app/lib/screens/settings_screen.dart index c8b3a6a..faa1cf7 100644 --- a/phone_app/lib/screens/settings_screen.dart +++ b/phone_app/lib/screens/settings_screen.dart @@ -1,17 +1,21 @@ import 'dart:async'; +import 'package:crdt_sync/crdt_sync.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:wake_alarm_sync/services/firebase_backend.dart'; import 'package:wake_alarm_sync/services/github_device_auth.dart'; import 'package:wake_alarm_sync/services/sync_settings.dart'; import 'package:wake_alarm_sync/ui/theme.dart'; -/// Settings screen for the GitHub connection used to sync the alarm. +/// Settings screen for the backends the alarm syncs through. /// -/// The "Connect GitHub" button runs the OAuth device flow — the user -/// enters a short code on github.com; no token pasting required. +/// Firebase is the primary path: sign in once with the shared sync account, +/// whose password is kept in the OS keystore. GitHub is only the cutover +/// mirror, so its device-flow connection sits under "Advanced" rather than +/// competing with Firebase as a visible choice. class SettingsScreen extends StatefulWidget { /// Creates a [SettingsScreen]. const SettingsScreen({super.key}); @@ -22,6 +26,9 @@ class SettingsScreen extends StatefulWidget { class _SettingsScreenState extends State { SyncSettings _settings = const SyncSettings(token: ''); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + bool _firebaseConnected = false; bool _busy = false; String _status = ''; @@ -31,9 +38,68 @@ class _SettingsScreenState extends State { unawaited(_load()); } + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + Future _load() async { final s = await SyncSettings.load(); - if (mounted) setState(() => _settings = s); + // Reflect a previously-stored account, so a returning user sees the real + // state instead of an empty form that looks unconfigured. + final account = await loadAccount(); + if (!mounted) return; + if (account != null) _emailController.text = account.email; + setState(() { + _settings = s; + _firebaseConnected = account != null; + }); + } + + /// Stores the typed account and signs in immediately, so a typo surfaces + /// here rather than as a silent background failure at push time. + Future _connectFirebase() async { + final email = _emailController.text.trim(); + final password = _passwordController.text; + if (email.isEmpty || password.isEmpty) { + setState(() => _status = 'Enter the sync account email and password.'); + return; + } + setState(() { + _busy = true; + _status = 'Signing in…'; + }); + await saveAccount(FirebaseAccount(email: email, password: password)); + final client = await openFirebase(); + if (!mounted) return; + if (client == null) { + await clearAccount(); + setState(() { + _busy = false; + _firebaseConnected = false; + _status = 'Error: Firebase rejected that account.'; + }); + return; + } + _passwordController.clear(); + setState(() { + _busy = false; + _firebaseConnected = true; + _status = 'Connected to Firebase.'; + }); + } + + Future _disconnectFirebase() async { + await clearAccount(); + if (!mounted) return; + _emailController.clear(); + _passwordController.clear(); + setState(() { + _firebaseConnected = false; + _status = 'Firebase disconnected.'; + }); } Future _connectGitHub() async { @@ -96,30 +162,74 @@ class _SettingsScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _StatusCard(connected: isConnected), + _StatusCard(connected: _firebaseConnected), const SizedBox(height: 16), - if (!isConnected) - ElevatedButton.icon( - onPressed: _busy ? null : _connectGitHub, + Text( + 'Firebase sync', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + _firebaseConnected + ? 'Connected. The alarm goes to Firebase first, and still ' + 'mirrors to GitHub until every device has moved.' + : 'Not connected — syncing over GitHub only. Enter the ' + 'shared sync account to move this device over. The ' + 'password is kept in the device keystore, never in ' + 'the app or the repo.', + style: TextStyle( + fontSize: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 12), + // Once connected the account is shown as read-only text: an + // editable email box beside an empty password box reads as + // "you still have to enter this", making a connected device + // look unconfigured. + if (_firebaseConnected) + Row( + children: [ + const Icon(Icons.cloud_done, size: 20), + const SizedBox(width: 8), + Expanded(child: Text(_emailController.text)), + TextButton( + onPressed: _busy ? null : _disconnectFirebase, + child: const Text('Disconnect'), + ), + ], + ) + else ...[ + TextField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + autocorrect: false, + decoration: const InputDecoration( + labelText: 'Sync account email', + ), + ), + const SizedBox(height: 8), + TextField( + controller: _passwordController, + obscureText: true, + autocorrect: false, + decoration: const InputDecoration( + labelText: 'Sync account password', + ), + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _busy ? null : _connectFirebase, icon: _busy ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ) - : const Icon(Icons.link), - label: const Text('Connect GitHub'), - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - textStyle: const TextStyle(fontSize: 16), - ), - ) - else - OutlinedButton.icon( - onPressed: _busy ? null : _disconnect, - icon: const Icon(Icons.link_off), - label: const Text('Disconnect'), + : const Icon(Icons.cloud_done), + label: const Text('Connect Firebase'), ), + ], const SizedBox(height: 16), if (_status.isNotEmpty) Text( @@ -133,19 +243,55 @@ class _SettingsScreenState extends State { ), ), const Divider(height: 32), - Text( - 'PC config — create this file:\n' - '~/.config/wake_alarm/sync.json\n\n' - '{ "token": "" }\n\n' - 'The token needs "repo" scope so the PC can read ' - 'kuhyx/syncs at shutdown.', - // Was 12px/Colors.grey — below the 16px reading floor (rule - // 20) and off-palette (rule 4); this is real setup - // instructions, not decorative chrome. - style: TextStyle( - fontSize: 16, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + // GitHub is the cutover mirror, not a choice: everything about + // it lives here so the primary path stays a single decision. + ExpansionTile( + title: const Text('Advanced (GitHub mirror)'), + tilePadding: EdgeInsets.zero, + childrenPadding: const EdgeInsets.only(bottom: 8), + children: [ + Align( + alignment: Alignment.centerLeft, + child: Text( + isConnected + ? 'Connected to GitHub.' + : 'Not connected to GitHub.', + style: TextStyle( + fontSize: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(height: 12), + if (!isConnected) + ElevatedButton.icon( + onPressed: _busy ? null : _connectGitHub, + icon: const Icon(Icons.link), + label: const Text('Connect GitHub'), + ) + else + OutlinedButton.icon( + onPressed: _busy ? null : _disconnect, + icon: const Icon(Icons.link_off), + label: const Text('Disconnect GitHub'), + ), + const SizedBox(height: 16), + Text( + 'PC config — the PC reads Firebase first and falls back ' + 'to GitHub. For the GitHub fallback, create:\n' + '~/.config/wake_alarm/sync.json\n\n' + '{ "token": "" }\n\n' + 'The token needs "repo" scope so the PC can read ' + 'kuhyx/syncs.', + // Was 12px/Colors.grey — below the 16px reading floor + // (rule 20) and off-palette (rule 4); this is real setup + // instructions, not decorative chrome. + style: TextStyle( + fontSize: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], ), ], ), @@ -178,7 +324,9 @@ class _StatusCard extends StatelessWidget { ), const SizedBox(width: 8), Expanded( - child: Text(connected ? 'Connected to GitHub' : 'Not connected'), + child: Text( + connected ? 'Connected to Firebase' : 'Not connected', + ), ), ], ), diff --git a/phone_app/lib/services/alarm_sync_service.dart b/phone_app/lib/services/alarm_sync_service.dart index b8badbe..1f9e130 100644 --- a/phone_app/lib/services/alarm_sync_service.dart +++ b/phone_app/lib/services/alarm_sync_service.dart @@ -5,6 +5,7 @@ import 'package:crdt_sync/crdt_sync_io.dart'; import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; +import 'package:wake_alarm_sync/services/firebase_backend.dart'; /// The alarm time this device holds, as read from or written to the store. typedef Alarm = ({int hour, int minute}); @@ -14,12 +15,15 @@ typedef Alarm = ({int hour, int minute}); /// /// The phone is the **sole writer**: it stores the chosen alarm locally in a /// [LogStore] (one HLC-stamped [Record], id [_recordId]) and pushes that log -/// to `wake-alarm-sync/devices/phone/alarm.json` in the private `kuhyx/syncs` -/// repo — the same "GitHub as dumb file storage under `devices//`" layout -/// diet_guard's app and the workout app already use. The PC side -/// (`shutdown-wrapper.sh`) is read-only: it fetches that file at shutdown to -/// set `rtcwake`. There is no pull/merge: the phone is the only writer, so its -/// local store is always the source of truth. +/// to `wake-alarm-sync/devices/phone/alarm.json` — Firebase first, mirrored +/// to the private `kuhyx/syncs` repo during the cutover (see [syncBackend]). +/// The path is deliberately identical on both backends, so only the transport +/// differs. +/// +/// The PC side is read-only (`wake_alarm/_sync.py`, which tries Firebase then +/// GitHub, caching the result for `shutdown-wrapper.sh`). There is no +/// pull/merge here: the phone is the only writer, so its local store is +/// always the source of truth. class AlarmSyncService { /// Wraps an already-loaded log store. AlarmSyncService(this._store); @@ -81,20 +85,29 @@ class AlarmSyncService { ); } - /// Pushes the local alarm log to `kuhyx/syncs` using [client]. - Future pushWith(GitHubClient client) => client.putFileText( + /// Pushes the local alarm log using [client]. + /// + /// Takes a [RemoteStore] rather than a `GitHubClient` so the same call + /// serves Firebase, GitHub, or the [MirrorStore] that writes both — the + /// backend is chosen once, in [syncBackend]. + Future pushWith(RemoteStore client) => client.putFileText( _remotePath, logToJson(_store.snapshot()), message: 'wake_alarm_sync: update alarm', ); - /// Pushes using a [token]-authenticated client for the fixed repo. - Future push(String token, {http.Client? httpClient}) => pushWith( - GitHubClient( + /// Pushes to Firebase when this device is set up for it, mirroring to + /// GitHub during the cutover; to GitHub alone when it is not. + /// + /// [token] still authenticates the GitHub half, so an un-migrated phone + /// behaves exactly as before. + Future push(String token, {http.Client? httpClient}) async { + final github = GitHubClient( owner: owner, repo: repo, token: token, httpClient: httpClient, - ), - ); + ); + await pushWith(await syncBackend(github)); + } } diff --git a/phone_app/lib/services/firebase_backend.dart b/phone_app/lib/services/firebase_backend.dart new file mode 100644 index 0000000..e53919f --- /dev/null +++ b/phone_app/lib/services/firebase_backend.dart @@ -0,0 +1,105 @@ +/// Wiring for the Firebase backend, during and after the GitHub cutover. +/// +/// Split by what is safe to publish, because this repo is public: +/// +/// * [kProject] holds the Web API key and database URL. Both are public +/// identifiers that already ship inside the APK; the security rules, not +/// their secrecy, are what protect the data. +/// * The account email and password are entered once per device and kept in +/// the OS keystore, next to the GitHub token this app already stores there. +/// +/// Nothing here reads `~/.config/crdt-sync/` — that is the desktop/Python +/// half (`wake_alarm/_sync.py`). On Android there is no such file. +library; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// The shared `kuhy-syncs` project. +/// +/// `databaseUrl` is the **regional** host. The plain `*.firebaseio.com` form +/// answers 404 with a `correctUrl` body rather than an obvious error, which +/// reads like an auth failure and wastes a debugging session. +const kProject = FirebaseProject( + apiKey: 'AIzaSyCF_sA3xCMehAYXK8eND-rAygb9NXXW_8E', + databaseUrl: + 'https://kuhy-syncs-default-rtdb.europe-west1.firebasedatabase.app', +); + +/// Same options the GitHub token uses: off Android's deprecated +/// `encryptedSharedPreferences` path, and libsecret on Linux. +const _secure = FlutterSecureStorage(); + +// Everything below reaches the OS keystore through a platform channel, which +// `flutter test` has no binding for. The logic these wrap (parsing, the +// public/private split, sign-in) lives in `crdt_sync` and is covered there at +// 100%; what is left here is the two-line adapter. +// coverage:ignore-start + +/// The keystore-backed home for the Firebase refresh token. +SecureCredentialStore credentialStore() => SecureCredentialStore( + read: (key) => _secure.read(key: key), + write: (key, value) => _secure.write(key: key, value: value), + delete: (key) => _secure.delete(key: key), +); + +/// Reads the per-device account, or null when sync has not been set up. +Future loadAccount() async { + try { + return FirebaseAccount.tryParse( + await _secure.read(key: kFirebaseAccountKey), + ); + } on Object { + // No secret service available: behave as "not configured" rather than + // crashing the settings screen. + return null; + } +} + +/// Stores the per-device account. Keystore only — never prefs, never source. +Future saveAccount(FirebaseAccount account) => + _secure.write(key: kFirebaseAccountKey, value: account.toJsonString()); + +/// Forgets the account and any cached session. +Future clearAccount() async { + await _secure.delete(key: kFirebaseAccountKey); + await credentialStore().clear(); +} + +/// Returns a signed-in Firebase client, or null when not configured. +/// +/// Signs in with the stored password only when there is no cached refresh +/// token, so the usual path costs no authentication round trip. +Future openFirebase() async { + final account = await loadAccount(); + if (account == null) return null; + try { + return await firebaseClientFor( + config: kProject.configFor(account.email), + store: credentialStore(), + password: account.password, + ); + } on Object { + // Unreachable keystore or a rejected sign-in must not fail the push: the + // app keeps syncing over GitHub, exactly as before the cutover. + // Broader than Exception because an uninitialised platform binding + // raises a FlutterError, which is an Error. + return null; + } +} + +// coverage:ignore-end + +/// Wraps [github] so Firebase is primary and GitHub is kept as a mirror. +/// +/// Returns [github] unchanged when this device has not been set up for +/// Firebase — a normal state during the cutover, not an error, and also the +/// rollback. The PC reads whichever backend answers first +/// (`_fetch_firebase_alarm() or _fetch_github_alarm()`), so a phone that has +/// not moved yet still reaches it. +Future syncBackend(RemoteStore github) async { + final firebase = await openFirebase(); + return firebase == null + ? github + : MirrorStore(primary: firebase, mirror: github); +} diff --git a/phone_app/pubspec.lock b/phone_app/pubspec.lock index 9802b68..0e0057d 100644 --- a/phone_app/pubspec.lock +++ b/phone_app/pubspec.lock @@ -93,11 +93,11 @@ packages: dependency: "direct main" description: path: crdt_sync_dart - ref: "crdt_sync_dart-v0.2.0" - resolved-ref: "5be99ab11d1657ec04edd65508b8c4ecae4da906" + ref: "crdt_sync_dart-v0.6.0" + resolved-ref: fc99eff00599a4c3195e4c1573ba08f15909a10d url: "https://github.com/kuhyx/utils" source: git - version: "0.2.0" + version: "0.6.0" crypto: dependency: transitive description: diff --git a/phone_app/pubspec.yaml b/phone_app/pubspec.yaml index db03060..adca658 100644 --- a/phone_app/pubspec.yaml +++ b/phone_app/pubspec.yaml @@ -12,7 +12,7 @@ dependencies: crdt_sync: git: url: https://github.com/kuhyx/utils - ref: crdt_sync_dart-v0.2.0 + ref: crdt_sync_dart-v0.6.0 path: crdt_sync_dart cupertino_icons: ^1.0.8 flutter: