diff --git a/README.md b/README.md index 96f4663..7e98814 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ kirakira began as [FlashMail](https://github.com/sarthakkimtani/flash-mail), a F - Pin a server's key, register or recall an address, then fetch and send sealed mail. - Trust-on-first-use for server and contact keys, with rotation support and on-screen warnings for anything unverified. - Export/import inbox, sent mail, contacts and server pins as one shareable backup file — never the seed. +- Share your address as a `smol://` QR code, and react to any `smol://` link a scanner or browser hands the device: its key lands in the confirm-first import dialog. Mail, contacts and pinned server keys live in fumi-core's SQLite store, bundled into the native library; the seed, read marks and settings stay app-side in Hive. @@ -37,7 +38,7 @@ devbox run test flutter run --flavor prod ``` -`test/e2e_test.dart` runs a live round-trip against the local bunshin on `127.0.0.1:1961` and skips itself when nothing's listening there. On-device flows are covered by the Patrol suite — `devbox run test-integration` builds the `integration` flavor (installed beside the real app, its data cleared between tests) and drives onboarding, registration, fetch cancellation, rotation and the unchanged-key refresh against the same server. +`test/e2e_test.dart` runs a live round-trip against the local bunshin on `127.0.0.1:1961` and skips itself when nothing's listening there. On-device flows are covered by the Patrol suite — `devbox run test-integration` builds the `integration` flavor (installed beside the real app, its data cleared between tests) and drives onboarding, registration, fetch cancellation, rotation and the unchanged-key refresh against the same server. To exercise the smol:// deep link by hand, scan `test-qr.png` — it encodes the `kirakira@localhost` identity — with any device camera and confirm the import dialog. ## License diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 3cd8ccf..b163da0 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -19,6 +19,15 @@ + + + + + + + { + late final AppRouter _router = AppRouter(ref); + final AppLinks _appLinks = AppLinks(); + StreamSubscription? _linkSub; + @override void initState() { super.initState(); @@ -37,6 +47,33 @@ class _AppWidgetState extends ConsumerState { ), ); }; + + _linkSub = _appLinks.uriLinkStream.listen(_handleLink); + } + + // A smol:// link is the self-certifying form: the §8 strong path, + // confirmed in the import dialog route before its key is bound. The push + // waits a frame — the stream can deliver the cold-start link while the + // first frame is still being laid out. + void _handleLink(Uri uri) { + final link = parseSmolLink(uri.toString()); + if (link == null) return; + if (ref.read(identityProvider) == null) { + rootScaffoldMessengerKey.currentState?.showSnackBar( + const SnackBar( + content: Text("finish onboarding before using smol:// links")), + ); + return; + } + WidgetsBinding.instance.addPostFrameCallback( + (_) => _router.push(ImportContactDialogRoute(prefill: link.source)), + ); + } + + @override + void dispose() { + _linkSub?.cancel(); + super.dispose(); } @override @@ -48,7 +85,16 @@ class _AppWidgetState extends ConsumerState { themeMode: ThemeMode.system, debugShowCheckedModeBanner: false, scaffoldMessengerKey: rootScaffoldMessengerKey, - routerConfig: AppRouter(ref).config(), + // smol:// URIs are for app_links, not the route table. The startup link + // must still resolve to a real route — auto_route throws "Can not + // resolve initial route" on an unmatched link and leaves a blank + // screen — so it lands on the normal entry route, whose guard + // redirects to home exactly like a launcher start. Every later + // platform link is ignored by the router; the stream handler reacts. + routerConfig: _router.config( + deepLinkBuilder: (link) => + link.initial ? DeepLink.single(const OnboardingRoute()) : DeepLink.none, + ), ); } } diff --git a/lib/presentation/routes/app_router.dart b/lib/presentation/routes/app_router.dart index 94dbe81..474f3a5 100644 --- a/lib/presentation/routes/app_router.dart +++ b/lib/presentation/routes/app_router.dart @@ -1,10 +1,25 @@ import "package:auto_route/auto_route.dart"; +import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:smol_mail/presentation/routes/home_guard.dart"; import "package:smol_mail/presentation/routes/identity_guard.dart"; import "app_router.gr.dart"; +/// A DialogRoute is a PageRoute whose settings carry the AutoRoutePage, so +/// auto_route's setNewRoutePath — which pops pageless top routes before +/// resolving a platform link — leaves dialog pages standing. +Route dialogRouteBuilder( + BuildContext context, + Widget child, + AutoRoutePage page, +) => + DialogRoute( + context: context, + settings: page, + builder: (ctx) => child, + ); + @AutoRouterConfig(replaceInRouteName: "Screen,Route") class AppRouter extends RootStackRouter { final WidgetRef ref; @@ -44,5 +59,11 @@ class AppRouter extends RootStackRouter { path: "/contact", guards: [IdentityGuard(ref)], ), + CustomRoute( + page: ImportContactDialogRoute.page, + path: "/import", + customRouteBuilder: dialogRouteBuilder, + guards: [IdentityGuard(ref)], + ), ]; } diff --git a/lib/presentation/routes/app_router.gr.dart b/lib/presentation/routes/app_router.gr.dart index 0a91d9a..2a7cec6 100644 --- a/lib/presentation/routes/app_router.gr.dart +++ b/lib/presentation/routes/app_router.gr.dart @@ -10,27 +10,29 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'package:auto_route/auto_route.dart' as _i9; -import 'package:flutter/material.dart' as _i10; +import 'package:auto_route/auto_route.dart' as _i10; +import 'package:flutter/material.dart' as _i11; import 'package:smol_mail/presentation/screens/compose_screen.dart' as _i1; import 'package:smol_mail/presentation/screens/contact_detail_screen.dart' as _i2; import 'package:smol_mail/presentation/screens/contacts_screen.dart' as _i3; import 'package:smol_mail/presentation/screens/home_screen.dart' as _i4; -import 'package:smol_mail/presentation/screens/inbox_screen.dart' as _i5; +import 'package:smol_mail/presentation/screens/import_contact_dialog.dart' + as _i5; +import 'package:smol_mail/presentation/screens/inbox_screen.dart' as _i6; import 'package:smol_mail/presentation/screens/message_detail_screen.dart' - as _i6; -import 'package:smol_mail/presentation/screens/onboarding_screen.dart' as _i7; -import 'package:smol_mail/presentation/screens/settings_screen.dart' as _i8; + as _i7; +import 'package:smol_mail/presentation/screens/onboarding_screen.dart' as _i8; +import 'package:smol_mail/presentation/screens/settings_screen.dart' as _i9; /// generated route for /// [_i1.ComposeScreen] -class ComposeRoute extends _i9.PageRouteInfo { +class ComposeRoute extends _i10.PageRouteInfo { ComposeRoute({ - _i10.Key? key, + _i11.Key? key, String? to, String? subject, - List<_i9.PageRouteInfo>? children, + List<_i10.PageRouteInfo>? children, }) : super( ComposeRoute.name, args: ComposeRouteArgs(key: key, to: to, subject: subject), @@ -39,7 +41,7 @@ class ComposeRoute extends _i9.PageRouteInfo { static const String name = 'ComposeRoute'; - static _i9.PageInfo page = _i9.PageInfo( + static _i10.PageInfo page = _i10.PageInfo( name, builder: (data) { final args = data.argsAs( @@ -57,7 +59,7 @@ class ComposeRoute extends _i9.PageRouteInfo { class ComposeRouteArgs { const ComposeRouteArgs({this.key, this.to, this.subject}); - final _i10.Key? key; + final _i11.Key? key; final String? to; @@ -81,11 +83,11 @@ class ComposeRouteArgs { /// generated route for /// [_i2.ContactDetailScreen] -class ContactDetailRoute extends _i9.PageRouteInfo { +class ContactDetailRoute extends _i10.PageRouteInfo { ContactDetailRoute({ - _i10.Key? key, + _i11.Key? key, required String address, - List<_i9.PageRouteInfo>? children, + List<_i10.PageRouteInfo>? children, }) : super( ContactDetailRoute.name, args: ContactDetailRouteArgs(key: key, address: address), @@ -94,7 +96,7 @@ class ContactDetailRoute extends _i9.PageRouteInfo { static const String name = 'ContactDetailRoute'; - static _i9.PageInfo page = _i9.PageInfo( + static _i10.PageInfo page = _i10.PageInfo( name, builder: (data) { final args = data.argsAs(); @@ -106,7 +108,7 @@ class ContactDetailRoute extends _i9.PageRouteInfo { class ContactDetailRouteArgs { const ContactDetailRouteArgs({this.key, required this.address}); - final _i10.Key? key; + final _i11.Key? key; final String address; @@ -128,13 +130,13 @@ class ContactDetailRouteArgs { /// generated route for /// [_i3.ContactsScreen] -class ContactsRoute extends _i9.PageRouteInfo { - const ContactsRoute({List<_i9.PageRouteInfo>? children}) +class ContactsRoute extends _i10.PageRouteInfo { + const ContactsRoute({List<_i10.PageRouteInfo>? children}) : super(ContactsRoute.name, initialChildren: children); static const String name = 'ContactsRoute'; - static _i9.PageInfo page = _i9.PageInfo( + static _i10.PageInfo page = _i10.PageInfo( name, builder: (data) { return const _i3.ContactsScreen(); @@ -144,13 +146,13 @@ class ContactsRoute extends _i9.PageRouteInfo { /// generated route for /// [_i4.HomeScreen] -class HomeRoute extends _i9.PageRouteInfo { - const HomeRoute({List<_i9.PageRouteInfo>? children}) +class HomeRoute extends _i10.PageRouteInfo { + const HomeRoute({List<_i10.PageRouteInfo>? children}) : super(HomeRoute.name, initialChildren: children); static const String name = 'HomeRoute'; - static _i9.PageInfo page = _i9.PageInfo( + static _i10.PageInfo page = _i10.PageInfo( name, builder: (data) { return const _i4.HomeScreen(); @@ -159,29 +161,82 @@ class HomeRoute extends _i9.PageRouteInfo { } /// generated route for -/// [_i5.InboxScreen] -class InboxRoute extends _i9.PageRouteInfo { - const InboxRoute({List<_i9.PageRouteInfo>? children}) +/// [_i5.ImportContactDialogScreen] +class ImportContactDialogRoute + extends _i10.PageRouteInfo { + ImportContactDialogRoute({ + _i11.Key? key, + String? prefill, + List<_i10.PageRouteInfo>? children, + }) : super( + ImportContactDialogRoute.name, + args: ImportContactDialogRouteArgs(key: key, prefill: prefill), + initialChildren: children, + ); + + static const String name = 'ImportContactDialogRoute'; + + static _i10.PageInfo page = _i10.PageInfo( + name, + builder: (data) { + final args = data.argsAs( + orElse: () => const ImportContactDialogRouteArgs(), + ); + return _i5.ImportContactDialogScreen( + key: args.key, + prefill: args.prefill, + ); + }, + ); +} + +class ImportContactDialogRouteArgs { + const ImportContactDialogRouteArgs({this.key, this.prefill}); + + final _i11.Key? key; + + final String? prefill; + + @override + String toString() { + return 'ImportContactDialogRouteArgs{key: $key, prefill: $prefill}'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! ImportContactDialogRouteArgs) return false; + return key == other.key && prefill == other.prefill; + } + + @override + int get hashCode => key.hashCode ^ prefill.hashCode; +} + +/// generated route for +/// [_i6.InboxScreen] +class InboxRoute extends _i10.PageRouteInfo { + const InboxRoute({List<_i10.PageRouteInfo>? children}) : super(InboxRoute.name, initialChildren: children); static const String name = 'InboxRoute'; - static _i9.PageInfo page = _i9.PageInfo( + static _i10.PageInfo page = _i10.PageInfo( name, builder: (data) { - return const _i5.InboxScreen(); + return const _i6.InboxScreen(); }, ); } /// generated route for -/// [_i6.MessageDetailScreen] -class MessageDetailRoute extends _i9.PageRouteInfo { +/// [_i7.MessageDetailScreen] +class MessageDetailRoute extends _i10.PageRouteInfo { MessageDetailRoute({ - _i10.Key? key, + _i11.Key? key, required String folder, required String id, - List<_i9.PageRouteInfo>? children, + List<_i10.PageRouteInfo>? children, }) : super( MessageDetailRoute.name, args: MessageDetailRouteArgs(key: key, folder: folder, id: id), @@ -190,11 +245,11 @@ class MessageDetailRoute extends _i9.PageRouteInfo { static const String name = 'MessageDetailRoute'; - static _i9.PageInfo page = _i9.PageInfo( + static _i10.PageInfo page = _i10.PageInfo( name, builder: (data) { final args = data.argsAs(); - return _i6.MessageDetailScreen( + return _i7.MessageDetailScreen( key: args.key, folder: args.folder, id: args.id, @@ -210,7 +265,7 @@ class MessageDetailRouteArgs { required this.id, }); - final _i10.Key? key; + final _i11.Key? key; final String folder; @@ -233,33 +288,33 @@ class MessageDetailRouteArgs { } /// generated route for -/// [_i7.OnboardingScreen] -class OnboardingRoute extends _i9.PageRouteInfo { - const OnboardingRoute({List<_i9.PageRouteInfo>? children}) +/// [_i8.OnboardingScreen] +class OnboardingRoute extends _i10.PageRouteInfo { + const OnboardingRoute({List<_i10.PageRouteInfo>? children}) : super(OnboardingRoute.name, initialChildren: children); static const String name = 'OnboardingRoute'; - static _i9.PageInfo page = _i9.PageInfo( + static _i10.PageInfo page = _i10.PageInfo( name, builder: (data) { - return const _i7.OnboardingScreen(); + return const _i8.OnboardingScreen(); }, ); } /// generated route for -/// [_i8.SettingsScreen] -class SettingsRoute extends _i9.PageRouteInfo { - const SettingsRoute({List<_i9.PageRouteInfo>? children}) +/// [_i9.SettingsScreen] +class SettingsRoute extends _i10.PageRouteInfo { + const SettingsRoute({List<_i10.PageRouteInfo>? children}) : super(SettingsRoute.name, initialChildren: children); static const String name = 'SettingsRoute'; - static _i9.PageInfo page = _i9.PageInfo( + static _i10.PageInfo page = _i10.PageInfo( name, builder: (data) { - return const _i8.SettingsScreen(); + return const _i9.SettingsScreen(); }, ); } diff --git a/lib/presentation/screens/contact_detail_screen.dart b/lib/presentation/screens/contact_detail_screen.dart index 27dc40e..8cb5ce6 100644 --- a/lib/presentation/screens/contact_detail_screen.dart +++ b/lib/presentation/screens/contact_detail_screen.dart @@ -6,6 +6,7 @@ import "package:smol_mail/data/providers/providers.dart"; import "package:smol_mail/shared/utils/format.dart"; import "package:smol_mail/shared/utils/snackbar.dart"; import "package:smol_mail/smol/address.dart"; +import "package:smol_mail/presentation/widgets/qr_code_sheet.dart"; import "package:smol_mail/smol/errors.dart"; import "package:smol_mail/smol/ui.dart"; @@ -88,6 +89,10 @@ class _ContactDetailScreenState extends ConsumerState { final contact = store.contact(widget.address); final accepted = store.accepted(widget.address); final isAccepted = accepted != null && accepted.active; + // Computed once per build; the branch below only renders it when a + // contact is bound to the address, which is when the parse succeeds. + final uri = + contact == null ? null : parseAddress(widget.address).uri(contact.key); return Scaffold( appBar: AppBar(title: Text(widget.address)), @@ -130,17 +135,16 @@ class _ContactDetailScreenState extends ConsumerState { Row( children: [ Expanded( - child: _field( - context, - "address", - parseAddress(widget.address) - .uri(contact.key)), + child: _field(context, "address", uri!), ), IconButton( icon: const Icon(Icons.copy, size: 18), - onPressed: () => copyText( - context, - parseAddress(widget.address).uri(contact.key)), + onPressed: () => copyText(context, uri), + ), + IconButton( + tooltip: "Show QR code", + icon: const Icon(Icons.qr_code, size: 18), + onPressed: () => showQrCodeSheet(context, uri), ), ], ), diff --git a/lib/presentation/screens/contacts_screen.dart b/lib/presentation/screens/contacts_screen.dart index 9a04b98..5fdaed9 100644 --- a/lib/presentation/screens/contacts_screen.dart +++ b/lib/presentation/screens/contacts_screen.dart @@ -5,8 +5,6 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:smol_mail/data/providers/providers.dart"; import "package:smol_mail/presentation/routes/app_router.gr.dart"; import "package:smol_mail/presentation/theme/app_colors.dart"; -import "package:smol_mail/shared/utils/snackbar.dart"; -import "package:smol_mail/smol/errors.dart"; import "package:smol_mail/smol/ui.dart"; /// Contacts: the addresses bound to keys, with the trust badge and the number @@ -15,45 +13,8 @@ import "package:smol_mail/smol/ui.dart"; class ContactsScreen extends ConsumerWidget { const ContactsScreen({super.key}); - Future _import(BuildContext context, WidgetRef ref) async { - final client = ref.read(clientProvider); - final controller = TextEditingController(); - final text = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: Theme.of(context).extension()!.cardFill, - title: const Text("Import contact"), - content: TextField( - controller: controller, - decoration: InputDecoration( - hintText: "smol://user@host/key", - filled: true, - fillColor: Theme.of(context).colorScheme.surface, - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(controller.text), - child: const Text("Import"), - ), - ], - ), - ); - if (text == null || text.trim().isEmpty) return; - try { - client.importContact(text); - ref.read(revisionProvider.notifier).bump(); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text("contact imported (verified key)")), - ); - } - } on SmolError catch (err) { - if (context.mounted) { - showErrorSnackBar(context, err.message); - } - } - } + void _import(BuildContext context) => + AutoRouter.of(context).push(ImportContactDialogRoute()); @override Widget build(BuildContext context, WidgetRef ref) { @@ -65,7 +26,7 @@ class ContactsScreen extends ConsumerWidget { actions: [ IconButton( tooltip: "Import smol:// address", - onPressed: () => _import(context, ref), + onPressed: () => _import(context), icon: const Icon(Icons.person_add), ), ], diff --git a/lib/presentation/screens/import_contact_dialog.dart b/lib/presentation/screens/import_contact_dialog.dart new file mode 100644 index 0000000..8b7927b --- /dev/null +++ b/lib/presentation/screens/import_contact_dialog.dart @@ -0,0 +1,131 @@ +import "package:auto_route/auto_route.dart"; +import "package:flutter/material.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:mobile_scanner/mobile_scanner.dart"; + +import "package:smol_mail/data/providers/providers.dart"; +import "package:smol_mail/presentation/app_widget.dart"; +import "package:smol_mail/presentation/theme/app_colors.dart"; +import "package:smol_mail/shared/utils/snackbar.dart"; +import "package:smol_mail/smol/errors.dart"; +import "package:smol_mail/smol/link.dart"; + +/// The §8 strong path: a self-certifying smol:// address is pasted, scanned +/// or deep-linked in, and the user confirms before its key is bound. A +/// routed dialog page rather than showDialog, so auto_route's platform-link +/// handling — which pops pageless top routes — leaves it standing. The QR +/// icon swaps the text field for a camera view; a scanned URI only fills +/// the field, the Import tap is still what binds the key. +@RoutePage() +class ImportContactDialogScreen extends ConsumerStatefulWidget { + final String? prefill; + + const ImportContactDialogScreen({super.key, this.prefill}); + + @override + ConsumerState createState() => + ImportContactDialogScreenState(); +} + +class ImportContactDialogScreenState + extends ConsumerState { + late final controller = TextEditingController(text: widget.prefill ?? ""); + bool scanning = false; + MobileScannerController? _scanner; + + @override + void dispose() { + _scanner?.dispose(); + controller.dispose(); + super.dispose(); + } + + void _toggleScan() { + setState(() { + _scanner?.dispose(); + _scanner = + scanning ? null : MobileScannerController(formats: [BarcodeFormat.qrCode]); + scanning = !scanning; + }); + } + + // Only a URI fumi accepts fills the field; anything else the camera sees + // is not for this dialog, so scanning simply continues. + void _onDetect(BarcodeCapture capture) { + if (!scanning) return; + for (final barcode in capture.barcodes) { + final raw = barcode.rawValue?.trim(); + if (raw != null && parseSmolLink(raw) != null) { + controller.text = raw; + _toggleScan(); + return; + } + } + } + + void _import() { + final text = controller.text; + if (text.trim().isEmpty) return; + try { + ref.read(clientProvider).importContact(text); + ref.read(revisionProvider.notifier).bump(); + rootScaffoldMessengerKey.currentState?.showSnackBar( + const SnackBar(content: Text("contact imported (verified key)")), + ); + AutoRouter.of(context).pop(); + } on SmolError catch (err) { + showErrorSnackBar(context, err.message); + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + backgroundColor: Theme.of(context).extension()!.cardFill, + title: const Text("Import contact"), + content: scanning + ? SizedBox( + height: 220, + width: double.maxFinite, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: MobileScanner( + controller: _scanner!, + onDetect: _onDetect, + errorBuilder: (context, error) => Center( + child: Padding( + padding: const EdgeInsets.all(12), + child: Text( + error.errorCode.name, + style: Theme.of(context).textTheme.labelSmall, + textAlign: TextAlign.center, + ), + ), + ), + ), + ), + ) + : TextField( + controller: controller, + autofocus: true, + onSubmitted: (_) => _import(), + decoration: InputDecoration( + hintText: "smol://user@host/key", + filled: true, + fillColor: Theme.of(context).colorScheme.surface, + ), + ), + actions: [ + IconButton( + tooltip: scanning ? "Type the address" : "Scan a QR code", + onPressed: _toggleScan, + icon: Icon(scanning ? Icons.keyboard : Icons.qr_code_scanner), + ), + TextButton( + onPressed: _import, + child: const Text("Import"), + ), + ], + ); + } +} diff --git a/lib/presentation/screens/inbox_screen.dart b/lib/presentation/screens/inbox_screen.dart index 30b4c62..62020f3 100644 --- a/lib/presentation/screens/inbox_screen.dart +++ b/lib/presentation/screens/inbox_screen.dart @@ -6,6 +6,7 @@ import "package:smol_mail/data/providers/providers.dart"; import "package:smol_mail/presentation/routes/app_router.gr.dart"; import "package:smol_mail/presentation/widgets/image_banner.dart"; import "package:smol_mail/presentation/widgets/message/message_list.dart"; +import "package:smol_mail/presentation/widgets/qr_code_sheet.dart"; import "package:smol_mail/shared/utils/snackbar.dart"; import "package:smol_mail/smol/errors.dart"; @@ -90,11 +91,24 @@ class _InboxScreenState extends ConsumerState final unread = ref.watch(unreadProvider); final requestsUnread = ref.watch(requestsUnreadProvider); final canFetch = _folders[_tabController.index] != "sent"; + // The same self-certifying URI the settings share row shows, one tap + // closer: what a second device scans to import this identity. + final me = ref.watch(identityProvider); + final account = ref.watch(accountProvider); + final share = (me == null || account == null) + ? null + : account.uri(me.publicKey); return Scaffold( appBar: AppBar( title: const Text("kirakira"), actions: [ + if (share != null) + IconButton( + tooltip: "Show QR code", + onPressed: () => showQrCodeSheet(context, share), + icon: const Icon(Icons.qr_code), + ), if (canFetch) IconButton( tooltip: "Fetch", diff --git a/lib/presentation/screens/settings_screen.dart b/lib/presentation/screens/settings_screen.dart index 70a0f6b..999938f 100644 --- a/lib/presentation/screens/settings_screen.dart +++ b/lib/presentation/screens/settings_screen.dart @@ -11,6 +11,7 @@ import "package:share_plus/share_plus.dart"; import "package:smol_mail/data/providers/providers.dart"; import "package:smol_mail/presentation/routes/app_router.gr.dart"; import "package:smol_mail/presentation/theme/app_colors.dart"; +import "package:smol_mail/presentation/widgets/qr_code_sheet.dart"; import "package:smol_mail/shared/utils/snackbar.dart"; import "package:smol_mail/smol/address.dart"; import "package:smol_mail/smol/errors.dart"; @@ -236,12 +237,18 @@ class _SettingsScreenState extends ConsumerState { overflow: TextOverflow.ellipsis, ), ), - if (share != null) + if (share != null) ...[ IconButton( icon: const Icon(Icons.copy, size: 18), onPressed: () => Clipboard.setData(ClipboardData(text: share)), ), + IconButton( + tooltip: "Show QR code", + icon: const Icon(Icons.qr_code, size: 18), + onPressed: () => showQrCodeSheet(context, share), + ), + ], ], ), ], diff --git a/lib/presentation/widgets/qr_code_sheet.dart b/lib/presentation/widgets/qr_code_sheet.dart new file mode 100644 index 0000000..6527270 --- /dev/null +++ b/lib/presentation/widgets/qr_code_sheet.dart @@ -0,0 +1,52 @@ +import "package:flutter/material.dart"; +import "package:qr_flutter/qr_flutter.dart"; + +import "package:smol_mail/shared/utils/format.dart"; + +/// Shows [payload] as a scannable QR code in a bottom sheet. Fixed dark +/// modules on white regardless of theme: contrast is what a scanner needs, +/// not the palette. Scrolled, because the sheet is height-capped on small +/// screens. +Future showQrCodeSheet(BuildContext context, String payload) { + return showModalBottomSheet( + context: context, + builder: (ctx) => SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(12), + color: Colors.white, + child: QrImageView( + data: payload, + version: QrVersions.auto, + size: 260, + backgroundColor: Colors.white, + eyeStyle: const QrEyeStyle( + eyeShape: QrEyeShape.square, + color: Color.fromRGBO(1, 17, 29, 1), + ), + dataModuleStyle: const QrDataModuleStyle( + dataModuleShape: QrDataModuleShape.square, + color: Color.fromRGBO(1, 17, 29, 1), + ), + ), + ), + const SizedBox(height: 15), + SelectableText( + payload, + style: Theme.of(ctx).textTheme.bodySmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 10), + TextButton.icon( + onPressed: () => copyText(ctx, payload), + icon: const Icon(Icons.copy, size: 18), + label: const Text("Copy"), + ), + ], + ), + ), + ); +} diff --git a/lib/smol/link.dart b/lib/smol/link.dart new file mode 100644 index 0000000..6a493fe --- /dev/null +++ b/lib/smol/link.dart @@ -0,0 +1,27 @@ +// Deep links (SPEC.md §3): the OS hands the app a URI a QR scanner or a +// browser produced. Only the smol scheme is ours to interpret, and the +// native parser decides validity — fumi rejects a smol:// URI without its +// key, so every link that parses is the self-certifying form. + +import "package:smol_mail/smol/address.dart"; +import "package:smol_mail/smol/errors.dart"; + +class SmolLink { + final SmolAddress address; + final String source; + + const SmolLink._(this.address, this.source); +} + +/// Parses a smol:// URI. Returns null for other schemes and for anything +/// the native parser rejects — those are not silent fixes, they are not +/// for this app to act on. +SmolLink? parseSmolLink(String uri) { + final text = uri.trim(); + if (!text.startsWith("smol://")) return null; + try { + return SmolLink._(parseAddress(text), text); + } on SmolError { + return null; + } +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index f6f23bf..3792af4 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,9 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) gtk_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); + gtk_plugin_register_with_registrar(gtk_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index df8d2f7..21d8f8b 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + gtk url_launcher_linux ) diff --git a/pubspec.lock b/pubspec.lock index b7c28cf..f7c6b29 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -33,6 +33,38 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" + app_links: + dependency: "direct main" + description: + name: app_links + sha256: "3462d9defc61565fde4944858b59bec5be2b9d5b05f20aed190adb3ad08a7abc" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: "direct dev" + description: + name: app_links_platform_interface + sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" archive: dependency: transitive description: @@ -426,6 +458,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + gtk: + dependency: transitive + description: + name: gtk + sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5" + url: "https://pub.dev" + source: hosted + version: "2.2.0" hive: dependency: "direct main" description: @@ -631,6 +671,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + mobile_scanner: + dependency: "direct main" + description: + name: mobile_scanner + sha256: "5a51ea79f0cf8293f616a5d08baae185b59b5e6bb24df01f2a63037ade0bade0" + url: "https://pub.dev" + source: hosted + version: "7.4.2" node_preamble: dependency: transitive description: @@ -799,6 +847,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.6.0" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + qr_flutter: + dependency: "direct main" + description: + name: qr_flutter + sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097" + url: "https://pub.dev" + source: hosted + version: "4.1.0" record_use: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 73768aa..f9999be 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,6 +21,9 @@ dependencies: share_plus: ^13.3.0 path_provider: ^2.1.6 file_picker: ^13.1.0 + qr_flutter: ^4.1.0 + app_links: ^7.0.0 + mobile_scanner: ^7.4.2 dev_dependencies: flutter_test: @@ -33,6 +36,7 @@ dev_dependencies: auto_route_generator: ^10.5.0 flutter_native_splash: ^2.3.4 flutter_launcher_icons: ^0.14.4 + app_links_platform_interface: ^2.0.2 # patrol test reads this block; android.package_name is the flavored # applicationId (the integration flavor adds the .integration suffix), and @@ -59,8 +63,13 @@ flutter_native_splash: web: false ios: false color: "#01111d" - image: assets/images/sparkle.png - branding: assets/images/sparkle.png + image: assets/images/bug_logo.png + branding: assets/images/bug_logo.png + android_12: + color: "#01111d" + icon_background_color: "#01111d" + image: assets/images/bug_logo.png + branding: assets/images/bug_logo.png flutter: uses-material-design: true @@ -68,7 +77,6 @@ flutter: - assets/images/bug_logo.png - assets/images/empty.png - assets/images/error.png - - assets/images/avatar.png fonts: - family: FlashMailIcons fonts: diff --git a/test-qr.png b/test-qr.png new file mode 100644 index 0000000..61269e0 Binary files /dev/null and b/test-qr.png differ diff --git a/test/link_test.dart b/test/link_test.dart new file mode 100644 index 0000000..f8a59f8 --- /dev/null +++ b/test/link_test.dart @@ -0,0 +1,45 @@ +// Deep links: only smol:// URIs react, and only the native parser's verdict +// decides that — the format rules stay in fumi, not a Dart copy of them. + +import "package:flutter_test/flutter_test.dart"; + +import "package:smol_mail/smol/link.dart"; + +const key = "lm2gqd7e5q67xq3isc5hx6jfj2q7a7xvq4l7trctxipudujovmgq"; + +void main() { + test("self-certifying URI carries its key", () { + final link = parseSmolLink("smol://alice@example.org/$key"); + expect(link, isNotNull); + expect(link!.address.identity, key); + expect(link.address.user, "alice"); + expect(link.address.host, "example.org"); + expect(link.address.port, 1961); + expect(link.source, "smol://alice@example.org/$key"); + }); + + test("a non-default port is kept", () { + final link = parseSmolLink("smol://alice@example.org:1962/$key"); + expect(link!.address.port, 1962); + }); + + test("a smol:// URI without its key is not a link", () { + // fumi rejects this form, so a truncated scan acts on nothing rather + // than importing a key the sender never signed into. + expect(parseSmolLink("smol://alice@example.org"), isNull); + }); + + test("other schemes are not ours", () { + expect(parseSmolLink("https://example.org/a"), isNull); + expect(parseSmolLink("mailto:alice@example.org"), isNull); + }); + + test("scheme-less addresses are not deep links", () { + expect(parseSmolLink("alice@example.org"), isNull); + }); + + test("a smol:// URI the parser rejects is ignored", () { + expect(parseSmolLink("smol://not a uri"), isNull); + expect(parseSmolLink("smol://alice@example.org/tooshort"), isNull); + }); +} diff --git a/test/qr_sheet_test.dart b/test/qr_sheet_test.dart new file mode 100644 index 0000000..f1e5309 --- /dev/null +++ b/test/qr_sheet_test.dart @@ -0,0 +1,28 @@ +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:qr_flutter/qr_flutter.dart"; + +import "package:smol_mail/presentation/theme/theme.dart"; +import "package:smol_mail/presentation/widgets/qr_code_sheet.dart"; + +void main() { + testWidgets("the sheet renders the payload as a scannable QR code", + (tester) async { + const payload = "smol://alice@example.org/lm2gqd7e5q67xq3isc5hx6jfj2q7a7xvq4l7trctxipudujovmgq"; + await tester.pumpWidget( + MaterialApp( + theme: appThemeLight, + home: Builder( + builder: (context) => TextButton( + onPressed: () => showQrCodeSheet(context, payload), + child: const Text("show"), + ), + ), + ), + ); + await tester.tap(find.text("show")); + await tester.pumpAndSettle(); + expect(find.byType(QrImageView), findsOneWidget); + expect(find.text(payload), findsOneWidget); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index e3689ba..bf33a97 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,5 +1,6 @@ import "dart:io"; +import "package:app_links_platform_interface/app_links_platform_interface.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_test/flutter_test.dart"; import "package:hive_flutter/hive_flutter.dart"; @@ -10,11 +11,22 @@ import "package:smol_mail/smol/store.dart"; late final SmolStore store; +// AppWidget subscribes to app_links on boot; in the test shell there is no +// platform plugin to talk to, so the subscription points at this no-op. +class _NoopAppLinks extends AppLinksPlatform { + @override + Future getInitialLink() async => null; + + @override + Stream get uriLinkStream => const Stream.empty(); +} + void main() { // Hive's box opening is real file IO, which never completes inside a // testWidgets fake-async zone — so it happens here, outside one. setUpAll(() async { TestWidgetsFlutterBinding.ensureInitialized(); + AppLinksPlatform.instance = _NoopAppLinks(); final dir = await Directory.systemTemp.createTemp("smol-widget-test"); Hive.init(dir.path); store = await SmolStore.open(dbPath: "${dir.path}/widget.db");