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");