feat: support smol:// QR sharing, deep links and in-dialog scanning
This commit is contained in:
parent
7d2a147fec
commit
aa74e12ee7
20 changed files with 591 additions and 101 deletions
|
|
@ -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.
|
- 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.
|
- 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.
|
- 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.
|
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
|
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
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,15 @@
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<!-- smol:// (SPEC.md §3): the self-certifying form a QR scanner
|
||||||
|
or a link hands over; the app imports the key it carries
|
||||||
|
after the user confirms. -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
|
<category android:name="android.intent.category.DEFAULT"/>
|
||||||
|
<category android:name="android.intent.category.BROWSABLE"/>
|
||||||
|
<data android:scheme="smol"/>
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,15 @@
|
||||||
|
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/material.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
|
|
||||||
import "package:smol_mail/data/providers/providers.dart";
|
import "package:smol_mail/data/providers/providers.dart";
|
||||||
import "package:smol_mail/presentation/theme/theme.dart";
|
|
||||||
import "package:smol_mail/presentation/routes/app_router.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";
|
||||||
|
|
||||||
/// SnackBars need no context through this key, so the client's trust
|
/// SnackBars need no context through this key, so the client's trust
|
||||||
/// warnings can be shown from anywhere in the protocol stack.
|
/// warnings can be shown from anywhere in the protocol stack.
|
||||||
|
|
@ -17,6 +23,10 @@ class AppWidget extends ConsumerStatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppWidgetState extends ConsumerState<AppWidget> {
|
class _AppWidgetState extends ConsumerState<AppWidget> {
|
||||||
|
late final AppRouter _router = AppRouter(ref);
|
||||||
|
final AppLinks _appLinks = AppLinks();
|
||||||
|
StreamSubscription<Uri>? _linkSub;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
@ -37,6 +47,33 @@ class _AppWidgetState extends ConsumerState<AppWidget> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
_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
|
@override
|
||||||
|
|
@ -48,7 +85,16 @@ class _AppWidgetState extends ConsumerState<AppWidget> {
|
||||||
themeMode: ThemeMode.system,
|
themeMode: ThemeMode.system,
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
scaffoldMessengerKey: rootScaffoldMessengerKey,
|
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,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,25 @@
|
||||||
import "package:auto_route/auto_route.dart";
|
import "package:auto_route/auto_route.dart";
|
||||||
|
import "package:flutter/material.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
|
|
||||||
import "package:smol_mail/presentation/routes/home_guard.dart";
|
import "package:smol_mail/presentation/routes/home_guard.dart";
|
||||||
import "package:smol_mail/presentation/routes/identity_guard.dart";
|
import "package:smol_mail/presentation/routes/identity_guard.dart";
|
||||||
import "app_router.gr.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<T> dialogRouteBuilder<T>(
|
||||||
|
BuildContext context,
|
||||||
|
Widget child,
|
||||||
|
AutoRoutePage<T> page,
|
||||||
|
) =>
|
||||||
|
DialogRoute<T>(
|
||||||
|
context: context,
|
||||||
|
settings: page,
|
||||||
|
builder: (ctx) => child,
|
||||||
|
);
|
||||||
|
|
||||||
@AutoRouterConfig(replaceInRouteName: "Screen,Route")
|
@AutoRouterConfig(replaceInRouteName: "Screen,Route")
|
||||||
class AppRouter extends RootStackRouter {
|
class AppRouter extends RootStackRouter {
|
||||||
final WidgetRef ref;
|
final WidgetRef ref;
|
||||||
|
|
@ -44,5 +59,11 @@ class AppRouter extends RootStackRouter {
|
||||||
path: "/contact",
|
path: "/contact",
|
||||||
guards: [IdentityGuard(ref)],
|
guards: [IdentityGuard(ref)],
|
||||||
),
|
),
|
||||||
|
CustomRoute(
|
||||||
|
page: ImportContactDialogRoute.page,
|
||||||
|
path: "/import",
|
||||||
|
customRouteBuilder: dialogRouteBuilder,
|
||||||
|
guards: [IdentityGuard(ref)],
|
||||||
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,27 +10,29 @@
|
||||||
|
|
||||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart' as _i9;
|
import 'package:auto_route/auto_route.dart' as _i10;
|
||||||
import 'package:flutter/material.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/compose_screen.dart' as _i1;
|
||||||
import 'package:smol_mail/presentation/screens/contact_detail_screen.dart'
|
import 'package:smol_mail/presentation/screens/contact_detail_screen.dart'
|
||||||
as _i2;
|
as _i2;
|
||||||
import 'package:smol_mail/presentation/screens/contacts_screen.dart' as _i3;
|
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/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'
|
import 'package:smol_mail/presentation/screens/message_detail_screen.dart'
|
||||||
as _i6;
|
as _i7;
|
||||||
import 'package:smol_mail/presentation/screens/onboarding_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 _i8;
|
import 'package:smol_mail/presentation/screens/settings_screen.dart' as _i9;
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i1.ComposeScreen]
|
/// [_i1.ComposeScreen]
|
||||||
class ComposeRoute extends _i9.PageRouteInfo<ComposeRouteArgs> {
|
class ComposeRoute extends _i10.PageRouteInfo<ComposeRouteArgs> {
|
||||||
ComposeRoute({
|
ComposeRoute({
|
||||||
_i10.Key? key,
|
_i11.Key? key,
|
||||||
String? to,
|
String? to,
|
||||||
String? subject,
|
String? subject,
|
||||||
List<_i9.PageRouteInfo>? children,
|
List<_i10.PageRouteInfo>? children,
|
||||||
}) : super(
|
}) : super(
|
||||||
ComposeRoute.name,
|
ComposeRoute.name,
|
||||||
args: ComposeRouteArgs(key: key, to: to, subject: subject),
|
args: ComposeRouteArgs(key: key, to: to, subject: subject),
|
||||||
|
|
@ -39,7 +41,7 @@ class ComposeRoute extends _i9.PageRouteInfo<ComposeRouteArgs> {
|
||||||
|
|
||||||
static const String name = 'ComposeRoute';
|
static const String name = 'ComposeRoute';
|
||||||
|
|
||||||
static _i9.PageInfo page = _i9.PageInfo(
|
static _i10.PageInfo page = _i10.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
final args = data.argsAs<ComposeRouteArgs>(
|
final args = data.argsAs<ComposeRouteArgs>(
|
||||||
|
|
@ -57,7 +59,7 @@ class ComposeRoute extends _i9.PageRouteInfo<ComposeRouteArgs> {
|
||||||
class ComposeRouteArgs {
|
class ComposeRouteArgs {
|
||||||
const ComposeRouteArgs({this.key, this.to, this.subject});
|
const ComposeRouteArgs({this.key, this.to, this.subject});
|
||||||
|
|
||||||
final _i10.Key? key;
|
final _i11.Key? key;
|
||||||
|
|
||||||
final String? to;
|
final String? to;
|
||||||
|
|
||||||
|
|
@ -81,11 +83,11 @@ class ComposeRouteArgs {
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i2.ContactDetailScreen]
|
/// [_i2.ContactDetailScreen]
|
||||||
class ContactDetailRoute extends _i9.PageRouteInfo<ContactDetailRouteArgs> {
|
class ContactDetailRoute extends _i10.PageRouteInfo<ContactDetailRouteArgs> {
|
||||||
ContactDetailRoute({
|
ContactDetailRoute({
|
||||||
_i10.Key? key,
|
_i11.Key? key,
|
||||||
required String address,
|
required String address,
|
||||||
List<_i9.PageRouteInfo>? children,
|
List<_i10.PageRouteInfo>? children,
|
||||||
}) : super(
|
}) : super(
|
||||||
ContactDetailRoute.name,
|
ContactDetailRoute.name,
|
||||||
args: ContactDetailRouteArgs(key: key, address: address),
|
args: ContactDetailRouteArgs(key: key, address: address),
|
||||||
|
|
@ -94,7 +96,7 @@ class ContactDetailRoute extends _i9.PageRouteInfo<ContactDetailRouteArgs> {
|
||||||
|
|
||||||
static const String name = 'ContactDetailRoute';
|
static const String name = 'ContactDetailRoute';
|
||||||
|
|
||||||
static _i9.PageInfo page = _i9.PageInfo(
|
static _i10.PageInfo page = _i10.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
final args = data.argsAs<ContactDetailRouteArgs>();
|
final args = data.argsAs<ContactDetailRouteArgs>();
|
||||||
|
|
@ -106,7 +108,7 @@ class ContactDetailRoute extends _i9.PageRouteInfo<ContactDetailRouteArgs> {
|
||||||
class ContactDetailRouteArgs {
|
class ContactDetailRouteArgs {
|
||||||
const ContactDetailRouteArgs({this.key, required this.address});
|
const ContactDetailRouteArgs({this.key, required this.address});
|
||||||
|
|
||||||
final _i10.Key? key;
|
final _i11.Key? key;
|
||||||
|
|
||||||
final String address;
|
final String address;
|
||||||
|
|
||||||
|
|
@ -128,13 +130,13 @@ class ContactDetailRouteArgs {
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i3.ContactsScreen]
|
/// [_i3.ContactsScreen]
|
||||||
class ContactsRoute extends _i9.PageRouteInfo<void> {
|
class ContactsRoute extends _i10.PageRouteInfo<void> {
|
||||||
const ContactsRoute({List<_i9.PageRouteInfo>? children})
|
const ContactsRoute({List<_i10.PageRouteInfo>? children})
|
||||||
: super(ContactsRoute.name, initialChildren: children);
|
: super(ContactsRoute.name, initialChildren: children);
|
||||||
|
|
||||||
static const String name = 'ContactsRoute';
|
static const String name = 'ContactsRoute';
|
||||||
|
|
||||||
static _i9.PageInfo page = _i9.PageInfo(
|
static _i10.PageInfo page = _i10.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
return const _i3.ContactsScreen();
|
return const _i3.ContactsScreen();
|
||||||
|
|
@ -144,13 +146,13 @@ class ContactsRoute extends _i9.PageRouteInfo<void> {
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i4.HomeScreen]
|
/// [_i4.HomeScreen]
|
||||||
class HomeRoute extends _i9.PageRouteInfo<void> {
|
class HomeRoute extends _i10.PageRouteInfo<void> {
|
||||||
const HomeRoute({List<_i9.PageRouteInfo>? children})
|
const HomeRoute({List<_i10.PageRouteInfo>? children})
|
||||||
: super(HomeRoute.name, initialChildren: children);
|
: super(HomeRoute.name, initialChildren: children);
|
||||||
|
|
||||||
static const String name = 'HomeRoute';
|
static const String name = 'HomeRoute';
|
||||||
|
|
||||||
static _i9.PageInfo page = _i9.PageInfo(
|
static _i10.PageInfo page = _i10.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
return const _i4.HomeScreen();
|
return const _i4.HomeScreen();
|
||||||
|
|
@ -159,29 +161,82 @@ class HomeRoute extends _i9.PageRouteInfo<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i5.InboxScreen]
|
/// [_i5.ImportContactDialogScreen]
|
||||||
class InboxRoute extends _i9.PageRouteInfo<void> {
|
class ImportContactDialogRoute
|
||||||
const InboxRoute({List<_i9.PageRouteInfo>? children})
|
extends _i10.PageRouteInfo<ImportContactDialogRouteArgs> {
|
||||||
|
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<ImportContactDialogRouteArgs>(
|
||||||
|
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<void> {
|
||||||
|
const InboxRoute({List<_i10.PageRouteInfo>? children})
|
||||||
: super(InboxRoute.name, initialChildren: children);
|
: super(InboxRoute.name, initialChildren: children);
|
||||||
|
|
||||||
static const String name = 'InboxRoute';
|
static const String name = 'InboxRoute';
|
||||||
|
|
||||||
static _i9.PageInfo page = _i9.PageInfo(
|
static _i10.PageInfo page = _i10.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
return const _i5.InboxScreen();
|
return const _i6.InboxScreen();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i6.MessageDetailScreen]
|
/// [_i7.MessageDetailScreen]
|
||||||
class MessageDetailRoute extends _i9.PageRouteInfo<MessageDetailRouteArgs> {
|
class MessageDetailRoute extends _i10.PageRouteInfo<MessageDetailRouteArgs> {
|
||||||
MessageDetailRoute({
|
MessageDetailRoute({
|
||||||
_i10.Key? key,
|
_i11.Key? key,
|
||||||
required String folder,
|
required String folder,
|
||||||
required String id,
|
required String id,
|
||||||
List<_i9.PageRouteInfo>? children,
|
List<_i10.PageRouteInfo>? children,
|
||||||
}) : super(
|
}) : super(
|
||||||
MessageDetailRoute.name,
|
MessageDetailRoute.name,
|
||||||
args: MessageDetailRouteArgs(key: key, folder: folder, id: id),
|
args: MessageDetailRouteArgs(key: key, folder: folder, id: id),
|
||||||
|
|
@ -190,11 +245,11 @@ class MessageDetailRoute extends _i9.PageRouteInfo<MessageDetailRouteArgs> {
|
||||||
|
|
||||||
static const String name = 'MessageDetailRoute';
|
static const String name = 'MessageDetailRoute';
|
||||||
|
|
||||||
static _i9.PageInfo page = _i9.PageInfo(
|
static _i10.PageInfo page = _i10.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
final args = data.argsAs<MessageDetailRouteArgs>();
|
final args = data.argsAs<MessageDetailRouteArgs>();
|
||||||
return _i6.MessageDetailScreen(
|
return _i7.MessageDetailScreen(
|
||||||
key: args.key,
|
key: args.key,
|
||||||
folder: args.folder,
|
folder: args.folder,
|
||||||
id: args.id,
|
id: args.id,
|
||||||
|
|
@ -210,7 +265,7 @@ class MessageDetailRouteArgs {
|
||||||
required this.id,
|
required this.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
final _i10.Key? key;
|
final _i11.Key? key;
|
||||||
|
|
||||||
final String folder;
|
final String folder;
|
||||||
|
|
||||||
|
|
@ -233,33 +288,33 @@ class MessageDetailRouteArgs {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i7.OnboardingScreen]
|
/// [_i8.OnboardingScreen]
|
||||||
class OnboardingRoute extends _i9.PageRouteInfo<void> {
|
class OnboardingRoute extends _i10.PageRouteInfo<void> {
|
||||||
const OnboardingRoute({List<_i9.PageRouteInfo>? children})
|
const OnboardingRoute({List<_i10.PageRouteInfo>? children})
|
||||||
: super(OnboardingRoute.name, initialChildren: children);
|
: super(OnboardingRoute.name, initialChildren: children);
|
||||||
|
|
||||||
static const String name = 'OnboardingRoute';
|
static const String name = 'OnboardingRoute';
|
||||||
|
|
||||||
static _i9.PageInfo page = _i9.PageInfo(
|
static _i10.PageInfo page = _i10.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
return const _i7.OnboardingScreen();
|
return const _i8.OnboardingScreen();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i8.SettingsScreen]
|
/// [_i9.SettingsScreen]
|
||||||
class SettingsRoute extends _i9.PageRouteInfo<void> {
|
class SettingsRoute extends _i10.PageRouteInfo<void> {
|
||||||
const SettingsRoute({List<_i9.PageRouteInfo>? children})
|
const SettingsRoute({List<_i10.PageRouteInfo>? children})
|
||||||
: super(SettingsRoute.name, initialChildren: children);
|
: super(SettingsRoute.name, initialChildren: children);
|
||||||
|
|
||||||
static const String name = 'SettingsRoute';
|
static const String name = 'SettingsRoute';
|
||||||
|
|
||||||
static _i9.PageInfo page = _i9.PageInfo(
|
static _i10.PageInfo page = _i10.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
return const _i8.SettingsScreen();
|
return const _i9.SettingsScreen();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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/format.dart";
|
||||||
import "package:smol_mail/shared/utils/snackbar.dart";
|
import "package:smol_mail/shared/utils/snackbar.dart";
|
||||||
import "package:smol_mail/smol/address.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/errors.dart";
|
||||||
import "package:smol_mail/smol/ui.dart";
|
import "package:smol_mail/smol/ui.dart";
|
||||||
|
|
||||||
|
|
@ -88,6 +89,10 @@ class _ContactDetailScreenState extends ConsumerState<ContactDetailScreen> {
|
||||||
final contact = store.contact(widget.address);
|
final contact = store.contact(widget.address);
|
||||||
final accepted = store.accepted(widget.address);
|
final accepted = store.accepted(widget.address);
|
||||||
final isAccepted = accepted != null && accepted.active;
|
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(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(widget.address)),
|
appBar: AppBar(title: Text(widget.address)),
|
||||||
|
|
@ -130,17 +135,16 @@ class _ContactDetailScreenState extends ConsumerState<ContactDetailScreen> {
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _field(
|
child: _field(context, "address", uri!),
|
||||||
context,
|
|
||||||
"address",
|
|
||||||
parseAddress(widget.address)
|
|
||||||
.uri(contact.key)),
|
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.copy, size: 18),
|
icon: const Icon(Icons.copy, size: 18),
|
||||||
onPressed: () => copyText(
|
onPressed: () => copyText(context, uri),
|
||||||
context,
|
),
|
||||||
parseAddress(widget.address).uri(contact.key)),
|
IconButton(
|
||||||
|
tooltip: "Show QR code",
|
||||||
|
icon: const Icon(Icons.qr_code, size: 18),
|
||||||
|
onPressed: () => showQrCodeSheet(context, uri),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,6 @@ import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:smol_mail/data/providers/providers.dart";
|
import "package:smol_mail/data/providers/providers.dart";
|
||||||
import "package:smol_mail/presentation/routes/app_router.gr.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/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";
|
import "package:smol_mail/smol/ui.dart";
|
||||||
|
|
||||||
/// Contacts: the addresses bound to keys, with the trust badge and the number
|
/// 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 {
|
class ContactsScreen extends ConsumerWidget {
|
||||||
const ContactsScreen({super.key});
|
const ContactsScreen({super.key});
|
||||||
|
|
||||||
Future<void> _import(BuildContext context, WidgetRef ref) async {
|
void _import(BuildContext context) =>
|
||||||
final client = ref.read(clientProvider);
|
AutoRouter.of(context).push(ImportContactDialogRoute());
|
||||||
final controller = TextEditingController();
|
|
||||||
final text = await showDialog<String>(
|
|
||||||
context: context,
|
|
||||||
builder: (ctx) => AlertDialog(
|
|
||||||
backgroundColor: Theme.of(context).extension<AppColors>()!.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
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
|
@ -65,7 +26,7 @@ class ContactsScreen extends ConsumerWidget {
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: "Import smol:// address",
|
tooltip: "Import smol:// address",
|
||||||
onPressed: () => _import(context, ref),
|
onPressed: () => _import(context),
|
||||||
icon: const Icon(Icons.person_add),
|
icon: const Icon(Icons.person_add),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
131
lib/presentation/screens/import_contact_dialog.dart
Normal file
131
lib/presentation/screens/import_contact_dialog.dart
Normal file
|
|
@ -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<ImportContactDialogScreen> createState() =>
|
||||||
|
ImportContactDialogScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class ImportContactDialogScreenState
|
||||||
|
extends ConsumerState<ImportContactDialogScreen> {
|
||||||
|
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<AppColors>()!.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"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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/routes/app_router.gr.dart";
|
||||||
import "package:smol_mail/presentation/widgets/image_banner.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/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/shared/utils/snackbar.dart";
|
||||||
import "package:smol_mail/smol/errors.dart";
|
import "package:smol_mail/smol/errors.dart";
|
||||||
|
|
||||||
|
|
@ -90,11 +91,24 @@ class _InboxScreenState extends ConsumerState<InboxScreen>
|
||||||
final unread = ref.watch(unreadProvider);
|
final unread = ref.watch(unreadProvider);
|
||||||
final requestsUnread = ref.watch(requestsUnreadProvider);
|
final requestsUnread = ref.watch(requestsUnreadProvider);
|
||||||
final canFetch = _folders[_tabController.index] != "sent";
|
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(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text("kirakira"),
|
title: const Text("kirakira"),
|
||||||
actions: [
|
actions: [
|
||||||
|
if (share != null)
|
||||||
|
IconButton(
|
||||||
|
tooltip: "Show QR code",
|
||||||
|
onPressed: () => showQrCodeSheet(context, share),
|
||||||
|
icon: const Icon(Icons.qr_code),
|
||||||
|
),
|
||||||
if (canFetch)
|
if (canFetch)
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: "Fetch",
|
tooltip: "Fetch",
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import "package:share_plus/share_plus.dart";
|
||||||
import "package:smol_mail/data/providers/providers.dart";
|
import "package:smol_mail/data/providers/providers.dart";
|
||||||
import "package:smol_mail/presentation/routes/app_router.gr.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/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/shared/utils/snackbar.dart";
|
||||||
import "package:smol_mail/smol/address.dart";
|
import "package:smol_mail/smol/address.dart";
|
||||||
import "package:smol_mail/smol/errors.dart";
|
import "package:smol_mail/smol/errors.dart";
|
||||||
|
|
@ -236,12 +237,18 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (share != null)
|
if (share != null) ...[
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.copy, size: 18),
|
icon: const Icon(Icons.copy, size: 18),
|
||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
Clipboard.setData(ClipboardData(text: share)),
|
Clipboard.setData(ClipboardData(text: share)),
|
||||||
),
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: "Show QR code",
|
||||||
|
icon: const Icon(Icons.qr_code, size: 18),
|
||||||
|
onPressed: () => showQrCodeSheet(context, share),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
52
lib/presentation/widgets/qr_code_sheet.dart
Normal file
52
lib/presentation/widgets/qr_code_sheet.dart
Normal file
|
|
@ -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<void> showQrCodeSheet(BuildContext context, String payload) {
|
||||||
|
return showModalBottomSheet<void>(
|
||||||
|
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"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
27
lib/smol/link.dart
Normal file
27
lib/smol/link.dart
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,9 +6,13 @@
|
||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <gtk/gtk_plugin.h>
|
||||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
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 =
|
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
gtk
|
||||||
url_launcher_linux
|
url_launcher_linux
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
64
pubspec.lock
64
pubspec.lock
|
|
@ -33,6 +33,38 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.3"
|
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:
|
archive:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -426,6 +458,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.2"
|
version: "2.3.2"
|
||||||
|
gtk:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: gtk
|
||||||
|
sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
hive:
|
hive:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -631,6 +671,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.0"
|
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:
|
node_preamble:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -799,6 +847,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.6.0"
|
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:
|
record_use:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
14
pubspec.yaml
14
pubspec.yaml
|
|
@ -21,6 +21,9 @@ dependencies:
|
||||||
share_plus: ^13.3.0
|
share_plus: ^13.3.0
|
||||||
path_provider: ^2.1.6
|
path_provider: ^2.1.6
|
||||||
file_picker: ^13.1.0
|
file_picker: ^13.1.0
|
||||||
|
qr_flutter: ^4.1.0
|
||||||
|
app_links: ^7.0.0
|
||||||
|
mobile_scanner: ^7.4.2
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
@ -33,6 +36,7 @@ dev_dependencies:
|
||||||
auto_route_generator: ^10.5.0
|
auto_route_generator: ^10.5.0
|
||||||
flutter_native_splash: ^2.3.4
|
flutter_native_splash: ^2.3.4
|
||||||
flutter_launcher_icons: ^0.14.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
|
# patrol test reads this block; android.package_name is the flavored
|
||||||
# applicationId (the integration flavor adds the .integration suffix), and
|
# applicationId (the integration flavor adds the .integration suffix), and
|
||||||
|
|
@ -59,8 +63,13 @@ flutter_native_splash:
|
||||||
web: false
|
web: false
|
||||||
ios: false
|
ios: false
|
||||||
color: "#01111d"
|
color: "#01111d"
|
||||||
image: assets/images/sparkle.png
|
image: assets/images/bug_logo.png
|
||||||
branding: assets/images/sparkle.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:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
@ -68,7 +77,6 @@ flutter:
|
||||||
- assets/images/bug_logo.png
|
- assets/images/bug_logo.png
|
||||||
- assets/images/empty.png
|
- assets/images/empty.png
|
||||||
- assets/images/error.png
|
- assets/images/error.png
|
||||||
- assets/images/avatar.png
|
|
||||||
fonts:
|
fonts:
|
||||||
- family: FlashMailIcons
|
- family: FlashMailIcons
|
||||||
fonts:
|
fonts:
|
||||||
|
|
|
||||||
BIN
test-qr.png
Normal file
BIN
test-qr.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 514 B |
45
test/link_test.dart
Normal file
45
test/link_test.dart
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
28
test/qr_sheet_test.dart
Normal file
28
test/qr_sheet_test.dart
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import "dart:io";
|
import "dart:io";
|
||||||
|
|
||||||
|
import "package:app_links_platform_interface/app_links_platform_interface.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:flutter_test/flutter_test.dart";
|
import "package:flutter_test/flutter_test.dart";
|
||||||
import "package:hive_flutter/hive_flutter.dart";
|
import "package:hive_flutter/hive_flutter.dart";
|
||||||
|
|
@ -10,11 +11,22 @@ import "package:smol_mail/smol/store.dart";
|
||||||
|
|
||||||
late final SmolStore store;
|
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<Uri?> getInitialLink() async => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<Uri> get uriLinkStream => const Stream.empty();
|
||||||
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
// Hive's box opening is real file IO, which never completes inside a
|
// Hive's box opening is real file IO, which never completes inside a
|
||||||
// testWidgets fake-async zone — so it happens here, outside one.
|
// testWidgets fake-async zone — so it happens here, outside one.
|
||||||
setUpAll(() async {
|
setUpAll(() async {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
AppLinksPlatform.instance = _NoopAppLinks();
|
||||||
final dir = await Directory.systemTemp.createTemp("smol-widget-test");
|
final dir = await Directory.systemTemp.createTemp("smol-widget-test");
|
||||||
Hive.init(dir.path);
|
Hive.init(dir.path);
|
||||||
store = await SmolStore.open(dbPath: "${dir.path}/widget.db");
|
store = await SmolStore.open(dbPath: "${dir.path}/widget.db");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue