diff --git a/crdt_sync_flutter/lib/crdt_sync_flutter.dart b/crdt_sync_flutter/lib/crdt_sync_flutter.dart index 7d6850e..9f0667f 100644 --- a/crdt_sync_flutter/lib/crdt_sync_flutter.dart +++ b/crdt_sync_flutter/lib/crdt_sync_flutter.dart @@ -15,6 +15,7 @@ export 'src/account_store.dart'; export 'src/bootstrap.dart'; export 'src/device_id.dart'; export 'src/e2e_bootstrap.dart'; +export 'src/e2e_passphrase_button.dart'; export 'src/google_any_sign_in.dart'; export 'src/google_desktop_sign_in.dart'; export 'src/google_pkce.dart'; diff --git a/crdt_sync_flutter/lib/src/e2e_passphrase_button.dart b/crdt_sync_flutter/lib/src/e2e_passphrase_button.dart new file mode 100644 index 0000000..ddc699f --- /dev/null +++ b/crdt_sync_flutter/lib/src/e2e_passphrase_button.dart @@ -0,0 +1,77 @@ +/// A text-free control to enter the shared crdt-sync passphrase: a lock icon +/// opens a hidden field; the check icon (or Enter) saves it to the keystore. +library; + +import 'package:crdt_sync_flutter/src/e2e_bootstrap.dart'; +import 'package:crdt_sync_flutter/src/keystore.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// Lock icon button; [locked] picks the closed or open lock glyph. +class E2ePassphraseButton extends StatelessWidget { + /// Creates the button. [onSaved] runs after the passphrase is stored. + const E2ePassphraseButton({ + required this.locked, + this.onSaved, + this.storage = kSecureStorage, + super.key, + }); + + /// Whether sync is currently locked (no or wrong passphrase). + final bool locked; + + /// Called after a passphrase is saved, e.g. to retry the sync. + final VoidCallback? onSaved; + + /// The keystore; only tests pass another. + final FlutterSecureStorage storage; + + Future _ask(BuildContext context) async { + final entered = await showDialog( + context: context, + builder: (_) => const _PassphraseDialog(), + ); + if (entered == null || entered.isEmpty) return; + await saveE2ePassphrase(entered, storage: storage); + onSaved?.call(); + } + + @override + Widget build(BuildContext context) => IconButton( + icon: Icon(locked ? Icons.lock : Icons.lock_open), + onPressed: () => _ask(context), + ); +} + +class _PassphraseDialog extends StatefulWidget { + const _PassphraseDialog(); + + @override + State<_PassphraseDialog> createState() => _PassphraseDialogState(); +} + +class _PassphraseDialogState extends State<_PassphraseDialog> { + final _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => AlertDialog( + content: TextField( + controller: _controller, + obscureText: true, + autofocus: true, + onSubmitted: (value) => Navigator.of(context).pop(value), + ), + actions: [ + IconButton( + icon: const Icon(Icons.check), + onPressed: () => Navigator.of(context).pop(_controller.text), + ), + ], + ); +} diff --git a/crdt_sync_flutter/pubspec.yaml b/crdt_sync_flutter/pubspec.yaml index 3afb9b3..483a9a9 100644 --- a/crdt_sync_flutter/pubspec.yaml +++ b/crdt_sync_flutter/pubspec.yaml @@ -4,7 +4,7 @@ description: >- persisted per-install device id, and a one-call bootstrap. Removes the ~150 lines of firebase_backend.dart every consuming app was copying. publish_to: 'none' -version: 0.6.1 +version: 0.7.0 environment: sdk: ^3.12.2 diff --git a/crdt_sync_flutter/test/e2e_bootstrap_test.dart b/crdt_sync_flutter/test/e2e_bootstrap_test.dart index e76ef33..6c9a185 100644 --- a/crdt_sync_flutter/test/e2e_bootstrap_test.dart +++ b/crdt_sync_flutter/test/e2e_bootstrap_test.dart @@ -88,7 +88,11 @@ void main() { final remote = _Remote(); await saveE2ePassphrase('pw', storage: storage); final first = - await openEncryptedStoreFromKeystore(store: remote, oldPrefix: _old, params: _cheap) + await openEncryptedStoreFromKeystore( + store: remote, + oldPrefix: _old, + params: _cheap, + ) as E2eReady; final layout = E2eLayout.of(_old); await first.store.putFileText( @@ -97,7 +101,11 @@ void main() { message: 'm', ); final second = - await openEncryptedStoreFromKeystore(store: remote, oldPrefix: _old, params: _cheap) + await openEncryptedStoreFromKeystore( + store: remote, + oldPrefix: _old, + params: _cheap, + ) as E2eReady; final text = await second.store.getFileText( '${layout.prefix}/phone/log.json', @@ -108,7 +116,11 @@ void main() { test('wrong passphrase is locked and merges nothing', () async { final remote = _Remote(); await saveE2ePassphrase('pw', storage: storage); - await openEncryptedStoreFromKeystore(store: remote, oldPrefix: _old, params: _cheap); + await openEncryptedStoreFromKeystore( + store: remote, + oldPrefix: _old, + params: _cheap, + ); await saveE2ePassphrase('nope', storage: storage); final state = await openEncryptedStoreFromKeystore( store: remote, diff --git a/crdt_sync_flutter/test/e2e_passphrase_button_test.dart b/crdt_sync_flutter/test/e2e_passphrase_button_test.dart new file mode 100644 index 0000000..2b6c313 --- /dev/null +++ b/crdt_sync_flutter/test/e2e_passphrase_button_test.dart @@ -0,0 +1,65 @@ +import 'package:crdt_sync_flutter/crdt_sync_flutter.dart'; +import 'package:crdt_sync_flutter/testing/fake_secure_storage.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(installFakeSecureStorage); + + Future pump(WidgetTester t, {required bool locked, VoidCallback? cb}) => + t.pumpWidget( + MaterialApp( + home: Scaffold( + body: E2ePassphraseButton(locked: locked, onSaved: cb), + ), + ), + ); + + testWidgets('icon follows the locked flag', (t) async { + await pump(t, locked: true); + expect(find.byIcon(Icons.lock), findsOneWidget); + await pump(t, locked: false); + expect(find.byIcon(Icons.lock_open), findsOneWidget); + }); + + testWidgets('entering a passphrase stores it and notifies', (t) async { + var saved = 0; + await pump(t, locked: true, cb: () => saved++); + await t.tap(find.byType(IconButton)); + await t.pumpAndSettle(); + await t.enterText(find.byType(TextField), 'pw'); + await t.tap(find.byIcon(Icons.check)); + await t.pumpAndSettle(); + expect( + await const FlutterSecureStorage().read(key: kE2ePassphraseKey), + 'pw', + ); + expect(saved, 1); + }); + + testWidgets('submitting with Enter saves; empty input saves nothing', ( + t, + ) async { + await pump(t, locked: true); + await t.tap(find.byType(IconButton)); + await t.pumpAndSettle(); + await t.enterText(find.byType(TextField), 'x1'); + await t.testTextInput.receiveAction(TextInputAction.done); + await t.pumpAndSettle(); + expect( + await const FlutterSecureStorage().read(key: kE2ePassphraseKey), + 'x1', + ); + await clearE2ePassphrase(); + await t.tap(find.byType(IconButton)); + await t.pumpAndSettle(); + await t.tap(find.byIcon(Icons.check)); + await t.pumpAndSettle(); + expect( + await const FlutterSecureStorage().read(key: kE2ePassphraseKey), + isNull, + ); + }); +}