feat: support smol:// QR sharing, deep links and in-dialog scanning

This commit is contained in:
randogoth 2026-09-30 17:14:47 +03:00
parent 7d2a147fec
commit aa74e12ee7
20 changed files with 591 additions and 101 deletions

45
test/link_test.dart Normal file
View 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
View 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);
});
}

View file

@ -1,5 +1,6 @@
import "dart:io";
import "package:app_links_platform_interface/app_links_platform_interface.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:flutter_test/flutter_test.dart";
import "package:hive_flutter/hive_flutter.dart";
@ -10,11 +11,22 @@ import "package:smol_mail/smol/store.dart";
late final SmolStore store;
// AppWidget subscribes to app_links on boot; in the test shell there is no
// platform plugin to talk to, so the subscription points at this no-op.
class _NoopAppLinks extends AppLinksPlatform {
@override
Future<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");