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: