Compare commits
No commits in common. "aa74e12ee76e2e7d6f4929e14401377faff398a5" and "996e879509375993bbe8b946256280f88a78e60e" have entirely different histories.
aa74e12ee7
...
996e879509
35 changed files with 180 additions and 700 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -19,15 +19,6 @@
|
|||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</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>
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
|
|
|
|||
|
|
@ -112,14 +112,14 @@ class FumiNative {
|
|||
|
||||
Future<int> rotations() => _io((ffi) => ffi.rotations(_s));
|
||||
|
||||
Future<void> pinServer(String host, String keyB32, int port) =>
|
||||
_io((ffi) => ffi.pinServer(_s, host, keyB32, port));
|
||||
Future<void> pinServer(String host, String keyB32) =>
|
||||
_io((ffi) => ffi.pinServer(_s, host, keyB32));
|
||||
|
||||
Future<String?> serverPin(String host, int port) =>
|
||||
_io((ffi) => ffi.serverPin(_s, host, port));
|
||||
Future<String?> serverPin(String host) =>
|
||||
_io((ffi) => ffi.serverPin(_s, host));
|
||||
|
||||
Future<void> unpinServer(String host, int port) =>
|
||||
_io((ffi) => ffi.unpinServer(_s, host, port));
|
||||
Future<void> unpinServer(String host) =>
|
||||
_io((ffi) => ffi.unpinServer(_s, host));
|
||||
|
||||
Future<List<dynamic>> pins() => _io((ffi) => ffi.pins(_s));
|
||||
|
||||
|
|
|
|||
|
|
@ -109,15 +109,15 @@ class SmolFfi {
|
|||
Void Function(Pointer<Void>),
|
||||
void Function(Pointer<Void>)>("smol_flag_free");
|
||||
|
||||
late final int Function(Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>, int)
|
||||
late final int Function(Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>)
|
||||
_pinServer = _lib.lookupFunction<
|
||||
Int32 Function(Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>, Uint16),
|
||||
int Function(Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>,
|
||||
int)>("smol_pin_server");
|
||||
late final Pointer<Utf8> Function(Pointer<Void>, Pointer<Utf8>, int)
|
||||
Int32 Function(Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>),
|
||||
int Function(
|
||||
Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>)>("smol_pin_server");
|
||||
late final Pointer<Utf8> Function(Pointer<Void>, Pointer<Utf8>)
|
||||
_serverPin = _lib.lookupFunction<
|
||||
Pointer<Utf8> Function(Pointer<Void>, Pointer<Utf8>, Uint16),
|
||||
Pointer<Utf8> Function(Pointer<Void>, Pointer<Utf8>, int)>(
|
||||
Pointer<Utf8> Function(Pointer<Void>, Pointer<Utf8>),
|
||||
Pointer<Utf8> Function(Pointer<Void>, Pointer<Utf8>)>(
|
||||
"smol_server_pin");
|
||||
late final Pointer<Utf8> Function(Pointer<Void>) _storeAccount = _lib
|
||||
.lookupFunction<Pointer<Utf8> Function(Pointer<Void>),
|
||||
|
|
@ -226,11 +226,9 @@ class SmolFfi {
|
|||
Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>, Int32),
|
||||
int Function(Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>,
|
||||
int)>("smol_contact_save");
|
||||
late final int Function(Pointer<Void>, Pointer<Utf8>, int) _unpinServer =
|
||||
_lib.lookupFunction<
|
||||
Int32 Function(Pointer<Void>, Pointer<Utf8>, Uint16),
|
||||
int Function(
|
||||
Pointer<Void>, Pointer<Utf8>, int)>("smol_unpin_server");
|
||||
late final int Function(Pointer<Void>, Pointer<Utf8>) _unpinServer = _lib
|
||||
.lookupFunction<Int32 Function(Pointer<Void>, Pointer<Utf8>),
|
||||
int Function(Pointer<Void>, Pointer<Utf8>)>("smol_unpin_server");
|
||||
late final Pointer<Utf8> Function(Pointer<Void>) _pins = _lib.lookupFunction<
|
||||
Pointer<Utf8> Function(Pointer<Void>),
|
||||
Pointer<Utf8> Function(Pointer<Void>)>("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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<AppWidget> {
|
||||
late final AppRouter _router = AppRouter(ref);
|
||||
final AppLinks _appLinks = AppLinks();
|
||||
StreamSubscription<Uri>? _linkSub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
|
@ -47,33 +37,6 @@ 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
|
||||
|
|
@ -85,16 +48,7 @@ class _AppWidgetState extends ConsumerState<AppWidget> {
|
|||
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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<T> dialogRouteBuilder<T>(
|
||||
BuildContext context,
|
||||
Widget child,
|
||||
AutoRoutePage<T> page,
|
||||
) =>
|
||||
DialogRoute<T>(
|
||||
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)],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ComposeRouteArgs> {
|
||||
class ComposeRoute extends _i9.PageRouteInfo<ComposeRouteArgs> {
|
||||
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<ComposeRouteArgs> {
|
|||
|
||||
static const String name = 'ComposeRoute';
|
||||
|
||||
static _i10.PageInfo page = _i10.PageInfo(
|
||||
static _i9.PageInfo page = _i9.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
final args = data.argsAs<ComposeRouteArgs>(
|
||||
|
|
@ -59,7 +57,7 @@ class ComposeRoute extends _i10.PageRouteInfo<ComposeRouteArgs> {
|
|||
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<ContactDetailRouteArgs> {
|
||||
class ContactDetailRoute extends _i9.PageRouteInfo<ContactDetailRouteArgs> {
|
||||
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<ContactDetailRouteArgs> {
|
|||
|
||||
static const String name = 'ContactDetailRoute';
|
||||
|
||||
static _i10.PageInfo page = _i10.PageInfo(
|
||||
static _i9.PageInfo page = _i9.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
final args = data.argsAs<ContactDetailRouteArgs>();
|
||||
|
|
@ -108,7 +106,7 @@ class ContactDetailRoute extends _i10.PageRouteInfo<ContactDetailRouteArgs> {
|
|||
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<void> {
|
||||
const ContactsRoute({List<_i10.PageRouteInfo>? children})
|
||||
class ContactsRoute extends _i9.PageRouteInfo<void> {
|
||||
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<void> {
|
|||
|
||||
/// generated route for
|
||||
/// [_i4.HomeScreen]
|
||||
class HomeRoute extends _i10.PageRouteInfo<void> {
|
||||
const HomeRoute({List<_i10.PageRouteInfo>? children})
|
||||
class HomeRoute extends _i9.PageRouteInfo<void> {
|
||||
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<void> {
|
|||
}
|
||||
|
||||
/// generated route for
|
||||
/// [_i5.ImportContactDialogScreen]
|
||||
class ImportContactDialogRoute
|
||||
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})
|
||||
/// [_i5.InboxScreen]
|
||||
class InboxRoute extends _i9.PageRouteInfo<void> {
|
||||
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<MessageDetailRouteArgs> {
|
||||
/// [_i6.MessageDetailScreen]
|
||||
class MessageDetailRoute extends _i9.PageRouteInfo<MessageDetailRouteArgs> {
|
||||
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<MessageDetailRouteArgs> {
|
|||
|
||||
static const String name = 'MessageDetailRoute';
|
||||
|
||||
static _i10.PageInfo page = _i10.PageInfo(
|
||||
static _i9.PageInfo page = _i9.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
final args = data.argsAs<MessageDetailRouteArgs>();
|
||||
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<void> {
|
||||
const OnboardingRoute({List<_i10.PageRouteInfo>? children})
|
||||
/// [_i7.OnboardingScreen]
|
||||
class OnboardingRoute extends _i9.PageRouteInfo<void> {
|
||||
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<void> {
|
||||
const SettingsRoute({List<_i10.PageRouteInfo>? children})
|
||||
/// [_i8.SettingsScreen]
|
||||
class SettingsRoute extends _i9.PageRouteInfo<void> {
|
||||
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();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ContactDetailScreen> {
|
|||
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<ContactDetailScreen> {
|
|||
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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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<void> _import(BuildContext context, WidgetRef ref) async {
|
||||
final client = ref.read(clientProvider);
|
||||
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
|
||||
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),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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<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,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<InboxScreen>
|
|||
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",
|
||||
|
|
|
|||
|
|
@ -161,7 +161,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
|||
// "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<OnboardingScreen> {
|
|||
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),
|
||||
|
|
|
|||
|
|
@ -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<SettingsScreen> {
|
|||
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<SettingsScreen> {
|
|||
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<SettingsScreen> {
|
|||
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) {
|
||||
|
|
|
|||
|
|
@ -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<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"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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<void> registerAccount(String addressText, {String token = ""}) async {
|
||||
final addr = parseAddress(addressText);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<void> unpinServer(String host, int port) async =>
|
||||
_native.unpinServer(host, port);
|
||||
Future<void> unpinServer(String host) async => _native.unpinServer(host);
|
||||
|
||||
// --- backups ----------------------------------------------------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -6,13 +6,9 @@
|
|||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <gtk/gtk_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
gtk
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
|
|
|
|||
2
native/Cargo.lock
generated
2
native/Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -126,29 +126,22 @@ struct ErrorJson {
|
|||
/// The raw byte behind an UNKNOWN_STATUS (code 11).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
status: Option<u8>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
port: Option<u16>,
|
||||
}
|
||||
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
64
pubspec.lock
64
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:
|
||||
|
|
|
|||
14
pubspec.yaml
14
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:
|
||||
|
|
|
|||
BIN
test-qr.png
BIN
test-qr.png
Binary file not shown.
|
Before Width: | Height: | Size: 514 B |
|
|
@ -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<SmolError>()));
|
||||
secondStore.pinServer(host, serverKey, port);
|
||||
secondStore.pinServer(host, serverKey);
|
||||
await expectLater(
|
||||
secondDevice.registerAccount(address.short), throwsA(isA<SmolError>()));
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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<String, dynamic>;
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -36,7 +36,7 @@ class StubClient extends SmolClient {
|
|||
@override
|
||||
Future<SmolAddress> 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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Uri?> getInitialLink() async => null;
|
||||
|
||||
@override
|
||||
Stream<Uri> 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");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue