diff --git a/app/lib/screens/github_client_id_dialog.dart b/app/lib/screens/github_client_id_dialog.dart new file mode 100644 index 0000000..0fa07cb --- /dev/null +++ b/app/lib/screens/github_client_id_dialog.dart @@ -0,0 +1,83 @@ +// The "paste your OAuth client id" dialog shown before the device flow. +// +// Split out of `github_mirror_screen.dart` for the repo's 250-line cap. A +// `part` so the class can stay library-private: it is an implementation +// detail of that screen and nothing else may construct it. + +part of 'github_mirror_screen.dart'; + +/// Dialog shown when "Connect GitHub" is tapped with no OAuth App client id +/// configured yet. Explains what it is, how to get one, and lets the user +/// paste it in directly — rather than leaving them to discover a buried +/// field on their own. Pops the trimmed client id, or null if cancelled. +class _ClientIdSetupDialog extends StatefulWidget { + const _ClientIdSetupDialog(); + + @override + State<_ClientIdSetupDialog> createState() => _ClientIdSetupDialogState(); +} + +class _ClientIdSetupDialogState extends State<_ClientIdSetupDialog> { + final _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('One-time GitHub setup needed'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Diet Guard signs in via a GitHub OAuth App (no password ' + 'typed into this app). You only have to set this up once:', + ), + const SizedBox(height: 12), + const Text( + '1. On any device, open ' + 'github.com/settings/developers → "New OAuth App".\n' + '2. Name/Homepage/Callback URL can be anything (device flow ' + "doesn't use the callback) — e.g. " + '"Diet Guard" and your GitHub profile URL.\n' + '3. Check "Enable Device Flow", then click "Register ' + 'application".\n' + "4. Copy the Client ID shown on the app's page and paste it " + 'below.', + ), + const SizedBox(height: 12), + const Text( + 'When you connect below, log in with the GitHub account that ' + 'has write access to kuhyx/syncs.', + ), + const SizedBox(height: 16), + TextField( + controller: _controller, + autofocus: true, + decoration: const InputDecoration(labelText: 'Client ID'), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final id = _controller.text.trim(); + if (id.isNotEmpty) Navigator.of(context).pop(id); + }, + child: const Text('Continue'), + ), + ], + ); + } +} diff --git a/app/lib/screens/github_mirror_actions.dart b/app/lib/screens/github_mirror_actions.dart new file mode 100644 index 0000000..69e2fb0 --- /dev/null +++ b/app/lib/screens/github_mirror_actions.dart @@ -0,0 +1,145 @@ +// Connect / save / test / sync actions for the GitHub mirror screen. +// +// Split out of `github_mirror_screen.dart` for the repo's 250-line cap. A +// `part` rather than a separate library: every one of these methods drives +// the screen's private state (`_busy`, `_status`, `_storedToken`, the four +// controllers) and calls its private helpers, none of which are reachable +// across a library boundary in Dart. +// +// A mixin rather than an extension, because these call `setState`, which is +// `@protected` -- an extension is not a subclass of `State`, so the analyzer +// (correctly) rejects it there. + +part of 'github_mirror_screen.dart'; + +mixin _GitHubMirrorActions on State { + // The four text controllers stay declared on the State, which creates and + // disposes them; the mixin only reads and writes their text. + TextEditingController get _ownerController; + TextEditingController get _repoController; + TextEditingController get _tokenController; + TextEditingController get _clientIdController; + + bool _busy = false; + String? _status; + String _storedToken = ''; + SyncSettings _currentSettings() { + final typed = _tokenController.text.trim(); + return SyncSettings( + owner: _ownerController.text.trim(), + repo: _repoController.text.trim(), + // The `_storedToken` side is web-only (the wrapper holds the real + // token), so it is unreachable from a VM test. + token: typed.isEmpty && !SyncSettings.exposesTokenValue + // coverage:ignore-line + ? _storedToken + : typed, + clientId: _clientIdController.text.trim(), + ); + } + void _showMessage(String message) { + if (!mounted) return; + setState(() => _status = message); + } + + Future _connectGitHub() async { + var clientId = _clientIdController.text.trim(); + if (clientId.isEmpty) { + final entered = await showDialog( + context: context, + builder: (_) => const _ClientIdSetupDialog(), + ); + if (entered == null || entered.isEmpty) return; + clientId = entered; + if (!mounted) return; + setState(() => _clientIdController.text = clientId); + await _currentSettings().save(); + } + final auth = createDeviceAuth(clientId, httpClient: widget.httpClient); + try { + final device = await auth.requestDeviceCode(); + if (!mounted) return; + final token = await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => DeviceCodeDialog(device: device, auth: auth), + ); + if (token != null && token.isNotEmpty) { + setState(() { + _storedToken = token; + if (SyncSettings.exposesTokenValue) _tokenController.text = token; + }); + _showMessage('Connected — syncing…'); + await _currentSettings().save(); + await _syncAfterConnect(); + } + } on Exception catch (e) { + _showMessage('Could not start device flow: $e'); + } finally { + auth.close(); + } + } + + /// Runs a sync right after connecting so the device-flow token is proven + /// to work immediately, with clear confirmation either way. + Future _syncAfterConnect() async { + final settings = _currentSettings(); + final client = createGitHubClient(settings, httpClient: widget.httpClient); + try { + await runSync(await syncBackend(client)); + await SyncHealth.recordSuccess(); + _showMessage('Connected and synced.'); + } on Exception catch (e) { + await SyncHealth.recordFailure(); + _showMessage('Connected, but sync failed: $e'); + } finally { + client.close(); + } + } + + Future _save() async { + setState(() => _busy = true); + await _currentSettings().save(); + if (!mounted) return; + setState(() => _busy = false); + _showMessage('Saved.'); + } + + Future _testConnection() async { + setState(() => _busy = true); + final settings = _currentSettings(); + final client = createGitHubClient(settings, httpClient: widget.httpClient); + try { + final ok = await client.canAccessRepo(); + _showMessage( + ok ? 'GitHub connection OK.' : 'GitHub connection failed.', + ); + } on Exception catch (e) { + _showMessage('GitHub connection failed: $e'); + } finally { + client.close(); + if (mounted) setState(() => _busy = false); + } + } + + Future _syncNow() async { + setState(() => _busy = true); + final settings = _currentSettings(); + await settings.save(); + final client = createGitHubClient(settings, httpClient: widget.httpClient); + try { + await runSync(await syncBackend(client)); + // Clears any stored failure: this is the button a user reaches + // *because* the banner told them syncing had stopped, so a successful + // run here must dismiss the warning it caused. + await SyncHealth.recordSuccess(); + _showMessage('Synced.'); + } on Exception catch (e) { + await SyncHealth.recordFailure(); + _showMessage('Sync failed: $e'); + } finally { + client.close(); + if (mounted) setState(() => _busy = false); + } + } +} diff --git a/app/lib/screens/github_mirror_screen.dart b/app/lib/screens/github_mirror_screen.dart index b558c3c..8a44072 100644 --- a/app/lib/screens/github_mirror_screen.dart +++ b/app/lib/screens/github_mirror_screen.dart @@ -8,7 +8,7 @@ library; import 'dart:async'; -import 'package:diet_guard_app/services/firebase_backend.dart'; +import 'package:diet_guard_app/services/firebase_client.dart'; import 'package:diet_guard_app/services/github_client_factory.dart'; import 'package:diet_guard_app/services/sync_health.dart'; import 'package:diet_guard_app/services/sync_service.dart'; @@ -18,6 +18,9 @@ import 'package:flutter/material.dart'; import 'package:github_device_auth/github_device_auth.dart'; import 'package:http/http.dart' as http; +part 'github_client_id_dialog.dart'; +part 'github_mirror_actions.dart'; + /// Screen for configuring and triggering the GitHub mirror sync. class GitHubMirrorScreen extends StatefulWidget { /// Creates a [GitHubMirrorScreen]. @@ -30,14 +33,17 @@ class GitHubMirrorScreen extends StatefulWidget { State createState() => _GitHubMirrorScreenState(); } -class _GitHubMirrorScreenState extends State { +class _GitHubMirrorScreenState extends State + with _GitHubMirrorActions { + @override final _ownerController = TextEditingController(); + @override final _repoController = TextEditingController(); + @override final _tokenController = TextEditingController(); + @override final _clientIdController = TextEditingController(); bool _loading = true; - bool _busy = false; - String? _status; @override void initState() { @@ -87,129 +93,13 @@ class _GitHubMirrorScreenState extends State { /// The token as loaded, so a platform that cannot display it (web) still /// round-trips it instead of blanking it on the next save. - String _storedToken = ''; - SyncSettings _currentSettings() { - final typed = _tokenController.text.trim(); - return SyncSettings( - owner: _ownerController.text.trim(), - repo: _repoController.text.trim(), - // The `_storedToken` side is web-only (the wrapper holds the real - // token), so it is unreachable from a VM test. - token: typed.isEmpty && !SyncSettings.exposesTokenValue - // coverage:ignore-line - ? _storedToken - : typed, - clientId: _clientIdController.text.trim(), - ); - } - void _showMessage(String message) { - if (!mounted) return; - setState(() => _status = message); - } + + /// Runs the OAuth device flow and, on success, fills in the token field. - Future _connectGitHub() async { - var clientId = _clientIdController.text.trim(); - if (clientId.isEmpty) { - final entered = await showDialog( - context: context, - builder: (_) => const _ClientIdSetupDialog(), - ); - if (entered == null || entered.isEmpty) return; - clientId = entered; - if (!mounted) return; - setState(() => _clientIdController.text = clientId); - await _currentSettings().save(); - } - final auth = createDeviceAuth(clientId, httpClient: widget.httpClient); - try { - final device = await auth.requestDeviceCode(); - if (!mounted) return; - final token = await showDialog( - context: context, - barrierDismissible: false, - builder: (_) => DeviceCodeDialog(device: device, auth: auth), - ); - if (token != null && token.isNotEmpty) { - setState(() { - _storedToken = token; - if (SyncSettings.exposesTokenValue) _tokenController.text = token; - }); - _showMessage('Connected — syncing…'); - await _currentSettings().save(); - await _syncAfterConnect(); - } - } on Exception catch (e) { - _showMessage('Could not start device flow: $e'); - } finally { - auth.close(); - } - } - /// Runs a sync right after connecting so the device-flow token is proven - /// to work immediately, with clear confirmation either way. - Future _syncAfterConnect() async { - final settings = _currentSettings(); - final client = createGitHubClient(settings, httpClient: widget.httpClient); - try { - await runSync(await syncBackend(client)); - await SyncHealth.recordSuccess(); - _showMessage('Connected and synced.'); - } on Exception catch (e) { - await SyncHealth.recordFailure(); - _showMessage('Connected, but sync failed: $e'); - } finally { - client.close(); - } - } - - Future _save() async { - setState(() => _busy = true); - await _currentSettings().save(); - if (!mounted) return; - setState(() => _busy = false); - _showMessage('Saved.'); - } - - Future _testConnection() async { - setState(() => _busy = true); - final settings = _currentSettings(); - final client = createGitHubClient(settings, httpClient: widget.httpClient); - try { - final ok = await client.canAccessRepo(); - _showMessage( - ok ? 'GitHub connection OK.' : 'GitHub connection failed.', - ); - } on Exception catch (e) { - _showMessage('GitHub connection failed: $e'); - } finally { - client.close(); - if (mounted) setState(() => _busy = false); - } - } - - Future _syncNow() async { - setState(() => _busy = true); - final settings = _currentSettings(); - await settings.save(); - final client = createGitHubClient(settings, httpClient: widget.httpClient); - try { - await runSync(await syncBackend(client)); - // Clears any stored failure: this is the button a user reaches - // *because* the banner told them syncing had stopped, so a successful - // run here must dismiss the warning it caused. - await SyncHealth.recordSuccess(); - _showMessage('Synced.'); - } on Exception catch (e) { - await SyncHealth.recordFailure(); - _showMessage('Sync failed: $e'); - } finally { - client.close(); - if (mounted) setState(() => _busy = false); - } - } @override Widget build(BuildContext context) { @@ -313,79 +203,3 @@ class _GitHubMirrorScreenState extends State { ); } } - -/// Dialog shown when "Connect GitHub" is tapped with no OAuth App client id -/// configured yet. Explains what it is, how to get one, and lets the user -/// paste it in directly — rather than leaving them to discover a buried -/// field on their own. Pops the trimmed client id, or null if cancelled. -class _ClientIdSetupDialog extends StatefulWidget { - const _ClientIdSetupDialog(); - - @override - State<_ClientIdSetupDialog> createState() => _ClientIdSetupDialogState(); -} - -class _ClientIdSetupDialogState extends State<_ClientIdSetupDialog> { - final _controller = TextEditingController(); - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: const Text('One-time GitHub setup needed'), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Diet Guard signs in via a GitHub OAuth App (no password ' - 'typed into this app). You only have to set this up once:', - ), - const SizedBox(height: 12), - const Text( - '1. On any device, open ' - 'github.com/settings/developers → "New OAuth App".\n' - '2. Name/Homepage/Callback URL can be anything (device flow ' - "doesn't use the callback) — e.g. " - '"Diet Guard" and your GitHub profile URL.\n' - '3. Check "Enable Device Flow", then click "Register ' - 'application".\n' - "4. Copy the Client ID shown on the app's page and paste it " - 'below.', - ), - const SizedBox(height: 12), - const Text( - 'When you connect below, log in with the GitHub account that ' - 'has write access to kuhyx/syncs.', - ), - const SizedBox(height: 16), - TextField( - controller: _controller, - autofocus: true, - decoration: const InputDecoration(labelText: 'Client ID'), - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - final id = _controller.text.trim(); - if (id.isNotEmpty) Navigator.of(context).pop(id); - }, - child: const Text('Continue'), - ), - ], - ); - } -} diff --git a/app/lib/screens/log_meal_sync_mixin.dart b/app/lib/screens/log_meal_sync_mixin.dart index 97e06de..791effc 100644 --- a/app/lib/screens/log_meal_sync_mixin.dart +++ b/app/lib/screens/log_meal_sync_mixin.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:developer'; import 'package:diet_guard_app/services/background_tasks.dart'; -import 'package:diet_guard_app/services/firebase_backend.dart'; +import 'package:diet_guard_app/services/firebase_client.dart'; import 'package:diet_guard_app/services/github_client_factory.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; import 'package:diet_guard_app/services/sync_health.dart'; diff --git a/app/lib/screens/settings_kcal_goal.dart b/app/lib/screens/settings_kcal_goal.dart new file mode 100644 index 0000000..7a22eb9 --- /dev/null +++ b/app/lib/screens/settings_kcal_goal.dart @@ -0,0 +1,62 @@ +// The debounced daily-kcal-goal field and the battery-exemption request. +// +// Split out of `settings_screen.dart` for the repo's 250-line cap. A `part` +// mixin: these drive the screen's private state and call `setState`, which is +// `@protected` and therefore unreachable from an extension. + +part of 'settings_screen.dart'; + +mixin _SettingsKcalGoal on State { + String? _status; + + void _showMessage(String message) { + if (!mounted) return; + setState(() => _status = message); + } + + Timer? _kcalGoalDebounce; + int? _pendingKcalGoal; + + /// Persists the pending goal, if any, and clears it. + void _flushKcalGoal() { + final goal = _pendingKcalGoal; + if (goal == null) return; + _pendingKcalGoal = null; + unawaited(AppSettingsService.instance.saveDailyKcalGoal(goal)); + } + + /// Saves the typed goal once typing settles. + /// + /// Debounced because every keystroke would otherwise be a real edit: + /// typing "2000" saved 2, 20, 200, 2000 in turn, and a sync tick landing + /// between keystrokes would push a nonsense budget to the other device. + void _onKcalGoalChanged(String value) { + _kcalGoalDebounce?.cancel(); + final goal = int.tryParse(value); + if (goal == null || goal <= 0) return; + _pendingKcalGoal = goal; + _kcalGoalDebounce = Timer( + const Duration(milliseconds: 600), + _flushKcalGoal, + ); + } + + /// Requests exemption from OEM battery optimization (MIUI, some Samsung + /// configs), which can otherwise degrade the 15-minute background-check + /// reliability well past its accepted ±15 min target. + Future _requestBatteryExemption() async { + final request = + widget.requestBatteryExemption ?? + () => Permission.ignoreBatteryOptimizations.request(); + try { + final status = await request(); + _showMessage( + status.isGranted + ? 'Battery optimization exemption granted.' + : 'Exemption not granted -- notifications may be delayed.', + ); + } on Exception catch (e) { + _showMessage('Could not request exemption: $e'); + } + } +} diff --git a/app/lib/screens/settings_screen.dart b/app/lib/screens/settings_screen.dart index 8c6fdfa..eddab4c 100644 --- a/app/lib/screens/settings_screen.dart +++ b/app/lib/screens/settings_screen.dart @@ -14,6 +14,7 @@ import 'package:crdt_sync/crdt_sync.dart'; import 'package:diet_guard_app/screens/github_mirror_screen.dart'; import 'package:diet_guard_app/services/app_settings_service.dart'; import 'package:diet_guard_app/services/firebase_backend.dart'; +import 'package:diet_guard_app/services/firebase_client.dart'; import 'package:diet_guard_app/services/google_sign_in_backend.dart'; import 'package:diet_guard_app/ui/theme.dart'; import 'package:flutter/foundation.dart'; @@ -23,6 +24,8 @@ import 'package:http/http.dart' as http; import 'package:permission_handler/permission_handler.dart'; import 'package:sync_settings_ui/sync_settings_ui.dart'; +part 'settings_kcal_goal.dart'; + /// Screen for app-specific settings and links to sync configuration. class SettingsScreen extends StatefulWidget { /// Creates a [SettingsScreen]. @@ -87,7 +90,8 @@ class SettingsScreen extends StatefulWidget { State createState() => _SettingsScreenState(); } -class _SettingsScreenState extends State { +class _SettingsScreenState extends State + with _SettingsKcalGoal { final _kcalGoalController = TextEditingController(); @override @@ -106,59 +110,6 @@ class _SettingsScreenState extends State { super.dispose(); } - Timer? _kcalGoalDebounce; - int? _pendingKcalGoal; - - /// Persists the pending goal, if any, and clears it. - void _flushKcalGoal() { - final goal = _pendingKcalGoal; - if (goal == null) return; - _pendingKcalGoal = null; - unawaited(AppSettingsService.instance.saveDailyKcalGoal(goal)); - } - - /// Saves the typed goal once typing settles. - /// - /// Debounced because every keystroke would otherwise be a real edit: - /// typing "2000" saved 2, 20, 200, 2000 in turn, and a sync tick landing - /// between keystrokes would push a nonsense budget to the other device. - void _onKcalGoalChanged(String value) { - _kcalGoalDebounce?.cancel(); - final goal = int.tryParse(value); - if (goal == null || goal <= 0) return; - _pendingKcalGoal = goal; - _kcalGoalDebounce = Timer( - const Duration(milliseconds: 600), - _flushKcalGoal, - ); - } - - /// Requests exemption from OEM battery optimization (MIUI, some Samsung - /// configs), which can otherwise degrade the 15-minute background-check - /// reliability well past its accepted ±15 min target. - Future _requestBatteryExemption() async { - final request = - widget.requestBatteryExemption ?? - () => Permission.ignoreBatteryOptimizations.request(); - try { - final status = await request(); - _showMessage( - status.isGranted - ? 'Battery optimization exemption granted.' - : 'Exemption not granted -- notifications may be delayed.', - ); - } on Exception catch (e) { - _showMessage('Could not request exemption: $e'); - } - } - - String? _status; - - void _showMessage(String message) { - if (!mounted) return; - setState(() => _status = message); - } - /// True on the one platform with OEM battery optimization to exempt from. /// /// Uses [defaultTargetPlatform] rather than `Platform.isAndroid` because diff --git a/app/lib/services/background_sync_service.dart b/app/lib/services/background_sync_service.dart index e307dc8..663557f 100644 --- a/app/lib/services/background_sync_service.dart +++ b/app/lib/services/background_sync_service.dart @@ -8,7 +8,7 @@ library; import 'dart:developer'; import 'package:diet_guard_app/services/app_settings_service.dart'; import 'package:diet_guard_app/services/budget_history_service.dart'; -import 'package:diet_guard_app/services/firebase_backend.dart'; +import 'package:diet_guard_app/services/firebase_client.dart'; import 'package:diet_guard_app/services/foodbank_service.dart'; import 'package:diet_guard_app/services/github_client_factory.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; diff --git a/app/lib/services/firebase_backend.dart b/app/lib/services/firebase_backend.dart index 7e55ec8..0288c6f 100644 --- a/app/lib/services/firebase_backend.dart +++ b/app/lib/services/firebase_backend.dart @@ -14,9 +14,7 @@ library; import 'dart:developer'; import 'package:crdt_sync/crdt_sync.dart'; -import 'package:diet_guard_app/services/google_sign_in_backend.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:http/http.dart' as http; /// The shared `kuhy-syncs` project. /// @@ -99,184 +97,10 @@ Future clearAccount() async { await credentialStore().clear(); } -/// Returns a signed-in Firebase client, or null when not configured. +/// Drops just the stored account marker, leaving the opt-out flag alone. /// -/// 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) { - // A stored refresh token is a signed-in device, even with no account - // marker beside it. Treating the marker as the source of truth is what - // turned one missing write into "syncs over GitHub and 401s forever": - // the credential that actually authenticates was sitting in the keystore - // the whole time, unused. Ask the store directly before giving up. - return _clientFromStoredSession(); - } - try { - return await firebaseClientFor( - config: kProject.configFor(account.email), - store: credentialStore(), - // A Google-provisioned account stores an empty password. - // Passing '' would make firebaseClientFor treat it as a usable - // credential and sign in with it, which fails; null correctly - // means "no password on this device". - password: account.password.isEmpty ? null : account.password, - // Deliberately NOT offering Google here. This path runs from background - // timers and, in some apps, before runApp -- offering it would let a - // non-interactive tick raise the OS account picker with no user action - // behind it. Interactive sign-in uses openFirebaseWithGoogle instead. - expectedUid: kSyncUid, - ); - } on Object catch (error, stackTrace) { - // Unreachable keystore or a rejected sign-in must not fail the tick: 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. - // - // Never silent, for the same reason loadAccount() above says why: a - // signed-in-but-failing device looked exactly like an unconfigured one, - // which is how the phone went days without publishing a single meal. - log( - 'openFirebase failed; falling back to the GitHub mirror', - level: 1000, - error: error, - stackTrace: stackTrace, - ); - return null; - } -} - -/// Whether this device can actually authenticate against Firebase. -/// -/// True when either half of the state is present: the account marker -/// [loadAccount] reads, or a stored refresh token. The token is the half that -/// matters -- it is what signs requests -- so a settings screen that reports -/// the marker alone calls a working device "Not connected", which is how a -/// phone that was in fact syncing looked broken for a whole session. -Future isFirebaseConfigured() async { - try { - final auth = FirebaseTokenProvider( - apiKey: kProject.apiKey, - store: credentialStore(), - ); - if (await auth.hasSession()) return true; - // storedAccount, not loadAccount: on Android the latter falls back to the - // desktop wrapper's /sync-account route, which resolves to file:/// and - // throws "No host specified in URI" -- observed on the phone, where it - // turned a successful sign-in into "Google sign-in failed". - if (await storedAccount() == null) return false; - // A marker with no session behind it cannot sign a single request. Drop - // just the marker so the settings screen offers a sign-in instead of a - // dead banner -- not clearAccount(), which also sets the opt-out flag and - // would stop the desktop wrapper re-provisioning after the next sign-in. - await _secure.delete(key: kFirebaseAccountKey); - return false; - } on Object catch (error, stackTrace) { - log( - 'session probe failed; reporting this device as not configured', - level: 1000, - error: error, - stackTrace: stackTrace, - ); - return false; - } -} - -/// Returns a client built from the keystore's refresh token alone, or null. -/// -/// The recovery path for a device that has a live session but no account -/// marker -- the state a Google sign-in used to leave behind. Costs one -/// keystore read and no network round trip when there is no session, so it is -/// safe on the background-tick path that [openFirebase] also serves. -/// -/// Does not re-save the account: a device that reaches this is already signed -/// in, and writing a marker from a read path would hide the condition rather -/// than repair it. [openFirebaseWithGoogle] is what writes the marker. -Future _clientFromStoredSession() async { - try { - final auth = FirebaseTokenProvider( - apiKey: kProject.apiKey, - store: credentialStore(), - ); - if (!await auth.hasSession()) return null; - return FirebaseRestClient(databaseUrl: kProject.databaseUrl, auth: auth); - } on Object catch (error, stackTrace) { - // Same contract as openFirebase's own catch: an unreachable keystore - // degrades to the GitHub mirror instead of failing the tick, but says so. - log( - 'stored-session recovery failed; falling back to the GitHub mirror', - level: 1000, - error: error, - stackTrace: stackTrace, - ); - return null; - } -} - -/// Signs in with Google alone, for a device that has no account stored yet. -/// -/// This is the one-tap path: [openFirebase] needs an account in the keystore -/// to know which email to use, but a fresh install has none. The Google token -/// carries the identity, so nothing needs to be typed. -/// -/// The account is stored under the email **Firebase reports**, never one read -/// from the UI: a fresh install has no email anywhere on the device, so taking -/// it from a text field would persist an empty account and send the next -/// launch down the password path with ''. -/// -/// Returns null when the user dismisses the picker; throws [FirebaseAuthError] -/// when Google succeeds but resolves to a uid the security rules do not pin, -/// which would otherwise authenticate fine and then be denied every read and -/// write with no other symptom. -Future openFirebaseWithGoogle({ - Future Function()? tokenFetcher, - Future Function(FirebaseAccount)? accountSaver, - http.Client? httpClient, -}) async { - final token = await (tokenFetcher ?? googleIdToken)(); - if (token == null) return null; - final auth = FirebaseTokenProvider( - apiKey: kProject.apiKey, - store: credentialStore(), - httpClient: httpClient, - ); - final email = await auth.signInWithGoogle( - idToken: token, - expectedUid: kSyncUid, - ); - // The account must be saved unconditionally, and the signed-in email is not - // reliable enough to gate it on. `signInWithIdp` omits `email` whenever the - // Google account hides it, so gating the save on a non-null email produced - // the worst possible outcome: this call returned a perfectly working client - // -- "Connected to Firebase" -- while the device stayed unconfigured, so the - // next launch found no account, `openFirebase()` returned null, and - // `syncBackend()` silently dropped to GitHub-only and 401'd. The session is - // already durable at this point (`signInWithGoogle` stores the refresh token - // via `_adopt`); only this marker was missing, which is exactly why the - // failure survived a restart and looked like a sign-in that never happened. - // - // The email is a display convenience, not the credential -- the refresh - // token in the keystore is. An empty string is the honest value when Google - // withholds it, and `openFirebase()` already treats an empty password as - // "no password on this device" and authenticates from the stored token. - await (accountSaver ?? saveAccount)( - FirebaseAccount(email: email ?? '', password: ''), - ); - return FirebaseRestClient(databaseUrl: kProject.databaseUrl, auth: auth); -} - -// 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. Reads union both, so a device that has not moved yet still -/// converges. -Future syncBackend(RemoteStore github) async { - final firebase = await openFirebase(); - return firebase == null - ? github - : MirrorStore(primary: firebase, mirror: github); -} +/// Deliberately narrower than [clearAccount]: a marker with no session behind +/// it cannot sign a request, but setting the opt-out flag as well would stop +/// the desktop wrapper re-provisioning after the next sign-in. +Future forgetAccountMarker() => + _secure.delete(key: kFirebaseAccountKey); diff --git a/app/lib/services/firebase_client.dart b/app/lib/services/firebase_client.dart new file mode 100644 index 0000000..8d47351 --- /dev/null +++ b/app/lib/services/firebase_client.dart @@ -0,0 +1,197 @@ +/// Opening an authenticated Firebase REST client, and choosing a backend. +/// +/// Split out of `firebase_backend.dart` for the repo's 250-line cap. That +/// module owns the project constants and the keystore-backed account record; +/// this one turns a stored account (or a Google sign-in) into a live client, +/// and decides whether a sync tick runs against Firebase or the GitHub +/// mirror. +library; + +import 'dart:developer'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:diet_guard_app/services/firebase_backend.dart'; +import 'package:diet_guard_app/services/google_sign_in_backend.dart'; +import 'package:http/http.dart' as http; + +/// 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) { + // A stored refresh token is a signed-in device, even with no account + // marker beside it. Treating the marker as the source of truth is what + // turned one missing write into "syncs over GitHub and 401s forever": + // the credential that actually authenticates was sitting in the keystore + // the whole time, unused. Ask the store directly before giving up. + return _clientFromStoredSession(); + } + try { + return await firebaseClientFor( + config: kProject.configFor(account.email), + store: credentialStore(), + // A Google-provisioned account stores an empty password. + // Passing '' would make firebaseClientFor treat it as a usable + // credential and sign in with it, which fails; null correctly + // means "no password on this device". + password: account.password.isEmpty ? null : account.password, + // Deliberately NOT offering Google here. This path runs from background + // timers and, in some apps, before runApp -- offering it would let a + // non-interactive tick raise the OS account picker with no user action + // behind it. Interactive sign-in uses openFirebaseWithGoogle instead. + expectedUid: kSyncUid, + ); + } on Object catch (error, stackTrace) { + // Unreachable keystore or a rejected sign-in must not fail the tick: 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. + // + // Never silent, for the same reason loadAccount() above says why: a + // signed-in-but-failing device looked exactly like an unconfigured one, + // which is how the phone went days without publishing a single meal. + log( + 'openFirebase failed; falling back to the GitHub mirror', + level: 1000, + error: error, + stackTrace: stackTrace, + ); + return null; + } +} + +/// Whether this device can actually authenticate against Firebase. +/// +/// True when either half of the state is present: the account marker +/// [loadAccount] reads, or a stored refresh token. The token is the half that +/// matters -- it is what signs requests -- so a settings screen that reports +/// the marker alone calls a working device "Not connected", which is how a +/// phone that was in fact syncing looked broken for a whole session. +Future isFirebaseConfigured() async { + try { + final auth = FirebaseTokenProvider( + apiKey: kProject.apiKey, + store: credentialStore(), + ); + if (await auth.hasSession()) return true; + // storedAccount, not loadAccount: on Android the latter falls back to the + // desktop wrapper's /sync-account route, which resolves to file:/// and + // throws "No host specified in URI" -- observed on the phone, where it + // turned a successful sign-in into "Google sign-in failed". + if (await storedAccount() == null) return false; + // A marker with no session behind it cannot sign a single request. Drop + // just the marker so the settings screen offers a sign-in instead of a + // dead banner -- not clearAccount(), which also sets the opt-out flag and + // would stop the desktop wrapper re-provisioning after the next sign-in. + await forgetAccountMarker(); + return false; + } on Object catch (error, stackTrace) { + log( + 'session probe failed; reporting this device as not configured', + level: 1000, + error: error, + stackTrace: stackTrace, + ); + return false; + } +} + +/// Returns a client built from the keystore's refresh token alone, or null. +/// +/// The recovery path for a device that has a live session but no account +/// marker -- the state a Google sign-in used to leave behind. Costs one +/// keystore read and no network round trip when there is no session, so it is +/// safe on the background-tick path that [openFirebase] also serves. +/// +/// Does not re-save the account: a device that reaches this is already signed +/// in, and writing a marker from a read path would hide the condition rather +/// than repair it. [openFirebaseWithGoogle] is what writes the marker. +Future _clientFromStoredSession() async { + try { + final auth = FirebaseTokenProvider( + apiKey: kProject.apiKey, + store: credentialStore(), + ); + if (!await auth.hasSession()) return null; + return FirebaseRestClient(databaseUrl: kProject.databaseUrl, auth: auth); + } on Object catch (error, stackTrace) { + // Same contract as openFirebase's own catch: an unreachable keystore + // degrades to the GitHub mirror instead of failing the tick, but says so. + log( + 'stored-session recovery failed; falling back to the GitHub mirror', + level: 1000, + error: error, + stackTrace: stackTrace, + ); + return null; + } +} + +/// Signs in with Google alone, for a device that has no account stored yet. +/// +/// This is the one-tap path: [openFirebase] needs an account in the keystore +/// to know which email to use, but a fresh install has none. The Google token +/// carries the identity, so nothing needs to be typed. +/// +/// The account is stored under the email **Firebase reports**, never one read +/// from the UI: a fresh install has no email anywhere on the device, so taking +/// it from a text field would persist an empty account and send the next +/// launch down the password path with ''. +/// +/// Returns null when the user dismisses the picker; throws [FirebaseAuthError] +/// when Google succeeds but resolves to a uid the security rules do not pin, +/// which would otherwise authenticate fine and then be denied every read and +/// write with no other symptom. +Future openFirebaseWithGoogle({ + Future Function()? tokenFetcher, + Future Function(FirebaseAccount)? accountSaver, + http.Client? httpClient, +}) async { + final token = await (tokenFetcher ?? googleIdToken)(); + if (token == null) return null; + final auth = FirebaseTokenProvider( + apiKey: kProject.apiKey, + store: credentialStore(), + httpClient: httpClient, + ); + final email = await auth.signInWithGoogle( + idToken: token, + expectedUid: kSyncUid, + ); + // The account must be saved unconditionally, and the signed-in email is not + // reliable enough to gate it on. `signInWithIdp` omits `email` whenever the + // Google account hides it, so gating the save on a non-null email produced + // the worst possible outcome: this call returned a perfectly working client + // -- "Connected to Firebase" -- while the device stayed unconfigured, so the + // next launch found no account, `openFirebase()` returned null, and + // `syncBackend()` silently dropped to GitHub-only and 401'd. The session is + // already durable at this point (`signInWithGoogle` stores the refresh token + // via `_adopt`); only this marker was missing, which is exactly why the + // failure survived a restart and looked like a sign-in that never happened. + // + // The email is a display convenience, not the credential -- the refresh + // token in the keystore is. An empty string is the honest value when Google + // withholds it, and `openFirebase()` already treats an empty password as + // "no password on this device" and authenticates from the stored token. + await (accountSaver ?? saveAccount)( + FirebaseAccount(email: email ?? '', password: ''), + ); + return FirebaseRestClient(databaseUrl: kProject.databaseUrl, auth: auth); +} + +// 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. Reads union both, so a device that has not moved yet still +/// converges. +Future syncBackend(RemoteStore github) async { + final firebase = await openFirebase(); + return firebase == null + ? github + : MirrorStore(primary: firebase, mirror: github); +} diff --git a/app/lib/services/foodbank_service.dart b/app/lib/services/foodbank_service.dart index a541747..059b83d 100644 --- a/app/lib/services/foodbank_service.dart +++ b/app/lib/services/foodbank_service.dart @@ -15,6 +15,8 @@ import 'package:diet_guard_app/services/fuzzy.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; import 'package:flutter/foundation.dart'; +part 'foodbank_service_manual.dart'; + /// Below this similarity ratio a non-substring candidate is dropped. /// Mirrors `_foodbank._FUZZY_THRESHOLD`. const double fuzzyThreshold = 0.6; @@ -204,111 +206,4 @@ class FoodBankService { await writeBank(bank); return bank; } - - // --------------------------------------------------------------------------- - // Manual bank (food items added directly without logging them as eaten) - // --------------------------------------------------------------------------- - - Future> _readManualBank() => - _readBank(manualDocumentName); - - Future _writeManualBank(Map bank) => - _writeBank(manualDocumentName, bank); - - /// Adds or updates [record] in the manually-curated bank without logging it - /// as eaten. A repeated call with the same normalized name overwrites the - /// previous entry. - Future addManualEntry(FoodBankRecord record) async { - final bank = await _readManualBank(); - bank[_normalize(record.desc)] = FoodBankRecord( - desc: record.desc, - kcal: record.kcal, - proteinG: record.proteinG, - carbsG: record.carbsG, - fatG: record.fatG, - grams: record.grams, - count: record.count, - components: record.components, - // Stamped here, not by callers: this is what the cross-device merge - // orders by, so it must be set on every write without exception. - editedAt: DateTime.now().toIso8601String(), - ); - await _writeManualBank(bank); - } - - /// Returns the hand-curated bank, for the sync layer. - Future> readManualBank() => _readManualBank(); - - /// Replaces the hand-curated bank with a merge result from the sync layer. - Future applyMergedManualBank(Map bank) => - _writeManualBank(bank); - - /// All known food records: log-derived entries merged with manually-added - /// ones, sorted by count descending. - /// - /// Log-derived records (from [readBank]) take precedence over manual records - /// with the same normalized name. - Future> mergedEntries() async { - final logBank = await readBank(); - final manualBank = await _readManualBank(); - final merged = {...manualBank, ...logBank}; - return merged.values.toList()..sort((a, b) => b.count.compareTo(a.count)); - } - - // --------------------------------------------------------------------------- - // Search - // --------------------------------------------------------------------------- - - /// Returns banked foods matching [query], best match first. - /// - /// An empty query returns the most-logged foods. Mirrors - /// `_foodbank.search_foods`. Searches both log-derived and manually-added - /// entries; log-derived entries win on name collision. - Future> search( - String query, { - int limit = defaultSuggestions, - }) async { - final logBank = await readBank(); - final manualBank = await _readManualBank(); - final bank = {...manualBank, ...logBank}; - final normalized = _normalize(query); - if (normalized.isEmpty) return _rankedAll(bank, limit); - - final scored = <(double score, double count, FoodSuggestion suggestion)>[]; - for (final mapEntry in bank.entries) { - final score = matchScore(normalized, mapEntry.key); - if (score < fuzzyThreshold) continue; - final record = mapEntry.value; - scored.add(( - score, - record.count, - FoodSuggestion( - name: _displayName(record, mapEntry.key), - nutrition: _recordToNutrition(record), - ), - )); - } - scored.sort((a, b) { - final byScore = b.$1.compareTo(a.$1); - return byScore != 0 ? byScore : b.$2.compareTo(a.$2); - }); - return scored.take(limit).map((s) => s.$3).toList(); - } - - List _rankedAll( - Map bank, - int limit, - ) { - final ranked = bank.entries.toList() - ..sort((a, b) => b.value.count.compareTo(a.value.count)); - return ranked - .take(limit) - .map( - (mapEntry) => FoodSuggestion( - name: _displayName(mapEntry.value, mapEntry.key), - nutrition: _recordToNutrition(mapEntry.value), - ), - ) - .toList(); - } } diff --git a/app/lib/services/foodbank_service_manual.dart b/app/lib/services/foodbank_service_manual.dart new file mode 100644 index 0000000..92b1ba8 --- /dev/null +++ b/app/lib/services/foodbank_service_manual.dart @@ -0,0 +1,121 @@ +// The curated ("manual") half of the food bank, plus ranked search across +// both halves. +// +// Split out of `foodbank_service.dart` for the repo's 250-line cap. It is a +// `part` rather than a separate library because these methods reach the +// service's private members (`_readBank`, `_writeBank`, `_normalize`, +// `_recordToNutrition`), which a `library`-private name makes invisible +// across a file boundary -- and widening them to public just to split a file +// would leak internals the rest of the app has no business calling. + +part of 'foodbank_service.dart'; + +/// The curated bank and cross-bank search, split out for the file cap. +extension FoodBankManual on FoodBankService { + // --------------------------------------------------------------------------- + // Manual bank (food items added directly without logging them as eaten) + // --------------------------------------------------------------------------- + + Future> _readManualBank() => + _readBank(FoodBankService.manualDocumentName); + + Future _writeManualBank(Map bank) => + _writeBank(FoodBankService.manualDocumentName, bank); + + /// Adds or updates [record] in the manually-curated bank without logging it + /// as eaten. A repeated call with the same normalized name overwrites the + /// previous entry. + Future addManualEntry(FoodBankRecord record) async { + final bank = await _readManualBank(); + bank[_normalize(record.desc)] = FoodBankRecord( + desc: record.desc, + kcal: record.kcal, + proteinG: record.proteinG, + carbsG: record.carbsG, + fatG: record.fatG, + grams: record.grams, + count: record.count, + components: record.components, + // Stamped here, not by callers: this is what the cross-device merge + // orders by, so it must be set on every write without exception. + editedAt: DateTime.now().toIso8601String(), + ); + await _writeManualBank(bank); + } + + /// Returns the hand-curated bank, for the sync layer. + Future> readManualBank() => _readManualBank(); + + /// Replaces the hand-curated bank with a merge result from the sync layer. + Future applyMergedManualBank(Map bank) => + _writeManualBank(bank); + + /// All known food records: log-derived entries merged with manually-added + /// ones, sorted by count descending. + /// + /// Log-derived records (from [readBank]) take precedence over manual records + /// with the same normalized name. + Future> mergedEntries() async { + final logBank = await readBank(); + final manualBank = await _readManualBank(); + final merged = {...manualBank, ...logBank}; + return merged.values.toList()..sort((a, b) => b.count.compareTo(a.count)); + } + + // --------------------------------------------------------------------------- + // Search + // --------------------------------------------------------------------------- + + /// Returns banked foods matching [query], best match first. + /// + /// An empty query returns the most-logged foods. Mirrors + /// `_foodbank.search_foods`. Searches both log-derived and manually-added + /// entries; log-derived entries win on name collision. + Future> search( + String query, { + int limit = defaultSuggestions, + }) async { + final logBank = await readBank(); + final manualBank = await _readManualBank(); + final bank = {...manualBank, ...logBank}; + final normalized = _normalize(query); + if (normalized.isEmpty) return _rankedAll(bank, limit); + + final scored = <(double score, double count, FoodSuggestion suggestion)>[]; + for (final mapEntry in bank.entries) { + final score = matchScore(normalized, mapEntry.key); + if (score < fuzzyThreshold) continue; + final record = mapEntry.value; + scored.add(( + score, + record.count, + FoodSuggestion( + name: _displayName(record, mapEntry.key), + nutrition: _recordToNutrition(record), + ), + )); + } + scored.sort((a, b) { + final byScore = b.$1.compareTo(a.$1); + return byScore != 0 ? byScore : b.$2.compareTo(a.$2); + }); + return scored.take(limit).map((s) => s.$3).toList(); + } + + List _rankedAll( + Map bank, + int limit, + ) { + final ranked = bank.entries.toList() + ..sort((a, b) => b.value.count.compareTo(a.value.count)); + return ranked + .take(limit) + .map( + (mapEntry) => FoodSuggestion( + name: _displayName(mapEntry.value, mapEntry.key), + nutrition: _recordToNutrition(mapEntry.value), + ), + ) + .toList(); + } +} diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 085d7b4..261a7e0 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -2,7 +2,7 @@ name: diet_guard_app description: "Companion phone app for diet_guard: log meals on the go." publish_to: 'none' -version: 1.0.26+1 +version: 1.0.27+1 environment: sdk: ^3.12.2 diff --git a/app/test/firebase_backend_google_test.dart b/app/test/firebase_backend_google_test.dart index 7ba859d..ca018b3 100644 --- a/app/test/firebase_backend_google_test.dart +++ b/app/test/firebase_backend_google_test.dart @@ -1,11 +1,11 @@ import 'dart:convert'; import 'package:crdt_sync/crdt_sync.dart'; +import 'package:diet_guard_app/services/firebase_client.dart'; +import 'package:diet_guard_app/services/google_sign_in_backend.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart' as http_testing; -import 'package:diet_guard_app/services/firebase_backend.dart'; -import 'package:diet_guard_app/services/google_sign_in_backend.dart'; import 'fake_secure_storage.dart'; diff --git a/app/test/services/sync_state_factory_test.dart b/app/test/services/sync_state_factory_test.dart index 7600947..2c625ca 100644 --- a/app/test/services/sync_state_factory_test.dart +++ b/app/test/services/sync_state_factory_test.dart @@ -10,7 +10,7 @@ library; import 'dart:io'; import 'package:crdt_sync/crdt_sync.dart'; -import 'package:diet_guard_app/services/firebase_backend.dart'; +import 'package:diet_guard_app/services/firebase_client.dart'; import 'package:diet_guard_app/services/sync_state_factory_io.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p;