diff --git a/README.md b/README.md index 7e98814..96f4663 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,6 @@ 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. @@ -38,7 +37,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. 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. +`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. ## License diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b163da0..3cd8ccf 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -19,15 +19,6 @@ - - - - - - - rotations() => _io((ffi) => ffi.rotations(_s)); - Future pinServer(String host, String keyB32, int port) => - _io((ffi) => ffi.pinServer(_s, host, keyB32, port)); + Future pinServer(String host, String keyB32) => + _io((ffi) => ffi.pinServer(_s, host, keyB32)); - Future serverPin(String host, int port) => - _io((ffi) => ffi.serverPin(_s, host, port)); + Future serverPin(String host) => + _io((ffi) => ffi.serverPin(_s, host)); - Future unpinServer(String host, int port) => - _io((ffi) => ffi.unpinServer(_s, host, port)); + Future unpinServer(String host) => + _io((ffi) => ffi.unpinServer(_s, host)); Future> pins() => _io((ffi) => ffi.pins(_s)); diff --git a/lib/native/ffi.dart b/lib/native/ffi.dart index 016943b..32a7d69 100644 --- a/lib/native/ffi.dart +++ b/lib/native/ffi.dart @@ -109,15 +109,15 @@ class SmolFfi { Void Function(Pointer), void Function(Pointer)>("smol_flag_free"); - late final int Function(Pointer, Pointer, Pointer, int) + late final int Function(Pointer, Pointer, Pointer) _pinServer = _lib.lookupFunction< - Int32 Function(Pointer, Pointer, Pointer, Uint16), - int Function(Pointer, Pointer, Pointer, - int)>("smol_pin_server"); - late final Pointer Function(Pointer, Pointer, int) + Int32 Function(Pointer, Pointer, Pointer), + int Function( + Pointer, Pointer, Pointer)>("smol_pin_server"); + late final Pointer Function(Pointer, Pointer) _serverPin = _lib.lookupFunction< - Pointer Function(Pointer, Pointer, Uint16), - Pointer Function(Pointer, Pointer, int)>( + Pointer Function(Pointer, Pointer), + Pointer Function(Pointer, Pointer)>( "smol_server_pin"); late final Pointer Function(Pointer) _storeAccount = _lib .lookupFunction Function(Pointer), @@ -226,11 +226,9 @@ class SmolFfi { Pointer, Pointer, Pointer, Int32), int Function(Pointer, Pointer, Pointer, int)>("smol_contact_save"); - late final int Function(Pointer, Pointer, int) _unpinServer = - _lib.lookupFunction< - Int32 Function(Pointer, Pointer, Uint16), - int Function( - Pointer, Pointer, int)>("smol_unpin_server"); + late final int Function(Pointer, Pointer) _unpinServer = _lib + .lookupFunction, Pointer), + int Function(Pointer, Pointer)>("smol_unpin_server"); late final Pointer Function(Pointer) _pins = _lib.lookupFunction< Pointer Function(Pointer), Pointer Function(Pointer)>("smol_pins"); @@ -336,14 +334,12 @@ class SmolFfi { void freeFlag(int flag) => _flagFree(_h(flag)); - void pinServer(int store, String host, String keyB32, int port) { - if (_pinServer(_h(store), _text(host), _text(keyB32), port) != smolOk) { - _fail(); - } + void pinServer(int store, String host, String keyB32) { + if (_pinServer(_h(store), _text(host), _text(keyB32)) != smolOk) _fail(); } - void unpinServer(int store, String host, int port) { - if (_unpinServer(_h(store), _text(host), port) != smolOk) _fail(); + void unpinServer(int store, String host) { + if (_unpinServer(_h(store), _text(host)) != smolOk) _fail(); } /// Every pin: [{host, key}] with keys as base32. @@ -359,8 +355,8 @@ class SmolFfi { _h(store), _text(address), _text(keyB32), verified ? 1 : 0); /// Null when nothing is pinned. - String? serverPin(int store, String host, int port) { - final text = take(_serverPin(_h(store), _text(host), port)); + String? serverPin(int store, String host) { + final text = take(_serverPin(_h(store), _text(host))); return text.isEmpty ? null : text; } diff --git a/lib/presentation/app_widget.dart b/lib/presentation/app_widget.dart index a51fe97..4526baf 100644 --- a/lib/presentation/app_widget.dart +++ b/lib/presentation/app_widget.dart @@ -1,15 +1,9 @@ -import "dart:async"; - -import "package:app_links/app_links.dart"; -import "package:auto_route/auto_route.dart"; import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:smol_mail/data/providers/providers.dart"; -import "package:smol_mail/presentation/routes/app_router.dart"; -import "package:smol_mail/presentation/routes/app_router.gr.dart"; import "package:smol_mail/presentation/theme/theme.dart"; -import "package:smol_mail/smol/link.dart"; +import "package:smol_mail/presentation/routes/app_router.dart"; /// SnackBars need no context through this key, so the client's trust /// warnings can be shown from anywhere in the protocol stack. @@ -23,10 +17,6 @@ class AppWidget extends ConsumerStatefulWidget { } class _AppWidgetState extends ConsumerState { - late final AppRouter _router = AppRouter(ref); - final AppLinks _appLinks = AppLinks(); - StreamSubscription? _linkSub; - @override void initState() { super.initState(); @@ -47,33 +37,6 @@ 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 @@ -85,16 +48,7 @@ class _AppWidgetState extends ConsumerState { themeMode: ThemeMode.system, debugShowCheckedModeBanner: false, scaffoldMessengerKey: rootScaffoldMessengerKey, - // 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, - ), + routerConfig: AppRouter(ref).config(), ); } } diff --git a/lib/presentation/routes/app_router.dart b/lib/presentation/routes/app_router.dart index 474f3a5..94dbe81 100644 --- a/lib/presentation/routes/app_router.dart +++ b/lib/presentation/routes/app_router.dart @@ -1,25 +1,10 @@ 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; @@ -59,11 +44,5 @@ 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 2a7cec6..0a91d9a 100644 --- a/lib/presentation/routes/app_router.gr.dart +++ b/lib/presentation/routes/app_router.gr.dart @@ -10,29 +10,27 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'package:auto_route/auto_route.dart' as _i10; -import 'package:flutter/material.dart' as _i11; +import 'package:auto_route/auto_route.dart' as _i9; +import 'package:flutter/material.dart' as _i10; 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/import_contact_dialog.dart' - as _i5; -import 'package:smol_mail/presentation/screens/inbox_screen.dart' as _i6; +import 'package:smol_mail/presentation/screens/inbox_screen.dart' as _i5; import 'package:smol_mail/presentation/screens/message_detail_screen.dart' - as _i7; -import 'package:smol_mail/presentation/screens/onboarding_screen.dart' as _i8; -import 'package:smol_mail/presentation/screens/settings_screen.dart' as _i9; + as _i6; +import 'package:smol_mail/presentation/screens/onboarding_screen.dart' as _i7; +import 'package:smol_mail/presentation/screens/settings_screen.dart' as _i8; /// generated route for /// [_i1.ComposeScreen] -class ComposeRoute extends _i10.PageRouteInfo { +class ComposeRoute extends _i9.PageRouteInfo { ComposeRoute({ - _i11.Key? key, + _i10.Key? key, String? to, String? subject, - List<_i10.PageRouteInfo>? children, + List<_i9.PageRouteInfo>? children, }) : super( ComposeRoute.name, args: ComposeRouteArgs(key: key, to: to, subject: subject), @@ -41,7 +39,7 @@ class ComposeRoute extends _i10.PageRouteInfo { static const String name = 'ComposeRoute'; - static _i10.PageInfo page = _i10.PageInfo( + static _i9.PageInfo page = _i9.PageInfo( name, builder: (data) { final args = data.argsAs( @@ -59,7 +57,7 @@ class ComposeRoute extends _i10.PageRouteInfo { class ComposeRouteArgs { const ComposeRouteArgs({this.key, this.to, this.subject}); - final _i11.Key? key; + final _i10.Key? key; final String? to; @@ -83,11 +81,11 @@ class ComposeRouteArgs { /// generated route for /// [_i2.ContactDetailScreen] -class ContactDetailRoute extends _i10.PageRouteInfo { +class ContactDetailRoute extends _i9.PageRouteInfo { ContactDetailRoute({ - _i11.Key? key, + _i10.Key? key, required String address, - List<_i10.PageRouteInfo>? children, + List<_i9.PageRouteInfo>? children, }) : super( ContactDetailRoute.name, args: ContactDetailRouteArgs(key: key, address: address), @@ -96,7 +94,7 @@ class ContactDetailRoute extends _i10.PageRouteInfo { static const String name = 'ContactDetailRoute'; - static _i10.PageInfo page = _i10.PageInfo( + static _i9.PageInfo page = _i9.PageInfo( name, builder: (data) { final args = data.argsAs(); @@ -108,7 +106,7 @@ class ContactDetailRoute extends _i10.PageRouteInfo { class ContactDetailRouteArgs { const ContactDetailRouteArgs({this.key, required this.address}); - final _i11.Key? key; + final _i10.Key? key; final String address; @@ -130,13 +128,13 @@ class ContactDetailRouteArgs { /// generated route for /// [_i3.ContactsScreen] -class ContactsRoute extends _i10.PageRouteInfo { - const ContactsRoute({List<_i10.PageRouteInfo>? children}) +class ContactsRoute extends _i9.PageRouteInfo { + const ContactsRoute({List<_i9.PageRouteInfo>? children}) : super(ContactsRoute.name, initialChildren: children); static const String name = 'ContactsRoute'; - static _i10.PageInfo page = _i10.PageInfo( + static _i9.PageInfo page = _i9.PageInfo( name, builder: (data) { return const _i3.ContactsScreen(); @@ -146,13 +144,13 @@ class ContactsRoute extends _i10.PageRouteInfo { /// generated route for /// [_i4.HomeScreen] -class HomeRoute extends _i10.PageRouteInfo { - const HomeRoute({List<_i10.PageRouteInfo>? children}) +class HomeRoute extends _i9.PageRouteInfo { + const HomeRoute({List<_i9.PageRouteInfo>? children}) : super(HomeRoute.name, initialChildren: children); static const String name = 'HomeRoute'; - static _i10.PageInfo page = _i10.PageInfo( + static _i9.PageInfo page = _i9.PageInfo( name, builder: (data) { return const _i4.HomeScreen(); @@ -161,82 +159,29 @@ class HomeRoute extends _i10.PageRouteInfo { } /// generated route for -/// [_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}) +/// [_i5.InboxScreen] +class InboxRoute extends _i9.PageRouteInfo { + const InboxRoute({List<_i9.PageRouteInfo>? children}) : super(InboxRoute.name, initialChildren: children); static const String name = 'InboxRoute'; - static _i10.PageInfo page = _i10.PageInfo( + static _i9.PageInfo page = _i9.PageInfo( name, builder: (data) { - return const _i6.InboxScreen(); + return const _i5.InboxScreen(); }, ); } /// generated route for -/// [_i7.MessageDetailScreen] -class MessageDetailRoute extends _i10.PageRouteInfo { +/// [_i6.MessageDetailScreen] +class MessageDetailRoute extends _i9.PageRouteInfo { MessageDetailRoute({ - _i11.Key? key, + _i10.Key? key, required String folder, required String id, - List<_i10.PageRouteInfo>? children, + List<_i9.PageRouteInfo>? children, }) : super( MessageDetailRoute.name, args: MessageDetailRouteArgs(key: key, folder: folder, id: id), @@ -245,11 +190,11 @@ class MessageDetailRoute extends _i10.PageRouteInfo { static const String name = 'MessageDetailRoute'; - static _i10.PageInfo page = _i10.PageInfo( + static _i9.PageInfo page = _i9.PageInfo( name, builder: (data) { final args = data.argsAs(); - return _i7.MessageDetailScreen( + return _i6.MessageDetailScreen( key: args.key, folder: args.folder, id: args.id, @@ -265,7 +210,7 @@ class MessageDetailRouteArgs { required this.id, }); - final _i11.Key? key; + final _i10.Key? key; final String folder; @@ -288,33 +233,33 @@ class MessageDetailRouteArgs { } /// generated route for -/// [_i8.OnboardingScreen] -class OnboardingRoute extends _i10.PageRouteInfo { - const OnboardingRoute({List<_i10.PageRouteInfo>? children}) +/// [_i7.OnboardingScreen] +class OnboardingRoute extends _i9.PageRouteInfo { + const OnboardingRoute({List<_i9.PageRouteInfo>? children}) : super(OnboardingRoute.name, initialChildren: children); static const String name = 'OnboardingRoute'; - static _i10.PageInfo page = _i10.PageInfo( + static _i9.PageInfo page = _i9.PageInfo( name, builder: (data) { - return const _i8.OnboardingScreen(); + return const _i7.OnboardingScreen(); }, ); } /// generated route for -/// [_i9.SettingsScreen] -class SettingsRoute extends _i10.PageRouteInfo { - const SettingsRoute({List<_i10.PageRouteInfo>? children}) +/// [_i8.SettingsScreen] +class SettingsRoute extends _i9.PageRouteInfo { + const SettingsRoute({List<_i9.PageRouteInfo>? children}) : super(SettingsRoute.name, initialChildren: children); static const String name = 'SettingsRoute'; - static _i10.PageInfo page = _i10.PageInfo( + static _i9.PageInfo page = _i9.PageInfo( name, builder: (data) { - return const _i9.SettingsScreen(); + return const _i8.SettingsScreen(); }, ); } diff --git a/lib/presentation/screens/contact_detail_screen.dart b/lib/presentation/screens/contact_detail_screen.dart index 8cb5ce6..27dc40e 100644 --- a/lib/presentation/screens/contact_detail_screen.dart +++ b/lib/presentation/screens/contact_detail_screen.dart @@ -6,7 +6,6 @@ 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"; @@ -89,10 +88,6 @@ 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)), @@ -135,16 +130,17 @@ class _ContactDetailScreenState extends ConsumerState { Row( children: [ Expanded( - child: _field(context, "address", uri!), + child: _field( + context, + "address", + parseAddress(widget.address) + .uri(contact.key)), ), IconButton( icon: const Icon(Icons.copy, size: 18), - onPressed: () => copyText(context, uri), - ), - IconButton( - tooltip: "Show QR code", - icon: const Icon(Icons.qr_code, size: 18), - onPressed: () => showQrCodeSheet(context, uri), + onPressed: () => copyText( + context, + parseAddress(widget.address).uri(contact.key)), ), ], ), diff --git a/lib/presentation/screens/contacts_screen.dart b/lib/presentation/screens/contacts_screen.dart index 5fdaed9..9a04b98 100644 --- a/lib/presentation/screens/contacts_screen.dart +++ b/lib/presentation/screens/contacts_screen.dart @@ -5,6 +5,8 @@ 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 @@ -13,8 +15,45 @@ import "package:smol_mail/smol/ui.dart"; class ContactsScreen extends ConsumerWidget { const ContactsScreen({super.key}); - void _import(BuildContext context) => - AutoRouter.of(context).push(ImportContactDialogRoute()); + 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); + } + } + } @override Widget build(BuildContext context, WidgetRef ref) { @@ -26,7 +65,7 @@ class ContactsScreen extends ConsumerWidget { actions: [ IconButton( tooltip: "Import smol:// address", - onPressed: () => _import(context), + onPressed: () => _import(context, ref), icon: const Icon(Icons.person_add), ), ], diff --git a/lib/presentation/screens/import_contact_dialog.dart b/lib/presentation/screens/import_contact_dialog.dart deleted file mode 100644 index 8b7927b..0000000 --- a/lib/presentation/screens/import_contact_dialog.dart +++ /dev/null @@ -1,131 +0,0 @@ -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 62020f3..30b4c62 100644 --- a/lib/presentation/screens/inbox_screen.dart +++ b/lib/presentation/screens/inbox_screen.dart @@ -6,7 +6,6 @@ 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"; @@ -91,24 +90,11 @@ 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/onboarding_screen.dart b/lib/presentation/screens/onboarding_screen.dart index cbfea57..75e62dc 100644 --- a/lib/presentation/screens/onboarding_screen.dart +++ b/lib/presentation/screens/onboarding_screen.dart @@ -161,7 +161,7 @@ class _OnboardingScreenState extends ConsumerState { // "already pinned, e.g. by a previous attempt or a preset server". final serverKey = serverKeyController.text.trim(); if (serverKey.isNotEmpty) { - client.pinServer(address.host, serverKey, address.port); + client.pinServer(address.host, serverKey); } if (recall && restoreIntent) { await client.restoreAndRecall(seedController.text, address.short); @@ -210,7 +210,7 @@ class _OnboardingScreenState extends ConsumerState { image: AssetImage("assets/images/bug_logo.png"), fit: BoxFit.contain), ), - Text("キラキラ", + Text("kirakira", style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 40), Text(title, style: Theme.of(context).textTheme.titleMedium), diff --git a/lib/presentation/screens/settings_screen.dart b/lib/presentation/screens/settings_screen.dart index 999938f..a9b5746 100644 --- a/lib/presentation/screens/settings_screen.dart +++ b/lib/presentation/screens/settings_screen.dart @@ -11,9 +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"; import "package:smol_mail/smol/ui.dart"; @@ -237,18 +235,12 @@ 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), - ), - ], ], ), ], @@ -328,7 +320,7 @@ class _SettingsScreenState extends ConsumerState { trailing: IconButton( icon: const Icon(Icons.delete_outline, size: 18), onPressed: () { - store.unpinServer(host, defaultPort); + store.unpinServer(host); ref.read(revisionProvider.notifier).bump(); setState(() {}); }, @@ -358,8 +350,9 @@ class _SettingsScreenState extends ConsumerState { TextButton( onPressed: () { try { - ref.read(clientProvider).pinServer( - hostController.text, keyController.text, defaultPort); + ref + .read(clientProvider) + .pinServer(hostController.text, keyController.text); keyController.clear(); setState(() {}); } on SmolError catch (err) { diff --git a/lib/presentation/widgets/qr_code_sheet.dart b/lib/presentation/widgets/qr_code_sheet.dart deleted file mode 100644 index 6527270..0000000 --- a/lib/presentation/widgets/qr_code_sheet.dart +++ /dev/null @@ -1,52 +0,0 @@ -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/client.dart b/lib/smol/client.dart index 750435b..1ea6a7a 100644 --- a/lib/smol/client.dart +++ b/lib/smol/client.dart @@ -144,7 +144,7 @@ class SmolClient { // registration and fetching demand it anyway (sec 4), so restoring is // stopped at the pin step rather than letting the account bind to a // server whose key nobody verified. - if (store.serverPin(addr.host, addr.port) == null) { + if (store.serverPin(addr.host) == null) { throw SmolError("no pinned key for ${addr.host}. Obtain it from the " "operator through a trusted channel, then pin it in settings."); } @@ -157,8 +157,7 @@ class SmolClient { } } - void pinServer(String host, String keyB32, int port) => - store.pinServer(host, keyB32, port); + void pinServer(String host, String keyB32) => store.pinServer(host, keyB32); Future registerAccount(String addressText, {String token = ""}) async { final addr = parseAddress(addressText); diff --git a/lib/smol/config.dart b/lib/smol/config.dart index c879c72..aae3fee 100644 --- a/lib/smol/config.dart +++ b/lib/smol/config.dart @@ -9,7 +9,6 @@ // removable in settings like any other, and never overwrites a host the user // (or a previous install) already pinned. -import "package:smol_mail/smol/address.dart"; import "package:smol_mail/smol/store.dart"; const _presetHost = String.fromEnvironment("SMOL_PRESET_SERVER"); @@ -17,7 +16,7 @@ const _presetKey = String.fromEnvironment("SMOL_PRESET_SERVER_KEY"); void applyPresetServer(SmolStore store) { if (_presetHost.isEmpty || _presetKey.isEmpty) return; - if (store.serverPin(_presetHost, defaultPort) != null) return; + if (store.serverPin(_presetHost) != null) return; // A malformed preset leaves the host unpinned rather than blocking boot. - store.pinServer(_presetHost, _presetKey, defaultPort); + store.pinServer(_presetHost, _presetKey); } diff --git a/lib/smol/link.dart b/lib/smol/link.dart deleted file mode 100644 index 6a493fe..0000000 --- a/lib/smol/link.dart +++ /dev/null @@ -1,27 +0,0 @@ -// 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/lib/smol/store.dart b/lib/smol/store.dart index 09ea825..ef4a414 100644 --- a/lib/smol/store.dart +++ b/lib/smol/store.dart @@ -291,11 +291,10 @@ class SmolStore { // --- pins ------------------------------------------------------------------- /// Pins a server's static key (§4): sync, because it is one local write. - void pinServer(String host, String keyB32, int port) => - _ffi.pinServer(_native.store!, host, keyB32, port); + void pinServer(String host, String keyB32) => + _ffi.pinServer(_native.store!, host, keyB32); - String? serverPin(String host, int port) => - _ffi.serverPin(_native.store!, host, port); + String? serverPin(String host) => _ffi.serverPin(_native.store!, host); List<(String, String)> allPins() { final rows = _ffi.pins(_native.store!); @@ -305,8 +304,7 @@ class SmolStore { ]; } - Future unpinServer(String host, int port) async => - _native.unpinServer(host, port); + Future unpinServer(String host) async => _native.unpinServer(host); // --- backups ---------------------------------------------------------------- diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 3792af4..f6f23bf 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,13 +6,9 @@ #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 21d8f8b..df8d2f7 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,7 +3,6 @@ # list(APPEND FLUTTER_PLUGIN_LIST - gtk url_launcher_linux ) diff --git a/native/Cargo.lock b/native/Cargo.lock index 9ae3a6b..209a64d 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -271,7 +271,7 @@ checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "fumi-core" version = "0.1.0" -source = "git+ssh://git@code.randogoth.com:2222/randogoth/fumi.git?rev=1468f3c#1468f3cbc0a89a0cb073f46a985911c0327885d1" +source = "git+ssh://git@code.randogoth.com:2222/randogoth/fumi.git?rev=2d65d66#2d65d66012fa40121e4dc3d8d15db9ffc486569a" dependencies = [ "chacha20poly1305", "data-encoding", diff --git a/native/Cargo.toml b/native/Cargo.toml index 52a28c3..adb0e95 100644 --- a/native/Cargo.toml +++ b/native/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" crate-type = ["lib", "cdylib"] [dependencies] -fumi-core = { git = "ssh://git@code.randogoth.com:2222/randogoth/fumi.git", rev = "1468f3c" } +fumi-core = { git = "ssh://git@code.randogoth.com:2222/randogoth/fumi.git", rev = "2d65d66" } rand_core = { version = "0.6", features = ["getrandom"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/native/src/ffi.rs b/native/src/ffi.rs index 8b036e5..f16f3c8 100644 --- a/native/src/ffi.rs +++ b/native/src/ffi.rs @@ -126,29 +126,22 @@ struct ErrorJson { /// The raw byte behind an UNKNOWN_STATUS (code 11). #[serde(skip_serializing_if = "Option::is_none")] status: Option, - #[serde(skip_serializing_if = "Option::is_none")] - port: Option, } impl ErrorJson { fn of(error: &Error) -> ErrorJson { let message = error.to_string(); - let (host, pinned, presented, address, known, offered, status, port) = match error { + let (host, pinned, presented, address, known, offered, status) = match error { Error::UnknownStatus(status, _) => { - (None, None, None, None, None, None, Some(*status), None) - } - Error::NotPinned { host, port } => { - (Some(host.clone()), None, None, None, None, None, None, Some(*port)) - } - Error::Unreachable { host, .. } => { - (Some(host.clone()), None, None, None, None, None, None, None) + (None, None, None, None, None, None, Some(*status)) } + Error::NotPinned { host } => (Some(host.clone()), None, None, None, None, None, None), + Error::Unreachable { host, .. } => (Some(host.clone()), None, None, None, None, None, None), Error::HandshakeRefused { host, .. } => { - (Some(host.clone()), None, None, None, None, None, None, None) + (Some(host.clone()), None, None, None, None, None, None) } Error::PinMismatch { host, - port, pinned, presented, } => ( @@ -159,7 +152,6 @@ impl ErrorJson { None, None, None, - Some(*port), ), Error::KeyChanged { address, @@ -173,9 +165,8 @@ impl ErrorJson { Some(b32(known)), Some(b32(offered)), None, - None, ), - _ => (None, None, None, None, None, None, None, None), + _ => (None, None, None, None, None, None, None), }; ErrorJson { code: code_of(error), @@ -187,7 +178,6 @@ impl ErrorJson { known, offered, status, - port, } } } @@ -393,24 +383,23 @@ pub extern "C" fn smol_pin_server( store: *mut Store, host: *const c_char, key_b32: *const c_char, - port: u16, ) -> i32 { status(|| { let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; let key: [u8; KEY_LEN] = unb32(text(key_b32)?)? .try_into() .map_err(|_| Error::Other("server key must decode to 32 bytes".into()))?; - store.pin_server(text(host)?, port, &key) + store.pin_server(text(host)?, &key) }) } /// The pinned key for a host, base32. Empty string: none pinned. Null: error. #[no_mangle] -pub extern "C" fn smol_server_pin(store: *mut Store, host: *const c_char, port: u16) -> *mut c_char { +pub extern "C" fn smol_server_pin(store: *mut Store, host: *const c_char) -> *mut c_char { match guarded(|| { let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; Ok(store - .server_pin(text(host)?, port)? + .server_pin(text(host)?)? .map(|key| b32(&key)) .unwrap_or_default()) }) { @@ -437,13 +426,12 @@ pub extern "C" fn smol_contact_save( }) } -/// Removes a pin: the next session against that host and port is trust on -/// first use. +/// Removes a pin: the next session against that host is trust on first use. #[no_mangle] -pub extern "C" fn smol_unpin_server(store: *mut Store, host: *const c_char, port: u16) -> i32 { +pub extern "C" fn smol_unpin_server(store: *mut Store, host: *const c_char) -> i32 { status(|| { let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; - store.unpin_server(text(host)?, port) + store.unpin_server(text(host)?) }) } @@ -1190,7 +1178,6 @@ mod tests { let json: serde_json::Value = serde_json::from_str(&slot).unwrap(); assert_eq!(json["code"], NOT_PINNED); assert_eq!(json["host"], "127.0.0.1"); - assert_eq!(json["port"], 19619); // Strings returned on success must be freed without double-free. assert_eq!(take(smol_store_account(store)), ""); @@ -1206,8 +1193,7 @@ mod tests { fn mail_and_contacts_render_as_json() { let store = smol_store_memory(); let s = unsafe { &*store }; - s.pin_server("example.org", fumi_core::address::DEFAULT_PORT, &[1u8; KEY_LEN]) - .unwrap(); + s.pin_server("example.org", &[1u8; KEY_LEN]).unwrap(); s.save_contact("alice@example.org", &[2u8; KEY_LEN], true).unwrap(); s.save_history("alice@example.org", &[9u8; KEY_LEN], 500).unwrap(); s.store_inbox(&[3u8; ID_LEN], b"sealed", 7, false, true).unwrap(); diff --git a/native/src/lib.rs b/native/src/lib.rs index 2008ce7..b04be2b 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -86,8 +86,8 @@ mod tests { let alice_addr = Address::parse(&format!("a{run}@127.0.0.1:19619")).unwrap(); let bob_addr = Address::parse(&format!("b{run}@127.0.0.1:19619")).unwrap(); - alice_store.pin_server("127.0.0.1", 19619, &server).unwrap(); - bob_store.pin_server("127.0.0.1", 19619, &server).unwrap(); + alice_store.pin_server("127.0.0.1", &server).unwrap(); + bob_store.pin_server("127.0.0.1", &server).unwrap(); register(&alice_store, &alice_addr, &alice, None, 5).unwrap(); register(&bob_store, &bob_addr, &bob, None, 5).unwrap(); @@ -168,8 +168,8 @@ mod tests { let run = fumi_core::crypto::b32(&suffix); let alice_addr = Address::parse(&format!("a{run}@127.0.0.1:19619")).unwrap(); let bob_addr = Address::parse(&format!("b{run}@127.0.0.1:19619")).unwrap(); - alice_store.pin_server("127.0.0.1", 19619, &server).unwrap(); - bob_store.pin_server("127.0.0.1", 19619, &server).unwrap(); + alice_store.pin_server("127.0.0.1", &server).unwrap(); + bob_store.pin_server("127.0.0.1", &server).unwrap(); register(&alice_store, &alice_addr, &alice, None, 5).unwrap(); register(&bob_store, &bob_addr, &bob, None, 5).unwrap(); @@ -272,7 +272,7 @@ mod tests { fumi_core::address::Address::parse(&format!("u{run}@{host}:19619")) .unwrap() .with_dial("127.0.0.1"); - store.pin_server(&host, 19619, &server).unwrap(); + store.pin_server(&host, &server).unwrap(); register(&store, &addr, &account, None, 5).unwrap(); assert_eq!(store.account().unwrap().unwrap().short(), format!("u{run}@{host}:19619")); @@ -296,10 +296,7 @@ mod tests { let unpinned = Store::open_in_memory().unwrap(); match connect(&unpinned, &addr, true, 5) { - Err(Error::NotPinned { host, port }) => { - assert_eq!(host, "127.0.0.1"); - assert_eq!(port, 19619); - } + Err(Error::NotPinned { host }) => assert_eq!(host, "127.0.0.1"), Err(err) => panic!("expected NotPinned, got {err}"), Ok(_) => panic!("expected NotPinned, connected"), } @@ -307,7 +304,7 @@ mod tests { let mut wrong = [0u8; KEY_LEN]; OsRng.fill_bytes(&mut wrong); let mismatched = Store::open_in_memory().unwrap(); - mismatched.pin_server("127.0.0.1", 19619, &wrong).unwrap(); + mismatched.pin_server("127.0.0.1", &wrong).unwrap(); match connect(&mismatched, &addr, true, 5) { Err(Error::PinMismatch { .. }) => {} Err(err) => panic!("expected PinMismatch, got {err}"), @@ -351,8 +348,8 @@ mod tests { let run = fumi_core::crypto::b32(&suffix); let carol_addr = Address::parse(&format!("c{run}@127.0.0.1:19619")).unwrap(); let alice_addr = Address::parse(&format!("d{run}@127.0.0.1:19619")).unwrap(); - carol_store.pin_server("127.0.0.1", 19619, &server).unwrap(); - alice_store.pin_server("127.0.0.1", 19619, &server).unwrap(); + carol_store.pin_server("127.0.0.1", &server).unwrap(); + alice_store.pin_server("127.0.0.1", &server).unwrap(); register(&carol_store, &carol_addr, &carol, None, 5).unwrap(); register(&alice_store, &alice_addr, &alice, None, 5).unwrap(); diff --git a/pubspec.lock b/pubspec.lock index f7c6b29..b7c28cf 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -33,38 +33,6 @@ 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: @@ -458,14 +426,6 @@ 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: @@ -671,14 +631,6 @@ 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: @@ -847,22 +799,6 @@ 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 f9999be..73768aa 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,9 +21,6 @@ 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: @@ -36,7 +33,6 @@ 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 @@ -63,13 +59,8 @@ flutter_native_splash: web: false ios: false color: "#01111d" - 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 + image: assets/images/sparkle.png + branding: assets/images/sparkle.png flutter: uses-material-design: true @@ -77,6 +68,7 @@ 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 deleted file mode 100644 index 61269e0..0000000 Binary files a/test-qr.png and /dev/null differ diff --git a/test/e2e_test.dart b/test/e2e_test.dart index 9a83dbd..2c67ea3 100644 --- a/test/e2e_test.dart +++ b/test/e2e_test.dart @@ -60,7 +60,7 @@ void main() { final me = client.identity!; final user = "e2e${hex(randomBytes(4))}"; final address = parseAddress("$user@$host"); - store.pinServer(host, serverKey, port); + store.pinServer(host, serverKey); await client.registerAccount(address.short); expect(client.accountAddress()!.short, address.short); @@ -129,7 +129,7 @@ void main() { secondStore.restoreMaster(store.master()!, 0); await expectLater( secondDevice.recallAccount(address.short), throwsA(isA())); - secondStore.pinServer(host, serverKey, port); + secondStore.pinServer(host, serverKey); await expectLater( secondDevice.registerAccount(address.short), throwsA(isA())); final bound = await secondDevice.recallAccount(address.short); @@ -157,7 +157,7 @@ void main() { await client.createIdentity(); final user = "e2ekeep${hex(randomBytes(4))}"; final address = parseAddress("$user@$host"); - store.pinServer(host, serverKey, port); + store.pinServer(host, serverKey); await client.registerAccount(address.short); await store.setLeaveOnServer(true); diff --git a/test/interop_fumi_test.dart b/test/interop_fumi_test.dart index 43d5d4e..c44f0f6 100644 --- a/test/interop_fumi_test.dart +++ b/test/interop_fumi_test.dart @@ -13,7 +13,6 @@ import "dart:typed_data"; import "package:flutter_test/flutter_test.dart"; import "package:hive_flutter/hive_flutter.dart"; -import "package:smol_mail/smol/address.dart"; import "package:smol_mail/smol/store.dart"; const fumiBinary = "../fumi/target/release/fumi"; @@ -39,7 +38,7 @@ void main() { stateBox: "interop-state", readBox: "interop-read"); mine.restoreMaster(master, 0); - mine.pinServer("example.org", pinKey, defaultPort); + mine.pinServer("example.org", pinKey); // One contact, bound to the current key; rotation history is written by // the trust engine on a validated rotation, not by a plain re-save. await mine.saveContact("alice@example.org", pinKey, verified: true); @@ -73,6 +72,6 @@ void main() { // The binding survived both directions of the round trip. final contact = restored.contact("alice@example.org")!; expect(contact.key, pinKey); - expect(restored.serverPin("example.org", defaultPort), pinKey); + expect(restored.serverPin("example.org"), pinKey); }); } diff --git a/test/link_test.dart b/test/link_test.dart deleted file mode 100644 index f8a59f8..0000000 --- a/test/link_test.dart +++ /dev/null @@ -1,45 +0,0 @@ -// 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/native_binding_test.dart b/test/native_binding_test.dart index 82750b5..3a36ebc 100644 --- a/test/native_binding_test.dart +++ b/test/native_binding_test.dart @@ -11,7 +11,6 @@ import "package:flutter_test/flutter_test.dart"; import "package:smol_mail/native/client.dart"; import "package:smol_mail/native/ffi.dart"; -import "package:smol_mail/smol/address.dart"; const spikeServerKey = "wukbhdiwboarbg4pujxstbqi3caveunrruaopbwxr226ga3xv5ga"; const spikeServer = "127.0.0.1"; @@ -40,9 +39,7 @@ void main() async { expect(await client.accountAddress(), isNull); // A pinned but dead host fails by code, never by prose: Unreachable (7). - // The pin is scoped to the dead port itself — a default-port pin would - // not cover it, and the register would fail NotPinned instead. - await client.pinServer("127.0.0.1", spikeServerKey, 19629); + await client.pinServer("127.0.0.1", spikeServerKey); client.setMaster(Uint8List.fromList(List.filled(32, 9))); try { await client.register("nobody@127.0.0.1:19629"); @@ -66,7 +63,7 @@ void main() async { final mine = FumiNative("${dir.path}/mine.db"); await mine.open(); mine.setMaster(master); - await mine.pinServer("example.org", spikeServerKey, defaultPort); + await mine.pinServer("example.org", spikeServerKey); await mine.importContact( "smol://alice@example.org/ayb6y3mwr3cfkcmcaqbn3cgfi6xsrsoqkalykw5s7oqmwqcpnmsq"); @@ -79,7 +76,7 @@ void main() async { as Map; expect(summary["contactsAdded"], 1); expect(summary["pinsAdded"], 1); - expect(await restored.serverPin("example.org", defaultPort), spikeServerKey); + expect(await restored.serverPin("example.org"), spikeServerKey); await mine.close(); await restored.close(); @@ -101,14 +98,14 @@ void main() async { final alice = FumiNative("${dir.path}/alice.db"); await alice.open(); alice.setMaster(aliceMaster); - await alice.pinServer(spikeServer, spikeServerKey, spikePort); + await alice.pinServer(spikeServer, spikeServerKey); await alice.register("a$run@$spikeServer:$spikePort"); expect(await alice.accountAddress(), "a$run@$spikeServer:$spikePort"); final bob = FumiNative("${dir.path}/bob.db"); await bob.open(); bob.setMaster(bobMaster); - await bob.pinServer(spikeServer, spikeServerKey, spikePort); + await bob.pinServer(spikeServer, spikeServerKey); await bob.register("b$run@$spikeServer:$spikePort"); final sent = await alice.send("b$run@$spikeServer:$spikePort", @@ -150,7 +147,7 @@ void main() async { final alice = FumiNative("${dir.path}/alice.db"); await alice.open(); alice.setMaster(aliceMaster); - await alice.pinServer(spikeServer, spikeServerKey, spikePort); + await alice.pinServer(spikeServer, spikeServerKey); await alice.register("a$run@$spikeServer:$spikePort"); final bob = FumiNative("${dir.path}/bob.db"); @@ -159,7 +156,7 @@ void main() async { // The onboarding screen zeroes its own master reference on dispose; // whatever holds the master after that must not share that buffer. bobMaster.fillRange(0, 32, 0); - await bob.pinServer(spikeServer, spikeServerKey, spikePort); + await bob.pinServer(spikeServer, spikeServerKey); await bob.register("b$run@$spikeServer:$spikePort"); await alice.send("b$run@$spikeServer:$spikePort", diff --git a/test/qr_sheet_test.dart b/test/qr_sheet_test.dart deleted file mode 100644 index f1e5309..0000000 --- a/test/qr_sheet_test.dart +++ /dev/null @@ -1,28 +0,0 @@ -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/recall_flow_test.dart b/test/recall_flow_test.dart index 4f18021..3ddfa97 100644 --- a/test/recall_flow_test.dart +++ b/test/recall_flow_test.dart @@ -36,7 +36,7 @@ class StubClient extends SmolClient { @override Future restoreAndRecall(String masterHex, String addressText) async { final addr = parseAddress(addressText); - if (store.serverPin(addr.host, addr.port) == null) { + if (store.serverPin(addr.host) == null) { throw SmolError("no pinned key for ${addr.host}"); } store.restoreMaster(unhex(masterHex.trim()), 0); diff --git a/test/store_test.dart b/test/store_test.dart index c31163b..5e296af 100644 --- a/test/store_test.dart +++ b/test/store_test.dart @@ -8,7 +8,6 @@ import "dart:typed_data"; import "package:flutter_test/flutter_test.dart"; import "package:hive_flutter/hive_flutter.dart"; -import "package:smol_mail/smol/address.dart"; import "package:smol_mail/smol/store.dart"; Uint8List randomBytes(int n) => @@ -67,11 +66,11 @@ void main() { expect(store.allPins(), isEmpty); // A syntactically valid key: the system server's, a real 52-char form. const key = "lm2gqd7e5q67xq3isc5hx6jfj2q7a7xvq4l7trctxipudujovmgq"; - store.pinServer("example.org", key, defaultPort); - expect(store.serverPin("example.org", defaultPort), key); + store.pinServer("example.org", key); + expect(store.serverPin("example.org"), key); expect(store.allPins().length, 1); - await store.unpinServer("example.org", defaultPort); - expect(store.serverPin("example.org", defaultPort), isNull); + await store.unpinServer("example.org"); + expect(store.serverPin("example.org"), isNull); }); test("wipe clears every secret and mark, overwriting the master", () async { @@ -81,7 +80,7 @@ void main() { // The store's own copy, read back before the wipe. final stored = store.master()!; store.pinServer("example.org", - "lm2gqd7e5q67xq3isc5hx6jfj2q7a7xvq4l7trctxipudujovmgq", defaultPort); + "lm2gqd7e5q67xq3isc5hx6jfj2q7a7xvq4l7trctxipudujovmgq"); store.markRead("cd" * 32); await store.wipe(); @@ -94,7 +93,7 @@ void main() { expect(master.every((b) => b == 0), isFalse); expect(store.master(), isNull); expect(store.identity(), isNull); - expect(store.serverPin("example.org", defaultPort), isNull); + expect(store.serverPin("example.org"), isNull); expect(store.isRead("cd" * 32), isFalse); }); } diff --git a/test/widget_test.dart b/test/widget_test.dart index bf33a97..e3689ba 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,6 +1,5 @@ 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"; @@ -11,22 +10,11 @@ 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");