From e94e4158ccf8ae6dfcad39ac812f482c97dc10d5 Mon Sep 17 00:00:00 2001 From: randogoth Date: Sat, 26 Sep 2026 22:45:38 +0300 Subject: [PATCH 01/22] feat: replace mail protocol with smolmail and rebuild the UI --- README.md | 107 +-- analysis_options.yaml | 3 +- android/app/build.gradle | 88 -- android/app/build.gradle.kts | 40 + android/app/src/main/AndroidManifest.xml | 30 +- .../{flash_mail => smol_mail}/MainActivity.kt | 2 +- android/build.gradle | 31 - android/build.gradle.kts | 33 + android/gradle.properties | 3 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- android/nix-android-sdk/flake.lock | 136 +++ android/nix-android-sdk/flake.nix | 27 + android/settings.gradle | 11 - android/settings.gradle.kts | 25 + devbox.json | 24 + devbox.lock | 65 ++ lib/data/api/api_client.dart | 123 --- lib/data/models/account/account.dart | 20 - lib/data/models/account/account.freezed.dart | 252 ------ lib/data/models/account/account.g.dart | 75 -- lib/data/models/message/message.dart | 23 - lib/data/models/message/message.freezed.dart | 363 -------- lib/data/models/message/message.g.dart | 37 - .../models/message_detail/message_detail.dart | 25 - .../message_detail.freezed.dart | 438 --------- .../message_detail/message_detail.g.dart | 45 - lib/data/notifiers/account_notifier.dart | 86 -- lib/data/providers/providers.dart | 75 +- lib/main.dart | 20 +- lib/presentation/app_widget.dart | 47 +- lib/presentation/routes/account_guard.dart | 18 - lib/presentation/routes/app_router.dart | 40 +- lib/presentation/routes/app_router.gr.dart | 291 ++++-- lib/presentation/routes/auth_guard.dart | 18 - lib/presentation/routes/home_guard.dart | 23 + lib/presentation/routes/identity_guard.dart | 22 + lib/presentation/screens/account_screen.dart | 34 - lib/presentation/screens/compose_screen.dart | 127 +++ .../screens/contact_detail_screen.dart | 158 ++++ lib/presentation/screens/contacts_screen.dart | 130 +++ lib/presentation/screens/inbox_screen.dart | 102 ++- lib/presentation/screens/login_screen.dart | 31 - .../screens/message_detail_screen.dart | 133 ++- .../screens/onboarding_screen.dart | 324 +++++++ lib/presentation/screens/settings_screen.dart | 425 +++++++++ lib/presentation/theme/app_colors.dart | 78 ++ lib/presentation/theme/theme.dart | 174 ++-- .../widgets/attachment/attachment_button.dart | 80 -- .../widgets/attachment/attachment_list.dart | 18 - .../widgets/auth/account_buttons.dart | 67 -- .../widgets/auth/input_field.dart | 58 -- lib/presentation/widgets/auth/login_form.dart | 107 --- .../widgets/drawer/app_drawer.dart | 26 +- .../widgets/drawer/drawer_list.dart | 61 +- .../widgets/drawer/drawer_list_tile.dart | 7 +- .../widgets/drawer/identity_header.dart | 64 ++ .../widgets/drawer/toast_message.dart | 46 - .../widgets/drawer/user_profile.dart | 75 -- .../widgets/drawer/user_quota.dart | 71 -- lib/presentation/widgets/image_banner.dart | 4 +- .../widgets/message/message_body.dart | 53 -- .../widgets/message/message_info.dart | 82 -- .../widgets/message/message_list.dart | 27 +- .../widgets/message/message_sender.dart | 84 -- .../widgets/message/message_tile.dart | 108 ++- .../widgets/message/message_view.dart | 199 ++++- .../widgets/shimmer/list_shimmer.dart | 45 - .../widgets/shimmer/message_shimmer.dart | 28 - .../widgets/small_loading_spinner.dart | 6 +- lib/shared/configs/flash_mail_icons.dart | 26 +- lib/shared/utils/avatar_color.dart | 15 + lib/shared/utils/format.dart | 37 + lib/shared/utils/random_color.dart | 12 - lib/shared/utils/random_string.dart | 12 - lib/shared/utils/regex_patterns.dart | 3 - lib/shared/utils/snackbar.dart | 15 + lib/smol/client.dart | 426 +++++++++ lib/smol/config.dart | 26 + lib/smol/crypto.dart | 435 +++++++++ lib/smol/errors.dart | 10 + lib/smol/noise.dart | 118 +++ lib/smol/proto.dart | 580 ++++++++++++ lib/smol/store.dart | 463 ++++++++++ lib/smol/transport.dart | 115 +++ pubspec.lock | 840 ++++++++++-------- pubspec.yaml | 33 +- test/dbg_test.dart | 61 ++ test/e2e_test.dart | 121 +++ test/recall_flow_test.dart | 104 +++ test/smol_test.dart | 306 +++++++ test/store_test.dart | 140 +++ test/vectors.json | 226 +++++ test/widget_test.dart | 42 +- 93 files changed, 6387 insertions(+), 3479 deletions(-) delete mode 100644 android/app/build.gradle create mode 100644 android/app/build.gradle.kts rename android/app/src/main/kotlin/com/example/{flash_mail => smol_mail}/MainActivity.kt (75%) delete mode 100644 android/build.gradle create mode 100644 android/build.gradle.kts create mode 100644 android/nix-android-sdk/flake.lock create mode 100644 android/nix-android-sdk/flake.nix delete mode 100644 android/settings.gradle create mode 100644 android/settings.gradle.kts create mode 100644 devbox.json create mode 100644 devbox.lock delete mode 100644 lib/data/api/api_client.dart delete mode 100644 lib/data/models/account/account.dart delete mode 100644 lib/data/models/account/account.freezed.dart delete mode 100644 lib/data/models/account/account.g.dart delete mode 100644 lib/data/models/message/message.dart delete mode 100644 lib/data/models/message/message.freezed.dart delete mode 100644 lib/data/models/message/message.g.dart delete mode 100644 lib/data/models/message_detail/message_detail.dart delete mode 100644 lib/data/models/message_detail/message_detail.freezed.dart delete mode 100644 lib/data/models/message_detail/message_detail.g.dart delete mode 100644 lib/data/notifiers/account_notifier.dart delete mode 100644 lib/presentation/routes/account_guard.dart delete mode 100644 lib/presentation/routes/auth_guard.dart create mode 100644 lib/presentation/routes/home_guard.dart create mode 100644 lib/presentation/routes/identity_guard.dart delete mode 100644 lib/presentation/screens/account_screen.dart create mode 100644 lib/presentation/screens/compose_screen.dart create mode 100644 lib/presentation/screens/contact_detail_screen.dart create mode 100644 lib/presentation/screens/contacts_screen.dart delete mode 100644 lib/presentation/screens/login_screen.dart create mode 100644 lib/presentation/screens/onboarding_screen.dart create mode 100644 lib/presentation/screens/settings_screen.dart create mode 100644 lib/presentation/theme/app_colors.dart delete mode 100644 lib/presentation/widgets/attachment/attachment_button.dart delete mode 100644 lib/presentation/widgets/attachment/attachment_list.dart delete mode 100644 lib/presentation/widgets/auth/account_buttons.dart delete mode 100644 lib/presentation/widgets/auth/input_field.dart delete mode 100644 lib/presentation/widgets/auth/login_form.dart create mode 100644 lib/presentation/widgets/drawer/identity_header.dart delete mode 100644 lib/presentation/widgets/drawer/toast_message.dart delete mode 100644 lib/presentation/widgets/drawer/user_profile.dart delete mode 100644 lib/presentation/widgets/drawer/user_quota.dart delete mode 100644 lib/presentation/widgets/message/message_body.dart delete mode 100644 lib/presentation/widgets/message/message_info.dart delete mode 100644 lib/presentation/widgets/message/message_sender.dart delete mode 100644 lib/presentation/widgets/shimmer/list_shimmer.dart delete mode 100644 lib/presentation/widgets/shimmer/message_shimmer.dart create mode 100644 lib/shared/utils/avatar_color.dart create mode 100644 lib/shared/utils/format.dart delete mode 100644 lib/shared/utils/random_color.dart delete mode 100644 lib/shared/utils/random_string.dart delete mode 100644 lib/shared/utils/regex_patterns.dart create mode 100644 lib/shared/utils/snackbar.dart create mode 100644 lib/smol/client.dart create mode 100644 lib/smol/config.dart create mode 100644 lib/smol/crypto.dart create mode 100644 lib/smol/errors.dart create mode 100644 lib/smol/noise.dart create mode 100644 lib/smol/proto.dart create mode 100644 lib/smol/store.dart create mode 100644 lib/smol/transport.dart create mode 100644 test/dbg_test.dart create mode 100644 test/e2e_test.dart create mode 100644 test/recall_flow_test.dart create mode 100644 test/smol_test.dart create mode 100644 test/store_test.dart create mode 100644 test/vectors.json diff --git a/README.md b/README.md index 13c2a07..32be7ae 100644 --- a/README.md +++ b/README.md @@ -1,80 +1,67 @@ -# FlashMail +# kirakira -FlashMail is a Mobile application designed to provide users with a temporary email solution powered by the mail.tm platform. With FlashMail, users can receive and view emails conveniently while maintaining their privacy. The app utilizes Riverpod as its state management solution for efficient and organized data handling. +A mobile client for [Smol Mail](../smolmail), the minimalist end-to-end encrypted mail protocol: one Ed25519 identity, five operations, Noise_NX transport, sealed and signed messages. The sibling of the reference CLI client (`../smolmail`) and the browser client (`../gsmol`) — everything they do, in an Android app. -This repository contains the source code for FlashMail developed using the Flutter framework. The app is designed to provide various features and functionalities to enhance user experience. Below, you will find information about how to download the app, its features, dependencies, license, and more. +## How it works -## Download +``` +app (all crypto, all keys) ⇄ smolmaild (TCP :1961, Noise_NX) +``` -You can download the latest version of the app from the [GitHub Releases](https://github.com/sarthakkimtani/flash-mail/releases) page. Look for the most recent release and download the corresponding APK or installer file for your device. +There is no bridge and no server-side account: the Noise session, sealing, signing, pinning and trust-on-first-use all run on the device. The seed lives in Hive app storage — the phone's app sandbox is the trust boundary, as with the CLI client's `identity.key` file. -## Requirements +## First use -- Any Operating System (ie. MacOS X, Linux, Windows) -- Any IDE with Flutter SDK installed (ie. IntelliJ, Android Studio, VSCode, etc) -- A little knowledge of Dart and Flutter +1. Create an identity (or restore from a seed) and back the seed up — it is the only secret. +2. Pin your home server's public key, obtained from the operator through a trusted channel (SPEC.md §4: registration and fetching refuse unpinned servers). +3. Register an address, then fetch. Restoring a seed on a new device? Enter the address alongside the seed — or "Recall" from settings — and it rebinds by RESOLVE + key check, without re-registering. -## Features +## What it implements -- Instantly create temporary email addresses to receive messages without revealing your personal email. -- Enjoy a seamless user experience with an intuitive and user-friendly interface, making it easy to navigate through your emails effortlessly. -- Access and download attachments with ease, allowing you to view and save important files directly from your emails. -- Access and read your emails effortlessly with a seamless viewing experience. -- Benefit from a compact APK size of just 22 MB, ensuring that FlashMail takes up minimal storage space on your device. +- **Identity** (§2): one 32-byte seed; the X25519 agreement keys are derived from the Ed25519 keypair. Superseded seeds are kept after rotation, since mail sealed to them is readable with nothing else. +- **Addressing** (§3): short `user@host[:1961]` and self-certifying `smol://user@host/key` addresses; base32 fingerprints. +- **Trust** (§4, §8): server keys pinned explicitly; mismatch aborts the handshake; registration and fetching require a pin. +- **Mail** (§5): sealed and signed envelopes with 1 KiB padding, flat frontmatter bodies, sent copies sealed to self. Fetch verifies id and signature before acknowledging — anything unreadable stays on the server. +- **Contacts**: outgoing mail carries a signed `Reply-To` field with the sender's full `smol://` address (an "anonymous" switch omits it); a first-contact claim binds only when its key matches the message's signer, and never over an address already pinned to a different key. The contacts screen shows each bound key with its trust badge and the keys it displaced — the only local record that a contact rotated — and a re-resolve applies §8: a chain-validated rotation is accepted and announced, an unexplained key change is refused until verified out of band. Trust warnings (unpinned sessions, rotations) persist on screen until dismissed, as the spec's §4/§8 messages demand. +- **Rotation** (§7): rotate with a signed certificate; contacts accept the change from the chain. +- **Backup**: settings can export the inbox, sent mail, contacts and server pins to one shareable file (and import it back) — the same JSON shape as gsmol's export, so backups move between the two clients. It never contains the seed, which has its own reveal-and-copy flow. Import never overwrites a pin or contact that already differs locally; malformed entries are skipped and counted. -## Screenshots +## Layout - - +``` +lib/smol/crypto.dart SHA-2, HKDF, ChaCha20-Poly1305, X25519, Ed25519, §2 conversions +lib/smol/noise.dart Noise_NX_25519_ChaChaPoly_SHA256 initiator +lib/smol/proto.dart addresses, seal/unseal, frontmatter, rotation, op framing +lib/smol/transport.dart TCP byte pipe (dart:io) +lib/smol/store.dart Hive: identity, pins, contacts, sealed mail, export/import +lib/smol/config.dart deploy-time preset server pin (--dart-define) +lib/smol/client.dart connect/fetch/send/register/rotate flows +lib/presentation/ the UI (Riverpod + auto_route) +test/smol_test.dart byte-exact vectors + protocol cases +test/store_test.dart contact key history, import +test/widget_test.dart UI smoke test +test/e2e_test.dart live round-trip against smolmaild +``` -## Installation +The crypto is pure Dart, mirroring gsmol's dependency-free modules, so `test/vectors.json` — generated from the reference stack (PyNaCl, noiseprotocol) by `../gsmol/test/gen_vectors.py` — pins every operation byte for byte. -To run the app locally and make modifications, follow these steps: +## Run and verify -1. Ensure you have Flutter SDK installed on your machine. You can download it from the official Flutter website: https://flutter.dev. -2. Clone this repository to your local machine using the following command:
- ```bash - git clone https://github.com/sarthakkimtani/flash-mail.git - ``` -3. Navigate to the project directory:

- ```bash - cd flash-mail - ``` -4. Fetch the app's dependencies by running the following command:
- ```bash - flutter pub get - ``` -5. Connect your device or start an emulator. -6. Run the app using the following command:
- ```bash - flutter run - ``` +``` +devbox run analyze +devbox run test +``` -## Dependencies +Then `flutter run` with a device or emulator attached. `test/e2e_test.dart` additionally runs a live round-trip (register, send to self, fetch, unseal, drain) against `../smolmail/smolmaild.py` on `127.0.0.1:1961`, and self-skips when no server is listening. -| Name | Usage | -| ------------------------------------------------------------------------- | --------------------------- | -| [**Auto Route**](https://pub.dev/packages/auto_route) | Navigation & Routing | -| [**Dio**](https://pub.dev/packages/dio) | HTTP Requests | -| [**External Path**](https://pub.dev/packages/external_path) | External Storage Path | -| [**Flash**](https://pub.dev/packages/flash) | Alerts & Dialogs | -| [**Flutter Downloader**](https://pub.dev/packages/flutter_downloader) | Download Files | -| [**Flutter InAppWebView**](https://pub.dev/packages/flutter_inappwebview) | In-App WebView | -| [**Flutter Riverpod**](https://pub.dev/packages/flutter_riverpod) | Global State Management | -| [**Freezed**](https://pub.dev/packages/freezed) | Code Generation | -| [**Hive**](https://pub.dev/packages/hive) | Local Database | -| [**Intl**](https://pub.dev/packages/intl) | Internationalization | -| [**Shimmer**](https://pub.dev/packages/shimmer) | Shimmer for Loading Screens | -| [**URL Launcher**](https://pub.dev/packages/url_launcher) | URL Launcher | +Not verified here: clicking through the app on a real device — the logic under every button is what the tests exercise, as with gsmol. -## Contributing +## Notes and limits -Contributions to this app are welcome! If you find any issues or have ideas for improvements, please open an issue or submit a pull request. Make sure to follow the repository's guidelines for contributing. +- No server push or notifications in v1 (SPEC.md §13): tap fetch. +- The seed sits in app storage: a compromised device is game over, same as a stolen `identity.key` file for the CLI client. +- Rotation is not revocation (§7): a stolen key can rotate onward and the chain validates. The settings screen surfaces rotations instead of applying them invisibly; out-of-band re-verification is the only defence. ## License -This app is distributed under the [MIT License](https://github.com/sarthakkimtani/flash-mail/blob/main/LICENSE). Feel free to modify and use it as per your requirements. - -## Disclaimer - -FlashMail is an independent project and is not affiliated with or endorsed by the mail.tm platform. Please review and comply with the terms of service of mail.tm when using this application. +Distributed under the MIT License; see LICENSE.md. diff --git a/analysis_options.yaml b/analysis_options.yaml index b07c1de..0fc3ec6 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -2,8 +2,7 @@ include: package:flutter_lints/flutter.yaml analyzer: exclude: - - lib/data/models/*/*.g.dart - - lib/data/models/*/*.freezed.dart + - lib/presentation/routes/app_router.gr.dart linter: rules: diff --git a/android/app/build.gradle b/android/app/build.gradle deleted file mode 100644 index a1b61ba..0000000 --- a/android/app/build.gradle +++ /dev/null @@ -1,88 +0,0 @@ -def localProperties = new Properties() -def localPropertiesFile = rootProject.file('local.properties') -if (localPropertiesFile.exists()) { - localPropertiesFile.withReader('UTF-8') { reader -> - localProperties.load(reader) - } -} - -def flutterRoot = localProperties.getProperty('flutter.sdk') -if (flutterRoot == null) { - throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") -} - -def flutterVersionCode = localProperties.getProperty('flutter.versionCode') -if (flutterVersionCode == null) { - flutterVersionCode = '1' -} - -def flutterVersionName = localProperties.getProperty('flutter.versionName') -if (flutterVersionName == null) { - flutterVersionName = '1.0' -} - -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" - -def keystoreProperties = new Properties() -def keystorePropertiesFile = rootProject.file('key.properties') -if (keystorePropertiesFile.exists()) { - keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) -} - -android { - namespace "com.example.flash_mail" - compileSdkVersion 34 - ndkVersion flutter.ndkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - kotlinOptions { - jvmTarget = '1.8' - } - - sourceSets { - main.java.srcDirs += 'src/main/kotlin' - } - - defaultConfig { - applicationId "com.app.flash_mail" - minSdkVersion flutter.minSdkVersion - targetSdkVersion flutter.targetSdkVersion - versionCode flutterVersionCode.toInteger() - versionName flutterVersionName - - signingConfigs { - release { - keyAlias keystoreProperties['keyAlias'] - keyPassword keystoreProperties['keyPassword'] - storeFile file(keystoreProperties['storeFile']) - storePassword keystoreProperties['storePassword'] - } - } - - buildTypes { - release { - signingConfig signingConfigs.release - } - } - } - - buildTypes { - release { - signingConfig signingConfigs.debug - } - } -} - -flutter { - source '../..' -} - -dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" -} diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..db7cfc9 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,40 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.smol_mail" + compileSdk = flutter.compileSdkVersion + ndkVersion = "27.0.12077973" + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } + + defaultConfig { + applicationId = "com.app.smol_mail" + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a32e4d5..3cd8ccf 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,6 @@ @@ -24,33 +23,6 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/android/app/src/main/kotlin/com/example/flash_mail/MainActivity.kt b/android/app/src/main/kotlin/com/example/smol_mail/MainActivity.kt similarity index 75% rename from android/app/src/main/kotlin/com/example/flash_mail/MainActivity.kt rename to android/app/src/main/kotlin/com/example/smol_mail/MainActivity.kt index 9ffc340..ba1c9bf 100644 --- a/android/app/src/main/kotlin/com/example/flash_mail/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/smol_mail/MainActivity.kt @@ -1,4 +1,4 @@ -package com.example.flash_mail +package com.example.smol_mail import io.flutter.embedding.android.FlutterActivity diff --git a/android/build.gradle b/android/build.gradle deleted file mode 100644 index f7eb7f6..0000000 --- a/android/build.gradle +++ /dev/null @@ -1,31 +0,0 @@ -buildscript { - ext.kotlin_version = '1.7.10' - repositories { - google() - mavenCentral() - } - - dependencies { - classpath 'com.android.tools.build:gradle:7.3.0' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - } -} - -allprojects { - repositories { - google() - mavenCentral() - } -} - -rootProject.buildDir = '../build' -subprojects { - project.buildDir = "${rootProject.buildDir}/${project.name}" -} -subprojects { - project.evaluationDependsOn(':app') -} - -tasks.register("clean", Delete) { - delete rootProject.buildDir -} diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..f63265d --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,33 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +// NDK 28 (flutter.ndkVersion default) is not installed; override all library +// modules to use the installed NDK 27. +subprojects { + afterEvaluate { + if (plugins.hasPlugin("com.android.library")) { + configure { + ndkVersion = "27.0.12077973" + } + } + } +} + +val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties index 94adc3a..9b650e9 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,3 +1,2 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true -android.enableJetifier=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 3c472b9..74b269f 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip diff --git a/android/nix-android-sdk/flake.lock b/android/nix-android-sdk/flake.lock new file mode 100644 index 0000000..23c99f2 --- /dev/null +++ b/android/nix-android-sdk/flake.lock @@ -0,0 +1,136 @@ +{ + "nodes": { + "android-nixpkgs": { + "inputs": { + "devshell": "devshell", + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + }, + "locked": { + "lastModified": 1768249546, + "narHash": "sha256-cpNxcSNveCBSDmocYlPrayaP8CX8ik0SKR4aNbrxOqA=", + "owner": "tadfisher", + "repo": "android-nixpkgs", + "rev": "001ffcb40e86888d38acf7b851f9a1bfac7d3a81", + "type": "github" + }, + "original": { + "owner": "tadfisher", + "ref": "stable", + "repo": "android-nixpkgs", + "type": "github" + } + }, + "devshell": { + "inputs": { + "nixpkgs": [ + "android-nixpkgs", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1764011051, + "narHash": "sha256-M7SZyPZiqZUR/EiiBJnmyUbOi5oE/03tCeFrTiUZchI=", + "owner": "numtide", + "repo": "devshell", + "rev": "17ed8d9744ebe70424659b0ef74ad6d41fc87071", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "devshell", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "flake-utils_2": { + "inputs": { + "systems": "systems_2" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1768127708, + "narHash": "sha256-1Sm77VfZh3mU0F5OqKABNLWxOuDeHIlcFjsXeeiPazs=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "android-nixpkgs": "android-nixpkgs", + "flake-utils": "flake-utils_2" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_2": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/android/nix-android-sdk/flake.nix b/android/nix-android-sdk/flake.nix new file mode 100644 index 0000000..f7bcb08 --- /dev/null +++ b/android/nix-android-sdk/flake.nix @@ -0,0 +1,27 @@ +{ + description = "A Flake for the Android SDK"; + + inputs = { + flake-utils.url = "github:numtide/flake-utils"; + android-nixpkgs.url = "github:tadfisher/android-nixpkgs/stable"; + }; + + outputs = { self, flake-utils, android-nixpkgs }: + flake-utils.lib.eachSystem ["x86_64-linux" "x86_64-darwin" "aarch64-darwin"] (system: { + packages.android-sdk = android-nixpkgs.sdk."${system}" (sdkPkgs: with sdkPkgs; [ + cmdline-tools-latest + build-tools-33-0-0 + build-tools-34-0-0 + build-tools-35-0-0 + build-tools-36-1-0 + cmake-3-22-1 + platform-tools + platforms-android-33 + platforms-android-34 + platforms-android-35 + platforms-android-36 + emulator + ndk-27-0-12077973 + ]); + }); +} diff --git a/android/settings.gradle b/android/settings.gradle deleted file mode 100644 index 44e62bc..0000000 --- a/android/settings.gradle +++ /dev/null @@ -1,11 +0,0 @@ -include ':app' - -def localPropertiesFile = new File(rootProject.projectDir, "local.properties") -def properties = new Properties() - -assert localPropertiesFile.exists() -localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } - -def flutterSdkPath = properties.getProperty("flutter.sdk") -assert flutterSdkPath != null, "flutter.sdk not set in local.properties" -apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..43394ed --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,25 @@ +pluginManagement { + val flutterSdkPath = run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.9.1" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false +} + +include(":app") diff --git a/devbox.json b/devbox.json new file mode 100644 index 0000000..7eb26b3 --- /dev/null +++ b/devbox.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/main/packages/devbox.schema.json", + "packages": [ + "flutter@3.41.9-sdk-links", + "nixpkgs#android-tools", + "nixpkgs#openjdk17", + "path:./android/nix-android-sdk#android-sdk" + ], + "shell": { + "init_hook": [ + "flutter config --jdk-dir \"$(dirname \"$(dirname \"$(command -v java)\")\")\" > /dev/null", + "flutter --version" + ], + "scripts": { + "analyze": "flutter analyze", + "test": "flutter test", + "link-android-tools": "if [ -n \"$ANDROID_HOME\" ] && [ ! -d \"$ANDROID_HOME/cmdline-tools/latest\" ] && [ -d \"$ANDROID_HOME/cmdline-tools/8.0\" ]; then ln -s \"$ANDROID_HOME/cmdline-tools/8.0\" \"$ANDROID_HOME/cmdline-tools/latest\"; fi" + } + }, + "env": { + "ANDROID_SDK_ROOT": "$DEVBOX_PACKAGES_DIR/share/android-sdk", + "ANDROID_HOME": "$DEVBOX_PACKAGES_DIR/share/android-sdk" + } +} \ No newline at end of file diff --git a/devbox.lock b/devbox.lock new file mode 100644 index 0000000..d61a7b7 --- /dev/null +++ b/devbox.lock @@ -0,0 +1,65 @@ +{ + "lockfile_version": "1", + "packages": { + "flutter@3.41.9-sdk-links": { + "last_modified": "2026-05-21T08:15:18Z", + "resolved": "github:NixOS/nixpkgs/4a29d733e8a7d5b824c3d8c958a946a9867b3eb2#flutter", + "source": "devbox-search", + "version": "3.41.9-sdk-links", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/mp9x3mn0x5hs11m8z4m20w8gndkkccb7-flutter-wrapped-3.41.9-sdk-links", + "default": true + } + ], + "store_path": "/nix/store/mp9x3mn0x5hs11m8z4m20w8gndkkccb7-flutter-wrapped-3.41.9-sdk-links" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/aidhap5ilmmf4fzyr0gwxxls0d7b9h7z-flutter-wrapped-3.41.9-sdk-links", + "default": true + } + ], + "store_path": "/nix/store/aidhap5ilmmf4fzyr0gwxxls0d7b9h7z-flutter-wrapped-3.41.9-sdk-links" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/27gbcgwqbaaf81i792zssg862xcwzwzg-flutter-wrapped-3.41.9-sdk-links", + "default": true + } + ], + "store_path": "/nix/store/27gbcgwqbaaf81i792zssg862xcwzwzg-flutter-wrapped-3.41.9-sdk-links" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/fqcjikcpdcn123csd805bmg05jv75szj-flutter-wrapped-3.41.9-sdk-links", + "default": true + } + ], + "store_path": "/nix/store/fqcjikcpdcn123csd805bmg05jv75szj-flutter-wrapped-3.41.9-sdk-links" + } + } + }, + "github:NixOS/nixpkgs/nixpkgs-unstable": { + "last_modified": "2026-08-27T07:16:00Z", + "resolved": "github:NixOS/nixpkgs/c27cdad491a991b11ed731760aa2ef8db0cb0410?lastModified=1787814960&narHash=sha256-PYZq1qzCJXC2zGI0mH07vrZBsw6DRBAOX0jN1pPtqOQ%3D" + }, + "nixpkgs#android-tools": { + "last_modified": "1970-01-01T00:00:00Z", + "resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#android-tools" + }, + "nixpkgs#openjdk17": { + "last_modified": "1970-01-01T00:00:00Z", + "resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#openjdk17" + } + } +} diff --git a/lib/data/api/api_client.dart b/lib/data/api/api_client.dart deleted file mode 100644 index 4bad7f6..0000000 --- a/lib/data/api/api_client.dart +++ /dev/null @@ -1,123 +0,0 @@ -import "dart:convert"; - -import "package:dio/dio.dart"; -import "package:flash_mail/data/providers/providers.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; - -import "package:flash_mail/data/models/account/account.dart"; -import "package:flash_mail/data/models/message/message.dart"; -import "package:flash_mail/data/models/message_detail/message_detail.dart"; -import "package:flash_mail/shared/utils/random_string.dart"; - -class ApiClient { - final Dio dio; - final Ref ref; - - ApiClient(this.dio, this.ref); - - Future generateToken(String address, String password) async { - final tokenResponse = await dio.post( - "/token", - data: jsonEncode({"address": address, "password": password}), - ); - final Map tokenData = - tokenResponse.data as Map; - - ref.read(tokenProvider.notifier).state = tokenData["token"]; - } - - Future createAccount() async { - final response = await dio.get("/domains"); - final Map data = response.data as Map; - final List> domains = List.from(data["hydra:member"]); - - final username = randomString(7); - final domain = domains[0]["domain"]; - Map payload = { - "address": "$username@$domain", - "password": randomString(10), - }; - - final userResponse = await dio.post( - "/accounts", - data: jsonEncode(payload), - ); - final Map info = userResponse.data as Map; - payload["address"] = info["address"]; - - await generateToken( - payload["address"] as String, - payload["password"] as String, - ); - - return TMAccount( - id: info["id"], - email: info["address"], - password: payload["password"]!, - quota: info["quota"], - used: info["used"], - ); - } - - Future login(String address, String password) async { - final response = await dio.post( - "/token", - data: jsonEncode({"address": address, "password": password}), - ); - final Map tokenData = - response.data as Map; - - ref.read(tokenProvider.notifier).state = tokenData["token"]; - final userId = tokenData["id"]; - - final options = Options(headers: { - "Authorization": "Bearer ${tokenData["token"]}", - }); - final userResponse = await dio.get("/accounts/$userId", options: options); - final Map info = userResponse.data as Map; - - return TMAccount( - id: info["id"], - email: address, - password: password, - quota: info["quota"], - used: info["used"], - ); - } - - Future> fetchMessages(String email, String password) async { - if (ref.read(tokenProvider) == null) { - await generateToken(email, password); - } - - final token = ref.read(tokenProvider); - final options = Options(headers: {"Authorization": "Bearer $token"}); - final response = await dio.get("/messages", options: options); - - Map messageData = response.data as Map; - final messages = - List>.from(messageData["hydra:member"]); - - return messages.map((message) => TMMessage.fromJson(message)).toList(); - } - - Future fetchMessageDetail(String id) async { - if (ref.read(tokenProvider) == null) throw Exception("Token not found"); - - final token = ref.read(tokenProvider); - final options = Options(headers: {"Authorization": "Bearer $token"}); - final response = await dio.get("/messages/$id", options: options); - - Map messageData = response.data as Map; - - return TMMessageDetail.fromJson(messageData); - } - - Future deleteMessage(String id) async { - if (ref.read(tokenProvider) == null) throw Exception("Token not found"); - - final token = ref.read(tokenProvider); - final options = Options(headers: {"Authorization": "Bearer $token"}); - await dio.delete("/messages/$id", options: options); - } -} diff --git a/lib/data/models/account/account.dart b/lib/data/models/account/account.dart deleted file mode 100644 index 22fe7f0..0000000 --- a/lib/data/models/account/account.dart +++ /dev/null @@ -1,20 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:hive/hive.dart"; - -part "account.freezed.dart"; -part "account.g.dart"; - -@freezed -class TMAccount with _$TMAccount { - @HiveType(typeId: 0, adapterName: "AccountAdapter") - factory TMAccount({ - @HiveField(0) required String id, - @HiveField(1) required String email, - @HiveField(2) required String password, - @HiveField(3) required int quota, - @HiveField(4) required int used, - }) = _TMAccount; - - factory TMAccount.fromJson(Map json) => - _$TMAccountFromJson(json); -} diff --git a/lib/data/models/account/account.freezed.dart b/lib/data/models/account/account.freezed.dart deleted file mode 100644 index 3abb17b..0000000 --- a/lib/data/models/account/account.freezed.dart +++ /dev/null @@ -1,252 +0,0 @@ -// coverage:ignore-file -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint -// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark - -part of 'account.dart'; - -// ************************************************************************** -// FreezedGenerator -// ************************************************************************** - -T _$identity(T value) => value; - -final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); - -TMAccount _$TMAccountFromJson(Map json) { - return _TMAccount.fromJson(json); -} - -/// @nodoc -mixin _$TMAccount { - @HiveField(0) - String get id => throw _privateConstructorUsedError; - @HiveField(1) - String get email => throw _privateConstructorUsedError; - @HiveField(2) - String get password => throw _privateConstructorUsedError; - @HiveField(3) - int get quota => throw _privateConstructorUsedError; - @HiveField(4) - int get used => throw _privateConstructorUsedError; - - Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) - $TMAccountCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $TMAccountCopyWith<$Res> { - factory $TMAccountCopyWith(TMAccount value, $Res Function(TMAccount) then) = - _$TMAccountCopyWithImpl<$Res, TMAccount>; - @useResult - $Res call( - {@HiveField(0) String id, - @HiveField(1) String email, - @HiveField(2) String password, - @HiveField(3) int quota, - @HiveField(4) int used}); -} - -/// @nodoc -class _$TMAccountCopyWithImpl<$Res, $Val extends TMAccount> - implements $TMAccountCopyWith<$Res> { - _$TMAccountCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? id = null, - Object? email = null, - Object? password = null, - Object? quota = null, - Object? used = null, - }) { - return _then(_value.copyWith( - id: null == id - ? _value.id - : id // ignore: cast_nullable_to_non_nullable - as String, - email: null == email - ? _value.email - : email // ignore: cast_nullable_to_non_nullable - as String, - password: null == password - ? _value.password - : password // ignore: cast_nullable_to_non_nullable - as String, - quota: null == quota - ? _value.quota - : quota // ignore: cast_nullable_to_non_nullable - as int, - used: null == used - ? _value.used - : used // ignore: cast_nullable_to_non_nullable - as int, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$TMAccountImplCopyWith<$Res> - implements $TMAccountCopyWith<$Res> { - factory _$$TMAccountImplCopyWith( - _$TMAccountImpl value, $Res Function(_$TMAccountImpl) then) = - __$$TMAccountImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {@HiveField(0) String id, - @HiveField(1) String email, - @HiveField(2) String password, - @HiveField(3) int quota, - @HiveField(4) int used}); -} - -/// @nodoc -class __$$TMAccountImplCopyWithImpl<$Res> - extends _$TMAccountCopyWithImpl<$Res, _$TMAccountImpl> - implements _$$TMAccountImplCopyWith<$Res> { - __$$TMAccountImplCopyWithImpl( - _$TMAccountImpl _value, $Res Function(_$TMAccountImpl) _then) - : super(_value, _then); - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? id = null, - Object? email = null, - Object? password = null, - Object? quota = null, - Object? used = null, - }) { - return _then(_$TMAccountImpl( - id: null == id - ? _value.id - : id // ignore: cast_nullable_to_non_nullable - as String, - email: null == email - ? _value.email - : email // ignore: cast_nullable_to_non_nullable - as String, - password: null == password - ? _value.password - : password // ignore: cast_nullable_to_non_nullable - as String, - quota: null == quota - ? _value.quota - : quota // ignore: cast_nullable_to_non_nullable - as int, - used: null == used - ? _value.used - : used // ignore: cast_nullable_to_non_nullable - as int, - )); - } -} - -/// @nodoc -@JsonSerializable() -@HiveType(typeId: 0, adapterName: "AccountAdapter") -class _$TMAccountImpl implements _TMAccount { - _$TMAccountImpl( - {@HiveField(0) required this.id, - @HiveField(1) required this.email, - @HiveField(2) required this.password, - @HiveField(3) required this.quota, - @HiveField(4) required this.used}); - - factory _$TMAccountImpl.fromJson(Map json) => - _$$TMAccountImplFromJson(json); - - @override - @HiveField(0) - final String id; - @override - @HiveField(1) - final String email; - @override - @HiveField(2) - final String password; - @override - @HiveField(3) - final int quota; - @override - @HiveField(4) - final int used; - - @override - String toString() { - return 'TMAccount(id: $id, email: $email, password: $password, quota: $quota, used: $used)'; - } - - @override - bool operator ==(dynamic other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$TMAccountImpl && - (identical(other.id, id) || other.id == id) && - (identical(other.email, email) || other.email == email) && - (identical(other.password, password) || - other.password == password) && - (identical(other.quota, quota) || other.quota == quota) && - (identical(other.used, used) || other.used == used)); - } - - @JsonKey(ignore: true) - @override - int get hashCode => - Object.hash(runtimeType, id, email, password, quota, used); - - @JsonKey(ignore: true) - @override - @pragma('vm:prefer-inline') - _$$TMAccountImplCopyWith<_$TMAccountImpl> get copyWith => - __$$TMAccountImplCopyWithImpl<_$TMAccountImpl>(this, _$identity); - - @override - Map toJson() { - return _$$TMAccountImplToJson( - this, - ); - } -} - -abstract class _TMAccount implements TMAccount { - factory _TMAccount( - {@HiveField(0) required final String id, - @HiveField(1) required final String email, - @HiveField(2) required final String password, - @HiveField(3) required final int quota, - @HiveField(4) required final int used}) = _$TMAccountImpl; - - factory _TMAccount.fromJson(Map json) = - _$TMAccountImpl.fromJson; - - @override - @HiveField(0) - String get id; - @override - @HiveField(1) - String get email; - @override - @HiveField(2) - String get password; - @override - @HiveField(3) - int get quota; - @override - @HiveField(4) - int get used; - @override - @JsonKey(ignore: true) - _$$TMAccountImplCopyWith<_$TMAccountImpl> get copyWith => - throw _privateConstructorUsedError; -} diff --git a/lib/data/models/account/account.g.dart b/lib/data/models/account/account.g.dart deleted file mode 100644 index e467819..0000000 --- a/lib/data/models/account/account.g.dart +++ /dev/null @@ -1,75 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'account.dart'; - -// ************************************************************************** -// TypeAdapterGenerator -// ************************************************************************** - -class AccountAdapter extends TypeAdapter<_$TMAccountImpl> { - @override - final int typeId = 0; - - @override - _$TMAccountImpl read(BinaryReader reader) { - final numOfFields = reader.readByte(); - final fields = { - for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), - }; - return _$TMAccountImpl( - id: fields[0] as String, - email: fields[1] as String, - password: fields[2] as String, - quota: fields[3] as int, - used: fields[4] as int, - ); - } - - @override - void write(BinaryWriter writer, _$TMAccountImpl obj) { - writer - ..writeByte(5) - ..writeByte(0) - ..write(obj.id) - ..writeByte(1) - ..write(obj.email) - ..writeByte(2) - ..write(obj.password) - ..writeByte(3) - ..write(obj.quota) - ..writeByte(4) - ..write(obj.used); - } - - @override - int get hashCode => typeId.hashCode; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is AccountAdapter && - runtimeType == other.runtimeType && - typeId == other.typeId; -} - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -_$TMAccountImpl _$$TMAccountImplFromJson(Map json) => - _$TMAccountImpl( - id: json['id'] as String, - email: json['email'] as String, - password: json['password'] as String, - quota: json['quota'] as int, - used: json['used'] as int, - ); - -Map _$$TMAccountImplToJson(_$TMAccountImpl instance) => - { - 'id': instance.id, - 'email': instance.email, - 'password': instance.password, - 'quota': instance.quota, - 'used': instance.used, - }; diff --git a/lib/data/models/message/message.dart b/lib/data/models/message/message.dart deleted file mode 100644 index f1536c6..0000000 --- a/lib/data/models/message/message.dart +++ /dev/null @@ -1,23 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; - -part "message.freezed.dart"; -part "message.g.dart"; - -@freezed -class TMMessage with _$TMMessage { - factory TMMessage({ - required String id, - required Map from, - required List> to, - required String? intro, - required String subject, - required bool seen, - required bool isDeleted, - required bool hasAttachments, - required String downloadUrl, - required DateTime createdAt, - }) = _TMMessage; - - factory TMMessage.fromJson(Map json) => - _$TMMessageFromJson(json); -} diff --git a/lib/data/models/message/message.freezed.dart b/lib/data/models/message/message.freezed.dart deleted file mode 100644 index f0815ca..0000000 --- a/lib/data/models/message/message.freezed.dart +++ /dev/null @@ -1,363 +0,0 @@ -// coverage:ignore-file -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint -// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark - -part of 'message.dart'; - -// ************************************************************************** -// FreezedGenerator -// ************************************************************************** - -T _$identity(T value) => value; - -final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); - -TMMessage _$TMMessageFromJson(Map json) { - return _TMMessage.fromJson(json); -} - -/// @nodoc -mixin _$TMMessage { - String get id => throw _privateConstructorUsedError; - Map get from => throw _privateConstructorUsedError; - List> get to => throw _privateConstructorUsedError; - String? get intro => throw _privateConstructorUsedError; - String get subject => throw _privateConstructorUsedError; - bool get seen => throw _privateConstructorUsedError; - bool get isDeleted => throw _privateConstructorUsedError; - bool get hasAttachments => throw _privateConstructorUsedError; - String get downloadUrl => throw _privateConstructorUsedError; - DateTime get createdAt => throw _privateConstructorUsedError; - - Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) - $TMMessageCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $TMMessageCopyWith<$Res> { - factory $TMMessageCopyWith(TMMessage value, $Res Function(TMMessage) then) = - _$TMMessageCopyWithImpl<$Res, TMMessage>; - @useResult - $Res call( - {String id, - Map from, - List> to, - String? intro, - String subject, - bool seen, - bool isDeleted, - bool hasAttachments, - String downloadUrl, - DateTime createdAt}); -} - -/// @nodoc -class _$TMMessageCopyWithImpl<$Res, $Val extends TMMessage> - implements $TMMessageCopyWith<$Res> { - _$TMMessageCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? id = null, - Object? from = null, - Object? to = null, - Object? intro = freezed, - Object? subject = null, - Object? seen = null, - Object? isDeleted = null, - Object? hasAttachments = null, - Object? downloadUrl = null, - Object? createdAt = null, - }) { - return _then(_value.copyWith( - id: null == id - ? _value.id - : id // ignore: cast_nullable_to_non_nullable - as String, - from: null == from - ? _value.from - : from // ignore: cast_nullable_to_non_nullable - as Map, - to: null == to - ? _value.to - : to // ignore: cast_nullable_to_non_nullable - as List>, - intro: freezed == intro - ? _value.intro - : intro // ignore: cast_nullable_to_non_nullable - as String?, - subject: null == subject - ? _value.subject - : subject // ignore: cast_nullable_to_non_nullable - as String, - seen: null == seen - ? _value.seen - : seen // ignore: cast_nullable_to_non_nullable - as bool, - isDeleted: null == isDeleted - ? _value.isDeleted - : isDeleted // ignore: cast_nullable_to_non_nullable - as bool, - hasAttachments: null == hasAttachments - ? _value.hasAttachments - : hasAttachments // ignore: cast_nullable_to_non_nullable - as bool, - downloadUrl: null == downloadUrl - ? _value.downloadUrl - : downloadUrl // ignore: cast_nullable_to_non_nullable - as String, - createdAt: null == createdAt - ? _value.createdAt - : createdAt // ignore: cast_nullable_to_non_nullable - as DateTime, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$TMMessageImplCopyWith<$Res> - implements $TMMessageCopyWith<$Res> { - factory _$$TMMessageImplCopyWith( - _$TMMessageImpl value, $Res Function(_$TMMessageImpl) then) = - __$$TMMessageImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {String id, - Map from, - List> to, - String? intro, - String subject, - bool seen, - bool isDeleted, - bool hasAttachments, - String downloadUrl, - DateTime createdAt}); -} - -/// @nodoc -class __$$TMMessageImplCopyWithImpl<$Res> - extends _$TMMessageCopyWithImpl<$Res, _$TMMessageImpl> - implements _$$TMMessageImplCopyWith<$Res> { - __$$TMMessageImplCopyWithImpl( - _$TMMessageImpl _value, $Res Function(_$TMMessageImpl) _then) - : super(_value, _then); - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? id = null, - Object? from = null, - Object? to = null, - Object? intro = freezed, - Object? subject = null, - Object? seen = null, - Object? isDeleted = null, - Object? hasAttachments = null, - Object? downloadUrl = null, - Object? createdAt = null, - }) { - return _then(_$TMMessageImpl( - id: null == id - ? _value.id - : id // ignore: cast_nullable_to_non_nullable - as String, - from: null == from - ? _value._from - : from // ignore: cast_nullable_to_non_nullable - as Map, - to: null == to - ? _value._to - : to // ignore: cast_nullable_to_non_nullable - as List>, - intro: freezed == intro - ? _value.intro - : intro // ignore: cast_nullable_to_non_nullable - as String?, - subject: null == subject - ? _value.subject - : subject // ignore: cast_nullable_to_non_nullable - as String, - seen: null == seen - ? _value.seen - : seen // ignore: cast_nullable_to_non_nullable - as bool, - isDeleted: null == isDeleted - ? _value.isDeleted - : isDeleted // ignore: cast_nullable_to_non_nullable - as bool, - hasAttachments: null == hasAttachments - ? _value.hasAttachments - : hasAttachments // ignore: cast_nullable_to_non_nullable - as bool, - downloadUrl: null == downloadUrl - ? _value.downloadUrl - : downloadUrl // ignore: cast_nullable_to_non_nullable - as String, - createdAt: null == createdAt - ? _value.createdAt - : createdAt // ignore: cast_nullable_to_non_nullable - as DateTime, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$TMMessageImpl implements _TMMessage { - _$TMMessageImpl( - {required this.id, - required final Map from, - required final List> to, - required this.intro, - required this.subject, - required this.seen, - required this.isDeleted, - required this.hasAttachments, - required this.downloadUrl, - required this.createdAt}) - : _from = from, - _to = to; - - factory _$TMMessageImpl.fromJson(Map json) => - _$$TMMessageImplFromJson(json); - - @override - final String id; - final Map _from; - @override - Map get from { - if (_from is EqualUnmodifiableMapView) return _from; - // ignore: implicit_dynamic_type - return EqualUnmodifiableMapView(_from); - } - - final List> _to; - @override - List> get to { - if (_to is EqualUnmodifiableListView) return _to; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_to); - } - - @override - final String? intro; - @override - final String subject; - @override - final bool seen; - @override - final bool isDeleted; - @override - final bool hasAttachments; - @override - final String downloadUrl; - @override - final DateTime createdAt; - - @override - String toString() { - return 'TMMessage(id: $id, from: $from, to: $to, intro: $intro, subject: $subject, seen: $seen, isDeleted: $isDeleted, hasAttachments: $hasAttachments, downloadUrl: $downloadUrl, createdAt: $createdAt)'; - } - - @override - bool operator ==(dynamic other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$TMMessageImpl && - (identical(other.id, id) || other.id == id) && - const DeepCollectionEquality().equals(other._from, _from) && - const DeepCollectionEquality().equals(other._to, _to) && - (identical(other.intro, intro) || other.intro == intro) && - (identical(other.subject, subject) || other.subject == subject) && - (identical(other.seen, seen) || other.seen == seen) && - (identical(other.isDeleted, isDeleted) || - other.isDeleted == isDeleted) && - (identical(other.hasAttachments, hasAttachments) || - other.hasAttachments == hasAttachments) && - (identical(other.downloadUrl, downloadUrl) || - other.downloadUrl == downloadUrl) && - (identical(other.createdAt, createdAt) || - other.createdAt == createdAt)); - } - - @JsonKey(ignore: true) - @override - int get hashCode => Object.hash( - runtimeType, - id, - const DeepCollectionEquality().hash(_from), - const DeepCollectionEquality().hash(_to), - intro, - subject, - seen, - isDeleted, - hasAttachments, - downloadUrl, - createdAt); - - @JsonKey(ignore: true) - @override - @pragma('vm:prefer-inline') - _$$TMMessageImplCopyWith<_$TMMessageImpl> get copyWith => - __$$TMMessageImplCopyWithImpl<_$TMMessageImpl>(this, _$identity); - - @override - Map toJson() { - return _$$TMMessageImplToJson( - this, - ); - } -} - -abstract class _TMMessage implements TMMessage { - factory _TMMessage( - {required final String id, - required final Map from, - required final List> to, - required final String? intro, - required final String subject, - required final bool seen, - required final bool isDeleted, - required final bool hasAttachments, - required final String downloadUrl, - required final DateTime createdAt}) = _$TMMessageImpl; - - factory _TMMessage.fromJson(Map json) = - _$TMMessageImpl.fromJson; - - @override - String get id; - @override - Map get from; - @override - List> get to; - @override - String? get intro; - @override - String get subject; - @override - bool get seen; - @override - bool get isDeleted; - @override - bool get hasAttachments; - @override - String get downloadUrl; - @override - DateTime get createdAt; - @override - @JsonKey(ignore: true) - _$$TMMessageImplCopyWith<_$TMMessageImpl> get copyWith => - throw _privateConstructorUsedError; -} diff --git a/lib/data/models/message/message.g.dart b/lib/data/models/message/message.g.dart deleted file mode 100644 index 1eea392..0000000 --- a/lib/data/models/message/message.g.dart +++ /dev/null @@ -1,37 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'message.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -_$TMMessageImpl _$$TMMessageImplFromJson(Map json) => - _$TMMessageImpl( - id: json['id'] as String, - from: Map.from(json['from'] as Map), - to: (json['to'] as List) - .map((e) => Map.from(e as Map)) - .toList(), - intro: json['intro'] as String?, - subject: json['subject'] as String, - seen: json['seen'] as bool, - isDeleted: json['isDeleted'] as bool, - hasAttachments: json['hasAttachments'] as bool, - downloadUrl: json['downloadUrl'] as String, - createdAt: DateTime.parse(json['createdAt'] as String), - ); - -Map _$$TMMessageImplToJson(_$TMMessageImpl instance) => - { - 'id': instance.id, - 'from': instance.from, - 'to': instance.to, - 'intro': instance.intro, - 'subject': instance.subject, - 'seen': instance.seen, - 'isDeleted': instance.isDeleted, - 'hasAttachments': instance.hasAttachments, - 'downloadUrl': instance.downloadUrl, - 'createdAt': instance.createdAt.toIso8601String(), - }; diff --git a/lib/data/models/message_detail/message_detail.dart b/lib/data/models/message_detail/message_detail.dart deleted file mode 100644 index 4dcb23e..0000000 --- a/lib/data/models/message_detail/message_detail.dart +++ /dev/null @@ -1,25 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; - -part "message_detail.freezed.dart"; -part "message_detail.g.dart"; - -@freezed -class TMMessageDetail with _$TMMessageDetail { - factory TMMessageDetail({ - required String id, - required Map from, - required List> to, - required List cc, - required List bcc, - required String subject, - required String? text, - required List html, - required bool hasAttachments, - required List>? attachments, - required String downloadUrl, - required DateTime createdAt, - }) = _TMMessageDetail; - - factory TMMessageDetail.fromJson(Map json) => - _$TMMessageDetailFromJson(json); -} diff --git a/lib/data/models/message_detail/message_detail.freezed.dart b/lib/data/models/message_detail/message_detail.freezed.dart deleted file mode 100644 index f21460a..0000000 --- a/lib/data/models/message_detail/message_detail.freezed.dart +++ /dev/null @@ -1,438 +0,0 @@ -// coverage:ignore-file -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint -// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark - -part of 'message_detail.dart'; - -// ************************************************************************** -// FreezedGenerator -// ************************************************************************** - -T _$identity(T value) => value; - -final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); - -TMMessageDetail _$TMMessageDetailFromJson(Map json) { - return _TMMessageDetail.fromJson(json); -} - -/// @nodoc -mixin _$TMMessageDetail { - String get id => throw _privateConstructorUsedError; - Map get from => throw _privateConstructorUsedError; - List> get to => throw _privateConstructorUsedError; - List get cc => throw _privateConstructorUsedError; - List get bcc => throw _privateConstructorUsedError; - String get subject => throw _privateConstructorUsedError; - String? get text => throw _privateConstructorUsedError; - List get html => throw _privateConstructorUsedError; - bool get hasAttachments => throw _privateConstructorUsedError; - List>? get attachments => - throw _privateConstructorUsedError; - String get downloadUrl => throw _privateConstructorUsedError; - DateTime get createdAt => throw _privateConstructorUsedError; - - Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) - $TMMessageDetailCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $TMMessageDetailCopyWith<$Res> { - factory $TMMessageDetailCopyWith( - TMMessageDetail value, $Res Function(TMMessageDetail) then) = - _$TMMessageDetailCopyWithImpl<$Res, TMMessageDetail>; - @useResult - $Res call( - {String id, - Map from, - List> to, - List cc, - List bcc, - String subject, - String? text, - List html, - bool hasAttachments, - List>? attachments, - String downloadUrl, - DateTime createdAt}); -} - -/// @nodoc -class _$TMMessageDetailCopyWithImpl<$Res, $Val extends TMMessageDetail> - implements $TMMessageDetailCopyWith<$Res> { - _$TMMessageDetailCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? id = null, - Object? from = null, - Object? to = null, - Object? cc = null, - Object? bcc = null, - Object? subject = null, - Object? text = freezed, - Object? html = null, - Object? hasAttachments = null, - Object? attachments = freezed, - Object? downloadUrl = null, - Object? createdAt = null, - }) { - return _then(_value.copyWith( - id: null == id - ? _value.id - : id // ignore: cast_nullable_to_non_nullable - as String, - from: null == from - ? _value.from - : from // ignore: cast_nullable_to_non_nullable - as Map, - to: null == to - ? _value.to - : to // ignore: cast_nullable_to_non_nullable - as List>, - cc: null == cc - ? _value.cc - : cc // ignore: cast_nullable_to_non_nullable - as List, - bcc: null == bcc - ? _value.bcc - : bcc // ignore: cast_nullable_to_non_nullable - as List, - subject: null == subject - ? _value.subject - : subject // ignore: cast_nullable_to_non_nullable - as String, - text: freezed == text - ? _value.text - : text // ignore: cast_nullable_to_non_nullable - as String?, - html: null == html - ? _value.html - : html // ignore: cast_nullable_to_non_nullable - as List, - hasAttachments: null == hasAttachments - ? _value.hasAttachments - : hasAttachments // ignore: cast_nullable_to_non_nullable - as bool, - attachments: freezed == attachments - ? _value.attachments - : attachments // ignore: cast_nullable_to_non_nullable - as List>?, - downloadUrl: null == downloadUrl - ? _value.downloadUrl - : downloadUrl // ignore: cast_nullable_to_non_nullable - as String, - createdAt: null == createdAt - ? _value.createdAt - : createdAt // ignore: cast_nullable_to_non_nullable - as DateTime, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$TMMessageDetailImplCopyWith<$Res> - implements $TMMessageDetailCopyWith<$Res> { - factory _$$TMMessageDetailImplCopyWith(_$TMMessageDetailImpl value, - $Res Function(_$TMMessageDetailImpl) then) = - __$$TMMessageDetailImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {String id, - Map from, - List> to, - List cc, - List bcc, - String subject, - String? text, - List html, - bool hasAttachments, - List>? attachments, - String downloadUrl, - DateTime createdAt}); -} - -/// @nodoc -class __$$TMMessageDetailImplCopyWithImpl<$Res> - extends _$TMMessageDetailCopyWithImpl<$Res, _$TMMessageDetailImpl> - implements _$$TMMessageDetailImplCopyWith<$Res> { - __$$TMMessageDetailImplCopyWithImpl( - _$TMMessageDetailImpl _value, $Res Function(_$TMMessageDetailImpl) _then) - : super(_value, _then); - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? id = null, - Object? from = null, - Object? to = null, - Object? cc = null, - Object? bcc = null, - Object? subject = null, - Object? text = freezed, - Object? html = null, - Object? hasAttachments = null, - Object? attachments = freezed, - Object? downloadUrl = null, - Object? createdAt = null, - }) { - return _then(_$TMMessageDetailImpl( - id: null == id - ? _value.id - : id // ignore: cast_nullable_to_non_nullable - as String, - from: null == from - ? _value._from - : from // ignore: cast_nullable_to_non_nullable - as Map, - to: null == to - ? _value._to - : to // ignore: cast_nullable_to_non_nullable - as List>, - cc: null == cc - ? _value._cc - : cc // ignore: cast_nullable_to_non_nullable - as List, - bcc: null == bcc - ? _value._bcc - : bcc // ignore: cast_nullable_to_non_nullable - as List, - subject: null == subject - ? _value.subject - : subject // ignore: cast_nullable_to_non_nullable - as String, - text: freezed == text - ? _value.text - : text // ignore: cast_nullable_to_non_nullable - as String?, - html: null == html - ? _value._html - : html // ignore: cast_nullable_to_non_nullable - as List, - hasAttachments: null == hasAttachments - ? _value.hasAttachments - : hasAttachments // ignore: cast_nullable_to_non_nullable - as bool, - attachments: freezed == attachments - ? _value._attachments - : attachments // ignore: cast_nullable_to_non_nullable - as List>?, - downloadUrl: null == downloadUrl - ? _value.downloadUrl - : downloadUrl // ignore: cast_nullable_to_non_nullable - as String, - createdAt: null == createdAt - ? _value.createdAt - : createdAt // ignore: cast_nullable_to_non_nullable - as DateTime, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$TMMessageDetailImpl implements _TMMessageDetail { - _$TMMessageDetailImpl( - {required this.id, - required final Map from, - required final List> to, - required final List cc, - required final List bcc, - required this.subject, - required this.text, - required final List html, - required this.hasAttachments, - required final List>? attachments, - required this.downloadUrl, - required this.createdAt}) - : _from = from, - _to = to, - _cc = cc, - _bcc = bcc, - _html = html, - _attachments = attachments; - - factory _$TMMessageDetailImpl.fromJson(Map json) => - _$$TMMessageDetailImplFromJson(json); - - @override - final String id; - final Map _from; - @override - Map get from { - if (_from is EqualUnmodifiableMapView) return _from; - // ignore: implicit_dynamic_type - return EqualUnmodifiableMapView(_from); - } - - final List> _to; - @override - List> get to { - if (_to is EqualUnmodifiableListView) return _to; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_to); - } - - final List _cc; - @override - List get cc { - if (_cc is EqualUnmodifiableListView) return _cc; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_cc); - } - - final List _bcc; - @override - List get bcc { - if (_bcc is EqualUnmodifiableListView) return _bcc; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_bcc); - } - - @override - final String subject; - @override - final String? text; - final List _html; - @override - List get html { - if (_html is EqualUnmodifiableListView) return _html; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_html); - } - - @override - final bool hasAttachments; - final List>? _attachments; - @override - List>? get attachments { - final value = _attachments; - if (value == null) return null; - if (_attachments is EqualUnmodifiableListView) return _attachments; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(value); - } - - @override - final String downloadUrl; - @override - final DateTime createdAt; - - @override - String toString() { - return 'TMMessageDetail(id: $id, from: $from, to: $to, cc: $cc, bcc: $bcc, subject: $subject, text: $text, html: $html, hasAttachments: $hasAttachments, attachments: $attachments, downloadUrl: $downloadUrl, createdAt: $createdAt)'; - } - - @override - bool operator ==(dynamic other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$TMMessageDetailImpl && - (identical(other.id, id) || other.id == id) && - const DeepCollectionEquality().equals(other._from, _from) && - const DeepCollectionEquality().equals(other._to, _to) && - const DeepCollectionEquality().equals(other._cc, _cc) && - const DeepCollectionEquality().equals(other._bcc, _bcc) && - (identical(other.subject, subject) || other.subject == subject) && - (identical(other.text, text) || other.text == text) && - const DeepCollectionEquality().equals(other._html, _html) && - (identical(other.hasAttachments, hasAttachments) || - other.hasAttachments == hasAttachments) && - const DeepCollectionEquality() - .equals(other._attachments, _attachments) && - (identical(other.downloadUrl, downloadUrl) || - other.downloadUrl == downloadUrl) && - (identical(other.createdAt, createdAt) || - other.createdAt == createdAt)); - } - - @JsonKey(ignore: true) - @override - int get hashCode => Object.hash( - runtimeType, - id, - const DeepCollectionEquality().hash(_from), - const DeepCollectionEquality().hash(_to), - const DeepCollectionEquality().hash(_cc), - const DeepCollectionEquality().hash(_bcc), - subject, - text, - const DeepCollectionEquality().hash(_html), - hasAttachments, - const DeepCollectionEquality().hash(_attachments), - downloadUrl, - createdAt); - - @JsonKey(ignore: true) - @override - @pragma('vm:prefer-inline') - _$$TMMessageDetailImplCopyWith<_$TMMessageDetailImpl> get copyWith => - __$$TMMessageDetailImplCopyWithImpl<_$TMMessageDetailImpl>( - this, _$identity); - - @override - Map toJson() { - return _$$TMMessageDetailImplToJson( - this, - ); - } -} - -abstract class _TMMessageDetail implements TMMessageDetail { - factory _TMMessageDetail( - {required final String id, - required final Map from, - required final List> to, - required final List cc, - required final List bcc, - required final String subject, - required final String? text, - required final List html, - required final bool hasAttachments, - required final List>? attachments, - required final String downloadUrl, - required final DateTime createdAt}) = _$TMMessageDetailImpl; - - factory _TMMessageDetail.fromJson(Map json) = - _$TMMessageDetailImpl.fromJson; - - @override - String get id; - @override - Map get from; - @override - List> get to; - @override - List get cc; - @override - List get bcc; - @override - String get subject; - @override - String? get text; - @override - List get html; - @override - bool get hasAttachments; - @override - List>? get attachments; - @override - String get downloadUrl; - @override - DateTime get createdAt; - @override - @JsonKey(ignore: true) - _$$TMMessageDetailImplCopyWith<_$TMMessageDetailImpl> get copyWith => - throw _privateConstructorUsedError; -} diff --git a/lib/data/models/message_detail/message_detail.g.dart b/lib/data/models/message_detail/message_detail.g.dart deleted file mode 100644 index 0bdbe51..0000000 --- a/lib/data/models/message_detail/message_detail.g.dart +++ /dev/null @@ -1,45 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'message_detail.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -_$TMMessageDetailImpl _$$TMMessageDetailImplFromJson( - Map json) => - _$TMMessageDetailImpl( - id: json['id'] as String, - from: Map.from(json['from'] as Map), - to: (json['to'] as List) - .map((e) => Map.from(e as Map)) - .toList(), - cc: (json['cc'] as List).map((e) => e as String).toList(), - bcc: (json['bcc'] as List).map((e) => e as String).toList(), - subject: json['subject'] as String, - text: json['text'] as String?, - html: (json['html'] as List).map((e) => e as String).toList(), - hasAttachments: json['hasAttachments'] as bool, - attachments: (json['attachments'] as List?) - ?.map((e) => e as Map) - .toList(), - downloadUrl: json['downloadUrl'] as String, - createdAt: DateTime.parse(json['createdAt'] as String), - ); - -Map _$$TMMessageDetailImplToJson( - _$TMMessageDetailImpl instance) => - { - 'id': instance.id, - 'from': instance.from, - 'to': instance.to, - 'cc': instance.cc, - 'bcc': instance.bcc, - 'subject': instance.subject, - 'text': instance.text, - 'html': instance.html, - 'hasAttachments': instance.hasAttachments, - 'attachments': instance.attachments, - 'downloadUrl': instance.downloadUrl, - 'createdAt': instance.createdAt.toIso8601String(), - }; diff --git a/lib/data/notifiers/account_notifier.dart b/lib/data/notifiers/account_notifier.dart deleted file mode 100644 index 2158d25..0000000 --- a/lib/data/notifiers/account_notifier.dart +++ /dev/null @@ -1,86 +0,0 @@ -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:hive_flutter/adapters.dart"; - -import "package:flash_mail/data/models/account/account.dart"; -import "package:flash_mail/data/providers/providers.dart"; - -class AccountState { - final bool isLoading; - final TMAccount? user; - - const AccountState({ - this.isLoading = false, - this.user, - }); - - bool isAuthenticated() { - return user != null; - } -} - -class AccountNotifier extends StateNotifier { - static const _boxName = "accounts"; - static const _key = "user"; - - final Ref ref; - - AccountNotifier(this.ref) : super(const AccountState()); - - Future fetchAccount() async { - var box = await Hive.openBox(_boxName); - final user = box.get(_key, defaultValue: null); - - if (user != null) { - ref.read(emailProvider.notifier).state = user.email; - ref.read(passwordProvider.notifier).state = user.password; - state = AccountState(isLoading: false, user: user); - } - } - - Future createAccount() async { - state = const AccountState(isLoading: true); - - try { - TMAccount account = await ref.read(clientProvider).createAccount(); - var box = Hive.box(_boxName); - await box.put(_key, account); - - ref.read(emailProvider.notifier).state = account.email; - ref.read(passwordProvider.notifier).state = account.password; - state = AccountState(isLoading: false, user: account); - } catch (err) { - state = const AccountState(isLoading: false); - rethrow; - } - } - - Future login(String? address, String? password) async { - state = const AccountState(isLoading: true); - - try { - if (address != null && password != null) { - TMAccount account = - await ref.read(clientProvider).login(address, password); - var box = Hive.box(_boxName); - await box.put(_key, account); - - state = AccountState(isLoading: false, user: account); - } - } catch (err) { - state = const AccountState(isLoading: false); - rethrow; - } - } - - Future logout() async { - AccountState previous = state; - try { - var box = Hive.box(_boxName); - await box.delete(_key); - state = const AccountState(); - } catch (err) { - state = previous; - rethrow; - } - } -} diff --git a/lib/data/providers/providers.dart b/lib/data/providers/providers.dart index 341b562..c4d86d1 100644 --- a/lib/data/providers/providers.dart +++ b/lib/data/providers/providers.dart @@ -1,40 +1,57 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:dio/dio.dart"; -import "package:flash_mail/data/api/api_client.dart"; -import "package:flash_mail/data/models/message/message.dart"; -import "package:flash_mail/data/models/message_detail/message_detail.dart"; -import "package:flash_mail/data/notifiers/account_notifier.dart"; +import "package:smol_mail/smol/client.dart"; +import "package:smol_mail/smol/proto.dart"; +import "package:smol_mail/smol/store.dart"; -final emailProvider = StateProvider((ref) => null); -final passwordProvider = StateProvider((ref) => null); +/// Overridden in main() with the Hive-backed store opened at boot. +final storeProvider = Provider((ref) => throw UnimplementedError()); -final dioProvider = Provider( - (ref) => Dio( - BaseOptions( - baseUrl: "https://api.mail.tm", - connectTimeout: 2000, - ), - ), +final clientProvider = Provider( + (ref) => SmolClient(ref.watch(storeProvider)), ); -final clientProvider = Provider( - (ref) => ApiClient(ref.read(dioProvider), ref), -); +/// Hive gives no change notifications; every mail or state mutation bumps this, +/// which is what the list, badge, identity and account providers watch. +class Revision extends Notifier { + @override + int build() => 0; -final accountProvider = StateNotifierProvider( - (ref) => AccountNotifier(ref), -); + void bump() => state++; +} -final tokenProvider = StateProvider((ref) => null); +final revisionProvider = NotifierProvider(Revision.new); -final messagesProvider = FutureProvider.autoDispose>((ref) { - final email = ref.read(emailProvider); - final password = ref.read(passwordProvider); - - return ref.read(clientProvider).fetchMessages(email!, password!); +final identityProvider = Provider((ref) { + ref.watch(revisionProvider); + return ref.watch(storeProvider).identity(); }); -final messageDetailProvider = FutureProvider.family( - (ref, id) => ref.read(clientProvider).fetchMessageDetail(id), -); +final accountProvider = Provider((ref) { + ref.watch(revisionProvider); + return ref.watch(clientProvider).accountAddress(); +}); + +class Folder extends Notifier { + @override + String build() => "inbox"; + + void select(String folder) => state = folder; +} + +final folderProvider = NotifierProvider(Folder.new); + +final messagesProvider = Provider>((ref) { + ref.watch(revisionProvider); + return ref.watch(storeProvider).listMessages(ref.watch(folderProvider)); +}); + +final unreadProvider = Provider((ref) { + ref.watch(revisionProvider); + return ref.watch(storeProvider).unreadCount(); +}); + +final contactsProvider = Provider>((ref) { + ref.watch(revisionProvider); + return ref.watch(storeProvider).allContacts(); +}); diff --git a/lib/main.dart b/lib/main.dart index f3bc786..73e138b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,23 +1,21 @@ import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:flutter_downloader/flutter_downloader.dart"; -import "package:hive_flutter/adapters.dart"; +import "package:hive_flutter/hive_flutter.dart"; -import "package:flash_mail/data/models/account/account.dart"; -import "package:flash_mail/data/providers/providers.dart"; -import "package:flash_mail/presentation/app_widget.dart"; +import "package:smol_mail/data/providers/providers.dart"; +import "package:smol_mail/smol/config.dart"; +import "package:smol_mail/smol/store.dart"; +import "package:smol_mail/presentation/app_widget.dart"; void main() async { WidgetsFlutterBinding.ensureInitialized(); await Hive.initFlutter(); - Hive.registerAdapter(AccountAdapter()); - ProviderContainer container = ProviderContainer(); - await container.read(accountProvider.notifier).fetchAccount(); - await FlutterDownloader.initialize(); + final store = await SmolStore.open(); + applyPresetServer(store); runApp( - UncontrolledProviderScope( - container: container, + ProviderScope( + overrides: [storeProvider.overrideWithValue(store)], child: const AppWidget(), ), ); diff --git a/lib/presentation/app_widget.dart b/lib/presentation/app_widget.dart index 3cd5b5e..4526baf 100644 --- a/lib/presentation/app_widget.dart +++ b/lib/presentation/app_widget.dart @@ -1,18 +1,53 @@ import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:flash_mail/presentation/theme/theme.dart"; -import "package:flash_mail/presentation/routes/app_router.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"; -class AppWidget extends ConsumerWidget { +/// SnackBars need no context through this key, so the client's trust +/// warnings can be shown from anywhere in the protocol stack. +final rootScaffoldMessengerKey = GlobalKey(); + +class AppWidget extends ConsumerStatefulWidget { const AppWidget({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _AppWidgetState(); +} + +class _AppWidgetState extends ConsumerState { + @override + void initState() { + super.initState(); + // §4/§8 trust warnings stay on screen until dismissed; ordinary notices + // do not use this channel. + ref.read(clientProvider).onWarning = (message) { + rootScaffoldMessengerKey.currentState?.showSnackBar( + SnackBar( + backgroundColor: const Color.fromRGBO(198, 40, 40, 1), + content: Text(message, style: const TextStyle(color: Colors.white)), + duration: const Duration(days: 1), + action: SnackBarAction( + label: "Dismiss", + textColor: Colors.white, + onPressed: () => + rootScaffoldMessengerKey.currentState?.hideCurrentSnackBar(), + ), + ), + ); + }; + } + + @override + Widget build(BuildContext context) { return MaterialApp.router( - title: "FlashMail", - theme: appTheme, + title: "kirakira", + theme: appThemeLight, + darkTheme: appThemeDark, + themeMode: ThemeMode.system, debugShowCheckedModeBanner: false, + scaffoldMessengerKey: rootScaffoldMessengerKey, routerConfig: AppRouter(ref).config(), ); } diff --git a/lib/presentation/routes/account_guard.dart b/lib/presentation/routes/account_guard.dart deleted file mode 100644 index 3aa608a..0000000 --- a/lib/presentation/routes/account_guard.dart +++ /dev/null @@ -1,18 +0,0 @@ -import "package:auto_route/auto_route.dart"; -import "package:flash_mail/data/providers/providers.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; - -class AccountGuard extends AutoRouteGuard { - final WidgetRef ref; - - AccountGuard(this.ref); - - @override - void onNavigation(NavigationResolver resolver, StackRouter router) { - if (!ref.read(accountProvider).isAuthenticated()) { - router.replaceNamed("/"); - } else { - resolver.next(true); - } - } -} diff --git a/lib/presentation/routes/app_router.dart b/lib/presentation/routes/app_router.dart index 334131a..1f20a4d 100644 --- a/lib/presentation/routes/app_router.dart +++ b/lib/presentation/routes/app_router.dart @@ -1,13 +1,12 @@ import "package:auto_route/auto_route.dart"; -import "package:flash_mail/presentation/routes/account_guard.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:flash_mail/presentation/routes/auth_guard.dart"; - +import "package:smol_mail/presentation/routes/home_guard.dart"; +import "package:smol_mail/presentation/routes/identity_guard.dart"; import "app_router.gr.dart"; @AutoRouterConfig(replaceInRouteName: "Screen,Route") -class AppRouter extends $AppRouter { +class AppRouter extends RootStackRouter { final WidgetRef ref; AppRouter(this.ref); @@ -15,23 +14,40 @@ class AppRouter extends $AppRouter { @override List get routes => [ AutoRoute( - page: AccountRoute.page, + page: OnboardingRoute.page, path: "/", initial: true, - guards: [AuthGuard(ref)], - ), - AutoRoute( - page: LoginRoute.page, - path: "/login", + guards: [HomeGuard(ref)], ), AutoRoute( page: InboxRoute.page, path: "/inbox", - guards: [AccountGuard(ref)], + guards: [IdentityGuard(ref)], ), AutoRoute( page: MessageDetailRoute.page, path: "/message", - ) + guards: [IdentityGuard(ref)], + ), + AutoRoute( + page: ComposeRoute.page, + path: "/compose", + guards: [IdentityGuard(ref)], + ), + AutoRoute( + page: ContactsRoute.page, + path: "/contacts", + guards: [IdentityGuard(ref)], + ), + AutoRoute( + page: ContactDetailRoute.page, + path: "/contact", + guards: [IdentityGuard(ref)], + ), + AutoRoute( + page: SettingsRoute.page, + path: "/settings", + guards: [IdentityGuard(ref)], + ), ]; } diff --git a/lib/presentation/routes/app_router.gr.dart b/lib/presentation/routes/app_router.gr.dart index 8fe6c27..d354d14 100644 --- a/lib/presentation/routes/app_router.gr.dart +++ b/lib/presentation/routes/app_router.gr.dart @@ -1,3 +1,4 @@ +// dart format width=80 // GENERATED CODE - DO NOT MODIFY BY HAND // ************************************************************************** @@ -8,132 +9,240 @@ // coverage:ignore-file // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'package:auto_route/auto_route.dart' as _i5; -import 'package:flash_mail/presentation/screens/account_screen.dart' as _i1; -import 'package:flash_mail/presentation/screens/inbox_screen.dart' as _i2; -import 'package:flash_mail/presentation/screens/login_screen.dart' as _i3; -import 'package:flash_mail/presentation/screens/message_detail_screen.dart' - as _i4; -import 'package:flutter/material.dart' as _i6; -abstract class $AppRouter extends _i5.RootStackRouter { - $AppRouter({super.navigatorKey}); +import 'package:auto_route/auto_route.dart' as _i8; +import 'package:flutter/material.dart' as _i9; +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/inbox_screen.dart' as _i4; +import 'package:smol_mail/presentation/screens/message_detail_screen.dart' + as _i5; +import 'package:smol_mail/presentation/screens/onboarding_screen.dart' as _i6; +import 'package:smol_mail/presentation/screens/settings_screen.dart' as _i7; + +/// generated route for +/// [_i1.ComposeScreen] +class ComposeRoute extends _i8.PageRouteInfo { + ComposeRoute({ + _i9.Key? key, + String? to, + String? subject, + List<_i8.PageRouteInfo>? children, + }) : super( + ComposeRoute.name, + args: ComposeRouteArgs(key: key, to: to, subject: subject), + initialChildren: children, + ); + + static const String name = 'ComposeRoute'; + + static _i8.PageInfo page = _i8.PageInfo( + name, + builder: (data) { + final args = data.argsAs( + orElse: () => const ComposeRouteArgs(), + ); + return _i1.ComposeScreen( + key: args.key, + to: args.to, + subject: args.subject, + ); + }, + ); +} + +class ComposeRouteArgs { + const ComposeRouteArgs({this.key, this.to, this.subject}); + + final _i9.Key? key; + + final String? to; + + final String? subject; @override - final Map pagesMap = { - AccountRoute.name: (routeData) { - return _i5.AutoRoutePage( - routeData: routeData, - child: const _i1.AccountScreen(), - ); - }, - InboxRoute.name: (routeData) { - return _i5.AutoRoutePage( - routeData: routeData, - child: const _i2.InboxScreen(), - ); - }, - LoginRoute.name: (routeData) { - return _i5.AutoRoutePage( - routeData: routeData, - child: const _i3.LoginScreen(), - ); - }, - MessageDetailRoute.name: (routeData) { - final args = routeData.argsAs(); - return _i5.AutoRoutePage( - routeData: routeData, - child: _i4.MessageDetailScreen( - key: args.key, - id: args.id, - subject: args.subject, - ), - ); - }, - }; + String toString() { + return 'ComposeRouteArgs{key: $key, to: $to, subject: $subject}'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! ComposeRouteArgs) return false; + return key == other.key && to == other.to && subject == other.subject; + } + + @override + int get hashCode => key.hashCode ^ to.hashCode ^ subject.hashCode; } /// generated route for -/// [_i1.AccountScreen] -class AccountRoute extends _i5.PageRouteInfo { - const AccountRoute({List<_i5.PageRouteInfo>? children}) - : super( - AccountRoute.name, - initialChildren: children, - ); +/// [_i2.ContactDetailScreen] +class ContactDetailRoute extends _i8.PageRouteInfo { + ContactDetailRoute({ + _i9.Key? key, + required String address, + List<_i8.PageRouteInfo>? children, + }) : super( + ContactDetailRoute.name, + args: ContactDetailRouteArgs(key: key, address: address), + initialChildren: children, + ); - static const String name = 'AccountRoute'; + static const String name = 'ContactDetailRoute'; - static const _i5.PageInfo page = _i5.PageInfo(name); + static _i8.PageInfo page = _i8.PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return _i2.ContactDetailScreen(key: args.key, address: args.address); + }, + ); +} + +class ContactDetailRouteArgs { + const ContactDetailRouteArgs({this.key, required this.address}); + + final _i9.Key? key; + + final String address; + + @override + String toString() { + return 'ContactDetailRouteArgs{key: $key, address: $address}'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! ContactDetailRouteArgs) return false; + return key == other.key && address == other.address; + } + + @override + int get hashCode => key.hashCode ^ address.hashCode; } /// generated route for -/// [_i2.InboxScreen] -class InboxRoute extends _i5.PageRouteInfo { - const InboxRoute({List<_i5.PageRouteInfo>? children}) - : super( - InboxRoute.name, - initialChildren: children, - ); +/// [_i3.ContactsScreen] +class ContactsRoute extends _i8.PageRouteInfo { + const ContactsRoute({List<_i8.PageRouteInfo>? children}) + : super(ContactsRoute.name, initialChildren: children); + + static const String name = 'ContactsRoute'; + + static _i8.PageInfo page = _i8.PageInfo( + name, + builder: (data) { + return const _i3.ContactsScreen(); + }, + ); +} + +/// generated route for +/// [_i4.InboxScreen] +class InboxRoute extends _i8.PageRouteInfo { + const InboxRoute({List<_i8.PageRouteInfo>? children}) + : super(InboxRoute.name, initialChildren: children); static const String name = 'InboxRoute'; - static const _i5.PageInfo page = _i5.PageInfo(name); + static _i8.PageInfo page = _i8.PageInfo( + name, + builder: (data) { + return const _i4.InboxScreen(); + }, + ); } /// generated route for -/// [_i3.LoginScreen] -class LoginRoute extends _i5.PageRouteInfo { - const LoginRoute({List<_i5.PageRouteInfo>? children}) - : super( - LoginRoute.name, - initialChildren: children, - ); - - static const String name = 'LoginRoute'; - - static const _i5.PageInfo page = _i5.PageInfo(name); -} - -/// generated route for -/// [_i4.MessageDetailScreen] -class MessageDetailRoute extends _i5.PageRouteInfo { +/// [_i5.MessageDetailScreen] +class MessageDetailRoute extends _i8.PageRouteInfo { MessageDetailRoute({ - _i6.Key? key, + _i9.Key? key, + required String folder, required String id, - required String subject, - List<_i5.PageRouteInfo>? children, + List<_i8.PageRouteInfo>? children, }) : super( - MessageDetailRoute.name, - args: MessageDetailRouteArgs( - key: key, - id: id, - subject: subject, - ), - initialChildren: children, - ); + MessageDetailRoute.name, + args: MessageDetailRouteArgs(key: key, folder: folder, id: id), + initialChildren: children, + ); static const String name = 'MessageDetailRoute'; - static const _i5.PageInfo page = - _i5.PageInfo(name); + static _i8.PageInfo page = _i8.PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return _i5.MessageDetailScreen( + key: args.key, + folder: args.folder, + id: args.id, + ); + }, + ); } class MessageDetailRouteArgs { const MessageDetailRouteArgs({ this.key, + required this.folder, required this.id, - required this.subject, }); - final _i6.Key? key; + final _i9.Key? key; + + final String folder; final String id; - final String subject; - @override String toString() { - return 'MessageDetailRouteArgs{key: $key, id: $id, subject: $subject}'; + return 'MessageDetailRouteArgs{key: $key, folder: $folder, id: $id}'; } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! MessageDetailRouteArgs) return false; + return key == other.key && folder == other.folder && id == other.id; + } + + @override + int get hashCode => key.hashCode ^ folder.hashCode ^ id.hashCode; +} + +/// generated route for +/// [_i6.OnboardingScreen] +class OnboardingRoute extends _i8.PageRouteInfo { + const OnboardingRoute({List<_i8.PageRouteInfo>? children}) + : super(OnboardingRoute.name, initialChildren: children); + + static const String name = 'OnboardingRoute'; + + static _i8.PageInfo page = _i8.PageInfo( + name, + builder: (data) { + return const _i6.OnboardingScreen(); + }, + ); +} + +/// generated route for +/// [_i7.SettingsScreen] +class SettingsRoute extends _i8.PageRouteInfo { + const SettingsRoute({List<_i8.PageRouteInfo>? children}) + : super(SettingsRoute.name, initialChildren: children); + + static const String name = 'SettingsRoute'; + + static _i8.PageInfo page = _i8.PageInfo( + name, + builder: (data) { + return const _i7.SettingsScreen(); + }, + ); } diff --git a/lib/presentation/routes/auth_guard.dart b/lib/presentation/routes/auth_guard.dart deleted file mode 100644 index 4e53bb9..0000000 --- a/lib/presentation/routes/auth_guard.dart +++ /dev/null @@ -1,18 +0,0 @@ -import "package:auto_route/auto_route.dart"; -import "package:flash_mail/data/providers/providers.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; - -class AuthGuard extends AutoRouteGuard { - final WidgetRef ref; - - AuthGuard(this.ref); - - @override - void onNavigation(NavigationResolver resolver, StackRouter router) { - if (ref.read(accountProvider).isAuthenticated()) { - router.replaceNamed("/inbox"); - } else { - resolver.next(true); - } - } -} diff --git a/lib/presentation/routes/home_guard.dart b/lib/presentation/routes/home_guard.dart new file mode 100644 index 0000000..6f07b86 --- /dev/null +++ b/lib/presentation/routes/home_guard.dart @@ -0,0 +1,23 @@ +import "package:auto_route/auto_route.dart"; +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"; + +/// The landing screen is only for users who cannot use the app yet: no +/// identity, or no registered account. +class HomeGuard extends AutoRouteGuard { + final WidgetRef ref; + + HomeGuard(this.ref); + + @override + void onNavigation(NavigationResolver resolver, StackRouter router) { + if (ref.read(identityProvider) != null && + ref.read(accountProvider) != null) { + router.replace(InboxRoute()); + } else { + resolver.next(true); + } + } +} diff --git a/lib/presentation/routes/identity_guard.dart b/lib/presentation/routes/identity_guard.dart new file mode 100644 index 0000000..c2c5c27 --- /dev/null +++ b/lib/presentation/routes/identity_guard.dart @@ -0,0 +1,22 @@ +import "package:auto_route/auto_route.dart"; +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"; + +/// Everything past onboarding needs an identity; the account can still be +/// registered from settings. +class IdentityGuard extends AutoRouteGuard { + final WidgetRef ref; + + IdentityGuard(this.ref); + + @override + void onNavigation(NavigationResolver resolver, StackRouter router) { + if (ref.read(identityProvider) == null) { + router.replace(OnboardingRoute()); + } else { + resolver.next(true); + } + } +} diff --git a/lib/presentation/screens/account_screen.dart b/lib/presentation/screens/account_screen.dart deleted file mode 100644 index 5125bdc..0000000 --- a/lib/presentation/screens/account_screen.dart +++ /dev/null @@ -1,34 +0,0 @@ -import "package:flutter/material.dart"; -import "package:auto_route/auto_route.dart"; - -import "package:flash_mail/presentation/widgets/auth/account_buttons.dart"; - -@RoutePage() -class AccountScreen extends StatelessWidget { - const AccountScreen({super.key}); - - @override - Widget build(BuildContext context) { - return Scaffold( - body: Column( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - const SizedBox(height: 20), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Image( - image: AssetImage("assets/images/logo-small.png"), - ), - Text( - "FlashMail", - style: Theme.of(context).textTheme.titleLarge, - ) - ], - ), - const AccountButtons() - ], - ), - ); - } -} diff --git a/lib/presentation/screens/compose_screen.dart b/lib/presentation/screens/compose_screen.dart new file mode 100644 index 0000000..c613439 --- /dev/null +++ b/lib/presentation/screens/compose_screen.dart @@ -0,0 +1,127 @@ +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/widgets/primary_button.dart"; +import "package:smol_mail/presentation/widgets/small_loading_spinner.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"; + +/// One tap seals one envelope; the button disables while the send is in +/// flight so a second tap cannot seal a second message. +@RoutePage() +class ComposeScreen extends ConsumerStatefulWidget { + final String? to; + final String? subject; + + const ComposeScreen({ + super.key, + this.to, + this.subject, + }); + + @override + ConsumerState createState() => _ComposeScreenState(); +} + +class _ComposeScreenState extends ConsumerState { + late final toController = TextEditingController(text: widget.to ?? ""); + late final subjectController = + TextEditingController(text: widget.subject ?? ""); + final bodyController = TextEditingController(); + bool anonymous = false; + bool busy = false; + + @override + void dispose() { + toController.dispose(); + subjectController.dispose(); + bodyController.dispose(); + super.dispose(); + } + + Future _send() async { + final client = ref.read(clientProvider); + setState(() => busy = true); + try { + final to = await client.send( + toController.text, + subjectController.text, + bodyController.text, + anonymous: anonymous, + ); + ref.read(revisionProvider.notifier).bump(); + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text("sent to $to"))); + AutoRouter.of(context).pop(); + } + } on SmolError catch (err) { + if (mounted) { + showErrorSnackBar(context, err.message); + } + } finally { + if (mounted) setState(() => busy = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text("Write")), + body: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: toController, + decoration: InputDecoration( + labelText: "To", + hintText: "user@host[:1961] or smol://user@host/key", + filled: true, + fillColor: Theme.of(context).extension()!.cardFill, + ), + ), + const SizedBox(height: 15), + TextField( + controller: subjectController, + decoration: InputDecoration( + labelText: "Subject", + filled: true, + fillColor: Theme.of(context).extension()!.cardFill, + ), + ), + const SizedBox(height: 15), + TextField( + controller: bodyController, + maxLines: 12, + decoration: InputDecoration( + labelText: "Body", + filled: true, + fillColor: Theme.of(context).extension()!.cardFill, + ), + ), + SwitchListTile( + value: anonymous, + onChanged: (value) => setState(() => anonymous = value), + title: Text( + "Anonymous (omit the signed Reply-To)", + style: Theme.of(context).textTheme.bodySmall, + ), + ), + const SizedBox(height: 10), + Center( + child: PrimaryButton( + onPressed: busy ? () {} : _send, + child: busy ? const SmallLoadingSpinner() : const Text("Send"), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/screens/contact_detail_screen.dart b/lib/presentation/screens/contact_detail_screen.dart new file mode 100644 index 0000000..88b76b5 --- /dev/null +++ b/lib/presentation/screens/contact_detail_screen.dart @@ -0,0 +1,158 @@ +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/shared/utils/format.dart"; +import "package:smol_mail/shared/utils/snackbar.dart"; +import "package:smol_mail/smol/errors.dart"; +import "package:smol_mail/smol/proto.dart"; + +/// The full key, the address to hand out, and any key this one replaced — +/// the row can only afford a fingerprint, and §8 turns on the user being able +/// to inspect exactly what changed. +@RoutePage() +class ContactDetailScreen extends ConsumerStatefulWidget { + final String address; + + const ContactDetailScreen({super.key, required this.address}); + + @override + ConsumerState createState() => + _ContactDetailScreenState(); +} + +class _ContactDetailScreenState extends ConsumerState { + bool resolving = false; + + Future _reResolve() async { + final client = ref.read(clientProvider); + setState(() => resolving = true); + try { + final outcome = await client.refreshContact(widget.address); + ref.read(revisionProvider.notifier).bump(); + if (mounted) { + // A rotation or an unexplained key change is §8 material; it stays + // until dismissed and stands out like any other warning. + if (outcome.warn) { + showErrorSnackBar(context, outcome.message, + duration: const Duration(days: 1)); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(outcome.message)), + ); + } + } + } on SmolError catch (err) { + if (mounted) { + showErrorSnackBar(context, err.message); + } + } finally { + if (mounted) setState(() => resolving = false); + } + } + + @override + Widget build(BuildContext context) { + final contact = ref.watch(storeProvider).contact(widget.address); + + return Scaffold( + appBar: AppBar(title: Text(widget.address)), + body: contact == null + ? Center( + child: Text("no such contact", + style: Theme.of(context).textTheme.labelSmall)) + : ListView( + padding: const EdgeInsets.all(20), + children: [ + _chip(context, contact.verified + ? "verified key" + : "key pinned on first use"), + const SizedBox(height: 20), + _field(context, "fingerprint", fingerprint(contact.key)), + _field(context, "public key", b32encode(contact.key)), + Row( + children: [ + Expanded( + child: _field( + context, + "address", + parseAddress(widget.address) + .uri(contact.key)), + ), + IconButton( + icon: const Icon(Icons.copy, size: 18), + onPressed: () => copyText( + context, + parseAddress(widget.address).uri(contact.key)), + ), + ], + ), + if (contact.history.isNotEmpty) ...[ + const SizedBox(height: 30), + Text("previous keys (${contact.history.length})", + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 5), + Text( + "Replaced by a signed rotation chain (SPEC.md §7). Mail signed with these " + "was sent before the change; rotation is not revocation, so a stolen key " + "can still rotate onward.", + style: Theme.of(context).textTheme.labelSmall, + ), + const SizedBox(height: 10), + for (final entry in contact.history) + Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(fingerprint(entry.key)), + Text(b32encode(entry.key), + style: Theme.of(context).textTheme.labelSmall), + Text( + "replaced ${formatTime(entry.until ~/ 1000)}", + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ), + ), + ], + const SizedBox(height: 30), + resolving + ? const Center(child: CircularProgressIndicator()) + : TextButton.icon( + onPressed: _reResolve, + icon: const Icon(Icons.refresh, size: 18), + label: const Text("Re-resolve"), + ), + ], + ), + ); + } + + Widget _chip(BuildContext context, String text) => Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).primaryColor), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + text, + style: Theme.of(context) + .textTheme + .labelSmall! + .copyWith(color: Theme.of(context).primaryColor), + ), + ); + + Widget _field(BuildContext context, String label, String value) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelSmall), + SelectableText(value, style: Theme.of(context).textTheme.bodySmall), + ], + ), + ); +} diff --git a/lib/presentation/screens/contacts_screen.dart b/lib/presentation/screens/contacts_screen.dart new file mode 100644 index 0000000..2e39406 --- /dev/null +++ b/lib/presentation/screens/contacts_screen.dart @@ -0,0 +1,130 @@ +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.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/proto.dart"; + +/// Contacts: the addresses bound to keys, with the trust badge and the number +/// of keys each has replaced — the §8 surface for noticing rotations. +@RoutePage() +class ContactsScreen extends ConsumerWidget { + const ContactsScreen({super.key}); + + Future _import(BuildContext context, WidgetRef ref) async { + final client = ref.read(clientProvider); + final controller = TextEditingController(); + final text = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: Theme.of(context).extension()!.cardFill, + title: const Text("Import contact"), + content: TextField( + controller: controller, + decoration: InputDecoration( + hintText: "smol://user@host/key", + filled: true, + fillColor: Theme.of(context).colorScheme.surface, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(controller.text), + child: const Text("Import"), + ), + ], + ), + ); + if (text == null || text.trim().isEmpty) return; + try { + client.importContact(text); + ref.read(revisionProvider.notifier).bump(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("contact imported (verified key)")), + ); + } + } on SmolError catch (err) { + if (context.mounted) { + showErrorSnackBar(context, err.message); + } + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final contacts = ref.watch(contactsProvider); + + return Scaffold( + appBar: AppBar( + title: const Text("Contacts"), + actions: [ + IconButton( + tooltip: "Import smol:// address", + onPressed: () => _import(context, ref), + icon: const Icon(Icons.person_add), + ), + ], + ), + body: contacts.isEmpty + ? Center( + child: Text( + "no contacts yet — import a smol:// address, or write to someone", + style: Theme.of(context).textTheme.labelSmall, + textAlign: TextAlign.center, + ), + ) + : ListView.builder( + itemCount: contacts.length, + itemBuilder: (ctx, i) { + final (address, contact) = contacts[i]; + return ListTile( + splashColor: Theme.of(context).extension()!.highlight, + onTap: () => AutoRouter.of(context) + .push(ContactDetailRoute(address: address)), + title: Text(address), + subtitle: Text( + fingerprint(contact.key), + style: Theme.of(context).textTheme.labelSmall, + ), + trailing: Wrap( + spacing: 6, + children: [ + _chip(context, contact.verified ? "verified" : "tofu", + ok: contact.verified), + if (contact.history.isNotEmpty) + _chip(context, "${contact.history.length} previous", + ok: false), + ], + ), + ); + }, + ), + ); + } + + Widget _chip(BuildContext context, String text, {required bool ok}) => + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + border: Border.all( + color: ok + ? Theme.of(context).primaryColor + : Theme.of(context).textTheme.labelSmall!.color!, + ), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + text, + style: Theme.of(context).textTheme.labelSmall!.copyWith( + color: ok + ? Theme.of(context).primaryColor + : Theme.of(context).textTheme.labelSmall!.color, + ), + ), + ); +} diff --git a/lib/presentation/screens/inbox_screen.dart b/lib/presentation/screens/inbox_screen.dart index dc8f5a1..f22ab0f 100644 --- a/lib/presentation/screens/inbox_screen.dart +++ b/lib/presentation/screens/inbox_screen.dart @@ -1,62 +1,82 @@ -import "dart:async"; - import "package:auto_route/auto_route.dart"; import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:flash_mail/data/providers/providers.dart"; -import "package:flash_mail/presentation/widgets/drawer/app_drawer.dart"; -import "package:flash_mail/presentation/widgets/image_banner.dart"; -import "package:flash_mail/presentation/widgets/message/message_list.dart"; -import "package:flash_mail/presentation/widgets/shimmer/message_shimmer.dart"; +import "package:smol_mail/data/providers/providers.dart"; +import "package:smol_mail/presentation/routes/app_router.gr.dart"; +import "package:smol_mail/presentation/widgets/drawer/app_drawer.dart"; +import "package:smol_mail/presentation/widgets/image_banner.dart"; +import "package:smol_mail/presentation/widgets/message/message_list.dart"; +import "package:smol_mail/shared/utils/snackbar.dart"; +import "package:smol_mail/smol/errors.dart"; +/// No server push in v1 (SPEC.md §13): new mail arrives when the user taps +/// fetch, which pulls everything and acknowledges what verifies. @RoutePage() -class InboxScreen extends ConsumerStatefulWidget { +class InboxScreen extends ConsumerWidget { const InboxScreen({super.key}); - @override - ConsumerState createState() => _InboxScreenState(); -} - -class _InboxScreenState extends ConsumerState { - Timer? _timer; - - @override - void initState() { - super.initState(); - _timer = Timer.periodic( - const Duration(seconds: 30), - (timer) => ref.refresh(messagesProvider), - ); + Future _fetch(BuildContext context, WidgetRef ref) async { + final client = ref.read(clientProvider); + try { + final summary = await client.fetch(); + ref.read(revisionProvider.notifier).bump(); + if (context.mounted) { + final tail = summary.rejected.isEmpty + ? "" + : "\n${summary.rejected.length} left on the server"; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("${summary.stored} new$tail")), + ); + } + } on SmolError catch (err) { + if (context.mounted) { + showErrorSnackBar(context, err.message); + } + } } @override - void dispose() { - _timer?.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final messages = ref.watch(messagesProvider); + final folder = ref.watch(folderProvider); return Scaffold( appBar: AppBar( - title: const Text("Inbox"), + title: Text(folder == "inbox" ? "Inbox" : "Sent"), + actions: [ + if (folder == "inbox") + IconButton( + tooltip: "Fetch", + onPressed: () => _fetch(context, ref), + icon: const Icon(Icons.cloud_download), + ), + ], ), drawer: const AppDrawer(), - body: messages.when( - data: (data) => MessageList( - messages: data, - onRefresh: () => ref.refresh(messagesProvider.future), - ), - error: (err, _) => const ImageBanner( - imgSrc: "assets/images/error.png", - text: "Something went wrong!", - ), - loading: () => const MessageShimmer(), - skipLoadingOnRefresh: true, + floatingActionButton: FloatingActionButton( + onPressed: () => AutoRouter.of(context).push(ComposeRoute()), + child: const Icon(Icons.edit), ), + body: messages.isEmpty + ? RefreshIndicator( + onRefresh: () => folder == "inbox" + ? _fetch(context, ref) + : Future.value(), + child: ListView( + children: const [ + ImageBanner( + imgSrc: "assets/images/empty.png", + text: "Nothing here yet!", + ), + ], + ), + ) + : MessageList( + onRefresh: () => folder == "inbox" + ? _fetch(context, ref) + : Future.value(), + ), ); } } diff --git a/lib/presentation/screens/login_screen.dart b/lib/presentation/screens/login_screen.dart deleted file mode 100644 index 80f1a85..0000000 --- a/lib/presentation/screens/login_screen.dart +++ /dev/null @@ -1,31 +0,0 @@ -import "package:flutter/material.dart"; -import "package:auto_route/auto_route.dart"; - -import "package:flash_mail/presentation/widgets/auth/login_form.dart"; - -@RoutePage() -class LoginScreen extends StatelessWidget { - const LoginScreen({super.key}); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(), - body: Padding( - padding: const EdgeInsets.all(25), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Login", - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 25), - LoginForm(), - ], - ), - ), - ); - } -} diff --git a/lib/presentation/screens/message_detail_screen.dart b/lib/presentation/screens/message_detail_screen.dart index 45ba171..4e2ffe4 100644 --- a/lib/presentation/screens/message_detail_screen.dart +++ b/lib/presentation/screens/message_detail_screen.dart @@ -1,69 +1,128 @@ +import "dart:typed_data"; + import "package:auto_route/auto_route.dart"; import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:flash_mail/data/providers/providers.dart"; -import "package:flash_mail/presentation/widgets/image_banner.dart"; -import "package:flash_mail/presentation/widgets/message/message_view.dart"; +import "package:smol_mail/data/providers/providers.dart"; +import "package:smol_mail/presentation/widgets/image_banner.dart"; +import "package:smol_mail/presentation/widgets/message/message_view.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"; +/// Fetched mail is acknowledged off the server during fetch, so deleting here +/// removes only the local sealed copy. @RoutePage() class MessageDetailScreen extends ConsumerWidget { + final String folder; final String id; - final String subject; const MessageDetailScreen({ super.key, + required this.folder, required this.id, - required this.subject, }); - void deleteMessage(BuildContext context, WidgetRef ref) async { - await ref.read(clientProvider).deleteMessage(id); - ref.invalidate(messagesProvider); + Future _delete(BuildContext context, WidgetRef ref) async { + await ref.read(storeProvider).deleteMessage(folder, id); + ref.read(revisionProvider.notifier).bump(); if (context.mounted) { AutoRouter.of(context).pop(); } } + Future _nameSender(BuildContext context, WidgetRef ref, + Uint8List senderKey) async { + final client = ref.read(clientProvider); + final controller = TextEditingController(); + final address = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: Theme.of(context).extension()!.cardFill, + content: TextField( + controller: controller, + decoration: InputDecoration( + hintText: "user@host or smol://…", + filled: true, + fillColor: Theme.of(context).colorScheme.surface, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(controller.text), + child: const Text("Save"), + ), + ], + ), + ); + if (address == null || address.trim().isEmpty) return; + try { + await client.nameSender(address, senderKey); + ref.read(revisionProvider.notifier).bump(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Address saved for this sender")), + ); + } + } on SmolError catch (err) { + if (context.mounted) { + showErrorSnackBar(context, err.message); + } + } + } + @override Widget build(BuildContext context, WidgetRef ref) { - final message = ref.watch(messageDetailProvider(id)); + final record = ref.watch(storeProvider).getMessage(folder, id); + if (record == null) { + return Scaffold( + appBar: AppBar(), + body: const ImageBanner( + imgSrc: "assets/images/error.png", + text: "Message not found!", + ), + ); + } + final client = ref.read(clientProvider); + final opened = client.describe(record); + final isSent = folder == "sent"; + final who = isSent + ? (record.recipient ?? "") + : (opened.sender == null + ? "" + : ref.watch(storeProvider).addressForKey(opened.sender!) ?? ""); return Scaffold( appBar: AppBar( + title: who.isEmpty + ? null + : Text( + who, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontFamily: "Inter", + fontWeight: FontWeight.w500, + fontSize: 18, + color: Theme.of(context).colorScheme.onSurface, + ), + ), actions: [ IconButton( - onPressed: () => deleteMessage(context, ref), - icon: const Icon(Icons.delete, color: Colors.white), - ) + tooltip: "Delete local copy", + onPressed: () => _delete(context, ref), + icon: const Icon(Icons.delete), + ), ], ), body: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), - child: Column( - children: [ - Container( - alignment: Alignment.centerLeft, - child: Text( - subject, - style: Theme.of(context) - .textTheme - .bodyLarge! - .copyWith(fontSize: 20), - ), - ), - const SizedBox(height: 30), - message.when( - data: (data) => MessageView(message: data), - error: (err, _) => const ImageBanner( - imgSrc: "assets/images/error.png", - text: "Something went wrong!", - ), - loading: () => const SizedBox.shrink(), - ), - ], - ), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + child: MessageView( + record: record, + opened: opened, + onNameSender: opened.sender == null + ? null + : (key) => _nameSender(context, ref, key), ), ), ); diff --git a/lib/presentation/screens/onboarding_screen.dart b/lib/presentation/screens/onboarding_screen.dart new file mode 100644 index 0000000..b6b0b3c --- /dev/null +++ b/lib/presentation/screens/onboarding_screen.dart @@ -0,0 +1,324 @@ +import "package:auto_route/auto_route.dart"; +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; +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/widgets/primary_button.dart"; +import "package:smol_mail/presentation/widgets/secondary_button.dart"; +import "package:smol_mail/presentation/widgets/small_loading_spinner.dart"; +import "package:smol_mail/smol/crypto.dart"; +import "package:smol_mail/smol/errors.dart"; +import "package:smol_mail/smol/proto.dart"; +import "package:smol_mail/presentation/theme/app_colors.dart"; +import "package:smol_mail/shared/utils/snackbar.dart"; + +/// First-run flow: create or restore an identity, back the seed up, pin the +/// home server's key and register an address (SPEC.md §4: registration against +/// an unpinned server is not permitted). +@RoutePage() +class OnboardingScreen extends ConsumerStatefulWidget { + const OnboardingScreen({super.key}); + + @override + ConsumerState createState() => _OnboardingScreenState(); +} + +enum _Step { welcome, backup, restore, register } + +class _OnboardingScreenState extends ConsumerState { + _Step step = _Step.welcome; + Uint8List? createdSeed; + bool busy = false; + + final seedController = TextEditingController(); + final restoreAddressController = TextEditingController(); + final addressController = TextEditingController(); + final serverKeyController = TextEditingController(); + final tokenController = TextEditingController(); + + @override + void dispose() { + seedController.dispose(); + restoreAddressController.dispose(); + addressController.dispose(); + serverKeyController.dispose(); + tokenController.dispose(); + super.dispose(); + } + + void _showError(Object error) { + if (!mounted) return; + // Replace rather than queue: a fresh error must not wait behind a stale + // one — a queued SnackBar is indistinguishable from "nothing happened". + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + showErrorSnackBar( + context, error is SmolError ? error.message : error.toString()); + } + + void _createIdentity() { + final client = ref.read(clientProvider); + final fresh = client.createIdentity(); + setState(() { + createdSeed = fresh.seed; + step = _Step.backup; + }); + } + + void _restoreIdentity() { + setState(() => step = _Step.restore); + } + + // Restoring must succeed on its own even if recall fails (server not + // pinned yet, offline, typo) — recall can always be retried from the + // register step or settings afterward. + void _submitRestore() { + final client = ref.read(clientProvider); + try { + client.restoreIdentity(seedController.text); + } on Exception catch (err) { + _showError(err); + return; + } + final addressText = restoreAddressController.text.trim(); + if (addressText.isEmpty) { + setState(() => step = _Step.register); + return; + } + () async { + try { + await client.recallAccount(addressText); + if (!mounted) return; + ref.read(revisionProvider.notifier).bump(); + AutoRouter.of(context).replace(InboxRoute()); + } on Exception catch (err) { + if (!mounted) return; + _showError(err); + setState(() => step = _Step.register); + } + }(); + } + + Future _submitRegistration() async { + await _submit(recall: false); + } + + // Restoring a seed on a new device knows the identity but not the address it + // was registered under; recall binds it without re-REGISTER. + Future _submitRecall() async { + await _submit(recall: true); + } + + Future _submit({required bool recall}) async { + final client = ref.read(clientProvider); + setState(() => busy = true); + try { + final address = parseAddress(addressController.text); + // Recall needs a pin too (SPEC.md §4), and the key for it is right + // here in the form — pin it before either path. An empty field means + // "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); + } + if (recall) { + await client.recallAccount(address.short); + } else { + await client.registerAccount(address.short, + token: tokenController.text.trim()); + } + if (mounted) { + ref.read(revisionProvider.notifier).bump(); + AutoRouter.of(context).replace(InboxRoute()); + } + } catch (err) { + _showError(err); + } finally { + if (mounted) setState(() => busy = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 25), + child: switch (step) { + _Step.welcome => _welcome(context), + _Step.backup => _backup(context), + _Step.restore => _restore(context), + _Step.register => _register(context), + }, + ), + ); + } + + Widget _header(BuildContext context, String title) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 60), + Row( + children: [ + const Image(image: AssetImage("assets/images/logo-small.png")), + const SizedBox(width: 10), + Text("kirakira", style: Theme.of(context).textTheme.titleLarge), + ], + ), + const SizedBox(height: 40), + Text(title, style: Theme.of(context).textTheme.titleMedium), + ], + ); + + Widget _welcome(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _header(context, + "One keypair is the whole identity: an address, a key and a message."), + const Spacer(), + PrimaryButton( + onPressed: _createIdentity, + child: const Text("Create Identity"), + ), + const SizedBox(height: 5), + SecondaryButton( + text: "Restore From Seed", + onPressed: _restoreIdentity, + ), + const SizedBox(height: 80), + ], + ); + } + + Widget _backup(BuildContext context) { + final seed = createdSeed!; + final seedHex = hex(seed); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _header(context, "Back up this seed; it is the only secret."), + const SizedBox(height: 20), + Text( + "fingerprint:\n${fingerprint(ed25519PublicKey(seed))}", + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 20), + SelectableText( + seedHex, + style: Theme.of(context) + .textTheme + .bodySmall! + .copyWith(color: Theme.of(context).colorScheme.primary), + ), + TextButton( + onPressed: () async { + await Clipboard.setData(ClipboardData(text: seedHex)); + if (mounted) { + ScaffoldMessenger.of(this.context).showSnackBar( + const SnackBar(content: Text("Seed copied to clipboard")), + ); + } + }, + child: const Text("Copy seed"), + ), + const Spacer(), + PrimaryButton( + onPressed: () => setState(() => step = _Step.register), + child: const Text("I have backed it up"), + ), + const SizedBox(height: 80), + ], + ); + } + + Widget _restore(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _header(context, "Enter the 32-byte seed as 64 hex characters."), + const SizedBox(height: 20), + TextField( + controller: seedController, + decoration: InputDecoration( + hintText: "64 hex characters", + filled: true, + fillColor: Theme.of(context).extension()!.cardFill, + ), + ), + const SizedBox(height: 15), + TextField( + controller: restoreAddressController, + decoration: InputDecoration( + labelText: "Address (if already registered)", + hintText: "alice@example.org", + filled: true, + fillColor: Theme.of(context).extension()!.cardFill, + ), + ), + const SizedBox(height: 30), + PrimaryButton( + onPressed: _submitRestore, + child: const Text("Restore"), + ), + const SizedBox(height: 5), + SecondaryButton( + text: "Back", + onPressed: () => setState(() => step = _Step.welcome), + ), + const Spacer(), + ], + ); + } + + Widget _register(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _header(context, + "Register an address. Pin the server's public key first — obtain it from the operator through a trusted channel."), + const SizedBox(height: 30), + TextField( + controller: addressController, + decoration: InputDecoration( + labelText: "Address", + hintText: "you@example.org[:1961]", + filled: true, + fillColor: Theme.of(context).extension()!.cardFill, + ), + ), + const SizedBox(height: 15), + TextField( + controller: serverKeyController, + decoration: InputDecoration( + labelText: "Server public key", + hintText: "base32, 52 characters", + filled: true, + fillColor: Theme.of(context).extension()!.cardFill, + ), + ), + const SizedBox(height: 15), + TextField( + controller: tokenController, + decoration: InputDecoration( + labelText: "Invite token (optional)", + filled: true, + fillColor: Theme.of(context).extension()!.cardFill, + ), + ), + const Spacer(), + PrimaryButton( + onPressed: busy ? () {} : _submitRegistration, + child: busy + ? const SmallLoadingSpinner() + : const Text("Pin and Register"), + ), + TextButton( + onPressed: busy ? () {} : _submitRecall, + child: const Text("Already registered? Recall"), + ), + const SizedBox(height: 80), + ], + ); + } +} diff --git a/lib/presentation/screens/settings_screen.dart b/lib/presentation/screens/settings_screen.dart new file mode 100644 index 0000000..0d01b8c --- /dev/null +++ b/lib/presentation/screens/settings_screen.dart @@ -0,0 +1,425 @@ +import "dart:convert"; +import "dart:io"; + +import "package:auto_route/auto_route.dart"; +import "package:file_picker/file_picker.dart"; +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +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/shared/utils/snackbar.dart"; +import "package:smol_mail/smol/crypto.dart"; +import "package:smol_mail/smol/errors.dart"; +import "package:smol_mail/smol/proto.dart"; + +/// Identity card, server pins, rotation and the wipe. The seed is the only +/// secret; revealing it is always an explicit action. +@RoutePage() +class SettingsScreen extends ConsumerStatefulWidget { + const SettingsScreen({super.key}); + + @override + ConsumerState createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends ConsumerState { + bool seedShown = false; + final hostController = TextEditingController(); + final keyController = TextEditingController(); + final addressController = TextEditingController(); + final tokenController = TextEditingController(); + + @override + void dispose() { + hostController.dispose(); + keyController.dispose(); + addressController.dispose(); + tokenController.dispose(); + super.dispose(); + } + + void _showError(Object error) { + if (!mounted) return; + showErrorSnackBar(context, + error is SmolError ? error.message : "Something went wrong!"); + } + + Future _register() async { + final client = ref.read(clientProvider); + try { + await client.registerAccount(addressController.text, + token: tokenController.text.trim()); + ref.read(revisionProvider.notifier).bump(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("registered")), + ); + } + } catch (err) { + _showError(err); + } + } + + // Mail, once fetched, exists only on this device — the server deletes it as + // it is picked up (SPEC.md §5). This backs up inbox, sent mail, contacts and + // pinned servers into one shareable file; never the seed. + Future _export() async { + final data = ref.read(storeProvider).exportData(); + final stamp = DateTime.now().toIso8601String().substring(0, 10); + final file = File("${Directory.systemTemp.path}/smolmail-export-$stamp.json"); + await file.writeAsString(jsonEncode(data)); + await SharePlus.instance.share(ShareParams( + files: [XFile(file.path)], text: "kirakira backup $stamp")); + if (mounted) { + final messages = (data["inbox"] as List).length + (data["sent"] as List).length; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("exported $messages messages, " + "${(data["contacts"] as Map).length} contacts, " + "${(data["servers"] as Map).length} server keys")), + ); + } + } + + Future _import() async { + final files = await FilePicker.pickFiles( + type: FileType.custom, allowedExtensions: ["json"]); + final path = files.isEmpty ? null : files.single.path; + if (path == null) return; + try { + final data = jsonDecode(File(path).readAsStringSync()) as Map; + final summary = await ref.read(storeProvider).importData(data); + ref.read(revisionProvider.notifier).bump(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("imported: $summary")), + ); + } + } on Exception catch (err) { + _showError(err); + } on TypeError catch (err) { + _showError(err); + } + } + + // Bind an address this seed was already registered under, without REGISTER. + Future _recall() async { + final client = ref.read(clientProvider); + try { + final addr = await client.recallAccount(addressController.text); + ref.read(revisionProvider.notifier).bump(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("recalled ${addr.short}")), + ); + } + } catch (err) { + _showError(err); + } + } + + Future _rotate() async { + final client = ref.read(clientProvider); + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: Theme.of(context).extension()!.cardFill, + title: const Text("Rotate the identity key?"), + content: const Text( + "Contacts accept the change from the signed chain. The old key is kept, " + "since mail sealed to it stays readable with nothing else."), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text("Cancel")), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text("Rotate")), + ], + ), + ); + if (confirmed != true) return; + try { + final fresh = await client.rotateIdentity(); + ref.read(revisionProvider.notifier).bump(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("rotated; new key ${b32encode(fresh.publicKey)}")), + ); + } + } catch (err) { + _showError(err); + } + } + + Future _wipe() async { + final client = ref.read(clientProvider); + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: Theme.of(context).extension()!.cardFill, + title: const Text("Wipe everything?"), + content: const Text( + "Deletes the identity, every pinned key and every stored message from " + "this device. Mail left on servers stays there."), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text("Cancel")), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text("Wipe")), + ], + ), + ); + if (confirmed != true) return; + await client.wipe(); + ref.read(revisionProvider.notifier).bump(); + if (mounted) { + AutoRouter.of(context).replace(OnboardingRoute()); + } + } + + @override + Widget build(BuildContext context) { + final store = ref.watch(storeProvider); + final me = ref.watch(identityProvider); + final account = ref.watch(accountProvider); + if (me == null) return Scaffold(appBar: AppBar(title: const Text("Settings"))); + + final seedHex = hex(me.seed); + final share = account?.uri(me.publicKey); + final pins = store.allPins(); + + return Scaffold( + appBar: AppBar(title: const Text("Settings")), + body: ListView( + padding: const EdgeInsets.all(20), + children: [ + _card( + context, + children: [ + Text("fingerprint", style: Theme.of(context).textTheme.labelSmall), + Text(fingerprint(me.publicKey)), + const SizedBox(height: 15), + Text("address", style: Theme.of(context).textTheme.labelSmall), + Text(account?.short ?? "(not registered)"), + const SizedBox(height: 15), + Text("share", style: Theme.of(context).textTheme.labelSmall), + Row( + children: [ + Expanded( + child: Text( + share ?? "(register to get a shareable address)", + overflow: TextOverflow.ellipsis, + ), + ), + if (share != null) + IconButton( + icon: const Icon(Icons.copy, size: 18), + onPressed: () => + Clipboard.setData(ClipboardData(text: share)), + ), + ], + ), + ], + ), + const SizedBox(height: 20), + _card( + context, + borderColor: Theme.of(context).colorScheme.error, + children: [ + Text("seed", style: Theme.of(context).textTheme.labelSmall), + Row( + children: [ + Expanded( + child: SelectableText( + seedShown ? seedHex : "•" * 64, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + TextButton( + onPressed: () => + setState(() => seedShown = !seedShown), + child: Text(seedShown ? "hide" : "reveal"), + ), + IconButton( + icon: const Icon(Icons.copy, size: 18), + onPressed: () => + Clipboard.setData(ClipboardData(text: seedHex)), + ), + ], + ), + Text( + "back this seed up; it is the only secret", + style: Theme.of(context) + .textTheme + .labelSmall! + .copyWith(color: Theme.of(context).colorScheme.error), + ), + const SizedBox(height: 10), + Text( + "retired keys: ${store.identities().length - 1} (kept to read old mail)", + style: Theme.of(context).textTheme.labelSmall), + ], + ), + const SizedBox(height: 30), + const Divider(height: 1), + const SizedBox(height: 20), + Text("pinned servers", style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 10), + for (final (host, key) in pins) + ListTile( + contentPadding: EdgeInsets.zero, + title: Text(host), + subtitle: Text( + b32encode(key), + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall, + ), + trailing: IconButton( + icon: const Icon(Icons.delete_outline, size: 18), + onPressed: () { + store.unpinServer(host); + ref.read(revisionProvider.notifier).bump(); + setState(() {}); + }, + ), + ), + if (pins.isEmpty) + Text("no pinned servers", style: Theme.of(context).textTheme.labelSmall), + const SizedBox(height: 10), + TextField( + controller: hostController, + decoration: InputDecoration( + labelText: "Host", + filled: true, + fillColor: Theme.of(context).extension()!.cardFill, + ), + ), + const SizedBox(height: 10), + TextField( + controller: keyController, + decoration: InputDecoration( + labelText: "Server public key (base32)", + filled: true, + fillColor: Theme.of(context).extension()!.cardFill, + ), + ), + const SizedBox(height: 10), + TextButton( + onPressed: () { + try { + ref + .read(clientProvider) + .pinServer(hostController.text, keyController.text); + keyController.clear(); + setState(() {}); + } on SmolError catch (err) { + _showError(err); + } catch (err) { + _showError(err); + } + }, + child: const Text("Pin server"), + ), + if (account == null) ...[ + const SizedBox(height: 30), + const Divider(height: 1), + const SizedBox(height: 20), + Text("register an address", + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 10), + TextField( + controller: addressController, + decoration: InputDecoration( + labelText: "Address", + hintText: "you@example.org[:1961]", + filled: true, + fillColor: Theme.of(context).extension()!.cardFill, + ), + ), + const SizedBox(height: 10), + TextField( + controller: tokenController, + decoration: InputDecoration( + labelText: "Invite token (optional)", + filled: true, + fillColor: Theme.of(context).extension()!.cardFill, + ), + ), + const SizedBox(height: 10), + TextButton( + onPressed: _register, + child: const Text("Register"), + ), + TextButton( + onPressed: _recall, + child: const Text("Already registered? Recall"), + ), + ], + const SizedBox(height: 30), + const Divider(height: 1), + const SizedBox(height: 20), + Text("backup", style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 5), + Text( + "Mail, once fetched, exists only on this device. Export backs up the " + "inbox, sent mail, contacts and server pins — never the seed.", + style: Theme.of(context).textTheme.labelSmall, + ), + const SizedBox(height: 10), + Row( + children: [ + TextButton.icon( + onPressed: _export, + icon: const Icon(Icons.share, size: 18), + label: const Text("Export"), + ), + TextButton.icon( + onPressed: _import, + icon: const Icon(Icons.file_upload, size: 18), + label: const Text("Import"), + ), + ], + ), + const SizedBox(height: 30), + const Divider(height: 1), + const SizedBox(height: 10), + ListTile( + contentPadding: EdgeInsets.zero, + title: const Text("Rotate identity key"), + trailing: const Icon(Icons.rotate_right), + onTap: _rotate, + ), + ListTile( + contentPadding: EdgeInsets.zero, + title: Text("Wipe identity and messages", + style: TextStyle( + color: Theme.of(context).colorScheme.error)), + trailing: Icon(Icons.delete_forever, + color: Theme.of(context).colorScheme.error), + onTap: _wipe, + ), + ], + ), + ); + } + + Widget _card(BuildContext context, + {required List children, Color? borderColor}) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).extension()!.cardFill, + borderRadius: BorderRadius.circular(10), + border: borderColor == null ? null : Border.all(color: borderColor), + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: children), + ); + } +} diff --git a/lib/presentation/theme/app_colors.dart b/lib/presentation/theme/app_colors.dart new file mode 100644 index 0000000..34e0a50 --- /dev/null +++ b/lib/presentation/theme/app_colors.dart @@ -0,0 +1,78 @@ +import "package:flutter/material.dart"; + +/// Theme-aware colors that don't map onto a standard [ColorScheme] role. +/// Access via `Theme.of(context).extension()!`. +class AppColors extends ThemeExtension { + const AppColors({ + required this.cardFill, + required this.highlight, + required this.snippetText, + required this.avatarPalette, + }); + + /// Elevated surface used for input fields, list tiles, and cards. + final Color cardFill; + + /// Pressed/selected state highlight (e.g. active drawer item). + final Color highlight; + + /// Dimmed body text, e.g. message preview snippets. + final Color snippetText; + + /// Deterministic background colors for contact avatars. + final List avatarPalette; + + static const dark = AppColors( + cardFill: Color.fromRGBO(22, 39, 56, 1), + highlight: Color.fromRGBO(40, 70, 100, 1), + snippetText: Color.fromRGBO(193, 203, 213, 1), + avatarPalette: [ + Color.fromRGBO(63, 207, 142, 1), + Color.fromRGBO(90, 156, 240, 1), + Color.fromRGBO(240, 176, 90, 1), + Color.fromRGBO(214, 110, 140, 1), + Color.fromRGBO(154, 133, 230, 1), + Color.fromRGBO(90, 200, 210, 1), + ], + ); + + static const light = AppColors( + cardFill: Color.fromRGBO(236, 240, 243, 1), + highlight: Color.fromRGBO(210, 231, 248, 1), + snippetText: Color.fromRGBO(91, 100, 112, 1), + avatarPalette: [ + Color.fromRGBO(43, 175, 116, 1), + Color.fromRGBO(56, 116, 203, 1), + Color.fromRGBO(196, 132, 41, 1), + Color.fromRGBO(180, 76, 108, 1), + Color.fromRGBO(115, 92, 199, 1), + Color.fromRGBO(46, 152, 163, 1), + ], + ); + + @override + AppColors copyWith({ + Color? cardFill, + Color? highlight, + Color? snippetText, + List? avatarPalette, + }) { + return AppColors( + cardFill: cardFill ?? this.cardFill, + highlight: highlight ?? this.highlight, + snippetText: snippetText ?? this.snippetText, + avatarPalette: avatarPalette ?? this.avatarPalette, + ); + } + + @override + AppColors lerp(ThemeExtension? other, double t) { + if (other is! AppColors) return this; + return AppColors( + cardFill: Color.lerp(cardFill, other.cardFill, t)!, + highlight: Color.lerp(highlight, other.highlight, t)!, + snippetText: Color.lerp(snippetText, other.snippetText, t)!, + avatarPalette: t < 0.5 ? avatarPalette : other.avatarPalette, + ); + } +} diff --git a/lib/presentation/theme/theme.dart b/lib/presentation/theme/theme.dart index d903bee..e729f1f 100644 --- a/lib/presentation/theme/theme.dart +++ b/lib/presentation/theme/theme.dart @@ -1,95 +1,143 @@ import "package:flutter/material.dart"; -final ThemeData appTheme = ThemeData( - colorScheme: ColorScheme.fromSwatch().copyWith( - primary: const Color.fromRGBO(63, 207, 142, 1), - background: const Color.fromRGBO(1, 17, 29, 1), - ), - appBarTheme: const AppBarTheme( - backgroundColor: Color.fromRGBO(1, 17, 29, 1), - iconTheme: IconThemeData( - color: Colors.white, - size: 30, - ), - titleTextStyle: TextStyle( - color: Colors.white, - fontFamily: "Inter", - fontWeight: FontWeight.w900, - fontSize: 30, - ), - elevation: 0.0, - ), - textTheme: const TextTheme( - bodySmall: TextStyle( +import "package:smol_mail/presentation/theme/app_colors.dart"; + +const _seedColor = Color.fromRGBO(63, 207, 142, 1); +const _darkSurface = Color.fromRGBO(1, 17, 29, 1); + +TextTheme _textTheme(TextTheme base, Color mutedColor) { + return base.copyWith( + bodySmall: base.bodySmall?.copyWith( fontFamily: "Inter", fontSize: 16, - color: Colors.white, fontWeight: FontWeight.normal, ), - bodyMedium: TextStyle( + bodyMedium: base.bodyMedium?.copyWith( fontFamily: "Inter", fontSize: 20, - color: Colors.white, fontWeight: FontWeight.w500, ), - bodyLarge: TextStyle( + bodyLarge: base.bodyLarge?.copyWith( fontFamily: "Inter", fontSize: 24, - color: Colors.white, fontWeight: FontWeight.bold, ), - titleMedium: TextStyle( - color: Colors.white, + titleMedium: base.titleMedium?.copyWith( fontSize: 14, fontWeight: FontWeight.w300, ), - titleLarge: TextStyle( + titleLarge: base.titleLarge?.copyWith( fontFamily: "Inter", fontSize: 35, - color: Colors.white, fontWeight: FontWeight.w900, ), - labelSmall: TextStyle( + labelSmall: base.labelSmall?.copyWith( fontFamily: "Inter", fontSize: 16, - color: Color.fromRGBO(127, 133, 140, 1), + color: mutedColor, fontWeight: FontWeight.w700, ), - ), - snackBarTheme: const SnackBarThemeData( - backgroundColor: Colors.red, - contentTextStyle: TextStyle( - fontFamily: "Inter", - fontSize: 16, - color: Colors.white, - ), - ), - elevatedButtonTheme: ElevatedButtonThemeData( - style: ElevatedButton.styleFrom( - backgroundColor: const Color.fromRGBO(63, 207, 142, 1), - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), - ), - textStyle: const TextStyle( + ); +} + +ThemeData _buildTheme(ColorScheme scheme, AppColors appColors) { + return ThemeData( + colorScheme: scheme, + // ThemeData defaults this legacy field to Colors.grey[900] on a dark + // theme when unset, which is nearly invisible against our dark surfaces — + // several screens still read it via Theme.of(context).primaryColor. + primaryColor: scheme.primary, + scaffoldBackgroundColor: scheme.surface, + extensions: [appColors], + appBarTheme: AppBarTheme( + backgroundColor: scheme.surface, + iconTheme: IconThemeData(color: scheme.onSurface, size: 30), + titleTextStyle: TextStyle( + color: scheme.onSurface, fontFamily: "Inter", - fontSize: 17, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w900, + fontSize: 30, ), + elevation: 0.0, ), - ), - textButtonTheme: TextButtonThemeData( - style: TextButton.styleFrom( - foregroundColor: const Color.fromRGBO(63, 207, 412, 1), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), - ), - textStyle: const TextStyle( + textTheme: _textTheme( + ThemeData(brightness: scheme.brightness).textTheme, + scheme.onSurfaceVariant, + ), + snackBarTheme: SnackBarThemeData( + backgroundColor: scheme.inverseSurface, + contentTextStyle: TextStyle( fontFamily: "Inter", - fontWeight: FontWeight.w500, - fontSize: 17, + fontSize: 16, + color: scheme.onInverseSurface, ), ), - ), - useMaterial3: true, + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: scheme.primary, + foregroundColor: scheme.onPrimary, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), + textStyle: const TextStyle( + fontFamily: "Inter", + fontSize: 17, + fontWeight: FontWeight.w500, + ), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: scheme.primary, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), + textStyle: const TextStyle( + fontFamily: "Inter", + fontWeight: FontWeight.w500, + fontSize: 17, + ), + ), + ), + // Without this, an empty/unfocused field's resting label inherits + // bodyLarge (24px bold) — that's what made "Host" etc. look huge. + inputDecorationTheme: InputDecorationTheme( + labelStyle: TextStyle( + fontFamily: "Inter", + fontSize: 16, + fontWeight: FontWeight.normal, + color: scheme.onSurfaceVariant, + ), + floatingLabelStyle: TextStyle( + fontFamily: "Inter", + fontSize: 14, + fontWeight: FontWeight.w500, + color: scheme.primary, + ), + hintStyle: TextStyle( + fontFamily: "Inter", + fontSize: 16, + fontWeight: FontWeight.normal, + color: scheme.onSurfaceVariant, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + ), + useMaterial3: true, + ); +} + +final ThemeData appThemeDark = _buildTheme( + ColorScheme.fromSeed( + seedColor: _seedColor, + brightness: Brightness.dark, + ).copyWith(surface: _darkSurface), + AppColors.dark, +); + +final ThemeData appThemeLight = _buildTheme( + ColorScheme.fromSeed( + seedColor: _seedColor, + brightness: Brightness.light, + ), + AppColors.light, ); diff --git a/lib/presentation/widgets/attachment/attachment_button.dart b/lib/presentation/widgets/attachment/attachment_button.dart deleted file mode 100644 index b5f9812..0000000 --- a/lib/presentation/widgets/attachment/attachment_button.dart +++ /dev/null @@ -1,80 +0,0 @@ -import "package:external_path/external_path.dart"; -import "package:flash/flash.dart"; -import "package:flutter/material.dart"; -import "package:flutter_downloader/flutter_downloader.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; - -import "package:flash_mail/data/providers/providers.dart"; -import "package:flash_mail/presentation/widgets/drawer/toast_message.dart"; - -class AttachmentButton extends ConsumerWidget { - final Map file; - - const AttachmentButton({super.key, required this.file}); - - Future downloadFile(BuildContext ctx, WidgetRef ref, String url) async { - final token = ref.read(tokenProvider); - showFlash( - context: ctx, - duration: const Duration(seconds: 2), - builder: (context, controller) => ToastMessage( - controller: controller, - icon: Icons.arrow_downward, - text: "Starting download!", - ), - ); - - await FlutterDownloader.enqueue( - headers: {"Authorization": "Bearer $token"}, - url: "https://api.mail.tm$url", - savedDir: await ExternalPath.getExternalStoragePublicDirectory( - ExternalPath.DIRECTORY_DOWNLOADS, - ), - saveInPublicStorage: true, - ); - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 5), - decoration: BoxDecoration( - border: Border.all( - color: const Color.fromRGBO(137, 147, 164, 1), - ), - borderRadius: BorderRadius.circular(10), - ), - child: Row( - children: [ - const Icon( - Icons.file_open_rounded, - color: Colors.red, - size: 24, - ), - const SizedBox(width: 10), - Text( - file["filename"], - style: Theme.of(context).textTheme.bodySmall!.copyWith( - fontWeight: FontWeight.w500, - ), - ), - Expanded( - child: Container( - alignment: Alignment.centerRight, - child: IconButton( - splashColor: const Color.fromRGBO(40, 70, 100, 1), - onPressed: () => - downloadFile(context, ref, file["downloadUrl"]), - icon: const Icon( - Icons.arrow_downward, - size: 24, - color: Colors.white, - ), - ), - ), - ) - ], - ), - ); - } -} diff --git a/lib/presentation/widgets/attachment/attachment_list.dart b/lib/presentation/widgets/attachment/attachment_list.dart deleted file mode 100644 index 879de26..0000000 --- a/lib/presentation/widgets/attachment/attachment_list.dart +++ /dev/null @@ -1,18 +0,0 @@ -import "package:flutter/material.dart"; - -import "package:flash_mail/presentation/widgets/attachment/attachment_button.dart"; - -class AttachmentList extends StatelessWidget { - final List> attachments; - - const AttachmentList({super.key, required this.attachments}); - - @override - Widget build(BuildContext context) { - return ListView.builder( - shrinkWrap: true, - itemCount: attachments.length, - itemBuilder: (ctx, i) => AttachmentButton(file: attachments[i]), - ); - } -} diff --git a/lib/presentation/widgets/auth/account_buttons.dart b/lib/presentation/widgets/auth/account_buttons.dart deleted file mode 100644 index 5cc99ab..0000000 --- a/lib/presentation/widgets/auth/account_buttons.dart +++ /dev/null @@ -1,67 +0,0 @@ -import "package:auto_route/auto_route.dart"; -import "package:dio/dio.dart"; -import "package:flutter/material.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; - -import "package:flash_mail/data/providers/providers.dart"; -import "package:flash_mail/presentation/widgets/primary_button.dart"; -import "package:flash_mail/presentation/widgets/secondary_button.dart"; -import "package:flash_mail/presentation/widgets/small_loading_spinner.dart"; - -class AccountButtons extends StatelessWidget { - const AccountButtons({super.key}); - - void showErrorSnackBar(BuildContext ctx, String message) { - ScaffoldMessenger.of(ctx).showSnackBar( - SnackBar( - content: Text(message), - behavior: SnackBarBehavior.floating, - ), - ); - } - - void _handleSubmit(BuildContext context, WidgetRef ref) async { - try { - await ref.read(accountProvider.notifier).createAccount(); - if (context.mounted) { - AutoRouter.of(context).replaceNamed("/inbox"); - } - } on DioError catch (err) { - if (context.mounted) { - showErrorSnackBar(context, err.message); - } - } catch (err) { - if (context.mounted) { - showErrorSnackBar(context, "Something went wrong!"); - } - } - } - - @override - Widget build(BuildContext context) { - return Column( - children: [ - Text( - "Your Inbox, Your Privacy, Zero Ads.", - style: Theme.of(context).textTheme.labelSmall, - ), - const SizedBox(height: 15), - Consumer( - builder: (context, ref, child) { - return PrimaryButton( - child: ref.watch(accountProvider).isLoading - ? const SmallLoadingSpinner() - : const Text("Create New Account"), - onPressed: () => _handleSubmit(context, ref), - ); - }, - ), - const SizedBox(height: 5), - SecondaryButton( - text: "Use Existing", - onPressed: () => AutoRouter.of(context).pushNamed("/login"), - ), - ], - ); - } -} diff --git a/lib/presentation/widgets/auth/input_field.dart b/lib/presentation/widgets/auth/input_field.dart deleted file mode 100644 index e04863d..0000000 --- a/lib/presentation/widgets/auth/input_field.dart +++ /dev/null @@ -1,58 +0,0 @@ -import "package:flutter/material.dart"; - -class InputField extends StatelessWidget { - final String label; - final String placeholder; - final TextInputAction inputAction; - final String? Function(String?) validator; - final void Function(String?) onSaved; - final TextInputType? inputType; - final bool? obscureText; - - const InputField({ - super.key, - required this.label, - required this.placeholder, - required this.inputAction, - required this.validator, - required this.onSaved, - this.inputType, - this.obscureText, - }); - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: Theme.of(context).textTheme.bodySmall!.copyWith( - color: Theme.of(context).primaryColor, - ), - ), - const SizedBox(height: 10), - TextFormField( - keyboardType: inputType, - textInputAction: inputAction, - obscureText: obscureText ?? false, - validator: validator, - onSaved: onSaved, - style: Theme.of(context).textTheme.bodySmall!.copyWith(fontSize: 18), - decoration: InputDecoration( - filled: true, - fillColor: const Color.fromRGBO(22, 39, 56, 1), - hintText: placeholder, - hintStyle: const TextStyle( - color: Color.fromRGBO(127, 133, 141, 1), - ), - errorStyle: const TextStyle( - fontFamily: "Inter", - fontSize: 16, - ), - ), - ), - ], - ); - } -} diff --git a/lib/presentation/widgets/auth/login_form.dart b/lib/presentation/widgets/auth/login_form.dart deleted file mode 100644 index 04b8b38..0000000 --- a/lib/presentation/widgets/auth/login_form.dart +++ /dev/null @@ -1,107 +0,0 @@ -import "package:auto_route/auto_route.dart"; -import "package:dio/dio.dart"; -import "package:flutter/material.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; - -import "package:flash_mail/data/providers/providers.dart"; -import "package:flash_mail/presentation/widgets/auth/input_field.dart"; -import "package:flash_mail/presentation/widgets/primary_button.dart"; -import "package:flash_mail/presentation/widgets/small_loading_spinner.dart"; -import "package:flash_mail/shared/utils/regex_patterns.dart"; - -class LoginForm extends ConsumerWidget { - final GlobalKey _formKey = GlobalKey(); - - LoginForm({super.key}); - - String? _validateEmail(String? value) { - if (value!.isEmpty || !emailRegex.hasMatch(value)) { - return "Invalid email address"; - } - return null; - } - - String? _validatePassword(String? value) { - if (value!.isEmpty || value.length < 8) { - return "Password too short"; - } - return null; - } - - void showErrorSnackBar(BuildContext ctx, String message) { - ScaffoldMessenger.of(ctx).showSnackBar( - SnackBar( - content: Text(message), - behavior: SnackBarBehavior.floating, - ), - ); - } - - void _handleSubmit(BuildContext context, WidgetRef ref) async { - if (!_formKey.currentState!.validate()) { - return; - } - FocusScope.of(context).unfocus(); - _formKey.currentState!.save(); - - try { - String? email = ref.read(emailProvider.notifier).state; - String? password = ref.read(passwordProvider.notifier).state; - - await ref.read(accountProvider.notifier).login(email, password); - - if (context.mounted) { - AutoRouter.of(context).replaceNamed("/inbox"); - } - } on DioError catch (err) { - if (context.mounted) { - showErrorSnackBar(context, err.message); - } - } catch (_) { - if (context.mounted) { - showErrorSnackBar(context, "Something went wrong!"); - } - } - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - final account = ref.watch(accountProvider); - - return Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - InputField( - label: "Email Address", - placeholder: "Enter email", - inputType: TextInputType.emailAddress, - inputAction: TextInputAction.next, - validator: _validateEmail, - onSaved: (value) => ref.read(emailProvider.notifier).state = value, - ), - const SizedBox(height: 25), - InputField( - label: "Password", - placeholder: "Enter password", - obscureText: true, - inputAction: TextInputAction.done, - validator: _validatePassword, - onSaved: (value) => - ref.read(passwordProvider.notifier).state = value, - ), - const SizedBox(height: 30), - Center( - child: PrimaryButton( - onPressed: () => _handleSubmit(context, ref), - child: account.isLoading - ? const SmallLoadingSpinner() - : const Text("Login"), - ), - ), - ], - ), - ); - } -} diff --git a/lib/presentation/widgets/drawer/app_drawer.dart b/lib/presentation/widgets/drawer/app_drawer.dart index 2a37eb0..56a1f04 100644 --- a/lib/presentation/widgets/drawer/app_drawer.dart +++ b/lib/presentation/widgets/drawer/app_drawer.dart @@ -1,34 +1,32 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter/material.dart"; -import "package:flash_mail/data/providers/providers.dart"; -import "package:flash_mail/presentation/widgets/drawer/user_profile.dart"; -import "package:flash_mail/presentation/widgets/drawer/drawer_list.dart"; -import "package:flash_mail/presentation/widgets/drawer/user_quota.dart"; +import "package:smol_mail/data/providers/providers.dart"; +import "package:smol_mail/smol/proto.dart"; +import "package:smol_mail/presentation/widgets/drawer/identity_header.dart"; +import "package:smol_mail/presentation/widgets/drawer/drawer_list.dart"; +import "package:smol_mail/presentation/theme/app_colors.dart"; class AppDrawer extends ConsumerWidget { const AppDrawer({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final account = ref.read(accountProvider); + final identity = ref.watch(identityProvider); + final account = ref.watch(accountProvider); + final address = account?.short ?? ""; + final fp = identity == null ? "" : fingerprint(identity.publicKey); return Drawer( - backgroundColor: const Color.fromRGBO(22, 39, 56, 1), + backgroundColor: Theme.of(context).extension()!.cardFill, child: Padding( - padding: const EdgeInsets.only(top: 90), + padding: const EdgeInsets.only(top: 60), child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ - UserProfile( - email: account.user!.email, - password: account.user!.password, - ), + IdentityHeader(address: address, fingerprint: fp), const SizedBox(height: 50), const DrawerList(), - UserQuota( - value: account.user!.used / account.user!.quota, - ), ], ), ), diff --git a/lib/presentation/widgets/drawer/drawer_list.dart b/lib/presentation/widgets/drawer/drawer_list.dart index 6bcc948..19b5c96 100644 --- a/lib/presentation/widgets/drawer/drawer_list.dart +++ b/lib/presentation/widgets/drawer/drawer_list.dart @@ -1,24 +1,19 @@ import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:auto_route/auto_route.dart"; -import "package:flash_mail/data/providers/providers.dart"; -import "package:flash_mail/shared/configs/flash_mail_icons.dart"; -import "package:flash_mail/presentation/widgets/drawer/drawer_list_tile.dart"; +import "package:smol_mail/data/providers/providers.dart"; +import "package:smol_mail/presentation/routes/app_router.gr.dart"; +import "package:auto_route/auto_route.dart"; +import "package:smol_mail/shared/configs/flash_mail_icons.dart"; +import "package:smol_mail/presentation/widgets/drawer/drawer_list_tile.dart"; class DrawerList extends ConsumerWidget { const DrawerList({super.key}); - Future signOut(BuildContext context, WidgetRef ref) async { - Navigator.of(context).pop(); - await ref.read(accountProvider.notifier).logout(); - if (context.mounted) { - AutoRouter.of(context).replaceNamed("/"); - } - } - @override Widget build(BuildContext context, WidgetRef ref) { + final unread = ref.watch(unreadProvider); + return Padding( padding: const EdgeInsets.only(left: 25), child: Column( @@ -26,16 +21,40 @@ class DrawerList extends ConsumerWidget { DrawerListTile( color: Theme.of(context).primaryColor, icon: FlashMailIcons.inbox, - title: "Inbox", - onTap: () => Navigator.of(context).pop(), + title: "Inbox${unread > 0 ? " ($unread)" : ""}", + onTap: () { + ref.read(folderProvider.notifier).select("inbox"); + ref.read(revisionProvider.notifier).bump(); + Navigator.of(context).pop(); + }, ), - Consumer( - builder: (context, ref, _) => DrawerListTile( - color: Theme.of(context).colorScheme.error, - icon: FlashMailIcons.sign_out, - title: "Sign out", - onTap: () => signOut(context, ref), - ), + DrawerListTile( + color: Theme.of(context).primaryColor, + icon: Icons.outgoing_mail, + title: "Sent", + onTap: () { + ref.read(folderProvider.notifier).select("sent"); + ref.read(revisionProvider.notifier).bump(); + Navigator.of(context).pop(); + }, + ), + DrawerListTile( + color: Theme.of(context).primaryColor, + icon: Icons.contacts, + title: "Contacts", + onTap: () { + Navigator.of(context).pop(); + AutoRouter.of(context).push(ContactsRoute()); + }, + ), + DrawerListTile( + color: Theme.of(context).primaryColor, + icon: Icons.settings, + title: "Settings", + onTap: () { + Navigator.of(context).pop(); + AutoRouter.of(context).push(SettingsRoute()); + }, ), ], ), diff --git a/lib/presentation/widgets/drawer/drawer_list_tile.dart b/lib/presentation/widgets/drawer/drawer_list_tile.dart index b3757f8..fb67c08 100644 --- a/lib/presentation/widgets/drawer/drawer_list_tile.dart +++ b/lib/presentation/widgets/drawer/drawer_list_tile.dart @@ -1,5 +1,7 @@ import "package:flutter/material.dart"; +import "package:smol_mail/presentation/theme/app_colors.dart"; + class DrawerListTile extends StatelessWidget { final Color color; final IconData icon; @@ -16,10 +18,11 @@ class DrawerListTile extends StatelessWidget { @override Widget build(BuildContext context) { + final appColors = Theme.of(context).extension()!; return Theme( data: Theme.of(context).copyWith( - splashColor: const Color.fromRGBO(40, 70, 100, 1), - highlightColor: const Color.fromRGBO(22, 39, 56, 1), + splashColor: appColors.highlight, + highlightColor: appColors.cardFill, ), child: ListTile( onTap: onTap, diff --git a/lib/presentation/widgets/drawer/identity_header.dart b/lib/presentation/widgets/drawer/identity_header.dart new file mode 100644 index 0000000..3128c49 --- /dev/null +++ b/lib/presentation/widgets/drawer/identity_header.dart @@ -0,0 +1,64 @@ +import "package:flutter/material.dart"; + +import "package:smol_mail/shared/utils/format.dart"; + +/// Identity summary in the drawer: the address is what gets shared, the +/// fingerprint is what gets verified. +class IdentityHeader extends StatelessWidget { + final String address; + final String fingerprint; + + const IdentityHeader({ + super.key, + required this.address, + required this.fingerprint, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + CircleAvatar( + backgroundColor: Theme.of(context).primaryColor, + radius: 35, + child: Text( + address.isEmpty ? "?" : address[0].toUpperCase(), + style: + Theme.of(context).textTheme.bodyLarge!.copyWith(fontSize: 30), + ), + ), + const SizedBox(height: 20), + InkWell( + onTap: () => copyText(context, address), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + address.isEmpty ? "(not registered)" : address, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w900, + ), + ), + ), + ), + const SizedBox(height: 5), + InkWell( + onTap: () => copyText(context, fingerprint), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + "fp: $fingerprint", + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/presentation/widgets/drawer/toast_message.dart b/lib/presentation/widgets/drawer/toast_message.dart deleted file mode 100644 index 33f8710..0000000 --- a/lib/presentation/widgets/drawer/toast_message.dart +++ /dev/null @@ -1,46 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flash/flash.dart"; - -class ToastMessage extends StatelessWidget { - final FlashController controller; - final IconData icon; - final String text; - - const ToastMessage({ - super.key, - required this.controller, - required this.icon, - required this.text, - }); - - @override - Widget build(BuildContext context) { - return Flash( - position: FlashPosition.bottom, - controller: controller, - child: AlertDialog( - alignment: Alignment.bottomCenter, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - backgroundColor: const Color.fromRGBO(217, 217, 217, 1), - content: Row( - children: [ - Icon( - icon, - color: Colors.black, - ), - const SizedBox(width: 20), - Text( - text, - style: Theme.of(context) - .textTheme - .bodySmall! - .copyWith(color: Colors.black), - ), - ], - ), - ), - ); - } -} diff --git a/lib/presentation/widgets/drawer/user_profile.dart b/lib/presentation/widgets/drawer/user_profile.dart deleted file mode 100644 index e211d51..0000000 --- a/lib/presentation/widgets/drawer/user_profile.dart +++ /dev/null @@ -1,75 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter/services.dart"; -import "package:flash/flash.dart"; - -import "package:flash_mail/presentation/widgets/drawer/toast_message.dart"; - -class UserProfile extends StatelessWidget { - final String email; - final String password; - - const UserProfile({ - super.key, - required this.email, - required this.password, - }); - - Future copyClipboard(BuildContext context, String text) async { - await Clipboard.setData(ClipboardData(text: text)); - if (context.mounted) { - showFlash( - context: context, - duration: const Duration(seconds: 2), - builder: (context, controller) => ToastMessage( - controller: controller, - icon: Icons.check, - text: "Copied to Clipboard!", - ), - ); - } - } - - @override - Widget build(BuildContext context) { - return Column( - children: [ - CircleAvatar( - backgroundColor: Theme.of(context).primaryColor, - radius: 35, - child: Text( - email[0].toUpperCase(), - style: - Theme.of(context).textTheme.bodyLarge!.copyWith(fontSize: 30), - ), - ), - const SizedBox(height: 20), - InkWell( - onTap: () => copyClipboard(context, email), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text( - email, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w900, - ), - ), - ), - ), - const SizedBox(height: 5), - InkWell( - onTap: () => copyClipboard(context, password), - child: Text( - "password: $password", - style: const TextStyle( - color: Color.fromRGBO(127, 133, 141, 1), - fontSize: 18, - fontWeight: FontWeight.w900, - ), - ), - ), - ], - ); - } -} diff --git a/lib/presentation/widgets/drawer/user_quota.dart b/lib/presentation/widgets/drawer/user_quota.dart deleted file mode 100644 index 4522930..0000000 --- a/lib/presentation/widgets/drawer/user_quota.dart +++ /dev/null @@ -1,71 +0,0 @@ -import "package:flutter/material.dart"; -import "package:url_launcher/url_launcher.dart"; - -import "package:flash_mail/shared/configs/flash_mail_icons.dart"; - -class UserQuota extends StatelessWidget { - final String _url = "https://mail.tm/"; - - final double value; - - const UserQuota({super.key, required this.value}); - - Future openUrl(Uri url) async { - if (await canLaunchUrl(url)) { - launchUrl(url); - } - } - - @override - Widget build(BuildContext context) { - return Expanded( - child: Padding( - padding: const EdgeInsets.only(bottom: 30, left: 40, right: 40), - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Quota", - style: TextStyle( - color: Theme.of(context).primaryColor, - fontSize: 18, - ), - ), - const SizedBox(height: 15), - LinearProgressIndicator( - backgroundColor: const Color.fromRGBO(85, 97, 112, 1), - color: Theme.of(context).primaryColor, - value: value, - minHeight: 10, - borderRadius: BorderRadius.circular(5), - ), - const SizedBox(height: 25), - InkWell( - onTap: () => openUrl(Uri.parse(_url)), - highlightColor: Colors.transparent, - splashColor: Colors.transparent, - child: Row( - children: [ - Text( - "Powered By Mail.tm", - style: TextStyle( - color: Theme.of(context).primaryColor, - fontSize: 18, - ), - ), - const SizedBox(width: 10), - Icon( - FlashMailIcons.redirect, - color: Theme.of(context).primaryColor, - size: 16, - ) - ], - ), - ) - ], - ), - ), - ); - } -} diff --git a/lib/presentation/widgets/image_banner.dart b/lib/presentation/widgets/image_banner.dart index 110b274..0a7da8b 100644 --- a/lib/presentation/widgets/image_banner.dart +++ b/lib/presentation/widgets/image_banner.dart @@ -5,10 +5,10 @@ class ImageBanner extends StatelessWidget { final String text; const ImageBanner({ - Key? key, + super.key, required this.imgSrc, required this.text, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/presentation/widgets/message/message_body.dart b/lib/presentation/widgets/message/message_body.dart deleted file mode 100644 index d12459e..0000000 --- a/lib/presentation/widgets/message/message_body.dart +++ /dev/null @@ -1,53 +0,0 @@ -import "package:flutter/widgets.dart"; -import "package:flutter_inappwebview/flutter_inappwebview.dart"; -import "package:url_launcher/url_launcher.dart"; - -class MessageBody extends StatefulWidget { - final String html; - - const MessageBody({super.key, required this.html}); - - @override - State createState() => _MessageBodyState(); -} - -class _MessageBodyState extends State { - double webviewHeight = 1; - - @override - Widget build(BuildContext context) { - return SizedBox( - height: webviewHeight, - child: InAppWebView( - initialData: InAppWebViewInitialData(data: widget.html), - initialSettings: InAppWebViewSettings( - transparentBackground: true, - forceDark: ForceDark.ON, - algorithmicDarkeningAllowed: true, - supportZoom: false, - javaScriptEnabled: true, - disableHorizontalScroll: false, - disableVerticalScroll: true, - useWideViewPort: false, - useHybridComposition: false, - preferredContentMode: UserPreferredContentMode.MOBILE, - ), - onLoadStop: (controller, url) async { - final contentHeight = await controller.getContentHeight(); - if (contentHeight != null) { - setState(() { - webviewHeight = contentHeight.toDouble(); - }); - } - }, - shouldOverrideUrlLoading: (controller, navigationAction) async { - final url = navigationAction.request.url; - if (url != null && await canLaunchUrl(url)) { - await launchUrl(url); - } - return NavigationActionPolicy.CANCEL; - }, - ), - ); - } -} diff --git a/lib/presentation/widgets/message/message_info.dart b/lib/presentation/widgets/message/message_info.dart deleted file mode 100644 index 318714c..0000000 --- a/lib/presentation/widgets/message/message_info.dart +++ /dev/null @@ -1,82 +0,0 @@ -import "package:flutter/material.dart"; -import "package:intl/intl.dart"; - -class MessageInfo extends StatelessWidget { - final String senderAddress; - final String receiverAddress; - final DateTime date; - - const MessageInfo({ - super.key, - required this.senderAddress, - required this.receiverAddress, - required this.date, - }); - - @override - Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.symmetric(vertical: 15), - padding: const EdgeInsets.all(15), - decoration: BoxDecoration( - border: Border.all( - color: const Color.fromRGBO(127, 133, 140, 1), - ), - borderRadius: const BorderRadius.all(Radius.circular(10)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - "From", - style: Theme.of(context).textTheme.labelSmall, - ), - const SizedBox(width: 10), - SizedBox( - width: MediaQuery.of(context).size.width * 0.65, - child: Text( - senderAddress, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall, - ), - ) - ], - ), - Row( - children: [ - Text( - "To", - style: Theme.of(context).textTheme.labelSmall, - ), - const SizedBox(width: 10), - SizedBox( - width: MediaQuery.of(context).size.width * 0.65, - child: Text( - receiverAddress, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall, - ), - ) - ], - ), - Row( - children: [ - Text( - "Date", - style: Theme.of(context).textTheme.labelSmall, - ), - const SizedBox(width: 10), - Text( - DateFormat.yMMMMd().add_jm().format(date.toLocal()), - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall, - ) - ], - ), - ], - ), - ); - } -} diff --git a/lib/presentation/widgets/message/message_list.dart b/lib/presentation/widgets/message/message_list.dart index 38dcef3..21b457b 100644 --- a/lib/presentation/widgets/message/message_list.dart +++ b/lib/presentation/widgets/message/message_list.dart @@ -1,32 +1,27 @@ import "package:flutter/material.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:flash_mail/data/models/message/message.dart"; -import "package:flash_mail/presentation/widgets/image_banner.dart"; -import "package:flash_mail/presentation/widgets/message/message_tile.dart"; +import "package:smol_mail/data/providers/providers.dart"; +import "package:smol_mail/presentation/widgets/message/message_tile.dart"; -class MessageList extends StatelessWidget { - final List messages; +class MessageList extends ConsumerWidget { final Future Function() onRefresh; const MessageList({ super.key, - required this.messages, required this.onRefresh, }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final messages = ref.watch(messagesProvider); + return RefreshIndicator( onRefresh: onRefresh, - child: messages.isEmpty - ? const ImageBanner( - imgSrc: "assets/images/empty.png", - text: "Your inbox is empty!", - ) - : ListView.builder( - itemBuilder: (ctx, i) => MessageTile(message: messages[i]), - itemCount: messages.length, - ), + child: ListView.builder( + itemBuilder: (ctx, i) => MessageTile(record: messages[i]), + itemCount: messages.length, + ), ); } } diff --git a/lib/presentation/widgets/message/message_sender.dart b/lib/presentation/widgets/message/message_sender.dart deleted file mode 100644 index a6a57a7..0000000 --- a/lib/presentation/widgets/message/message_sender.dart +++ /dev/null @@ -1,84 +0,0 @@ -import "package:flutter/material.dart"; - -import "package:flash_mail/presentation/widgets/message/message_info.dart"; - -class MessageSender extends StatefulWidget { - final String senderName; - final String senderAddress; - final String receiverAddress; - final DateTime date; - - const MessageSender({ - super.key, - required this.senderName, - required this.senderAddress, - required this.receiverAddress, - required this.date, - }); - - @override - State createState() => _MessageSenderState(); -} - -class _MessageSenderState extends State { - bool showInfo = false; - - void toggleInfo() { - setState(() { - showInfo = !showInfo; - }); - } - - @override - Widget build(BuildContext context) { - return Column( - children: [ - Row( - children: [ - Image.asset("assets/images/avatar.png", width: 50, height: 50), - const SizedBox(width: 15), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.senderName, - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontWeight: FontWeight.bold, - ), - ), - GestureDetector( - onTap: () => toggleInfo(), - child: Row( - children: [ - SizedBox( - width: MediaQuery.of(context).size.width * 0.6, - child: Text( - widget.senderAddress, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall, - ), - ), - Icon( - showInfo - ? Icons.keyboard_arrow_up_outlined - : Icons.keyboard_arrow_down_outlined, - color: const Color.fromRGBO(127, 133, 140, 1), - ), - ], - ), - ), - ], - ) - ], - ), - showInfo - ? MessageInfo( - senderAddress: widget.senderAddress, - receiverAddress: widget.receiverAddress, - date: widget.date, - ) - : const SizedBox.shrink(), - ], - ); - } -} diff --git a/lib/presentation/widgets/message/message_tile.dart b/lib/presentation/widgets/message/message_tile.dart index f3e3e77..e2c854c 100644 --- a/lib/presentation/widgets/message/message_tile.dart +++ b/lib/presentation/widgets/message/message_tile.dart @@ -1,77 +1,101 @@ import "package:auto_route/auto_route.dart"; import "package:flutter/material.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:flash_mail/data/models/message/message.dart"; -import "package:flash_mail/presentation/routes/app_router.gr.dart"; -import "package:flash_mail/shared/utils/random_color.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/avatar_color.dart"; +import "package:smol_mail/shared/utils/format.dart"; +import "package:smol_mail/smol/proto.dart"; +import "package:smol_mail/smol/store.dart"; -class MessageTile extends StatefulWidget { - final TMMessage message; +class MessageTile extends ConsumerWidget { + final MailRecord record; - const MessageTile({super.key, required this.message}); + const MessageTile({super.key, required this.record}); @override - State createState() => _MessageTileState(); -} + Widget build(BuildContext context, WidgetRef ref) { + final folder = ref.watch(folderProvider); + final store = ref.watch(storeProvider); + final opened = ref.read(clientProvider).describe(record); + final appColors = Theme.of(context).extension()!; -class _MessageTileState extends State { - late Color randomColor; + String who; + if (folder == "sent") { + who = record.recipient ?? ""; + } else if (opened.sender != null) { + final known = store.addressForKey(opened.sender!); + who = known ?? "unknown · ${b32encode(opened.sender!).substring(0, 4)}"; + } else { + who = "?"; + } + final unread = folder == "inbox" && !store.isRead(record.id); + final errorText = opened.error != null ? "" : null; + final subject = + errorText ?? (opened.subject.isEmpty ? "(no subject)" : opened.subject); + final snippet = errorText == null + ? opened.body.replaceAll(RegExp(r"\s+"), " ").trim() + : ""; + final avatarColor = colorForKey(who, appColors.avatarPalette); - @override - void initState() { - super.initState(); - randomColor = getRandomColor(); - } - - @override - Widget build(BuildContext context) { return ListTile( - splashColor: const Color.fromRGBO(40, 70, 100, 1), - focusColor: const Color.fromRGBO(22, 39, 56, 1), - onTap: () => AutoRouter.of(context).push( - MessageDetailRoute( - id: widget.message.id, - subject: widget.message.subject, - ), - ), + splashColor: appColors.highlight, + focusColor: appColors.cardFill, + onTap: () { + if (folder == "inbox") { + store.markRead(record.id); + ref.read(revisionProvider.notifier).bump(); + } + AutoRouter.of(context).push( + MessageDetailRoute(folder: folder, id: record.id), + ); + }, leading: CircleAvatar( radius: 25, - backgroundColor: randomColor, + backgroundColor: avatarColor, child: Text( - widget.message.from["name"]!.toUpperCase()[0], + who.isEmpty ? "?" : who[0].toUpperCase(), style: Theme.of(context).textTheme.bodyLarge!.copyWith( - color: Colors.black, + color: contrastingTextColor(avatarColor), fontWeight: FontWeight.w500, ), ), ), title: Text( - widget.message.from["name"] as String, + who, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontWeight: - widget.message.seen ? FontWeight.w500 : FontWeight.bold, + fontWeight: unread ? FontWeight.bold : FontWeight.w500, ), ), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - widget.message.subject, + subject, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: - widget.message.seen ? FontWeight.w300 : FontWeight.w500, + fontWeight: unread ? FontWeight.w500 : FontWeight.w300, ), ), - Text( - widget.message.intro ?? widget.message.subject, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: const Color.fromRGBO(193, 203, 213, 1), - ), - ) + if (snippet.isNotEmpty) + Text( + snippet.length > 140 ? snippet.substring(0, 140) : snippet, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: appColors.snippetText, + ), + ) ], ), + trailing: Text( + formatShortTime(folder == "sent" ? record.sentAt : opened.time), + style: Theme.of(context).textTheme.labelSmall, + ), ); } } + diff --git a/lib/presentation/widgets/message/message_view.dart b/lib/presentation/widgets/message/message_view.dart index 8051135..cb6853a 100644 --- a/lib/presentation/widgets/message/message_view.dart +++ b/lib/presentation/widgets/message/message_view.dart @@ -1,39 +1,190 @@ -import "package:flutter/widgets.dart"; +import "dart:typed_data"; + +import "package:auto_route/auto_route.dart"; +import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:flash_mail/data/models/message_detail/message_detail.dart"; -import "package:flash_mail/data/providers/providers.dart"; -import "package:flash_mail/presentation/widgets/attachment/attachment_list.dart"; -import "package:flash_mail/presentation/widgets/message/message_body.dart"; -import "package:flash_mail/presentation/widgets/message/message_sender.dart"; +import "package:smol_mail/data/providers/providers.dart"; +import "package:smol_mail/presentation/routes/app_router.gr.dart"; +import "package:smol_mail/shared/utils/format.dart"; +import "package:smol_mail/shared/utils/snackbar.dart"; +import "package:smol_mail/smol/client.dart"; +import "package:smol_mail/smol/proto.dart"; +import "package:smol_mail/smol/store.dart"; -class MessageView extends StatelessWidget { - final TMMessageDetail message; +/// The opened message: trust row (fingerprint and how the sender's key is +/// known), frontmatter fields, and the plain body — smol mail has no HTML. +class MessageView extends ConsumerWidget { + final MailRecord record; + final OpenedRecord opened; + final void Function(Uint8List senderKey)? onNameSender; - const MessageView({super.key, required this.message}); + const MessageView({ + super.key, + required this.record, + required this.opened, + this.onNameSender, + }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + if (opened.error != null) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("", + style: TextStyle(color: Theme.of(context).colorScheme.error)), + ], + ); + } + + final folder = ref.watch(folderProvider); + final store = ref.watch(storeProvider); + final client = ref.read(clientProvider); + + // Sent copies carry no signature from a third party; the trust row then + // just says what they are. + final isSent = folder == "sent"; + final senderKey = opened.sender; + final known = senderKey == null ? null : store.addressForKey(senderKey); + final contact = known == null ? null : store.contact(known); + final verifiedSender = contact?.verified ?? false; + final replyTo = isSent ? null : client.replyAddress(opened); + + final subject = + opened.subject.isEmpty ? "(no subject)" : opened.subject; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Consumer( - builder: (context, ref, child) => MessageSender( - senderName: message.from["name"] as String, - senderAddress: message.from["address"] as String, - receiverAddress: ref.read(emailProvider) as String, - date: message.createdAt, + Text(subject, style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 14), + if (isSent) _badge(context, "your own sealed copy") else + _badge( + context, + verifiedSender + ? "verified sender key" + : known != null + ? "key pinned on first use" + : "key bound to no address", + alert: known == null, ), + if (!isSent && senderKey != null) ...[ + const SizedBox(height: 10), + _keyBlock(context, "${fingerprint(senderKey)}\n${b32encode(senderKey)}"), + ], + const SizedBox(height: 18), + _field(context, isSent ? "To" : "From", + isSent ? (record.recipient ?? "") : (known ?? "unknown sender")), + _field(context, "Date", formatTime(opened.time)), + for (final entry in opened.fields.entries) + if (entry.key != "Subject" && entry.key != "Reply-To") + _field(context, entry.key, entry.value), + if (!isSent && replyTo != null) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: _field(context, "Reply-To", replyTo.short)), + TextButton( + onPressed: () async { + try { + await client.saveReplyAddress(replyTo, senderKey!); + ref.read(revisionProvider.notifier).bump(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Address saved for this sender")), + ); + } + } catch (err) { + if (context.mounted) { + showErrorSnackBar(context, err.toString()); + } + } + }, + child: const Text("Save"), + ), + ], + ), + if (!isSent && senderKey != null && onNameSender != null) + Align( + alignment: Alignment.centerLeft, + child: TextButton( + onPressed: () => onNameSender!(senderKey), + child: const Text("Name this sender"), + ), + ), + const SizedBox(height: 10), + const Divider(height: 1), + const SizedBox(height: 20), + SelectableText( + opened.body, + style: Theme.of(context).textTheme.bodyMedium, ), const SizedBox(height: 25), - MessageBody( - html: message.html[0], - ), - message.hasAttachments - ? AttachmentList( - attachments: message.attachments!, - ) - : const SizedBox.shrink() + if (!isSent && senderKey != null) + Align( + alignment: Alignment.centerRight, + child: OutlinedButton.icon( + icon: const Icon(Icons.reply, size: 18), + label: const Text("Reply"), + onPressed: () => AutoRouter.of(context).push( + ComposeRoute( + to: known ?? replyTo?.short ?? "", + subject: replySubject(opened.subject), + ), + ), + ), + ), ], ); } + + Widget _badge(BuildContext context, String text, {bool alert = false}) { + final scheme = Theme.of(context).colorScheme; + final background = alert ? scheme.errorContainer : scheme.primaryContainer; + final foreground = alert ? scheme.onErrorContainer : scheme.onPrimaryContainer; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + text, + style: Theme.of(context) + .textTheme + .labelSmall! + .copyWith(color: foreground, fontWeight: FontWeight.w500), + ), + ); + } + + /// Technical detail (fingerprint, full key) — quiet by design, so it never + /// competes with the subject or body for attention. + Widget _keyBlock(BuildContext context, String text) => Text( + text, + style: Theme.of(context).textTheme.bodySmall!.copyWith( + fontSize: 12, + height: 1.5, + fontWeight: FontWeight.normal, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ); + + Widget _field(BuildContext context, String label, String value) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 90, + child: Text(label, style: Theme.of(context).textTheme.labelSmall), + ), + Expanded( + child: Text(value, style: Theme.of(context).textTheme.bodySmall), + ), + ], + ), + ); } diff --git a/lib/presentation/widgets/shimmer/list_shimmer.dart b/lib/presentation/widgets/shimmer/list_shimmer.dart deleted file mode 100644 index 1e9b516..0000000 --- a/lib/presentation/widgets/shimmer/list_shimmer.dart +++ /dev/null @@ -1,45 +0,0 @@ -import "package:flutter/material.dart"; - -class ListShimmer extends StatelessWidget { - const ListShimmer({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - width: 50, - height: 50, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(25), - color: const Color.fromRGBO(160, 176, 186, 1), - ), - ), - const SizedBox(width: 15), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Container( - color: const Color.fromRGBO(160, 176, 186, 1), - width: MediaQuery.of(context).size.width * 0.65, - height: 18, - ), - ), - const SizedBox(height: 10), - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Container( - color: const Color.fromRGBO(160, 176, 186, 1), - width: MediaQuery.of(context).size.width * 0.65, - height: 18, - ), - ), - ], - ) - ], - ); - } -} diff --git a/lib/presentation/widgets/shimmer/message_shimmer.dart b/lib/presentation/widgets/shimmer/message_shimmer.dart deleted file mode 100644 index 640d40a..0000000 --- a/lib/presentation/widgets/shimmer/message_shimmer.dart +++ /dev/null @@ -1,28 +0,0 @@ -import "package:flutter/material.dart"; -import "package:shimmer/shimmer.dart"; - -import "package:flash_mail/presentation/widgets/shimmer/list_shimmer.dart"; - -class MessageShimmer extends StatelessWidget { - const MessageShimmer({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(15), - child: Shimmer.fromColors( - baseColor: Colors.grey[500]!, - highlightColor: Colors.grey[300]!, - child: ListView.builder( - itemBuilder: (ctx, i) => const Column( - children: [ - ListShimmer(), - SizedBox(height: 20), - ], - ), - itemCount: 8, - ), - ), - ); - } -} diff --git a/lib/presentation/widgets/small_loading_spinner.dart b/lib/presentation/widgets/small_loading_spinner.dart index 980d25b..768eeaf 100644 --- a/lib/presentation/widgets/small_loading_spinner.dart +++ b/lib/presentation/widgets/small_loading_spinner.dart @@ -1,17 +1,17 @@ import "package:flutter/material.dart"; class SmallLoadingSpinner extends StatelessWidget { - const SmallLoadingSpinner({Key? key}) : super(key: key); + const SmallLoadingSpinner({super.key}); @override Widget build(BuildContext context) { - return const SizedBox( + return SizedBox( width: 20, height: 20, child: Center( child: CircularProgressIndicator( strokeWidth: 2, - color: Colors.white, + color: Theme.of(context).colorScheme.onPrimary, ), ), ); diff --git a/lib/shared/configs/flash_mail_icons.dart b/lib/shared/configs/flash_mail_icons.dart index 6f8c51b..e8ed05b 100644 --- a/lib/shared/configs/flash_mail_icons.dart +++ b/lib/shared/configs/flash_mail_icons.dart @@ -1,17 +1,17 @@ // ignore_for_file: constant_identifier_names -/// -/// Flutter icons FlashMailIcons -/// Copyright (C) 2023 by original authors @ fluttericon.com, fontello.com -/// This font was generated by FlutterIcon.com, which is derived from Fontello. -/// -/// To use this font, place it in your fonts/ directory and include the -/// following in your pubspec.yaml -/// -/// flutter: -/// fonts: -/// - family: FlashMailIcons -/// fonts: -/// - asset: fonts/FlashMailIcons.ttf +// +// Flutter icons FlashMailIcons +// Copyright (C) 2023 by original authors @ fluttericon.com, fontello.com +// This font was generated by FlutterIcon.com, which is derived from Fontello. +// +// To use this font, place it in your fonts/ directory and include the +// following in your pubspec.yaml +// +// flutter: +// fonts: +// - family: FlashMailIcons +// fonts: +// - asset: fonts/FlashMailIcons.ttf import "package:flutter/widgets.dart"; diff --git a/lib/shared/utils/avatar_color.dart b/lib/shared/utils/avatar_color.dart new file mode 100644 index 0000000..7785c96 --- /dev/null +++ b/lib/shared/utils/avatar_color.dart @@ -0,0 +1,15 @@ +import "package:flutter/material.dart"; + +/// A deterministic color for [key] (e.g. a contact address), picked from +/// [palette] — same contact always gets the same avatar color, and every +/// choice comes from the app's own palette instead of arbitrary RGB space. +Color colorForKey(String key, List palette) { + if (palette.isEmpty) return Colors.grey; + final hash = key.codeUnits.fold(0, (acc, c) => (acc * 31 + c) & 0x7fffffff); + return palette[hash % palette.length]; +} + +/// Black or white, whichever reads clearly on [background]. +Color contrastingTextColor(Color background) { + return background.computeLuminance() > 0.5 ? Colors.black : Colors.white; +} diff --git a/lib/shared/utils/format.dart b/lib/shared/utils/format.dart new file mode 100644 index 0000000..743bf30 --- /dev/null +++ b/lib/shared/utils/format.dart @@ -0,0 +1,37 @@ +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; +import "package:intl/intl.dart"; + +/// One path for all replies so the Re: rule lives once. +String replySubject(String subject) => + subject.isEmpty || subject.startsWith("Re:") ? subject : "Re: $subject"; + +String formatTime(int? seconds) { + if (seconds == null) return ""; + final date = DateTime.fromMillisecondsSinceEpoch(seconds * 1000, isUtc: true); + return DateFormat.yMMMMd().add_jm().format(date.toLocal()); +} + +/// The full date is too wide for a list row's trailing slot — this is what +/// message_tile.dart shows instead: just a time for today, else a short date. +String formatShortTime(int? seconds) { + if (seconds == null) return ""; + final date = + DateTime.fromMillisecondsSinceEpoch(seconds * 1000, isUtc: true).toLocal(); + final now = DateTime.now(); + if (date.year == now.year && date.month == now.month && date.day == now.day) { + return DateFormat.jm().format(date); + } + if (date.year == now.year) { + return DateFormat.MMMd().format(date); + } + return DateFormat.yMd().format(date); +} + +Future copyText(BuildContext context, String text) async { + await Clipboard.setData(ClipboardData(text: text)); + if (context.mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text("Copied to clipboard"))); + } +} diff --git a/lib/shared/utils/random_color.dart b/lib/shared/utils/random_color.dart deleted file mode 100644 index ead4c5c..0000000 --- a/lib/shared/utils/random_color.dart +++ /dev/null @@ -1,12 +0,0 @@ -import "dart:math"; -import "dart:ui"; - -Color getRandomColor() { - Random random = Random(); - return Color.fromRGBO( - random.nextInt(256), - random.nextInt(256), - random.nextInt(256), - 1, - ); -} diff --git a/lib/shared/utils/random_string.dart b/lib/shared/utils/random_string.dart deleted file mode 100644 index 3f12989..0000000 --- a/lib/shared/utils/random_string.dart +++ /dev/null @@ -1,12 +0,0 @@ -import "dart:math"; - -String randomString(int length) { - const charset = - "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; - final random = Random.secure(); - final codeUnits = List.generate( - length, - (index) => charset.codeUnitAt(random.nextInt(charset.length)), - ); - return String.fromCharCodes(codeUnits); -} diff --git a/lib/shared/utils/regex_patterns.dart b/lib/shared/utils/regex_patterns.dart deleted file mode 100644 index c3de514..0000000 --- a/lib/shared/utils/regex_patterns.dart +++ /dev/null @@ -1,3 +0,0 @@ -final RegExp emailRegex = RegExp( - r'^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$', -); diff --git a/lib/shared/utils/snackbar.dart b/lib/shared/utils/snackbar.dart new file mode 100644 index 0000000..ba9427d --- /dev/null +++ b/lib/shared/utils/snackbar.dart @@ -0,0 +1,15 @@ +import "package:flutter/material.dart"; + +/// Errors get the error color explicitly — the theme's default SnackBar is +/// neutral, so a plain SnackBar(content: ...) is *not* red anymore. +void showErrorSnackBar(BuildContext context, String message, + {Duration? duration}) { + final scheme = Theme.of(context).colorScheme; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: scheme.error, + duration: duration ?? const Duration(seconds: 4), + content: Text(message, style: TextStyle(color: scheme.onError)), + ), + ); +} diff --git a/lib/smol/client.dart b/lib/smol/client.dart new file mode 100644 index 0000000..ffa283f --- /dev/null +++ b/lib/smol/client.dart @@ -0,0 +1,426 @@ +// App-level client: the flows of gsmol's app.js — connect with pinning, fetch +// with verification and acknowledgment, send with sent copies, contacts and +// rotation — on top of the pure protocol modules. + +import "dart:convert"; +import "dart:typed_data"; + +import "package:smol_mail/smol/crypto.dart"; +import "package:smol_mail/smol/errors.dart"; +import "package:smol_mail/smol/proto.dart"; +import "package:smol_mail/smol/store.dart"; +import "package:smol_mail/smol/transport.dart"; + +class RefreshOutcome { + final String message; + final bool warn; + + const RefreshOutcome(this.message, this.warn); +} + +class FetchSummary { + final int stored; + final List rejected; + + const FetchSummary(this.stored, this.rejected); +} + +class OpenedRecord { + final String id; + final Uint8List? sender; + final int? time; + final Map fields; + final String body; + final String? error; + + const OpenedRecord(this.id, + {this.sender, this.time, this.fields = const {}, this.body = "", this.error}); + + String get subject => error == null ? (fields["Subject"] ?? "") : ""; +} + +class SmolClient { + final SmolStore store; + + /// Trust warnings (§4/§8) the UI must not let the user miss; the app wires + /// this to a persistent banner in app_widget.dart. + void Function(String message)? onWarning; + + SmolClient(this.store); + + void _warn(String message) => onWarning?.call(message); + + // Opening an envelope costs an X25519 agreement and an Ed25519 + // verification, and an envelope's plaintext never changes — so the result + // is kept. Failures are cached too, so one bad message is not retried on + // every render. Rotation clears it, since the key set grew. + final _openedCache = {}; + + SmolIdentity? get identity => store.identity(); + + SmolAddress? accountAddress() { + final account = store.account(); + if (account == null) return null; + final suffix = account.port == defaultPort ? "" : ":${account.port}"; + return parseAddress("${account.user}@${account.host}$suffix"); + } + + // §4: registration and fetching demand a pinned key; sending to a recipient + // whose key we already hold tolerates an unpinned server. + Future connect(SmolAddress addr, + {required bool requirePin}) async { + final pinned = store.serverPin(addr.host); + if (requirePin && pinned == null) { + throw SmolError("no pinned key for ${addr.host}. Obtain it from the " + "operator through a trusted channel, then pin it in settings."); + } + final wire = await TcpWire.connect(addr.host, addr.port); + try { + final opened = await openSession(wire, addr.host, pinned: pinned); + if (pinned == null) { + _warn("${addr.host} is not pinned; its key is " + "${b32encode(opened.serverStatic)}.\n" + "RESOLVE results from this session are UNVERIFIED (SPEC.md §8)."); + } + return opened; + } catch (_) { + wire.close(); + rethrow; + } + } + + // --- identity setup ------------------------------------------------------------ + + SmolIdentity createIdentity() { + final fresh = newIdentity(); + store.setIdentity(fresh.seed); + return fresh; + } + + SmolIdentity restoreIdentity(String seedHex) { + Uint8List seed; + try { + seed = unhex(seedHex.trim()); + } on Exception { + throw const SmolError("seed must be 64 hex characters"); + } + if (seed.length != keyLen) { + throw SmolError("seed is ${seed.length} bytes, expected $keyLen"); + } + final restored = identityFromSeed(seed); + store.setIdentity(seed); + return restored; + } + + void pinServer(String host, String keyB32) { + final key = b32decode(keyB32); + if (key.length != keyLen) { + throw SmolError("server key is ${key.length} bytes, expected $keyLen"); + } + store.pinServer(host.trim().toLowerCase(), key); + } + + Future registerAccount(String addressText, {String token = ""}) async { + final me = identity; + if (me == null) throw const SmolError("no identity yet"); + final addr = parseAddress(addressText); + final opened = await connect(addr, requirePin: true); + try { + await registerOp(opened.session, addr.user, me, + RegisterOptions(token: token)); + } finally { + opened.session.wire.close(); + } + store.setAccount(addr); + } + + /// Restoring a seed brings back the identity, not the memory of what + /// address a *different device* registered it under — "account" is + /// local-only state, never asked of the server. This binds it without + /// REGISTER: RESOLVE the address and require it name this exact key, so a + /// typo or someone else's address cannot misfile fetch and Reply-To. + Future recallAccount(String addressText) async { + final me = identity; + if (me == null) throw const SmolError("no identity yet"); + final addr = parseAddress(addressText); + final opened = await connect(addr, requirePin: true); + Uint8List current; + try { + current = (await resolveOp(opened.session, addr.user)).identity; + } finally { + opened.session.wire.close(); + } + if (!timingSafeEqual(current, me.publicKey)) { + throw SmolError( + "${addr.short} resolves to a different key — not this identity"); + } + store.setAccount(addr); + return addr; + } + + // --- fetch ---------------------------------------------------------------------- + + Future fetch() async { + final me = identity; + final addr = accountAddress(); + if (me == null) throw const SmolError("no identity yet"); + if (addr == null) { + throw const SmolError("not registered; register an address first"); + } + var stored = 0; + final rejected = []; + final opened = await connect(addr, requirePin: true); + try { + await authenticate(opened.session, opened.handshakeHash, addr.user, me); + while (true) { + final records = await fetchOp(opened.session); + if (records.isEmpty) break; + final acked = []; + for (final record in records) { + try { + if (!timingSafeEqual(messageId(record.envelope), record.id)) { + throw const SmolError("id does not match the envelope"); + } + unseal(store.identities(), record.envelope); + } on SmolError catch (err) { + // Left on the server rather than destroyed, so a client-side bug + // cannot lose mail. + rejected.add("${hex(record.id)}: ${err.message}"); + continue; + } + final fresh = await store.storeIfNew("inbox", MailRecord(hex(record.id), record.envelope, + receivedAt: record.receivedAt)); + if (fresh != null) stored++; + acked.add(record.id); + } + if (acked.isEmpty) break; + await deleteOp(opened.session, acked); + } + } finally { + opened.session.wire.close(); + } + return FetchSummary(stored, rejected); + } + + // --- compose and send ----------------------------------------------------------- + + // Prefer a key we already trust; fall back to RESOLVE with trust on first + // use. + Future resolveRecipient(SmolAddress addr) async { + if (addr.identity != null) { + store.saveContact(addr.short, addr.identity!, true); + return addr.identity!; + } + final known = store.contact(addr.short); + if (known != null) return known.key; + final opened = await connect(addr, requirePin: false); + Uint8List current; + try { + current = (await resolveOp(opened.session, addr.user)).identity; + } finally { + opened.session.wire.close(); + } + store.saveContact(addr.short, current, false); + return current; + } + + Future send(String toText, String subject, String body, + {String? replyTo, bool anonymous = false}) async { + final me = identity; + if (me == null) throw const SmolError("no identity yet"); + final addr = parseAddress(toText); + final recipient = await resolveRecipient(addr); + final account = accountAddress(); + final fields = {"Subject": subject, "In-Reply-To": replyTo ?? ""}; + // A signed Reply-To lets a first-time recipient name and answer us + // (§5.5 allows unknown keys); "anonymous" omits it. + if (account != null && !anonymous) { + fields["Reply-To"] = account.uri(me.publicKey); + } + final bodyBytes = utf8Bytes(buildFrontmatter( + fields, "${body.replaceFirst(RegExp(r"\s+$"), "")}\n")); + final envelope = seal(me, recipient, bodyBytes); + final opened = await connect(addr, requirePin: false); + try { + await sendOp(opened.session, envelope); + } finally { + opened.session.wire.close(); + } + // §5.6: the ephemeral is gone, so keep a copy sealed to ourselves. + await store.storeMessage("sent", MailRecord(hex(messageId(envelope)), + seal(me, me.publicKey, bodyBytes), + recipient: addr.short, sentAt: nowSeconds())); + return addr.short; + } + + // --- reading ----------------------------------------------------------------- + + // What is known about a sender changes as the user binds addresses to keys, + // so this layer sits over the cached envelope and is recomputed per call — + // it is a map lookup, not crypto. + OpenedRecord describe(MailRecord row) { + final opened = _openEnvelope(row); + if (opened.error != null) return opened; + return OpenedRecord( + row.id, + sender: opened.sender, + time: opened.time, + fields: opened.fields, + body: opened.body, + ); + } + + OpenedRecord _openEnvelope(MailRecord row) { + var entry = _openedCache[row.id]; + if (entry == null) { + try { + final opened = unseal(store.identities(), row.envelope); + final parsed = parseFrontmatter(utf8.decode(opened.body, allowMalformed: true)); + entry = OpenedRecord(row.id, + sender: opened.sender, + time: opened.time, + fields: parsed.fields, + body: parsed.body); + } on SmolError catch (err) { + entry = OpenedRecord(row.id, error: err.message); + } + _openedCache[row.id] = entry; + } + return entry; + } + + /// The Reply-To address carried inside the message, but only when it is a + /// full smol:// URI whose key matches the signer (§5.7); anything else is + /// ordinary text. + SmolAddress? replyAddress(OpenedRecord opened) { + final claim = opened.fields["Reply-To"]; + if (claim == null || opened.sender == null) return null; + try { + final parsed = parseAddress(claim); + if (parsed.identity != null && + timingSafeEqual(parsed.identity!, opened.sender!)) { + return parsed; + } + } on SmolError { + // malformed claim: display, never bind + } + return null; + } + + /// Bind a user-supplied address to the key that signed a message. A smol:// + /// address carries its own key (verified); a short address is resolved and + /// the result kept on first use. Anything that binds a different key is + /// refused. + Future nameSender(String text, Uint8List senderKey) async { + final addr = parseAddress(text.trim()); + Uint8List key; + var verified = true; + if (addr.identity == null) { + final opened = await connect(addr, requirePin: false); + try { + key = (await resolveOp(opened.session, addr.user)).identity; + } finally { + opened.session.wire.close(); + } + verified = false; // trust on first use, as with any RESOLVE + } else { + key = addr.identity!; + } + if (!timingSafeEqual(key, senderKey)) { + throw const SmolError( + "that address carries a different key than this message's sender"); + } + store.saveContact(addr.short, senderKey, verified); + } + + /// A signed Reply-To is the sender's own claim, so it saves as verified — + /// but never over an address already pinned to a different key (§8: a key + /// change without a rotation chain needs out-of-band confirmation). + Future saveReplyAddress(SmolAddress addr, Uint8List senderKey) async { + final existing = store.contact(addr.short); + if (existing != null && !timingSafeEqual(existing.key, senderKey)) { + throw SmolError("${addr.short} is already known with a different key — " + "verify out of band before replying"); + } + store.saveContact(addr.short, senderKey, true); + } + + // Re-resolve a contact and apply §8: a valid rotation chain is accepted and + // surfaced; anything else requires out-of-band verification. + Future refreshContact(String address) async { + final addr = parseAddress(address); + if (addr.identity != null) { + throw const SmolError("that address already carries a key; use import instead"); + } + final known = store.contact(addr.short); + final opened = await connect(addr, requirePin: false); + Resolved resolved; + try { + resolved = await resolveOp(opened.session, addr.user); + } finally { + opened.session.wire.close(); + } + if (known == null) { + store.saveContact(addr.short, resolved.identity, false); + return RefreshOutcome( + "${addr.short} pinned (trust on first use" + "${opened.pinned ? "" : ", UNVERIFIED server"})", + !opened.pinned); + } + if (timingSafeEqual(known.key, resolved.identity)) { + return RefreshOutcome("${addr.short}: key unchanged", false); + } + if (walkChain(known.key, resolved.identity, resolved.chain)) { + store.saveContact(addr.short, resolved.identity, known.verified); + return RefreshOutcome( + "${addr.short} rotated its key; a signed chain confirms it.\n" + "now ${b32encode(resolved.identity)}", + true); + } + return RefreshOutcome( + "${addr.short} presents a different key with no valid rotation chain.\n" + "Verify out of band, then import the new smol:// address.", + true); + } + + // Bind a smol:// address to the key it carries (§8's strong path); the + // displaced key, if any, lands in the contact's history. + void importContact(String text) { + final addr = parseAddress(text.trim()); + if (addr.identity == null) { + throw const SmolError("import needs a smol:// address carrying a key"); + } + store.saveContact(addr.short, addr.identity!, true); + } + + // --- rotation ----------------------------------------------------------------- + + // §7: rotate to a fresh seed and rebind the account with a signed + // certificate. The old seed is kept by the store, since mail sealed to it + // stays readable with nothing else. + Future rotateIdentity() async { + final me = identity; + final addr = accountAddress(); + if (me == null || addr == null) { + throw const SmolError("rotate needs a registered account"); + } + final fresh = newIdentity(); + final cert = makeCert(me, fresh.seed); + final opened = await connect(addr, requirePin: true); + try { + await registerOp(opened.session, addr.user, fresh, + RegisterOptions(cert: cert)); + } finally { + opened.session.wire.close(); + } + store.rotateIdentity(fresh.seed); + _openedCache.clear(); + return fresh; + } + + // A full wipe: every secret and every stored envelope. The UI must confirm. + Future wipe() async { + await store.wipe(); + _openedCache.clear(); + } +} diff --git a/lib/smol/config.dart b/lib/smol/config.dart new file mode 100644 index 0000000..102c73d --- /dev/null +++ b/lib/smol/config.dart @@ -0,0 +1,26 @@ +import "package:smol_mail/smol/proto.dart"; +import "package:smol_mail/smol/errors.dart"; +import "package:smol_mail/smol/store.dart"; + +// Deploy-time configuration, empty by default (mirrors gsmol's config.js). +// A release may bake in a server key with: +// flutter build apk --dart-define=SMOL_PRESET_SERVER=example.org \ +// --dart-define=SMOL_PRESET_SERVER_KEY=base32key +// It only makes sense when whoever ships this app and whoever runs that +// smolmaild are the same trusted party: the value arrives with the app +// itself, which is the trusted channel SPEC.md §4 asks a pin to come from. +// This only seeds the first run — once written it is an ordinary pin, +// removable in settings like any other, and never overwrites a host the user +// (or a previous install) already pinned. +const _presetHost = String.fromEnvironment("SMOL_PRESET_SERVER"); +const _presetKey = String.fromEnvironment("SMOL_PRESET_SERVER_KEY"); + +void applyPresetServer(SmolStore store) { + if (_presetHost.isEmpty || _presetKey.isEmpty) return; + if (store.serverPin(_presetHost) != null) return; + try { + store.pinServer(_presetHost, b32decode(_presetKey)); + } on SmolError { + // malformed preset: leave unpinned rather than block boot + } +} diff --git a/lib/smol/crypto.dart b/lib/smol/crypto.dart new file mode 100644 index 0000000..345ce64 --- /dev/null +++ b/lib/smol/crypto.dart @@ -0,0 +1,435 @@ +// Smol Mail primitives (SPEC.md §1): SHA-2, HMAC/HKDF-SHA256, ChaCha20-Poly1305, +// X25519, Ed25519, and the §2 key conversions between the two curves. Pure +// Dart rather than PointyCastle so the byte-exact vectors from the reference +// client (test/vectors.json) can pin every operation. + +import "dart:convert"; +import "dart:math"; +import "dart:typed_data"; + +import "package:crypto/crypto.dart" as hashes; + +import "package:smol_mail/smol/errors.dart"; + +// --- bytes -------------------------------------------------------------------- + +Uint8List concat(List> parts) { + final out = Uint8List(parts.fold(0, (n, p) => n + p.length)); + var off = 0; + for (final p in parts) { + out.setRange(off, off + p.length, p); + off += p.length; + } + return out; +} + +Uint8List utf8Bytes(String text) => Uint8List.fromList(utf8.encode(text)); + +String hex(List bytes) => + bytes.map((b) => b.toRadixString(16).padLeft(2, "0")).join(); + +Uint8List unhex(String text) { + if (text.length.isOdd) throw ArgumentError("odd-length hex string: $text"); + final out = Uint8List(text.length ~/ 2); + for (var i = 0; i < out.length; i++) { + out[i] = int.parse(text.substring(i * 2, i * 2 + 2), radix: 16); + } + return out; +} + +BigInt leBytesToBigInt(Uint8List bytes) { + var n = BigInt.zero; + for (var i = bytes.length - 1; i >= 0; i--) { + n = (n << 8) | BigInt.from(bytes[i]); + } + return n; +} + +Uint8List bigIntToLeBytes(BigInt value, int length) { + final out = Uint8List(length); + var v = value; + for (var i = 0; i < length; i++) { + out[i] = (v & BigInt.from(0xff)).toInt(); + v >>= 8; + } + return out; +} + +Uint8List randomBytes(int n) { + final out = Uint8List(n); + final rng = Random.secure(); + for (var i = 0; i < n; i++) { + out[i] = rng.nextInt(256); + } + return out; +} + +bool timingSafeEqual(List a, List b) { + if (a.length != b.length) return false; + var diff = 0; + for (var i = 0; i < a.length; i++) { + diff |= a[i] ^ b[i]; + } + return diff == 0; +} + +// --- SHA-256 / SHA-512 / HMAC-SHA256 / HKDF (RFC 2104, RFC 5869) --------------- + +Uint8List sha256(List message) => + Uint8List.fromList(hashes.sha256.convert(message).bytes); + +Uint8List sha512(List message) => + Uint8List.fromList(hashes.sha512.convert(message).bytes); + +Uint8List hmacSha256(List key, List message) => + Uint8List.fromList(hashes.Hmac(hashes.sha256, key).convert(message).bytes); + +Uint8List hkdfSha256(List ikm, List salt, List info, + [int length = 32]) { + final prk = hmacSha256(salt, ikm); + var out = []; + var block = []; + var counter = 1; + while (out.length < length) { + block = hmacSha256(prk, concat([block, info, [counter]])); + out.addAll(block); + counter++; + } + return Uint8List.fromList(out.sublist(0, length)); +} + +// --- ChaCha20-Poly1305 AEAD (RFC 8439) ----------------------------------------- + +const _mask32 = 0xFFFFFFFF; + +int _rotl32(int x, int n) => ((x << n) | (x >>> (32 - n))) & _mask32; + +Uint8List _chachaBlock(Uint8List key, int counter, Uint8List nonce) { + final state = Uint32List(16); + state.setAll(0, [0x61707865, 0x3320646e, 0x79622d32, 0x6b206574]); + final kview = ByteData.view(key.buffer, key.offsetInBytes, key.length); + for (var i = 0; i < 8; i++) { + state[4 + i] = kview.getUint32(i * 4, Endian.little); + } + state[12] = counter & _mask32; + final nview = ByteData.view(nonce.buffer, nonce.offsetInBytes, nonce.length); + for (var i = 0; i < 3; i++) { + state[13 + i] = nview.getUint32(i * 4, Endian.little); + } + final x = Uint32List.fromList(state); + void qr(int a, int b, int c, int d) { + x[a] = (x[a] + x[b]) & _mask32; + x[d] = _rotl32(x[d] ^ x[a], 16); + x[c] = (x[c] + x[d]) & _mask32; + x[b] = _rotl32(x[b] ^ x[c], 12); + x[a] = (x[a] + x[b]) & _mask32; + x[d] = _rotl32(x[d] ^ x[a], 8); + x[c] = (x[c] + x[d]) & _mask32; + x[b] = _rotl32(x[b] ^ x[c], 7); + } + + for (var i = 0; i < 10; i++) { + qr(0, 4, 8, 12); + qr(1, 5, 9, 13); + qr(2, 6, 10, 14); + qr(3, 7, 11, 15); + qr(0, 5, 10, 15); + qr(1, 6, 11, 12); + qr(2, 7, 8, 13); + qr(3, 4, 9, 14); + } + final out = Uint8List(64); + final view = ByteData.view(out.buffer); + for (var i = 0; i < 16; i++) { + view.setUint32(i * 4, (x[i] + state[i]) & _mask32, Endian.little); + } + return out; +} + +Uint8List _chacha20Xor(Uint8List key, int counter, Uint8List nonce, Uint8List data) { + final out = Uint8List(data.length); + for (var off = 0; off < data.length; off += 64) { + final stream = _chachaBlock(key, counter + off ~/ 64, nonce); + final n = min(64, data.length - off); + for (var i = 0; i < n; i++) { + out[off + i] = data[off + i] ^ stream[i]; + } + } + return out; +} + +// Poly1305 over BigInt; correctness over speed, messages here stay small. +Uint8List _poly1305(Uint8List key, List message) { + final p = (BigInt.one << 130) - BigInt.from(5); + final r = leBytesToBigInt(key.sublist(0, 16)) & + BigInt.parse("0x0ffffffc0ffffffc0ffffffc0fffffff"); + final s = leBytesToBigInt(key.sublist(16, 32)); + var acc = BigInt.zero; + for (var off = 0; off < message.length; off += 16) { + final block = message.sublist(off, min(off + 16, message.length)); + acc = (acc + leBytesToBigInt(Uint8List.fromList(block)) + + (BigInt.one << (8 * block.length))) * + r % + p; + } + return bigIntToLeBytes((acc + s) & ((BigInt.one << 128) - BigInt.one), 16); +} + +Uint8List _pad16(int n) => Uint8List((16 - (n % 16)) % 16); + +Uint8List _le64(int n) => bigIntToLeBytes(BigInt.from(n), 8); + +Uint8List aeadEncrypt(Uint8List key, Uint8List nonce, Uint8List plaintext, + Uint8List aad) { + final polyKey = _chachaBlock(key, 0, nonce).sublist(0, 32); + final ciphertext = _chacha20Xor(key, 1, nonce, plaintext); + final mac = _poly1305(polyKey, + concat([aad, _pad16(aad.length), ciphertext, _pad16(ciphertext.length), _le64(aad.length), _le64(ciphertext.length)])); + return concat([ciphertext, mac]); +} + +Uint8List aeadDecrypt(Uint8List key, Uint8List nonce, Uint8List sealed, Uint8List aad) { + if (sealed.length < 16) { + throw const SmolError("ciphertext shorter than the Poly1305 tag"); + } + final ciphertext = sealed.sublist(0, sealed.length - 16); + final polyKey = _chachaBlock(key, 0, nonce).sublist(0, 32); + final expect = _poly1305(polyKey, + concat([aad, _pad16(aad.length), ciphertext, _pad16(ciphertext.length), _le64(aad.length), _le64(ciphertext.length)])); + if (!timingSafeEqual(expect, sealed.sublist(sealed.length - 16))) { + throw const SmolError("decryption failed: bad Poly1305 tag"); + } + return _chacha20Xor(key, 1, nonce, ciphertext); +} + +// --- X25519 (RFC 7748) --------------------------------------------------------- + +final BigInt _p = (BigInt.one << 255) - BigInt.from(19); +final BigInt _mask255 = (BigInt.one << 255) - BigInt.one; + +BigInt _mod(BigInt value, [BigInt? p]) { + final m = p ?? _p; + return ((value % m) + m) % m; +} + +BigInt _powMod(BigInt base, BigInt exponent, [BigInt? p]) { + final m = p ?? _p; + var out = BigInt.one; + base = _mod(base, m); + while (exponent > BigInt.zero) { + if (exponent & BigInt.one == BigInt.one) out = out * base % m; + base = base * base % m; + exponent >>= 1; + } + return out; +} + +Uint8List clampScalar(Uint8List scalar) { + final k = Uint8List.fromList(scalar); + k[0] &= 248; + k[31] &= 127; + k[31] |= 64; + return k; +} + +BigInt _x25519Raw(Uint8List scalar, Uint8List u) { + final k = leBytesToBigInt(clampScalar(scalar)); + final x1 = leBytesToBigInt(u) & _mask255; + const a24 = 121665; + var x2 = BigInt.one, z2 = BigInt.zero, x3 = x1, z3 = BigInt.one; + var swap = BigInt.zero; + for (var t = 254; t >= 0; t--) { + final kt = (k >> t) & BigInt.one; + swap ^= kt; + if (swap == BigInt.one) { + var tmp = x2; + x2 = x3; + x3 = tmp; + tmp = z2; + z2 = z3; + z3 = tmp; + } + swap = kt; + final a = _mod(x2 + z2), aa = a * a % _p; + final b = _mod(x2 - z2), bb = b * b % _p; + final e = _mod(aa - bb); + final c = _mod(x3 + z3), d = _mod(x3 - z3); + final da = d * a % _p, cb = c * b % _p; + final sum = _mod(da + cb), diff = _mod(da - cb); + x3 = sum * sum % _p; + z3 = x1 * diff * diff % _p; + x2 = aa * bb % _p; + z2 = e * _mod(aa + BigInt.from(a24) * e) % _p; + } + if (swap == BigInt.one) { + var tmp = x2; + x2 = x3; + x3 = tmp; + tmp = z2; + z2 = z3; + z3 = tmp; + } + return x2 * _powMod(z2, _p - BigInt.two) % _p; +} + +// §2's low-order rejection: a clamped scalar is a multiple of 8, so any +// low-order peer point yields an all-zero shared secret — rejecting the zero +// output rejects all of them. +Uint8List x25519(Uint8List scalar, Uint8List peerPublic) { + final shared = bigIntToLeBytes(_x25519Raw(scalar, peerPublic), 32); + if (shared.every((b) => b == 0)) { + throw const SmolError("rejected low-order key agreement point"); + } + return shared; +} + +Uint8List x25519Base(Uint8List scalar) => bigIntToLeBytes( + _x25519Raw(scalar, unhex("0900000000000000000000000000000000000000000000000000000000000000")), + 32); + +// --- Ed25519 (RFC 8032) -------------------------------------------------------- + +final BigInt _l = (BigInt.one << 252) + + BigInt.parse("27742317777372353535851937790883648493"); +final BigInt _d = _mod(-BigInt.from(121665) * _powMod(BigInt.from(121666), _p - BigInt.two)); +final _Point _b = _Point.fromAffine( + BigInt.parse( + "15112221349535400772501151409588531511454012693041857206046113283949847762202"), + _mod(BigInt.from(4) * _powMod(BigInt.from(5), _p - BigInt.two))); + +class _Point { + final BigInt x, y, z, t; + + const _Point(this.x, this.y, this.z, this.t); + + _Point.fromAffine(BigInt x, BigInt y) + : this(x, y, BigInt.one, _mod(x * y)); +} + +final _Point _identity = _Point( + BigInt.zero, BigInt.one, BigInt.one, BigInt.zero); + +_Point _pointAdd(_Point p, _Point q) { + final a = _mod(p.y - p.x) * _mod(q.y - q.x) % _p; + final b = _mod(p.y + p.x) * _mod(q.y + q.x) % _p; + final c = BigInt.two * p.t * q.t % _p * _d % _p; + final d = BigInt.two * p.z * q.z % _p; + final e = _mod(b - a), f = _mod(d - c), g = _mod(d + c); + final h = b + a; + return _Point(e * f % _p, g * h % _p, f * g % _p, e * h % _p); +} + +_Point _pointDouble(_Point p) { + final a = p.x * p.x % _p; + final b = p.y * p.y % _p; + final c = BigInt.two * p.z * p.z % _p; + final d = _p - a; // a = -1 on this curve, so d = -A + final e = _mod(_mod(p.x + p.y) * _mod(p.x + p.y) - a - b); + final g = _mod(d + b); + final f = _mod(g - c); + final h = _mod(d - b); + return _Point(e * f % _p, g * h % _p, f * g % _p, e * h % _p); +} + +_Point _scalarMult(BigInt scalar, _Point point) { + var result = _identity; + for (var t = 254; t >= 0; t--) { + result = _pointDouble(result); + if ((scalar >> t) & BigInt.one == BigInt.one) result = _pointAdd(result, point); + } + return result; +} + +Uint8List _encodePoint(_Point p) { + final zInv = _powMod(p.z, _p - BigInt.two); + final x = p.x * zInv % _p, y = p.y * zInv % _p; + final out = bigIntToLeBytes(y, 32); + out[31] |= (x & BigInt.one).toInt() << 7; + return out; +} + +_Point _decodePoint(Uint8List bytes) { + if (bytes.length != 32) { + throw const SmolError("Ed25519 public key must be 32 bytes"); + } + final sign = bytes[31] >> 7; + final y = leBytesToBigInt(bytes) & _mask255; + if (y >= _p) { + throw const SmolError("non-canonical Ed25519 public key"); + } + final u = _mod(y * y - BigInt.one), v = _mod(_d * y * y + BigInt.one); + final v2 = v * v % _p, v3 = v2 * v % _p, v4 = v2 * v2 % _p; + var x = u * v3 % _p * _powMod(u * v4 % _p * v3 % _p, (_p - BigInt.from(5)) ~/ BigInt.from(8)) % _p; + if (_mod(v * x % _p * x) != u) { + if (_mod(v * x % _p * x) == _mod(-u)) { + x = x * _powMod(BigInt.two, (_p - BigInt.one) ~/ BigInt.from(4)) % _p; + } else { + throw const SmolError("not a point on the Ed25519 curve"); + } + } + if (x == BigInt.zero && sign == 1) { + throw const SmolError("invalid sign bit on x = 0"); + } + if ((x & BigInt.one).toInt() != sign) x = _p - x; + return _Point.fromAffine(x, y); +} + +BigInt _seedToScalar(Uint8List seed) { + final h = sha512(seed); + return leBytesToBigInt(clampScalar(h.sublist(0, 32))); +} + +Uint8List ed25519PublicKey(Uint8List seed) { + if (seed.length != 32) { + throw const SmolError("identity seed must be 32 bytes"); + } + return _encodePoint(_scalarMult(_seedToScalar(seed), _Point.fromAffine(_b.x, _b.y))); +} + +Uint8List ed25519Sign(Uint8List seed, List message) { + final h = sha512(seed); + final a = leBytesToBigInt(clampScalar(h.sublist(0, 32))); + final publicKey = + _encodePoint(_scalarMult(a, _Point.fromAffine(_b.x, _b.y))); + final r = leBytesToBigInt(sha512(concat([h.sublist(32), message]))) % _l; + final rEnc = _encodePoint(_scalarMult(r, _Point.fromAffine(_b.x, _b.y))); + final k = leBytesToBigInt(sha512(concat([rEnc, publicKey, message]))) % _l; + return concat([rEnc, bigIntToLeBytes((r + k * a) % _l, 32)]); +} + +bool ed25519Verify(Uint8List publicKey, List message, Uint8List signature) { + if (signature.length != 64) return false; + try { + final decodedPk = _decodePoint(publicKey); + final decodedR = _decodePoint(signature.sublist(0, 32)); + final a = _Point.fromAffine(decodedPk.x, decodedPk.y); + final r = _Point.fromAffine(decodedR.x, decodedR.y); + final s = leBytesToBigInt(signature.sublist(32, 64)); + if (s >= _l) return false; + final k = leBytesToBigInt(sha512(concat([signature.sublist(0, 32), publicKey, message]))) % _l; + final lhs = _scalarMult(s, _Point.fromAffine(_b.x, _b.y)); + final rhs = _pointAdd(_scalarMult(k, a), r); + return lhs.x * rhs.z % _p == rhs.x * lhs.z % _p && + lhs.y * rhs.z % _p == rhs.y * lhs.z % _p; + } on Exception { + return false; + } +} + +// --- §2 conversions between the identity key and X25519 ------------------------- + +Uint8List ed25519ToX25519(Uint8List publicKey) { + final y = leBytesToBigInt(publicKey) & _mask255; + if (y >= _p) { + throw const SmolError("non-canonical Ed25519 public key"); + } + if (_mod(BigInt.one - y) == BigInt.zero) { + throw const SmolError("identity element has no X25519 image"); + } + return bigIntToLeBytes( + _mod(BigInt.one + y) * _powMod(BigInt.one - y, _p - BigInt.two) % _p, 32); +} + +Uint8List ed25519SeedToX25519(Uint8List seed) => + clampScalar(sha512(seed).sublist(0, 32)); + diff --git a/lib/smol/errors.dart b/lib/smol/errors.dart new file mode 100644 index 0000000..853d7da --- /dev/null +++ b/lib/smol/errors.dart @@ -0,0 +1,10 @@ +/// Raised for protocol-level failures (malformed envelopes, bad statuses, +/// unpinned servers); programmer errors keep throwing normally. +class SmolError implements Exception { + final String message; + + const SmolError(this.message); + + @override + String toString() => message; +} diff --git a/lib/smol/noise.dart b/lib/smol/noise.dart new file mode 100644 index 0000000..fa5d46b --- /dev/null +++ b/lib/smol/noise.dart @@ -0,0 +1,118 @@ +// Noise_NX_25519_ChaChaPoly_SHA256 initiator (SPEC.md §4), rev-34 semantics. +// The initiator is anonymous; the responder's static key arrives encrypted in +// message two, which is what server pinning checks. + +import "dart:typed_data"; + +import "package:smol_mail/smol/crypto.dart"; +import "package:smol_mail/smol/errors.dart"; + +const String _protocol = "Noise_NX_25519_ChaChaPoly_SHA256"; // exactly 32 bytes, so h = name + +Uint8List _prologue() => utf8Bytes("smolmail/1"); + +// Noise's ChaChaPoly nonce: 4 zero bytes then the counter as u64 LE. +Uint8List _nonce(int n) { + final out = Uint8List(12); + ByteData.view(out.buffer).setUint64(4, n, Endian.little); + return out; +} + +/// One direction of the post-handshake transport; tests substitute a +/// passthrough so framing guards can be exercised without crypto. +abstract class SessionCipher { + Uint8List encrypt(Uint8List plaintext); + + Uint8List decrypt(Uint8List sealed); +} + +// The key is unique per session, so the counter starting at zero is safe. +class CipherState implements SessionCipher { + final Uint8List key; + int counter = 0; + + CipherState(this.key); + + @override + Uint8List encrypt(Uint8List plaintext) { + final sealed = aeadEncrypt(key, _nonce(counter), plaintext, Uint8List(0)); + counter++; + return sealed; + } + + @override + Uint8List decrypt(Uint8List sealed) { + final plaintext = aeadDecrypt(key, _nonce(counter), sealed, Uint8List(0)); + counter++; + return plaintext; + } +} + +class NxResult { + final CipherState send, recv; + final Uint8List serverStatic; + final Uint8List handshakeHash; + + const NxResult(this.send, this.recv, this.serverStatic, this.handshakeHash); +} + +class NxInitiator { + late Uint8List h; + late Uint8List ck; + Uint8List? key; + late Uint8List esk; + late Uint8List epk; + + NxInitiator() { + h = utf8Bytes(_protocol); + ck = Uint8List.fromList(h); + mixHash(_prologue()); + } + + void mixHash(Uint8List data) { + h = sha256(concat([h, data])); + } + + void mixKey(Uint8List ikm) { + final okm = hkdfSha256(ikm, ck, Uint8List(0), 64); + ck = okm.sublist(0, 32); + key = okm.sublist(32); + } + + // Message one is just our ephemeral public key. No key is set yet, so the + // empty payload travels in the clear — and is still mixed into h. + Uint8List writeMessage1([Uint8List? esk]) { + this.esk = esk ?? randomBytes(32); + epk = x25519Base(this.esk); + mixHash(epk); + mixHash(Uint8List(0)); + return Uint8List.fromList(epk); + } + + // Message two: e (plaintext), ee, then the responder's static and the + // (empty) payload as AEAD ciphertexts chained through h. Each MixKey + // restarts the nonce at zero. + NxResult readMessage2(Uint8List message) { + if (message.length != 32 + 48 + 16) { + throw SmolError("unexpected NX message length ${message.length}"); + } + final re = message.sublist(0, 32); + mixHash(re); + mixKey(x25519(esk, re)); + final serverStatic = decryptAndHash(message.sublist(32, 80)); + mixKey(x25519(esk, serverStatic)); // es + final payload = decryptAndHash(message.sublist(80)); + if (payload.isNotEmpty) throw const SmolError("unexpected payload in handshake"); + final handshakeHash = h; + // Split(): two transport keys from the final chaining key, zero-length ikm + final okm = hkdfSha256(Uint8List(0), ck, Uint8List(0), 64); + return NxResult(CipherState(okm.sublist(0, 32)), CipherState(okm.sublist(32)), + serverStatic, handshakeHash); + } + + Uint8List decryptAndHash(Uint8List sealed) { + final plaintext = aeadDecrypt(key!, _nonce(0), sealed, h); + mixHash(sealed); + return plaintext; + } +} diff --git a/lib/smol/proto.dart b/lib/smol/proto.dart new file mode 100644 index 0000000..881963f --- /dev/null +++ b/lib/smol/proto.dart @@ -0,0 +1,580 @@ +// Smol Mail protocol, version 1 (../smolmail SPEC.md): addresses, sealed and +// signed envelopes, body frontmatter, key rotation, and the framed request +// and response bodies of the five operations. + +import "dart:math"; +import "dart:typed_data"; + +import "package:smol_mail/smol/crypto.dart"; +import "package:smol_mail/smol/errors.dart"; +import "package:smol_mail/smol/noise.dart"; + +const defaultPort = 1961; +const keyLen = 32, sigLen = 64, certLen = 136, idLen = 16; +const maxFrame = 1 << 20, noisePayload = 65535 - 16, padTo = 1024; +const envelopeHeader = 69, payloadHeader = 45, maxChain = 16; +const _frontmatterMax = 4096, _frontmatterKeys = 64; + +const opAuth = 0x00, opResolve = 0x01, opSend = 0x02, opFetch = 0x03, + opDelete = 0x04, opRegister = 0x05; + +const _statusNames = { + 0: "ok", 1: "malformed", 2: "bad version", 3: "unknown user", + 4: "auth required", 5: "auth failed", 6: "quota exceeded", 7: "too large", + 8: "rate limited", 9: "not permitted", 10: "internal error", +}; + +String statusName(int status) => _statusNames[status] ?? "$status"; + +final _label = ( + auth: utf8Bytes("smolmail/1 auth"), + seal: utf8Bytes("smolmail/1 seal"), + msg: utf8Bytes("smolmail/1 msg"), + id: utf8Bytes("smolmail/1 id"), + rotate: utf8Bytes("smolmail/1 rotate"), +); + +// --- encoding helpers --------------------------------------------------------- + +const _b32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +String b32encode(List bytes) { + var out = ""; + var value = 0, bits = 0; + for (final b in bytes) { + value = (value << 8) | b; + bits += 8; + while (bits >= 5) { + bits -= 5; + out += _b32[(value >>> bits) & 31]; + } + } + if (bits > 0) out += _b32[(value << (5 - bits)) & 31]; + return out.toLowerCase(); +} + +Uint8List b32decode(String text) { + final out = []; + var value = 0, bits = 0; + final clean = text.trim().toUpperCase().replaceAll(RegExp(r"=+$"), ""); + for (final ch in clean.split("")) { + final idx = _b32.indexOf(ch); + if (idx < 0) throw SmolError("invalid base32 character '$ch'"); + value = (value << 5) | idx; + bits += 5; + if (bits >= 8) { + bits -= 8; + out.add((value >>> bits) & 0xff); + } + } + return Uint8List.fromList(out); +} + +// §3: the first 20 base32 characters of the identity, in groups of four. +String fingerprint(Uint8List identity) { + final s = b32encode(identity).substring(0, 20); + return RegExp(".{4}").allMatches(s).map((m) => m[0]).join(" "); +} + +Uint8List u16be(int n) => Uint8List.fromList([(n >> 8) & 0xff, n & 0xff]); + +Uint8List u32be(int n) => Uint8List.fromList( + [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]); + +// Dart 3.2's ByteData has no setBigInt, so write big-endian manually. +Uint8List i64be(BigInt n) { + final out = Uint8List(8); + for (var i = 0; i < 8; i++) { + out[i] = ((n >> (8 * (7 - i))) & BigInt.from(0xff)).toInt(); + } + return out; +} + +int nowSeconds() => DateTime.now().millisecondsSinceEpoch ~/ 1000; + +// Fail-closed reader; every parse raises rather than reading past the end. +class Reader { + final Uint8List buf; + int pos = 0; + + Reader(this.buf); + + Uint8List take(int n) { + if (n < 0 || pos + n > buf.length) throw const SmolError("truncated message"); + final out = buf.sublist(pos, pos + n); + pos += n; + return out; + } + + int u8() => take(1)[0]; + + int u16() { + final b = take(2); + return (b[0] << 8) | b[1]; + } + + int u32() => ByteData.view(take(4).buffer).getUint32(0); + + int i64() => ByteData.view(take(8).buffer).getInt64(0); + + int get left => buf.length - pos; +} + +// --- identity ----------------------------------------------------------------- + +// §2: an Ed25519 keypair with the X25519 agreement keys derived from it. +class SmolIdentity { + final Uint8List seed; + final Uint8List publicKey; + + const SmolIdentity(this.seed, this.publicKey); +} + +SmolIdentity identityFromSeed(Uint8List seed) { + if (seed.length != keyLen) { + throw const SmolError("identity seed must be $keyLen bytes"); + } + return SmolIdentity(seed, ed25519PublicKey(seed)); +} + +SmolIdentity newIdentity() => identityFromSeed(randomBytes(keyLen)); + +// --- addressing (§3) ----------------------------------------------------------- + +final _address = + RegExp(r"^(?[a-z0-9._-]{1,63})@(?[^/:]+)(?::(?\d+))?$"); + +class SmolAddress { + final String user; + final String host; + final int port; + + /// The key carried by a `smol://` address; null for short addresses. + final Uint8List? identity; + + const SmolAddress(this.user, this.host, this.port, this.identity); + + String get short => + "$user@$host${port == defaultPort ? "" : ":$port"}"; + + String uri(Uint8List key) => + "smol://$user@$host${port == defaultPort ? "" : ":$port"}/${b32encode(key)}"; +} + +SmolAddress parseAddress(String text) { + text = text.trim(); + Uint8List? identity; + if (text.startsWith("smol://")) { + final rest = text.substring("smol://".length); + final slash = rest.lastIndexOf("/"); + if (slash < 0) throw SmolError("$text: smol:// address carries no key"); + identity = b32decode(rest.substring(slash + 1)); + if (identity.length != keyLen) { + throw SmolError( + "$text: key is ${identity.length} bytes, expected $keyLen"); + } + text = rest.substring(0, slash); + } + final m = _address.firstMatch(text.toLowerCase()); + if (m == null) throw SmolError("'$text' is not a valid address"); + final user = m.namedGroup("user")!; + final host = m.namedGroup("host")!; + if ("._-".contains(user[0]) || "._-".contains(user[user.length - 1])) { + throw SmolError("$user may not begin or end with a separator"); + } + final portText = m.namedGroup("port"); + final port = portText != null ? int.parse(portText) : defaultPort; + return SmolAddress(user, host, port, identity); +} + +// --- message format (§5) ------------------------------------------------------- + +Uint8List messageId(List envelope) => + sha256(concat([_label.id, envelope])).sublist(0, idLen); + +class OpenedMessage { + final Uint8List sender; + final int time; + final Uint8List body; + final Uint8List id; + + const OpenedMessage(this.sender, this.time, this.body, this.id); +} + +/// Options for [seal]; [esk] and [pad] exist so tests can pin them, mirroring +/// the spec's fixed-ephemeral vectors. +class SealOptions { + final Uint8List? esk; + final bool pad; + + const SealOptions({this.esk, this.pad = true}); +} + +// §5.2 and §5.3. The ephemeral key is thrown away after sealing, so the sender +// cannot decrypt what they sent. +Uint8List seal(SmolIdentity identity, Uint8List recipient, Uint8List body, + [int? when, SealOptions opts = const SealOptions()]) { + final esk = opts.esk ?? randomBytes(keyLen); + final epk = x25519Base(esk); + final key = hkdfSha256(x25519(esk, ed25519ToX25519(recipient)), + concat([epk, recipient]), _label.seal); + final header = concat([ + Uint8List.fromList([1]), + identity.publicKey, + i64be(BigInt.from(when ?? nowSeconds())), + u32be(body.length), + ]); + var plaintext = concat([ + header, + body, + ed25519Sign(identity.seed, concat([_label.msg, recipient, epk, header, body])), + ]); + if (opts.pad) { + plaintext = concat( + [plaintext, Uint8List((padTo - plaintext.length % padTo) % padTo)]); + } + final aad = concat([utf8Bytes("SMOL"), Uint8List.fromList([1]), recipient, epk]); + return concat([aad, aeadEncrypt(key, Uint8List(12), plaintext, aad)]); +} + +// Inverse of seal(); throws unless the signature and the recipient both check +// out. [identities] may include retired keys, per §7. +OpenedMessage unseal(List identities, Uint8List envelope) { + if (envelope.length < envelopeHeader + 16) { + throw const SmolError("envelope too short"); + } + final magic = utf8Bytes("SMOL"); + for (var i = 0; i < 4; i++) { + if (magic[i] != envelope[i]) { + throw const SmolError("not a Smol Mail envelope"); + } + } + if (envelope[4] != 1) { + throw SmolError("unsupported envelope version ${envelope[4]}"); + } + final to = envelope.sublist(5, 37), epk = envelope.sublist(37, 69); + final sealed = envelope.sublist(69); + SmolIdentity? me; + for (final i in identities) { + if (timingSafeEqual(i.publicKey, to)) { + me = i; + break; + } + } + if (me == null) { + throw SmolError( + "addressed to ${b32encode(to).substring(0, 16)}…, not one of our keys"); + } + final key = hkdfSha256( + x25519(ed25519SeedToX25519(me.seed), epk), concat([epk, to]), _label.seal); + Uint8List plaintext; + try { + plaintext = aeadDecrypt(key, Uint8List(12), sealed, envelope.sublist(0, envelopeHeader)); + } on SmolError { + throw const SmolError("decryption failed: wrong key or corrupt envelope"); + } + final r = Reader(plaintext); + if (r.u8() != 1) throw const SmolError("unsupported payload version"); + final sender = r.take(keyLen); + final when = r.i64(); + final bodyLen = r.u32(); + if (bodyLen > r.left) { + throw const SmolError("payload body length exceeds the payload"); + } + final body = r.take(bodyLen); + final signature = r.take(sigLen); // trailing bytes are padding + if (!ed25519Verify(sender, + concat([_label.msg, to, epk, plaintext.sublist(0, payloadHeader), body]), + signature)) { + throw const SmolError("signature does not verify"); + } + return OpenedMessage(sender, when, body, messageId(envelope)); +} + +// --- body frontmatter (§5.5) --------------------------------------------------- + +final _fmKey = RegExp(r"^[A-Za-z0-9-]{1,64}$"); + +class Frontmatter { + final Map fields; + final String body; + + const Frontmatter(this.fields, this.body); +} + +// A flat `Key: value` block, deliberately not YAML. Any malformed line +// invalidates the whole block, which is then returned as ordinary body text: +// frontmatter fails closed toward display, never toward silent discard. +Frontmatter parseFrontmatter(String text) { + if (!text.startsWith("---\n")) return Frontmatter(const {}, text); + final lines = text.split("\n"); + final close = lines.indexOf("---", 1); + if (close < 0) return Frontmatter(const {}, text); + final block = lines.sublist(1, close); + final rest = lines.sublist(close + 1).join("\n"); + var encoded = 0; + for (final line in block) { + encoded += utf8Bytes(line).length + 1; + } + if (block.length > _frontmatterKeys || encoded > _frontmatterMax) { + return Frontmatter(const {}, text); + } + final fields = {}; + for (final line in block) { + final colon = line.indexOf(":"); + final head = colon < 0 ? "" : line.substring(0, colon); + if (colon < 0 || !_fmKey.hasMatch(head)) { + return Frontmatter(const {}, text); + } + // first occurrence wins + fields.putIfAbsent(head, () => line.substring(colon + 1).trim()); + } + return Frontmatter(fields, rest); +} + +// Emit a block only when needed, including to escape a body that genuinely +// begins with `---` (§5.5). +String buildFrontmatter(Map fields, String body) { + final entries = fields.entries.where((e) => e.value.isNotEmpty).toList(); + if (entries.isEmpty && !body.startsWith("---\n")) return body; + final block = entries.map((e) => "${e.key}: ${e.value}\n").join(); + return "---\n$block---\n$body"; +} + +// --- key rotation (§7) --------------------------------------------------------- + +Uint8List makeCert(SmolIdentity oldIdentity, Uint8List newSeed, [int? when]) { + final newPub = ed25519PublicKey(newSeed); + final time = i64be(BigInt.from(when ?? nowSeconds())); + return concat([ + oldIdentity.publicKey, + newPub, + time, + ed25519Sign(oldIdentity.seed, + concat([_label.rotate, oldIdentity.publicKey, newPub, time])), + ]); +} + +// Accept a key change only when a signed chain leads from the key we hold to +// the one the server now returns (§7). +bool walkChain(Uint8List pinned, Uint8List current, List chain) { + if (timingSafeEqual(pinned, current)) return true; + if (chain.isEmpty || chain.length > maxChain) return false; + var key = pinned; + var started = false; + for (final cert in chain) { + final old = cert.sublist(0, 32), next = cert.sublist(32, 64); + final when = cert.sublist(64, 72), sig = cert.sublist(72); + if (!started) { + if (!timingSafeEqual(old, key)) continue; // a link predating the key we hold + started = true; + } else if (!timingSafeEqual(old, key)) { + return false; // the chain is not continuous + } + if (!ed25519Verify(old, concat([_label.rotate, old, next, when]), sig)) { + return false; + } + key = next; + } + return started && timingSafeEqual(key, current); +} + +// --- framing and operations (§4, §6) ------------------------------------------- + +/// An ordered byte pipe (TCP socket, or an in-memory queue in tests). +abstract class Wire { + void send(Uint8List bytes); + + void close(); + + Future readExact(int n); +} + +// One Noise session: application frames split across u16-prefixed Noise +// messages, requests and responses as in §6.1. +class Session { + final Wire wire; + final SessionCipher send, recv; + + Session(this.wire, this.send, this.recv); + + Future _readNoise() async { + final head = await wire.readExact(2); + final length = (head[0] << 8) | head[1]; + if (length < 16) throw SmolError("server sent a $length-byte Noise message"); + return recv.decrypt(await wire.readExact(length)); + } + + Future call(int op, [Uint8List? body]) async { + final payload = body ?? Uint8List(0); + final frame = concat([u32be(1 + payload.length), Uint8List.fromList([op]), payload]); + if (frame.length > maxFrame + 4) { + throw const SmolError("request exceeds the maximum frame size"); + } + for (var off = 0; off < frame.length; off += noisePayload) { + final packet = send.encrypt(frame.sublist(off, min(off + noisePayload, frame.length))); + wire.send(concat([u16be(packet.length), packet])); + } + var length = -1; + var have = []; + while (length < 0 || have.length < 4 + length) { + have.addAll(await _readNoise()); + if (length < 0 && have.length >= 4) { + length = (have[0] << 24) | (have[1] << 16) | (have[2] << 8) | have[3]; + // §6.1: the shortest response is a type byte and a status byte. + if (length < 2 || length > maxFrame) { + throw SmolError("server sent a frame of length $length"); + } + } + } + final payloadOut = Uint8List.fromList(have.sublist(4, 4 + length)); + // §6.1: a response reuses the request's type byte. A mismatch means the + // session desynchronised, which must not be mistaken for a status. + if (payloadOut[0] != op) { + throw SmolError( + "server answered op 0x${payloadOut[0].toRadixString(16)}, expected 0x${op.toRadixString(16)}"); + } + return Response(payloadOut[1], payloadOut.sublist(2)); + } +} + +class Response { + final int status; + final Uint8List body; + + const Response(this.status, this.body); +} + +class OpenedSession { + final Session session; + final Uint8List serverStatic; + final bool pinned; + final Uint8List handshakeHash; + + const OpenedSession(this.session, this.serverStatic, this.pinned, this.handshakeHash); +} + +// Handshake plus §4 pinning. Returns the session, the server's static key as +// revealed by the handshake, and whether that key was already pinned. +Future openSession(Wire wire, String host, + {Uint8List? pinned}) async { + final nx = NxInitiator(); + final m1 = nx.writeMessage1(); + wire.send(concat([u16be(m1.length), m1])); + final head = await wire.readExact(2); + final result = nx.readMessage2(await wire.readExact((head[0] << 8) | head[1])); + if (pinned != null && !timingSafeEqual(pinned, result.serverStatic)) { + throw SmolError("$host presented a different key than the one pinned\n" + " pinned: ${b32encode(pinned)}\n" + " presented: ${b32encode(result.serverStatic)}"); + } + return OpenedSession( + Session(wire, result.send, result.recv), + result.serverStatic, + pinned != null, + result.handshakeHash); +} + +void expectOk(int status, String what) { + if (status != 0) { + throw SmolError("$what failed: ${statusName(status)} ($status)"); + } +} + +// §4 session authentication: sign the handshake hash, which binds the +// signature to this session's server ephemeral and cannot be replayed. +Future authenticate( + Session session, Uint8List handshakeHash, String username, SmolIdentity identity) async { + final name = utf8Bytes(username); + if (name.length > 255) throw const SmolError("username too long"); + final body = concat([ + Uint8List.fromList([name.length]), + name, + identity.publicKey, + ed25519Sign(identity.seed, concat([_label.auth, handshakeHash])), + ]); + expectOk((await session.call(opAuth, body)).status, "authentication"); +} + +class Resolved { + final Uint8List identity; + final List chain; + + const Resolved(this.identity, this.chain); +} + +// RESOLVE, returning the current key and its rotation chain (§6.1). +Future resolveOp(Session session, String user) async { + final name = utf8Bytes(user); + if (name.length > 255) throw const SmolError("username too long"); + final response = + await session.call(opResolve, concat([Uint8List.fromList([name.length]), name])); + expectOk(response.status, "resolving $user"); + final r = Reader(response.body); + return Resolved( + r.take(keyLen), + List.generate(r.u8(), (_) => r.take(certLen))); +} + +Future sendOp(Session session, Uint8List envelope) async { + final response = await session.call(opSend, envelope); + expectOk(response.status, "sending"); + return response.body.length == idLen + ? response.body + : messageId(envelope); +} + +class FetchedRecord { + final Uint8List id; + final int receivedAt; + final Uint8List envelope; + + const FetchedRecord(this.id, this.receivedAt, this.envelope); +} + +Future> fetchOp(Session session) async { + final response = await session.call(opFetch); + expectOk(response.status, "fetching"); + final r = Reader(response.body); + return List.generate(r.u16(), (_) { + final id = r.take(idLen); + final receivedAt = r.i64(); + return FetchedRecord(id, receivedAt, r.take(r.u32())); + }); +} + +Future deleteOp(Session session, List ids) async { + if (ids.length > 0xffff) throw const SmolError("too many ids for one DELETE"); + final body = concat([u16be(ids.length), ...ids]); + final response = await session.call(opDelete, body); + expectOk(response.status, "acknowledging"); + return Reader(response.body).u16(); +} + +class RegisterOptions { + final String token; + final Uint8List? cert; + + const RegisterOptions({this.token = "", this.cert}); +} + +Future registerOp( + Session session, String username, SmolIdentity identity, + [RegisterOptions opts = const RegisterOptions()]) async { + final name = utf8Bytes(username); + final tokenBytes = utf8Bytes(opts.token); + final cert = opts.cert ?? Uint8List(0); + if (name.length > 255 || tokenBytes.length > 255 || cert.length > 255) { + throw const SmolError("REGISTER field too long"); + } + final body = concat([ + Uint8List.fromList([name.length]), + name, + identity.publicKey, + Uint8List.fromList([tokenBytes.length]), + tokenBytes, + Uint8List.fromList([cert.length]), + cert, + ]); + expectOk((await session.call(opRegister, body)).status, "registering $username"); +} diff --git a/lib/smol/store.dart b/lib/smol/store.dart new file mode 100644 index 0000000..788e520 --- /dev/null +++ b/lib/smol/store.dart @@ -0,0 +1,463 @@ +// Device state: identity, pins, contacts and read markers in one JSON blob; +// sealed envelopes in a second Hive box, opened only on demand, so nothing at +// rest is plaintext (the seed excepted — the device's app storage is the trust +// boundary, like gsmol's browser profile). + +import "dart:convert"; +import "dart:typed_data"; + +import "package:hive_flutter/hive_flutter.dart"; + +import "package:smol_mail/smol/crypto.dart"; +import "package:smol_mail/smol/errors.dart"; +import "package:smol_mail/smol/proto.dart"; + +const _stateBox = "smol"; +const _mailBox = "mail"; +const _stateKey = "state"; + +class StoredAccount { + final String user; + final String host; + final int port; + + const StoredAccount(this.user, this.host, this.port); +} + +/// A key this contact replaced, per §7/§8 — the only local record that a +/// rotation happened, kept so the user can notice such changes. +class ContactHistoryEntry { + final Uint8List key; + final int until; // epoch ms of the displacement + + const ContactHistoryEntry(this.key, this.until); +} + +class StoredContact { + final Uint8List key; + final bool verified; + final List history; + + const StoredContact(this.key, this.verified, [this.history = const []]); +} + +class MailRecord { + final String id; // hex of the 16-byte message id + final Uint8List envelope; + final int? receivedAt; + final String? recipient; // sent copies only + final int? sentAt; + + const MailRecord(this.id, this.envelope, + {this.receivedAt, this.recipient, this.sentAt}); +} + +class ImportSummary { + int pinsAdded = 0, pinsConflicted = 0, contactsAdded = 0, + contactsConflicted = 0, mailAdded = 0, malformed = 0; + + @override + String toString() => + "$mailAdded messages, $contactsAdded contacts ($contactsConflicted conflicted), " + "$pinsAdded server keys ($pinsConflicted conflicted), $malformed malformed"; +} + +class SmolStore { + final Box _state; + final Box _mail; + + SmolStore(this._state, this._mail); + + /// [stateBox]/[mailBox] exist so tests can hold several isolated stores + /// in one process; production always uses the defaults. + static Future open( + {String stateBox = _stateBox, String mailBox = _mailBox}) async { + final state = await Hive.openBox(stateBox); + final mail = await Hive.openBox(mailBox); + return SmolStore(state, mail); + } + + Map _load() { + final blob = _state.get(_stateKey); + return blob is Map ? blob : {}; + } + + void _update(Map Function(Map state) fn) { + final next = fn(_load()); + _state.put(_stateKey, next); + } + + // --- identity -------------------------------------------------------------- + + Uint8List? seed() { + final raw = _load()["seed"]; + return raw == null ? null : unhex(raw as String); + } + + /// The active identity, or null before the user creates or restores one. + SmolIdentity? identity() { + final s = seed(); + return s == null ? null : identityFromSeed(s); + } + + void setIdentity(Uint8List newSeed) { + if (seed() != null) { + throw const SmolIdentityExistsException(); + } + _update((state) => state..["seed"] = hex(newSeed)); + } + + // Rotation (§7): the old seed is retained, since mail sealed to a + // superseded key is readable with nothing else. + void rotateIdentity(Uint8List newSeed) { + final old = seed(); + if (old == null) throw const SmolNoIdentityException(); + _update((state) { + final retired = (state["retired"] as List? ?? []) + ..add({"seed": hex(old), "at": DateTime.now().millisecondsSinceEpoch}); + state["retired"] = retired; + state["seed"] = hex(newSeed); + return state; + }); + } + + /// §7: seeds rotated away from are retained, since mail sealed to a + /// superseded key is readable with nothing else. + List identities() { + final s = seed(); + if (s == null) return const []; + final retired = (_load()["retired"] as List? ?? const []) + .whereType() + .map((entry) => identityFromSeed(unhex(entry["seed"] as String))); + return [identityFromSeed(s), ...retired]; + } + + // --- account and server pins ------------------------------------------------- + + StoredAccount? account() { + final a = _load()["account"]; + if (a is! Map) return null; + return StoredAccount( + a["user"] as String, a["host"] as String, a["port"] as int); + } + + void setAccount(SmolAddress address) { + _update((state) => state + ..["account"] = { + "user": address.user, + "host": address.host, + "port": address.port, + }); + } + + Uint8List? serverPin(String host) { + final raw = ((_load()["servers"] as Map?) ?? {})[host]; + return raw == null ? null : b32decode(raw as String); + } + + void pinServer(String host, Uint8List key) { + _update((state) { + final servers = (state["servers"] as Map? ?? {}).cast(); + servers[host] = b32encode(key); + state["servers"] = servers; + return state; + }); + } + + void unpinServer(String host) { + _update((state) { + (state["servers"] as Map?)?.remove(host); + return state; + }); + } + + List<(String, Uint8List)> allPins() { + final servers = ((_load()["servers"] as Map?) ?? {}).cast(); + return [for (final e in servers.entries) (e.key, b32decode(e.value))]; + } + + // --- contacts ------------------------------------------------------------------ + + StoredContact? contact(String address) { + final c = ((_load()["contacts"] as Map?) ?? {})[address]; + if (c is! Map) return null; + final history = ((c["history"] as List?) ?? const []) + .whereType() + .map((e) => ContactHistoryEntry( + b32decode(e["key"] as String), e["until"] as int)) + .toList(); + return StoredContact(b32decode(c["key"] as String), + c["verified"] as bool, history); + } + + // A key that displaces another is kept in the history (§8): it is the + // only local record that the contact rotated. Re-saving the same key is + // not a rotation and must not add an entry. + void saveContact(String address, Uint8List key, bool verified) { + _update((state) { + final contacts = + (state["contacts"] as Map? ?? {}).cast(); + final wanted = b32encode(key); + final previous = contacts[address]; + final history = ((previous?["history"] as List?) ?? const []) + .whereType() + .toList(); + if (previous != null && previous["key"] != wanted) { + history.add({ + "key": previous["key"], + "until": DateTime.now().millisecondsSinceEpoch, + }); + } + contacts[address] = { + "key": wanted, + "verified": verified, + "seenAt": DateTime.now().millisecondsSinceEpoch, + if (history.isNotEmpty) "history": history, + }; + state["contacts"] = contacts; + return state; + }); + } + + String? addressForKey(Uint8List key) { + final contacts = ((_load()["contacts"] as Map?) ?? {}).cast(); + final wanted = b32encode(key); + for (final entry in contacts.entries) { + if (entry.value["key"] == wanted) return entry.key; + } + return null; + } + + List<(String, StoredContact)> allContacts() { + final contacts = ((_load()["contacts"] as Map?) ?? {}).cast(); + return [for (final entry in contacts.entries) (entry.key, contact(entry.key)!)]; + } + + // --- read markers --------------------------------------------------------------- + + void markRead(String idHex) { + _update((state) { + final read = (state["read"] as Map? ?? {}).cast(); + read[idHex] = true; + state["read"] = read; + return state; + }); + } + + bool isRead(String idHex) => + ((_load()["read"] as Map?) ?? {})[idHex] == true; + + // --- sealed mail ------------------------------------------------------------ + + Map _recordToMap(MailRecord record) => { + "id": record.id, + "envelope": record.envelope, + "receivedAt": record.receivedAt, + "recipient": record.recipient, + "sentAt": record.sentAt, + }; + + MailRecord _mapToRecord(Map map) => MailRecord( + map["id"] as String, + (map["envelope"] as Uint8List), + receivedAt: map["receivedAt"] as int?, + recipient: map["recipient"] as String?, + sentAt: map["sentAt"] as int?, + ); + + static String mailKey(String folder, String id) => "$folder/$id"; + + Future storeMessage(String folder, MailRecord record) => + _mail.put(mailKey(folder, record.id), _recordToMap(record)); + + /// Returns null when the id already exists, so fetch can leave server + /// state alone. + Future storeIfNew(String folder, MailRecord record) async { + if (_mail.containsKey(mailKey(folder, record.id))) return null; + await storeMessage(folder, record); + return record; + } + + List listMessages(String folder) { + final prefix = "$folder/"; + final rows = []; + for (final key in _mail.keys.cast()) { + if (!key.startsWith(prefix)) continue; + final row = _mail.get(key); + if (row is Map) rows.add(_mapToRecord(row)); + } + rows.sort((a, b) => + (b.receivedAt ?? b.sentAt ?? 0).compareTo(a.receivedAt ?? a.sentAt ?? 0)); + return rows; + } + + MailRecord? getMessage(String folder, String id) { + final row = _mail.get(mailKey(folder, id)); + return row is Map ? _mapToRecord(row) : null; + } + + Future deleteMessage(String folder, String id) => + _mail.delete(mailKey(folder, id)); + +// --- export / import: mail, contacts, pins — never the seed -------------------- + + /// The marker matches gsmol's web export, so backups move between the two + /// clients. Deliberately excludes the seed: it has its own reveal-and-copy + /// flow in settings, meant for a password manager, not a shareable file. + Map exportData() { + final state = _load(); + final contacts = ((state["contacts"] as Map?) ?? {}).cast(); + return { + "gsmolExport": 1, + "exportedAt": DateTime.now().millisecondsSinceEpoch, + "servers": ((state["servers"] as Map?) ?? {}).cast(), + "contacts": { + for (final entry in contacts.entries) + entry.key: { + "key": entry.value["key"], + "verified": entry.value["verified"], + if ((entry.value["history"] as List?)?.isNotEmpty == true) + "history": entry.value["history"], + } + }, + "inbox": [ + for (final row in listMessages("inbox")) + { + "id": row.id, + "receivedAt": row.receivedAt, + "envelope": base64Encode(row.envelope), + } + ], + "sent": [ + for (final row in listMessages("sent")) + { + "id": row.id, + "recipient": row.recipient, + "sentAt": row.sentAt, + "envelope": base64Encode(row.envelope), + } + ], + }; + } + + /// Never overwrites a trust binding that already differs locally — the same + /// rule refreshContact()/saveReplyAddress() apply elsewhere. A malformed + /// entry is skipped and counted, not fatal: one bad record cannot abort the + /// rest of the import. + Future importData(Map data) async { + if (data["gsmolExport"] != 1) { + throw const SmolError("not a SmolMail export file"); + } + final summary = ImportSummary(); + + _update((state) { + final servers = (state["servers"] as Map? ?? {}).cast(); + final incomingPins = data["servers"] is Map ? data["servers"] as Map : null; + if (data["servers"] != null && incomingPins == null) summary.malformed++; + for (final entry in (incomingPins ?? const {}).entries) { + final host = entry.key, key = entry.value; + if (host is! String || key is! String || _pinKeyOk(key) != true) { + summary.malformed++; + continue; + } + if (!servers.containsKey(host)) { + servers[host] = key; + summary.pinsAdded++; + } else if (servers[host] != key) { + summary.pinsConflicted++; + } + } + state["servers"] = servers; + + final contacts = (state["contacts"] as Map? ?? {}).cast(); + final incoming = data["contacts"] is Map ? data["contacts"] as Map : null; + if (data["contacts"] != null && incoming == null) summary.malformed++; + for (final entry in (incoming ?? const {}).entries) { + final address = entry.key, contact = entry.value; + if (address is! String || + contact is! Map || + contact["key"] is! String || + _pinKeyOk(contact["key"] as String) != true) { + summary.malformed++; + continue; + } + if (!contacts.containsKey(address)) { + contacts[address] = { + "key": contact["key"], + "verified": contact["verified"] == true, + "seenAt": DateTime.now().millisecondsSinceEpoch, + if (contact["history"] is List && (contact["history"] as List).isNotEmpty) + "history": contact["history"], + }; + summary.contactsAdded++; + } else if (contacts[address]!["key"] != contact["key"]) { + summary.contactsConflicted++; + } + } + state["contacts"] = contacts; + return state; + }); + + for (final folder in ["inbox", "sent"]) { + final rows = data[folder] is List ? data[folder] as List : null; + if (data[folder] != null && rows == null) summary.malformed++; + for (final row in rows ?? const []) { + try { + final map = row as Map; + final record = MailRecord( + map["id"] as String, + base64Decode(map["envelope"] as String), + receivedAt: folder == "inbox" ? map["receivedAt"] as int? : null, + recipient: folder == "sent" ? map["recipient"] as String? : null, + sentAt: folder == "sent" ? map["sentAt"] as int? : null, + ); + if (await storeIfNew(folder, record) != null) summary.mailAdded++; + } on Exception { + summary.malformed++; + } on TypeError { + summary.malformed++; + } + } + } + return summary; + } + + // A pin key must decode to exactly 32 bytes of base32. + bool _pinKeyOk(String key) { + try { + return b32decode(key).length == keyLen; + } on SmolError { + return false; + } + } + + /// Remove every secret and every stored envelope; the UI must confirm first. + Future wipe() async { + await _state.delete(_stateKey); + await _mail.clear(); + } + + int unreadCount() { + var count = 0; + for (final row in listMessages("inbox")) { + if (!isRead(row.id)) count++; + } + return count; + } +} + +/// The store throws these typed errors so the UI can tell "no identity yet" +/// and "an identity already exists" apart without string matching. +class SmolIdentityExistsException implements Exception { + const SmolIdentityExistsException(); + + @override + String toString() => "an identity already exists; rotate it instead"; +} + +class SmolNoIdentityException implements Exception { + const SmolNoIdentityException(); + + @override + String toString() => "no identity to rotate"; +} diff --git a/lib/smol/transport.dart b/lib/smol/transport.dart new file mode 100644 index 0000000..c768957 --- /dev/null +++ b/lib/smol/transport.dart @@ -0,0 +1,115 @@ +// The byte pipe to a smolmaild server: raw TCP (dart:io), framed elsewhere. +// Mobile is this app's only target platform, so dart:io is fine here. + +import "dart:async"; +import "dart:io"; +import "dart:typed_data"; + +import "package:smol_mail/smol/errors.dart"; +import "package:smol_mail/smol/proto.dart"; + +/// Buffers the socket's stream and serves exact-length reads, so protocol +/// code never sees a partial frame. +class TcpWire implements Wire { + final Socket _socket; + final _chunks = []; + final _waiters = <_ReadRequest>[]; + int _buffered = 0; + Object? _closed; + late final StreamSubscription _subscription; + + TcpWire(this._socket) { + _subscription = _socket.listen(_onData, + onError: (Object error) => _fail(error), + onDone: () => _fail(const SmolError("server closed the connection"))); + } + + static Future connect(String host, int port) async { + try { + // Mobile networks routinely need longer than a LAN handshake; 30s keeps + // flaky handovers from surfacing as user-facing timeouts. + return TcpWire(await Socket.connect(host, port, + timeout: const Duration(seconds: 30))); + } on SocketException catch (error) { + throw SmolError("cannot reach $host:$port (${error.message})"); + } + } + + void _onData(Uint8List data) { + _chunks.add(data); + _buffered += data.length; + _wake(); + } + + void _wake() { + _waiters.removeWhere((w) { + if (_closed != null) { + w.completer.completeError(_closed!); + return true; + } + if (_buffered >= w.need) { + w.completer.complete(); + return true; + } + return false; + }); + } + + void _fail(Object error) { + _closed = error; + for (final w in _waiters) { + w.completer.completeError(error); + } + _waiters.clear(); + } + + @override + void send(Uint8List bytes) { + if (_closed != null) throw _closed!; + _socket.add(bytes); + } + + @override + void close() { + _subscription.cancel(); + _socket.destroy(); + _fail(const SmolError("connection closed")); + } + + @override + Future readExact(int n) async { + if (_closed != null) throw _closed!; + if (_buffered < n) { + final request = _ReadRequest(n); + _waiters.add(request); + try { + await request.completer.future; + } finally { + _waiters.remove(request); + } + if (_closed != null) throw _closed!; + } + final out = Uint8List(n); + var off = 0; + while (off < n) { + final chunk = _chunks.first; + final take = chunk.length < n - off ? chunk.length : n - off; + out.setRange(off, off + take, chunk); + if (take == chunk.length) { + _chunks.removeAt(0); + } else { + _chunks[0] = Uint8List.sublistView(chunk, take); + } + off += take; + _buffered -= take; + } + return out; + } +} + +class _ReadRequest { + final int need; + final completer = Completer(); + + _ReadRequest(this.need); +} diff --git a/pubspec.lock b/pubspec.lock index 4797e65..9496b01 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,114 +5,114 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: eb376e9acf6938204f90eb3b1f00b578640d3188b4c8a8ec054f9f479af8d051 + sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" url: "https://pub.dev" source: hosted - version: "64.0.0" + version: "93.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: "69f54f967773f6c26c7dcb13e93d7ccee8b17a641689da39e878d5cf13b06893" + sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b url: "https://pub.dev" source: hosted - version: "6.2.0" + version: "10.0.1" + android_file_picker: + dependency: transitive + description: + name: android_file_picker + sha256: "14ab27769b54c48d5a8a71aa9858372b7a3ae77572ea0a8aee744643c5d8c53d" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + ansicolor: + dependency: transitive + description: + name: ansicolor + sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f" + url: "https://pub.dev" + source: hosted + version: "2.0.3" archive: dependency: transitive description: name: archive - sha256: "7e0d52067d05f2e0324268097ba723b71cb41ac8a6a2b24d1edf9c536b987b03" + sha256: "6c5bcd986e06b94e3c40244af471750840a3d2341d1f9763a1100a14add517b4" url: "https://pub.dev" source: hosted - version: "3.4.6" + version: "4.3.0" args: dependency: transitive description: name: args - sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.7.0" async: dependency: transitive description: name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 url: "https://pub.dev" source: hosted - version: "2.11.0" + version: "2.13.1" auto_route: dependency: "direct main" description: name: auto_route - sha256: "82f8df1d177416bc6b7a449127d0270ff1f0f633a91f2ceb7a85d4f07c3affa1" + sha256: e9acfeb3df33d188fce4ad0239ef4238f333b7aa4d95ec52af3c2b9360dcd969 url: "https://pub.dev" source: hosted - version: "7.8.4" + version: "11.1.0" auto_route_generator: dependency: "direct dev" description: name: auto_route_generator - sha256: "11067a3bcd643812518fe26c0c9ec073990286cabfd9d74b6da9ef9b913c4d22" + sha256: "7aa0e90874928e78709f0a21a69fb5bc2ae1aa932dec862930d2af85c40adb01" url: "https://pub.dev" source: hosted - version: "7.3.2" + version: "10.5.0" boolean_selector: dependency: transitive description: name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" build: dependency: transitive description: name: build - sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" + sha256: "45d14a0fb23e018d8287c32fc98d726ce466b231928ed9b9200f29bd3ccd39ae" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "4.0.7" build_config: dependency: transitive description: name: build_config - sha256: bf80fcfb46a29945b423bd9aad884590fb1dc69b330a4d4700cac476af1708d1 + sha256: d466ed2dc9c6cd1d169948879b84ee061eb5e22c64a7c6089879c6296d272a8d url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.3.3" build_daemon: dependency: transitive description: name: build_daemon - sha256: "5f02d73eb2ba16483e693f80bee4f088563a820e47d1027d4cdfe62b5bb43e65" + sha256: e1d40ef3f7934986d5da2271b1ba07794921ce263e44d622fb6c406d76589e33 url: "https://pub.dev" source: hosted - version: "4.0.0" - build_resolvers: - dependency: transitive - description: - name: build_resolvers - sha256: "64e12b0521812d1684b1917bc80945625391cb9bdd4312536b1d69dcb6133ed8" - url: "https://pub.dev" - source: hosted - version: "2.4.1" + version: "4.1.6" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "10c6bcdbf9d049a0b666702cf1cee4ddfdc38f02a19d35ae392863b47519848b" + sha256: "5367e521935b102bdf1e735d2aab461e36b2edca6517662d088dd04cc39f8d16" url: "https://pub.dev" source: hosted - version: "2.4.6" - build_runner_core: - dependency: transitive - description: - name: build_runner_core - sha256: c9e32d21dd6626b5c163d48b037ce906bbe428bc23ab77bcd77bb21e593b6185 - url: "https://pub.dev" - source: hosted - version: "7.2.11" + version: "2.15.1" built_collection: dependency: transitive description: @@ -125,247 +125,247 @@ packages: dependency: transitive description: name: built_value - sha256: a8de5955205b4d1dbbbc267daddf2178bd737e4bab8987c04a500478c9651e74 + sha256: f87ea98192116f7093cb214551ce1929caae0681fdba282b3d8b4462adee7bb7 url: "https://pub.dev" source: hosted - version: "8.6.3" + version: "8.13.0" characters: dependency: transitive description: name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.1" checked_yaml: dependency: transitive description: name: checked_yaml - sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" url: "https://pub.dev" source: hosted - version: "2.0.3" + version: "2.0.4" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" cli_util: dependency: transitive description: name: cli_util - sha256: b8db3080e59b2503ca9e7922c3df2072cf13992354d5e944074ffa836fba43b7 + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c url: "https://pub.dev" source: hosted - version: "0.4.0" + version: "0.4.2" clock: dependency: transitive description: name: clock - sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" code_builder: dependency: transitive description: name: code_builder - sha256: "1be9be30396d7e4c0db42c35ea6ccd7cc6a1e19916b5dc64d6ac216b5544d677" + sha256: aa5932e94c6c39c2f9ec4e5e06dfdd11a9430a61f6c41b6ba75b28ce0c481baf url: "https://pub.dev" source: hosted - version: "4.7.0" + version: "4.12.0" collection: dependency: transitive description: name: collection - sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687 + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" url: "https://pub.dev" source: hosted - version: "1.17.2" + version: "1.19.1" convert: dependency: transitive description: name: convert - sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 url: "https://pub.dev" source: hosted - version: "3.1.1" - crypto: + version: "3.1.2" + coverage: dependency: transitive description: - name: crypto - sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + name: coverage + sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "1.15.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 + url: "https://pub.dev" + source: hosted + version: "0.3.5+5" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" csslib: dependency: transitive description: name: csslib - sha256: "831883fb353c8bdc1d71979e5b342c7d88acfbc643113c14ae51e2442ea0f20f" + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" url: "https://pub.dev" source: hosted - version: "0.17.3" + version: "1.0.2" dart_style: dependency: transitive description: name: dart_style - sha256: abd7625e16f51f554ea244d090292945ec4d4be7bfbaf2ec8cccea568919d334 + sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2" url: "https://pub.dev" source: hosted - version: "2.3.3" - dio: - dependency: "direct main" + version: "3.1.7" + dbus: + dependency: transitive description: - name: dio - sha256: "7d328c4d898a61efc3cd93655a0955858e29a0aa647f0f9e02d59b3bb275e2e8" + name: dbus + sha256: f7db8504e715b835f5ebe5fe74604ccd961d504c73389a5d8e81bafc2e2f3612 url: "https://pub.dev" source: hosted - version: "4.0.6" - external_path: - dependency: "direct main" - description: - name: external_path - sha256: "2095c626fbbefe70d5a4afc9b1137172a68ee2c276e51c3c1283394485bea8f4" - url: "https://pub.dev" - source: hosted - version: "1.0.3" + version: "0.8.0" fake_async: dependency: transitive description: name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.3.3" ffi: dependency: transitive description: name: ffi - sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878" + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" file: dependency: transitive description: name: file - sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: "98c0b156b6380ba55bc767a14e2e6b3feb246e713ad40092424596fa79005bea" + url: "https://pub.dev" + source: hosted + version: "13.1.0" + file_picker_darwin: + dependency: transitive + description: + name: file_picker_darwin + sha256: "51aef9f4c80449c736e7cc02198675fda73689f0bd45a84da642cfeab0250d46" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + file_picker_linux: + dependency: transitive + description: + name: file_picker_linux + sha256: "9c870771500c758eb46506c98532c3ee60e6e51d0a62dcde24a849ef213ea5ce" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + file_picker_platform_interface: + dependency: transitive + description: + name: file_picker_platform_interface + sha256: bbdc085a6f168e63f147e9ce8988f79fa0800a58e7f25a48f762c60837107ba5 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + file_picker_web: + dependency: transitive + description: + name: file_picker_web + sha256: b3004268da0c1b52baa18df430e7ec4438194de855a807d26b447cbf07ef3496 + url: "https://pub.dev" + source: hosted + version: "4.0.0" fixnum: dependency: transitive description: name: fixnum - sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be url: "https://pub.dev" source: hosted - version: "1.1.0" - flash: - dependency: "direct main" - description: - name: flash - sha256: "1f1632d83f42fe0b9038f72694969a6adf52fa2a3df46d05cfc392dde3c1505f" - url: "https://pub.dev" - source: hosted - version: "3.0.5+2" + version: "1.1.1" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" - flutter_downloader: - dependency: "direct main" - description: - name: flutter_downloader - sha256: e130001cf85d8d7450b8318a4670c19e495dd8c102ad4b15a512e9405e7c451d - url: "https://pub.dev" - source: hosted - version: "1.11.6" - flutter_inappwebview: - dependency: "direct main" - description: - name: flutter_inappwebview - sha256: "3e9a443a18ecef966fb930c3a76ca5ab6a7aafc0c7b5e14a4a850cf107b09959" - url: "https://pub.dev" - source: hosted - version: "6.0.0" - flutter_inappwebview_android: - dependency: transitive - description: - name: flutter_inappwebview_android - sha256: fd4db51e46f49b140d83a3206851432c54ea920b381137c0ba82d0cf59be1dee - url: "https://pub.dev" - source: hosted - version: "1.0.12" - flutter_inappwebview_internal_annotations: - dependency: transitive - description: - name: flutter_inappwebview_internal_annotations - sha256: "5f80fd30e208ddded7dbbcd0d569e7995f9f63d45ea3f548d8dd4c0b473fb4c8" - url: "https://pub.dev" - source: hosted - version: "1.1.1" - flutter_inappwebview_ios: - dependency: transitive - description: - name: flutter_inappwebview_ios - sha256: f363577208b97b10b319cd0c428555cd8493e88b468019a8c5635a0e4312bd0f - url: "https://pub.dev" - source: hosted - version: "1.0.13" - flutter_inappwebview_macos: - dependency: transitive - description: - name: flutter_inappwebview_macos - sha256: b55b9e506c549ce88e26580351d2c71d54f4825901666bd6cfa4be9415bb2636 - url: "https://pub.dev" - source: hosted - version: "1.0.11" - flutter_inappwebview_platform_interface: - dependency: transitive - description: - name: flutter_inappwebview_platform_interface - sha256: "545fd4c25a07d2775f7d5af05a979b2cac4fbf79393b0a7f5d33ba39ba4f6187" - url: "https://pub.dev" - source: hosted - version: "1.0.10" - flutter_inappwebview_web: - dependency: transitive - description: - name: flutter_inappwebview_web - sha256: d8c680abfb6fec71609a700199635d38a744df0febd5544c5a020bd73de8ee07 - url: "https://pub.dev" - source: hosted - version: "1.0.8" flutter_launcher_icons: dependency: "direct dev" description: name: flutter_launcher_icons - sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea" + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" url: "https://pub.dev" source: hosted - version: "0.13.1" + version: "0.14.4" flutter_lints: dependency: "direct dev" description: name: flutter_lints - sha256: a25a15ebbdfc33ab1cd26c63a6ee519df92338a9c10f122adda92938253bef04 + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" url: "https://pub.dev" source: hosted - version: "2.0.3" + version: "6.0.0" flutter_native_splash: dependency: "direct dev" description: name: flutter_native_splash - sha256: "5bf4c3e5e5a0426c1e2fc8ca3555a9e617e76369c3442e1dae8385c7767ba97a" + sha256: "4fb9f4113350d3a80841ce05ebf1976a36de622af7d19aca0ca9a9911c7ff002" url: "https://pub.dev" source: hosted - version: "2.3.4" + version: "2.4.7" flutter_riverpod: dependency: "direct main" description: name: flutter_riverpod - sha256: e667e406a74d67715f1fa0bd941d9ded49aff72f3a9f4440a36aece4e8d457a7 + sha256: "9255e1e3ad6e38906a1b4f8287678f95f378744c5b46b1985588543f3f19046e" url: "https://pub.dev" source: hosted - version: "2.4.3" + version: "3.3.2" flutter_test: dependency: "direct dev" description: flutter @@ -376,46 +376,30 @@ packages: description: flutter source: sdk version: "0.0.0" - freezed: - dependency: "direct dev" - description: - name: freezed - sha256: "21bf2825311de65501d22e563e3d7605dff57fb5e6da982db785ae5372ff018a" - url: "https://pub.dev" - source: hosted - version: "2.4.5" - freezed_annotation: - dependency: "direct main" - description: - name: freezed_annotation - sha256: c3fd9336eb55a38cc1bbd79ab17573113a8deccd0ecbbf926cca3c62803b5c2d - url: "https://pub.dev" - source: hosted - version: "2.4.1" frontend_server_client: dependency: transitive description: name: frontend_server_client - sha256: "408e3ca148b31c20282ad6f37ebfa6f4bdc8fede5b74bc2f08d9d92b55db3612" + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 url: "https://pub.dev" source: hosted - version: "3.2.0" + version: "4.0.0" glob: dependency: transitive description: name: glob - sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.2.0" graphs: dependency: transitive description: name: graphs - sha256: aedc5a15e78fc65a6e23bcd927f24c64dd995062bcd1ca6eda65a3cff92a4d19 + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" hive: dependency: "direct main" description: @@ -432,283 +416,379 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" - hive_generator: - dependency: "direct dev" + hooks: + dependency: transitive description: - name: hive_generator - sha256: "06cb8f58ace74de61f63500564931f9505368f45f98958bd7a6c35ba24159db4" + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.0.2" + hotreloader: + dependency: transitive + description: + name: hotreloader + sha256: "66871df468fc24eee81f1a0a7cb98acc104716f9b7376d355437b48d633c4ebf" + url: "https://pub.dev" + source: hosted + version: "4.4.0" html: dependency: transitive description: name: html - sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a" + sha256: "43b67b8f43321ab066817dfac5619596c98bb1b61624e77203bb4351785f9699" url: "https://pub.dev" source: hosted - version: "0.15.4" + version: "0.15.7" http_multi_server: dependency: transitive description: name: http_multi_server - sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "3.2.2" http_parser: dependency: transitive description: name: http_parser - sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" url: "https://pub.dev" source: hosted - version: "4.0.2" + version: "4.1.2" image: dependency: transitive description: name: image - sha256: "028f61960d56f26414eb616b48b04eb37d700cbe477b7fb09bf1d7ce57fd9271" + sha256: a1e7f4951e538a568e14b856702afc9ae1d2f4b202daced8d22c1b9cd211ce89 url: "https://pub.dev" source: hosted - version: "4.1.3" + version: "4.10.1" intl: dependency: "direct main" description: name: intl - sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" url: "https://pub.dev" source: hosted - version: "0.19.0" + version: "0.20.3" io: dependency: transitive description: name: io - sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + sha256: "2635216ca6a737e60de577ffa1a48a0bec76ca8a62917cfc1bb88c14c570646f" url: "https://pub.dev" source: hosted - version: "1.0.4" - js: + version: "1.1.0" + jni: dependency: transitive description: - name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + name: jni + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 url: "https://pub.dev" source: hosted - version: "0.6.7" + version: "1.0.3" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: b2310cdd4c18c65c081ab141a41efa94aa26c65431803703ece51996f174f351 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" json_annotation: - dependency: "direct main" + dependency: transitive description: name: json_annotation - sha256: b10a7b2ff83d83c777edba3c6a0f97045ddadd56c944e1a23a3fdf43a1bf4467 + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" url: "https://pub.dev" source: hosted - version: "4.8.1" - json_serializable: - dependency: "direct dev" + version: "4.12.0" + leak_tracker: + dependency: transitive description: - name: json_serializable - sha256: aa1f5a8912615733e0fdc7a02af03308933c93235bdc8d50d0b0c8a8ccb0b969 + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "6.7.1" + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lean_builder: + dependency: transitive + description: + name: lean_builder + sha256: c16e95ddf7b2d49dd551357b7212fe2ce9f13ec7ad1b1e660c157184031e96c0 + url: "https://pub.dev" + source: hosted + version: "0.1.10" lints: dependency: transitive description: name: lints - sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452" + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "6.1.0" logging: dependency: transitive description: name: logging - sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" matcher: dependency: transitive description: name: matcher - sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.16" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.5.0" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3" + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.9.1" + version: "1.17.0" mime: dependency: transitive description: name: mime - sha256: e4ff8e8564c03f255408decd16e7899da1733852a9110a58fe6d1b817684a63e + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "2.1.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" + source: hosted + version: "9.5.0" package_config: dependency: transitive description: name: package_config - sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.2.0" path: dependency: transitive description: name: path - sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.dev" source: hosted - version: "1.8.3" + version: "1.9.1" path_provider: dependency: transitive description: name: path_provider - sha256: a1aa8aaa2542a6bc57e381f132af822420216c80d4781f7aa085ca3229208aaa + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.6" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: "6b8b19bd80da4f11ce91b2d1fb931f3006911477cec227cce23d3253d80df3f1" + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.3.1" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "19314d595120f82aca0ba62787d58dde2cc6b5df7d2f0daf72489e38d1b57f2d" + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.6.0" path_provider_linux: dependency: transitive description: name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.2" path_provider_platform_interface: dependency: transitive description: name: path_provider_platform_interface - sha256: "94b1e0dd80970c1ce43d5d4e050a9918fce4f4a775e6142424c30a29a363265c" + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.3" path_provider_windows: dependency: transitive description: name: path_provider_windows - sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170" + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.3.0" petitparser: dependency: transitive description: name: petitparser - sha256: cb3798bef7fc021ac45b308f4b51208a152792445cce0448c9a4ba5879dd8750 + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" url: "https://pub.dev" source: hosted - version: "5.4.0" + version: "7.0.2" platform: dependency: transitive description: name: platform - sha256: "0a279f0707af40c890e80b1e9df8bb761694c074ba7e1d4ab1bc4b728e200b59" + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.1.6" plugin_platform_interface: dependency: transitive description: name: plugin_platform_interface - sha256: da3fdfeccc4d4ff2da8f8c556704c08f912542c5fb3cf2233ed75372384a034d + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" url: "https://pub.dev" source: hosted - version: "2.1.6" - pointycastle: - dependency: transitive - description: - name: pointycastle - sha256: "7c1e5f0d23c9016c5bbd8b1473d0d3fb3fc851b876046039509e18e0c7485f2c" - url: "https://pub.dev" - source: hosted - version: "3.7.3" + version: "2.1.8" pool: dependency: transitive description: name: pool - sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + sha256: "4177f68c237ea2128d1bee66ac17b2ce05ba3dbaafcbdd54c5d40a39d0b6b11c" url: "https://pub.dev" source: hosted - version: "1.5.1" + version: "1.5.3" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" + source: hosted + version: "6.5.2" pub_semver: dependency: transitive description: name: pub_semver - sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.1" pubspec_parse: dependency: transitive description: name: pubspec_parse - sha256: c63b2876e58e194e4b0828fcb080ad0e06d051cb607a6be51a9e084f47cb9367 + sha256: c38b81cbf34450b67e0265d73433569d12e34782e30ed769c9cc99c9d5f2e796 url: "https://pub.dev" source: hosted - version: "1.2.3" + version: "1.6.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" riverpod: dependency: transitive description: name: riverpod - sha256: "494bf2cfb4df30000273d3052bdb1cc1de738574c6b678f0beb146ea56f5e208" + sha256: "17100416c51db7810c71a7bb2c34d1f881faa0074fd452afb0c4db6f8f126c76" url: "https://pub.dev" source: hosted - version: "2.4.3" + version: "3.3.2" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: "34f00f9becd2743c1fb05363d624f9f70d37f7ccdcdda47450bc0b8c9d327b8c" + url: "https://pub.dev" + source: hosted + version: "13.3.0" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "365ef7379fc22507256adda3385152942ffce08935452bc972c2e52a0bebae41" + url: "https://pub.dev" + source: hosted + version: "7.2.0" shelf: dependency: transitive description: name: shelf - sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" shelf_web_socket: dependency: transitive description: name: shelf_web_socket - sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" - url: "https://pub.dev" - source: hosted - version: "1.0.4" - shimmer: - dependency: "direct main" - description: - name: shimmer - sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9" + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" url: "https://pub.dev" source: hosted version: "3.0.0" @@ -716,39 +796,47 @@ packages: dependency: transitive description: flutter source: sdk - version: "0.0.99" + version: "0.0.0" source_gen: dependency: transitive description: name: source_gen - sha256: fc0da689e5302edb6177fdd964efcb7f58912f43c28c2047a808f5bfff643d16 + sha256: a603f1fb984a7391ae5978d1b92bfaaa08b350dca5c825256f925818f7943bf5 url: "https://pub.dev" source: hosted - version: "1.4.0" - source_helper: + version: "4.2.4" + source_map_stack_trace: dependency: transitive description: - name: source_helper - sha256: "6adebc0006c37dd63fe05bca0a929b99f06402fc95aa35bf36d67f5c06de01fd" + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b url: "https://pub.dev" source: hosted - version: "1.3.4" + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "14c2945847669b44089bb1222f66873d7ff7103c58911917f2a63c5a62327898" + url: "https://pub.dev" + source: hosted + version: "0.10.14" source_span: dependency: transitive description: name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.10.2" stack_trace: dependency: transitive description: name: stack_trace - sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5 + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" url: "https://pub.dev" source: hosted - version: "1.11.0" + version: "1.12.1" state_notifier: dependency: transitive description: @@ -761,194 +849,218 @@ packages: dependency: transitive description: name: stream_channel - sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8" + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.4" stream_transform: dependency: transitive description: name: stream_transform - sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f" + sha256: a00e5f18bffc764f923e7dec1038527f7fe7a1791361a7117f0358193f13d53a url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.2" string_scanner: dependency: transitive description: name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.4.1" term_glyph: dependency: transitive description: name: term_glyph - sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" + test: + dependency: transitive + description: + name: test + sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" + url: "https://pub.dev" + source: hosted + version: "1.30.0" test_api: dependency: transitive description: name: test_api - sha256: "75760ffd7786fffdfb9597c35c5b27eaeec82be8edfb6d71d32651128ed7aab8" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.6.0" - timing: + version: "0.7.10" + test_core: dependency: transitive description: - name: timing - sha256: "70a3b636575d4163c477e6de42f247a23b315ae20e86442bebe32d3cabf61c32" + name: test_core + sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "0.6.16" typed_data: dependency: transitive description: name: typed_data - sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.4.0" universal_io: dependency: transitive description: name: universal_io - sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" + sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 url: "https://pub.dev" source: hosted - version: "2.2.2" - url_launcher: - dependency: "direct main" - description: - name: url_launcher - sha256: b1c9e98774adf8820c96fbc7ae3601231d324a7d5ebd8babe27b6dfac91357ba - url: "https://pub.dev" - source: hosted - version: "6.2.1" - url_launcher_android: - dependency: transitive - description: - name: url_launcher_android - sha256: "31222ffb0063171b526d3e569079cf1f8b294075ba323443fdc690842bfd4def" - url: "https://pub.dev" - source: hosted - version: "6.2.0" - url_launcher_ios: - dependency: transitive - description: - name: url_launcher_ios - sha256: "4ac97281cf60e2e8c5cc703b2b28528f9b50c8f7cebc71df6bdf0845f647268a" - url: "https://pub.dev" - source: hosted - version: "6.2.0" + version: "2.3.1" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: "9f2d390e096fdbe1e6e6256f97851e51afc2d9c423d3432f1d6a02a8a9a8b9fd" + sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0" url: "https://pub.dev" source: hosted - version: "3.1.0" - url_launcher_macos: - dependency: transitive - description: - name: url_launcher_macos - sha256: b7244901ea3cf489c5335bdacda07264a6e960b1c1b1a9f91e4bc371d9e68234 - url: "https://pub.dev" - source: hosted - version: "3.1.0" + version: "3.2.3" url_launcher_platform_interface: dependency: transitive description: name: url_launcher_platform_interface - sha256: "980e8d9af422f477be6948bdfb68df8433be71f5743a188968b0c1b887807e50" + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.3.2" url_launcher_web: dependency: transitive description: name: url_launcher_web - sha256: "7fd2f55fe86cea2897b963e864dc01a7eb0719ecc65fcef4c1cc3d686d718bb2" + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.3" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: "7754a1ad30ee896b265f8d14078b0513a4dba28d358eabb9d5f339886f4a1adc" + sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.1.6" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" vector_math: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" + source: hosted + version: "15.3.0" watcher: dependency: transitive description: name: watcher - sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.2.1" web: dependency: transitive description: name: web - sha256: dc8ccd225a2005c1be616fe02951e2e342092edf968cf0844220383757ef8f10 + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" url: "https://pub.dev" source: hosted - version: "0.1.4-beta" + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" win32: dependency: transitive description: name: win32 - sha256: "350a11abd2d1d97e0cc7a28a81b781c08002aa2864d9e3f192ca0ffa18b06ed3" + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d url: "https://pub.dev" source: hosted - version: "5.0.9" + version: "6.4.0" + windows_file_picker: + dependency: transitive + description: + name: windows_file_picker + sha256: "9f3aa833068b09e380fdc59560ad260a4a14e9397173abb533699f967443602e" + url: "https://pub.dev" + source: hosted + version: "2.0.0" xdg_directories: dependency: transitive description: name: xdg_directories - sha256: "589ada45ba9e39405c198fe34eb0f607cddb2108527e658136120892beac46d2" + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "1.1.0" xml: dependency: transitive description: name: xml - sha256: "5bc72e1e45e941d825fd7468b9b4cc3b9327942649aeb6fc5cdbf135f0a86e84" + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" url: "https://pub.dev" source: hosted - version: "6.3.0" + version: "6.6.1" + xxh3: + dependency: transitive + description: + name: xxh3 + sha256: "399a0438f5d426785723c99da6b16e136f4953fb1e9db0bf270bd41dd4619916" + url: "https://pub.dev" + source: hosted + version: "1.2.0" yaml: dependency: transitive description: name: yaml - sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "3.1.4" sdks: - dart: ">=3.1.0 <4.0.0" - flutter: ">=3.13.0" + dart: ">=3.11.0 <4.0.0" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml index 4271a88..573249b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,42 +1,33 @@ -name: flash_mail -description: Temporary E-mail App, powered by mail.tm +name: smol_mail +description: Smol Mail client for the minimalist end-to-end encrypted mail protocol (../smolmail) publish_to: "none" version: 1.0.0+1 environment: - sdk: ">=3.0.1 <4.0.0" + sdk: ^3.11.0 dependencies: flutter: sdk: flutter + crypto: ^3.0.3 hive: ^2.2.3 hive_flutter: ^1.1.0 - auto_route: ^7.4.0 - flutter_riverpod: ^2.4.0 - url_launcher: ^6.2.1 - flash: ^3.0.5+2 - freezed_annotation: ^2.4.1 - json_annotation: ^4.8.1 - dio: ^4.0.6 - shimmer: ^3.0.0 - flutter_inappwebview: ^6.0.0 - flutter_downloader: ^1.11.6 - external_path: ^1.0.3 - intl: ^0.19.0 + auto_route: ">=11.1.0 <11.2.0" + flutter_riverpod: ^3.3.2 + intl: ^0.20.3 + share_plus: ^13.3.0 + file_picker: ^13.1.0 dev_dependencies: flutter_test: sdk: flutter build_runner: ^2.4.6 - flutter_lints: ^2.0.0 - auto_route_generator: ^7.3.0 + flutter_lints: ^6.0.0 + auto_route_generator: ^10.5.0 flutter_native_splash: ^2.3.4 - flutter_launcher_icons: ^0.13.1 - freezed: ^2.4.5 - json_serializable: ^6.7.1 - hive_generator: ^2.0.1 + flutter_launcher_icons: ^0.14.4 flutter_launcher_icons: android: "launcher_icon" diff --git a/test/dbg_test.dart b/test/dbg_test.dart new file mode 100644 index 0000000..9df2990 --- /dev/null +++ b/test/dbg_test.dart @@ -0,0 +1,61 @@ +import "dart:io"; + +import "package:auto_route/auto_route.dart"; +import "package:flutter/material.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:hive_flutter/hive_flutter.dart"; + +import "package:smol_mail/data/providers/providers.dart"; +import "package:smol_mail/presentation/app_widget.dart"; +import "package:smol_mail/presentation/routes/app_router.gr.dart"; +import "package:smol_mail/smol/client.dart"; +import "package:smol_mail/smol/crypto.dart"; +import "package:smol_mail/smol/proto.dart"; +import "package:smol_mail/smol/store.dart"; + +class StubClient extends SmolClient { + StubClient(super.store); + + @override + Future recallAccount(String addressText) async { + final addr = parseAddress(addressText); + store.setAccount(addr); + return addr; + } +} + +void main() { + late SmolStore store; + setUpAll(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + final dir = await Directory.systemTemp.createTemp("dbg4"); + Hive.init(dir.path); + store = await SmolStore.open(stateBox: "dbg-state", mailBox: "dbg-mail"); + }); + + testWidgets("direct replace", (tester) async { + final seed = randomBytes(32); + await tester.pumpWidget(ProviderScope( + overrides: [ + storeProvider.overrideWithValue(store), + clientProvider.overrideWithValue(StubClient(store)), + ], + child: const AppWidget(), + )); + await tester.pumpAndSettle(); + await tester.tap(find.text("Restore From Seed")); + await tester.pumpAndSettle(); + final fields = find.byType(TextField); + await tester.enterText(fields.at(0), hex(seed)); + await tester.enterText(fields.at(1), "randogoth@smol.place"); + await tester.tap(find.text("Restore")); + await tester.pumpAndSettle(); + expect(store.account(), isNotNull, reason: "recall stub ran"); + final element = tester.element(find.text("Back")); + final router = AutoRouter.of(element); + await router.replace(InboxRoute()); + await tester.pumpAndSettle(); + expect(find.text("Inbox"), findsOneWidget); + }); +} diff --git a/test/e2e_test.dart b/test/e2e_test.dart new file mode 100644 index 0000000..4db7129 --- /dev/null +++ b/test/e2e_test.dart @@ -0,0 +1,121 @@ +// End-to-end against a live reference server: register an address on a +// locally running smolmaild, send a sealed message to ourselves, fetch it back, +// and check the server is drained afterwards. Skips when nothing listens on +// 127.0.0.1:1961, so `devbox run test` does not depend on a server. +// +// To run it: (cd ../smolmail && uv run smolmaild.py keygen --key server.key && +// uv run smolmaild.py serve --key server.key --db mail.db) +// then `devbox run test`. + +import "dart:io"; +import "package:flutter_test/flutter_test.dart"; +import "package:hive_flutter/hive_flutter.dart"; + +import "package:smol_mail/smol/client.dart"; +import "package:smol_mail/smol/crypto.dart"; +import "package:smol_mail/smol/errors.dart"; +import "package:smol_mail/smol/proto.dart"; +import "package:smol_mail/smol/store.dart"; + +const host = "127.0.0.1"; +const port = 1961; + +Future serverUp() async { + try { + final probe = await Socket.connect(host, port, + timeout: const Duration(seconds: 2)); + probe.destroy(); + return true; + } catch (_) { + return false; + } +} + +void main() { + test("register, send to self, fetch, unseal, drain", () async { + if (!await serverUp()) { + markTestSkipped("no smolmaild on $host:$port"); + return; + } + final dir = await Directory.systemTemp.createTemp("smol-e2e"); + Hive.init(dir.path); + final store = await SmolStore.open(); + final client = SmolClient(store); + + // First contact is trust-on-first-use: learn the key the handshake reveals, + // then pin it — the flow a user with an operator-supplied key skips. + final warnings = []; + client.onWarning = warnings.add; + + final me = client.createIdentity(); + final user = "e2e${hex(randomBytes(4))}"; + final address = parseAddress("$user@$host"); + final learned = await client.connect(address, requirePin: false); + // §8: the first, unpinned session must be announced as unverified. + expect(warnings, isNotEmpty); + expect(warnings.single, contains("not pinned")); + store.pinServer(host, learned.serverStatic); + learned.session.wire.close(); + + await client.registerAccount(address.short); + expect(store.account()?.user, user); + + await client.send(address.short, "hello e2e", "sealed and signed"); + await client.send(address.short, "second", "another sealed envelope"); + + final summary = await client.fetch(); + expect(summary.stored, 2); + expect(summary.rejected, isEmpty); + + final inbox = store.listMessages("inbox"); + expect(inbox.length, 2); + // receivedAt has second granularity, so the order of the two is not + // guaranteed; assert on the pair, then open the one we care about. + final subjects = inbox.map((m) => client.describe(m).subject).toSet(); + expect(subjects, {"hello e2e", "second"}); + final hello = inbox.firstWhere( + (m) => client.describe(m).subject == "hello e2e"); + final opened = client.describe(hello); + expect(opened.error, isNull); + expect(opened.body, "sealed and signed\n"); + expect(hex(opened.sender!), hex(me.publicKey)); + + // The server must be drained: everything that verified was acknowledged. + final again = await client.fetch(); + expect(again.stored, 0); + + // The sent copy is sealed to ourselves and readable (§5.6). + final sent = store.listMessages("sent"); + expect(sent.length, 2); + expect(client.describe(sent.first).error, isNull); + + // A restored seed can rebind the address without REGISTER; the address + // must resolve to this identity's key. + final recalled = await client.recallAccount(address.short); + expect(recalled.short, address.short); + expect( + () => client.recallAccount("nobody@$host"), throwsA(isA())); + + // Restore on a second device: same seed, fresh store, no pin. Unpinned + // recall is refused; re-registering a taken name is refused; recall with + // the operator-supplied key then binds the account without REGISTER. + final restored = await SmolStore.open( + stateBox: "e2e-restore-state", mailBox: "e2e-restore-mail"); + final secondDevice = SmolClient(restored); + restored.setIdentity(me.seed); + expect( + secondDevice.recallAccount(address.short), throwsA(isA())); + restored.pinServer(host, learned.serverStatic); + await expectLater( + secondDevice.registerAccount(address.short), throwsA(isA())); + final bound = await secondDevice.recallAccount(address.short); + expect(bound.short, address.short); + expect(restored.account()!.user, user); + + // Re-resolving our own address finds the same key and says so quietly. + final outcome = await client.refreshContact(address.short); + expect(outcome.warn, isFalse); + expect(outcome.message, contains("key unchanged")); + expect(store.contact(address.short)!.history, isEmpty); + }, timeout: const Timeout(Duration(minutes: 2))); +} diff --git a/test/recall_flow_test.dart b/test/recall_flow_test.dart new file mode 100644 index 0000000..144d861 --- /dev/null +++ b/test/recall_flow_test.dart @@ -0,0 +1,104 @@ +// Regression tests for the onboarding recall flows: restoring a seed and +// recalling the registered address must land the user in the inbox. The +// client's network operations are stubbed; what is under test is the UI and +// router flow itself. + +import "dart:io"; + +import "package:flutter/material.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:hive_flutter/hive_flutter.dart"; + +import "package:smol_mail/data/providers/providers.dart"; +import "package:smol_mail/presentation/app_widget.dart"; +import "package:smol_mail/smol/client.dart"; +import "package:smol_mail/smol/crypto.dart"; +import "package:smol_mail/smol/proto.dart"; +import "package:smol_mail/smol/store.dart"; + +/// A [SmolClient] whose recall completes instantly, so the test exercises +/// the flow rather than the network. +class StubClient extends SmolClient { + StubClient(super.store); + + @override + Future recallAccount(String addressText) async { + final addr = parseAddress(addressText); + store.setAccount(addr); + return addr; + } +} + +void main() { + // Hive box opening is real file IO and must happen outside testWidgets' + // fake-async zone — including the per-test stores. + late final SmolStore storeA, storeB; + setUpAll(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + final dir = await Directory.systemTemp.createTemp("smol-recall-flow"); + Hive.init(dir.path); + storeA = await SmolStore.open( + stateBox: "recall-a-state", mailBox: "recall-a-mail"); + storeB = await SmolStore.open( + stateBox: "recall-b-state", mailBox: "recall-b-mail"); + }); + + Widget app(SmolStore store) => ProviderScope( + overrides: [ + storeProvider.overrideWithValue(store), + clientProvider.overrideWithValue(StubClient(store)), + ], + child: const AppWidget(), + ); + + testWidgets("restore with an address recalls and lands in the inbox", + (tester) async { + final store = storeA; + final seed = randomBytes(32); + await tester.pumpWidget(app(store)); + await tester.pumpAndSettle(); + expect(find.text("Create Identity"), findsOneWidget); + + await tester.tap(find.text("Restore From Seed")); + await tester.pumpAndSettle(); + + final fields = find.byType(TextField); + expect(fields, findsNWidgets(2)); + await tester.enterText(fields.at(0), hex(seed)); + await tester.enterText(fields.at(1), "randogoth@smol.place"); + await tester.tap(find.text("Restore")); + await tester.pumpAndSettle(); + + expect(find.text("Inbox"), findsOneWidget); + expect(store.seed(), seed); + expect(store.account()!.user, "randogoth"); + }); + + testWidgets("restore without an address recalls from the register step", + (tester) async { + final store = storeB; + final seed = randomBytes(32); + await tester.pumpWidget(app(store)); + await tester.pumpAndSettle(); + + await tester.tap(find.text("Restore From Seed")); + await tester.pumpAndSettle(); + + var fields = find.byType(TextField); + await tester.enterText(fields.at(0), hex(seed)); + await tester.tap(find.text("Restore")); + await tester.pumpAndSettle(); + + // The register step: address, server key, optional invite token. + fields = find.byType(TextField); + expect(fields, findsNWidgets(3)); + await tester.enterText(fields.at(0), "randogoth@smol.place"); + await tester.enterText(fields.at(1), b32encode(randomBytes(32))); + await tester.tap(find.text("Already registered? Recall")); + await tester.pumpAndSettle(); + + expect(find.text("Inbox"), findsOneWidget); + expect(store.account()!.user, "randogoth"); + }); +} diff --git a/test/smol_test.dart b/test/smol_test.dart new file mode 100644 index 0000000..be77744 --- /dev/null +++ b/test/smol_test.dart @@ -0,0 +1,306 @@ +// Unit tests: lib/smol must reproduce test/vectors.json byte for byte (the +// vectors are generated from the reference stack — PyNaCl, noiseprotocol — by +// ../gsmol/test/gen_vectors.py), plus protocol-level checks for frontmatter, +// addresses, rotation chains and response framing. +// Run: devbox run test + +import "dart:convert"; +import "dart:io"; +import "dart:typed_data"; + +import "package:flutter_test/flutter_test.dart"; + +import "package:smol_mail/smol/crypto.dart"; +import "package:smol_mail/smol/errors.dart"; +import "package:smol_mail/smol/noise.dart"; +import "package:smol_mail/smol/proto.dart"; + +final vectors = + jsonDecode(File("test/vectors.json").readAsStringSync()) as Map; + +Uint8List vhex(String text) => unhex(text); +String vstring(dynamic value) => value as String; + +void main() { + test("hashes, HMAC/HKDF and AEAD match the reference vectors", () { + for (final v in vectors["sha256"] as List) { + final m = v as Map; + expect(hex(sha256(vhex(vstring(m["in"])))), vstring(m["out"])); + } + for (final v in vectors["sha512"] as List) { + final m = v as Map; + expect(hex(sha512(vhex(vstring(m["in"])))), vstring(m["out"])); + } + for (final v in vectors["hkdf"] as List) { + final m = v as Map; + expect( + hex(hkdfSha256(vhex(vstring(m["ikm"])), vhex(vstring(m["salt"])), + vhex(vstring(m["info"])), m["len"] as int)), + vstring(m["out"])); + } + for (final v in vectors["aead"] as List) { + final m = v as Map; + final key = vhex(vstring(m["key"])); + final nonce = vhex(vstring(m["nonce"])); + final aad = vhex(vstring(m["aad"])); + final sealed = aeadEncrypt( + key, nonce, vhex(vstring(m["plaintext"])), aad); + expect(hex(sealed), vstring(m["sealed"]), reason: "aead-seal ${m["name"]}"); + expect( + hex(aeadDecrypt(key, nonce, vhex(vstring(m["sealed"])), aad)), + vstring(m["plaintext"])); + expect( + () => aeadDecrypt( + key, nonce, Uint8List.fromList(vhex(vstring(m["sealed"])).sublist(0, vhex(vstring(m["sealed"])).length - 1)), aad), + throwsA(isA())); + } + }); + + test("X25519 matches and rejects low-order points", () { + final byName = {}; + for (final v in vectors["x25519"] as List) { + final m = v as Map; + byName[m["name"] as String] = m; + } + expect(hex(x25519Base(vhex(vstring(byName["alice"]!["priv"])))), byName["alice"]!["pub"]); + expect(hex(x25519Base(vhex(vstring(byName["bob"]!["priv"])))), byName["bob"]!["pub"]); + expect( + hex(x25519(vhex(vstring(byName["alice"]!["priv"])), vhex(vstring(byName["bob"]!["pub"])))), + byName["agree"]!["shared"]); + for (final v in vectors["x25519"] as List) { + final m = v as Map; + if ((m["name"] as String).startsWith("low-order")) { + expect( + () => x25519(vhex(vstring(byName["alice"]!["priv"])), vhex(vstring(m["peer"]))), + throwsA(isA())); + } + } + }); + + test("Ed25519 signs and verifies like the reference stack", () { + for (final v in vectors["ed25519"] as List) { + final m = v as Map; + final seed = vhex(vstring(m["seed"])); + expect(hex(ed25519PublicKey(seed)), vstring(m["pub"]), reason: "ed25519-pub ${m["name"]}"); + final sig = ed25519Sign(seed, vhex(vstring(m["message"]))); + if (m["valid"] as bool) { + expect(hex(sig), vstring(m["signature"]), reason: "ed25519-sign ${m["name"]}"); + expect(ed25519Verify(vhex(vstring(m["pub"])), vhex(vstring(m["message"])), sig), isTrue); + expect( + ed25519Verify(vhex(vstring(m["pub"])), vhex(vstring(m["message"])), + vhex(vstring(m["signature"]))), + isTrue, + reason: "ed25519-verify-pynacl ${m["name"]}"); + } else { + expect( + ed25519Verify(vhex(vstring(m["pub"])), vhex(vstring(m["message"])), + vhex(vstring(m["signature"]))), + isFalse, + reason: "ed25519-reject ${m["name"]}"); + } + } + }); + + test("§2 conversions between the identity key and X25519", () { + for (final v in vectors["ed_to_x25519"] as List) { + final m = v as Map; + expect(hex(ed25519SeedToX25519(vhex(vstring(m["seed"])))), vstring(m["x_priv"])); + expect(hex(ed25519ToX25519(ed25519PublicKey(vhex(vstring(m["seed"]))))), vstring(m["x_pub"])); + } + }); + + test("§5 envelope seals, ids and unseals byte for byte", () { + final v = vectors["envelope"] as Map; + final sender = identityFromSeed(vhex(vstring(v["sender_seed"]))); + final recipient = identityFromSeed(vhex(vstring(v["recipient_seed"]))); + Uint8List sealWith(bool pad) => seal(sender, recipient.publicKey, + vhex(vstring(v["body"])), v["time"] as int, + SealOptions(esk: vhex(vstring(v["esk"])), pad: pad)); + + expect(hex(sealWith(false)), vstring(v["envelope"])); + expect(hex(messageId(vhex(vstring(v["envelope"])))), vstring(v["id"])); + final opened = unseal([recipient], vhex(vstring(v["envelope"]))); + expect(hex(opened.sender), hex(sender.publicKey)); + expect(opened.time, v["time"] as int); + expect(hex(opened.body), vstring(v["body"])); + expect( + () => unseal([identityFromSeed(vhex(vstring(v["esk"])))], + vhex(vstring(v["envelope"]))), + throwsA(isA())); + // padding round-trips and is ignored by the receiver (§5.3) + final padded = sealWith(true); + expect((padded.length - envelopeHeader - 16) % padTo, 0); + expect(padded.length > vstring(v["envelope"]).length ~/ 2, isTrue); + expect(hex(unseal([recipient], padded).body), vstring(v["body"])); + }); + + test("Noise NX transcript matches the reference", () { + final v = vectors["noise"] as Map; + final nx = NxInitiator(); + expect(hex(nx.writeMessage1(vhex(vstring(v["initiator_eph_priv"])))), vstring(v["message1"])); + final result = nx.readMessage2(vhex(vstring(v["message2"]))); + expect(hex(result.serverStatic), vstring(v["server_static_pub"])); + expect(hex(result.handshakeHash), vstring(v["handshake_hash"])); + final initiatorFrames = (v["initiator_frames"] as List).cast(); + final responderFrames = (v["responder_frames"] as List).cast(); + for (var i = 0; i < initiatorFrames.length; i++) { + expect(hex(result.send.encrypt(vhex(vstring(initiatorFrames[i]["plaintext"])))), + vstring(initiatorFrames[i]["sealed"]), + reason: "noise-frame-i$i"); + } + for (var i = 0; i < responderFrames.length; i++) { + expect(hex(result.recv.decrypt(vhex(vstring(responderFrames[i]["sealed"])))), + vstring(responderFrames[i]["plaintext"]), + reason: "noise-frame-r$i"); + } + // The responder direction must also produce identical ciphertexts (AEAD is + // deterministic), so the recv cipher can be checked in both directions. + final mirrored = NxInitiator(); + mirrored.writeMessage1(vhex(vstring(v["initiator_eph_priv"]))); + final mirror = mirrored.readMessage2(vhex(vstring(v["message2"]))); + expect(hex(mirror.recv.encrypt(vhex(vstring(responderFrames[0]["plaintext"])))), + vstring(responderFrames[0]["sealed"])); + }); + + test("frontmatter parses and fails closed (§5.5)", () { + const spec = + "---\nSubject: Re: the thing\nIn-Reply-To: 4f2a1c9e8b7d6a5f3e2d1c0b9a8f7e6d\nX-Mood: cautiously optimistic\n---\nBody text starts here."; + final parsed = parseFrontmatter(spec); + expect(parsed.fields["Subject"], "Re: the thing"); + expect(parsed.fields["In-Reply-To"], "4f2a1c9e8b7d6a5f3e2d1c0b9a8f7e6d"); + expect(parsed.body, "Body text starts here."); + // a malformed line invalidates the whole block, which fails closed toward display + expect(parseFrontmatter("---\nno colon here\n---\nrest").body, + "---\nno colon here\n---\nrest"); + expect(parseFrontmatter("---\nSubject: x\nno end").body, + "---\nSubject: x\nno end"); + expect(parseFrontmatter("---\nA: 1\nA: 2\n---\ntext").fields["A"], "1"); + expect(buildFrontmatter(const {}, "---\nactual body"), + "---\n---\n---\nactual body"); + expect(buildFrontmatter({"Subject": "hi"}, "there"), "---\nSubject: hi\n---\nthere"); + expect(buildFrontmatter(const {}, "plain"), "plain"); + expect( + parseFrontmatter("---\n${"X: y\n" * 65}---\nbody").fields["Subject"], + isNull); + }); + + test("addresses parse per §3 and round-trip through smol://", () { + final a = parseAddress("Alice@Example.ORG:1961"); + expect(a.user, "alice"); + expect(a.port, 1961); + expect(a.short, "alice@example.org"); // a default port is dropped + expect(parseAddress("bob@host").port, defaultPort); + final key = vhex(vstring((vectors["ed25519"] as List).cast().first["pub"])); + final parsed = parseAddress(parseAddress("bob@h").uri(key)); + expect(parsed.identity, key); + expect(parsed.user, "bob"); + expect(() => parseAddress("-bob@h"), throwsA(isA())); + // §3: fingerprints are the first 20 base32 characters in groups of four + final b32 = b32encode(key); + expect( + fingerprint(key), + [ + b32.substring(0, 4), b32.substring(4, 8), b32.substring(8, 12), + b32.substring(12, 16), b32.substring(16, 20) + ].join(" ")); + for (final n in [1, 2, 5, 32, 52, 64]) { + final raw = randomBytes(n); + expect(b32decode(b32encode(raw)), raw, reason: "b32[$n]"); + } + }); + + test("rotation chains validate, break and oversize per §7", () { + final old = identityFromSeed(randomBytes(32)); + final mid = randomBytes(32); + final fresh = randomBytes(32); + final when = nowSeconds(); + final chain = [ + makeCert(old, mid, when), + makeCert(identityFromSeed(mid), fresh, when), + ]; + expect(walkChain(old.publicKey, ed25519PublicKey(fresh), chain), isTrue); + expect(walkChain(old.publicKey, old.publicKey, []), isTrue); + expect( + walkChain(old.publicKey, ed25519PublicKey(fresh), chain.sublist(1)), + isFalse); + final forged = List.from(chain); + forged[1] = makeCert(identityFromSeed(mid), randomBytes(32), when); + expect( + walkChain(old.publicKey, ed25519PublicKey(fresh), forged), isFalse); + expect( + walkChain(old.publicKey, ed25519PublicKey(fresh), + List.filled(17, chain[0])), + isFalse); + expect(chain[0].length, certLen); + }); + + test("response framing guards (§6.1)", () async { + Session scripted(Uint8List frame) { + // readNoise wants a u16 length prefix and at least 16 bytes of Noise + // message; the frame is padded up to that floor. + final message = Uint8List.fromList([ + ...frame, + ...List.filled(frame.length < 16 ? 16 - frame.length : 0, 0), + ]); + final session = Session(_ScriptedWire(concat([u16be(message.length), message])), + _PassthroughCipher(), _PassthroughCipher()); + return session; + } + + Future refuses(String name, Uint8List frame, int op) async { + try { + await scripted(frame).call(op); + fail("$name: call resolved instead of throwing"); + } on TestFailure { + rethrow; + } catch (_) { + // expected + } + } + + // The shortest legal response is a type byte and a status byte. + await refuses("frame-too-short", concat([u32be(1), Uint8List.fromList([opFetch])]), opFetch); + // A response reuses the request's type byte; a mismatch means the session + // desynchronised, which must not be read as a status. + await refuses("frame-op-mismatch", + concat([u32be(2), Uint8List.fromList([opResolve, 0])]), opFetch); + // The same frame with the right echo still passes, so the guard is not + // simply rejecting everything. + final okSession = + scripted(concat([u32be(2), Uint8List.fromList([opFetch, 0])])); + expect((await okSession.call(opFetch)).status, 0); + }); +} + +/// Serves a scripted response and ignores sends, so framing can be tested +/// without a server. +class _ScriptedWire implements Wire { + final Uint8List _queue; + int _pos = 0; + + _ScriptedWire(this._queue); + + @override + void send(Uint8List bytes) {} + + @override + void close() {} + + @override + Future readExact(int n) async { + if (_pos + n > _queue.length) { + throw const SmolError("script exhausted"); + } + final out = Uint8List.fromList(_queue.sublist(_pos, _pos + n)); + _pos += n; + return out; + } +} + +class _PassthroughCipher implements SessionCipher { + @override + Uint8List encrypt(Uint8List plaintext) => Uint8List.fromList(plaintext); + + @override + Uint8List decrypt(Uint8List sealed) => Uint8List.fromList(sealed); +} diff --git a/test/store_test.dart b/test/store_test.dart new file mode 100644 index 0000000..74ee0b7 --- /dev/null +++ b/test/store_test.dart @@ -0,0 +1,140 @@ +// Store-level behavior: contact key history (§8), import binding, and the +// export/import backup file. + +import "dart:convert"; +import "dart:io"; + +import "package:flutter_test/flutter_test.dart"; +import "package:hive_flutter/hive_flutter.dart"; + +import "package:smol_mail/smol/client.dart"; +import "package:smol_mail/smol/crypto.dart"; +import "package:smol_mail/smol/errors.dart"; +import "package:smol_mail/smol/proto.dart"; +import "package:smol_mail/smol/store.dart"; + +// Each store gets its own boxes; Hive is a per-process singleton, so without +// this the "exporting" and "importing" stores would be the same store. +Future freshStore(String tag) => + SmolStore.open(stateBox: "test-$tag-state", mailBox: "test-$tag-mail"); + +void main() { + setUpAll(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + final dir = await Directory.systemTemp.createTemp("smol-store-test"); + Hive.init(dir.path); + }); + + test("contact history keeps displaced keys, not re-saves", () async { + final store = await freshStore("history"); + final a = randomBytes(32), b = randomBytes(32), c = randomBytes(32); + + store.saveContact("alice@example.org", a, true); + expect(store.contact("alice@example.org")!.history, isEmpty); + + store.saveContact("alice@example.org", b, false); + final rotated = store.contact("alice@example.org")!; + expect(rotated.key, b); + expect(rotated.history.length, 1); + expect(rotated.history.first.key, a); + expect(rotated.history.first.until, greaterThan(0)); + + // Re-saving the same key is not a rotation and must not add an entry. + store.saveContact("alice@example.org", b, true); + expect(store.contact("alice@example.org")!.history.length, 1); + + // A second displacement appends, oldest first. + store.saveContact("alice@example.org", c, false); + final history = store.contact("alice@example.org")!.history; + expect(history.length, 2); + expect(history[0].key, a); + expect(history[1].key, b); + expect(store.allContacts().single.$2.history.length, 2); + }); + + test("importContact binds a smol:// address to the key it carries", () async { + final store = await freshStore("import-contact"); + final client = SmolClient(store); + final identity = identityFromSeed(randomBytes(32)); + client.importContact( + "smol://bob@example.org/${b32encode(identity.publicKey)}"); + final saved = store.contact("bob@example.org")!; + expect(saved.verified, isTrue); + expect(saved.key, identity.publicKey); + }); + + test("export never contains the seed and round-trips through import", + () async { + final a = await freshStore("export"); + final b = await freshStore("round-trip"); + a.setIdentity(randomBytes(32)); + a.pinServer("example.org", randomBytes(32)); + a.saveContact("alice@example.org", randomBytes(32), true); + a.saveContact("alice@example.org", randomBytes(32), false); // history grows + await a.storeMessage( + "inbox", MailRecord("aa", randomBytes(64), receivedAt: 5)); + await a.storeMessage("sent", + MailRecord("bb", randomBytes(64), recipient: "bob@example.org", sentAt: 6)); + + final data = a.exportData(); + expect(data["gsmolExport"], 1); // gsmol-compatible marker + expect(jsonEncode(data).contains(hex(a.seed()!)), isFalse); + + final summary = await b.importData(data); + expect(summary.mailAdded, 2); + expect(summary.pinsAdded, 1); + expect(summary.contactsAdded, 1); + expect(b.serverPin("example.org"), a.serverPin("example.org")); + expect(b.contact("alice@example.org")!.history.length, 1); + expect(b.getMessage("inbox", "aa"), isNotNull); + expect(b.getMessage("sent", "bb"), isNotNull); + expect(b.seed(), isNull); // never the seed + }); + + test("import never overwrites a differing trust binding", () async { + final store = await freshStore("conflict"); + final mine = randomBytes(32), other = randomBytes(32); + store.pinServer("example.org", mine); + store.saveContact("alice@example.org", mine, true); + final summary = await store.importData({ + "gsmolExport": 1, + "servers": {"example.org": b32encode(other)}, + "contacts": { + "alice@example.org": {"key": b32encode(other), "verified": true}, + "bob@example.org": {"key": b32encode(randomBytes(32)), "verified": false}, + }, + }); + expect(summary.pinsConflicted, 1); + expect(summary.pinsAdded, 0); + expect(summary.contactsConflicted, 1); + expect(summary.contactsAdded, 1); + expect(store.serverPin("example.org"), mine); + expect(store.contact("alice@example.org")!.key, mine); + expect(store.contact("bob@example.org"), isNotNull); + }); + + test("import skips malformed entries and rejects wrong files", () async { + final store = await freshStore("malformed"); + final summary = await store.importData({ + "gsmolExport": 1, + "servers": {"bad": "notbase32!", "short": b32encode(randomBytes(8))}, + "contacts": { + "x": {"key": "nope"}, + "y": "not a record", + }, + "inbox": [ + {"id": "ok", "envelope": base64Encode(randomBytes(64)), "receivedAt": 1}, + {"id": "bad", "envelope": "!!!not base64!!!"}, + "not a record", + ], + "sent": "not a list", + }); + expect(summary.malformed, 7); // 2 pins, 2 contacts, 2 mail, 1 sent + expect(summary.pinsAdded, 0); + expect(summary.mailAdded, 1); + expect(store.getMessage("inbox", "ok"), isNotNull); + expect(store.getMessage("inbox", "bad"), isNull); + + expect(() => store.importData({"nope": 1}), throwsA(isA())); + }); +} diff --git a/test/vectors.json b/test/vectors.json new file mode 100644 index 0000000..61fb897 --- /dev/null +++ b/test/vectors.json @@ -0,0 +1,226 @@ +{ + "sha256": [ + { + "in": "", + "out": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "in": "616263", + "out": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + }, + { + "in": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "out": "fdeab9acf3710362bd2658cdc9a29e8f9c757fcf9811603a8c447cd1d9151108" + }, + { + "in": "736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c", + "out": "58a0adce483c517ae1b37025dbe3b534880972be4d9d10b78959a13fb8b13462" + } + ], + "sha512": [ + { + "in": "", + "out": "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e" + }, + { + "in": "616263", + "out": "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f" + }, + { + "in": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "out": "ee4320ebaf3fdb4f2c832b137200c08e235e0fa7bbd0eb1740c7063ba8a0d151da77e003398e1714a955d475b05e3e950b639503b452ec185de4229bc4873949" + } + ], + "hkdf": [ + { + "name": "rfc5869-1", + "ikm": "0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b", + "salt": "000102030405060708090a0b0c", + "info": "f0f1f2f3f4f5f6f7f8f9", + "len": 42, + "out": "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865" + }, + { + "name": "seal-shaped", + "ikm": "61677265656d656e7420736861726564", + "salt": "65706b726563697069656e74", + "info": "736d6f6c6d61696c2f31207365616c", + "len": 32, + "out": "b9f729e45d7bab89598edbce35f3f5bb91d6f403a5ff85943a838defbfcd7a0c" + } + ], + "aead": [ + { + "name": "empty", + "key": "831386140c054287c460b9f6092d43a5bb6eb2d2c549b00fc3fce0e7cca1a19d", + "nonce": "3edd69829394f0807df7b01d", + "aad": "", + "plaintext": "", + "sealed": "941b286ea0ee86bb66236fc3c16b2e48" + }, + { + "name": "short", + "key": "831386140c054287c460b9f6092d43a5bb6eb2d2c549b00fc3fce0e7cca1a19d", + "nonce": "3edd69829394f0807df7b01d", + "aad": "50515253c0c1c2c3c4c5c6c7", + "plaintext": "68656c6c6f", + "sealed": "7dbede6dd11ffa046f102dedea535912be561e3c29" + }, + { + "name": "multiline", + "key": "831386140c054287c460b9f6092d43a5bb6eb2d2c549b00fc3fce0e7cca1a19d", + "nonce": "3edd69829394f0807df7b01d", + "aad": "50515253c0c1c2c3c4c5c6c7", + "plaintext": "4c616469657320616e642047656e746c656d656e206f662074686520636c617373206f66202739393a206966204920636f756c64206f6666657220796f75206f6e6c79206f6e652074697020666f7220746865206675747572652c2073756e73637265656e20776f756c642062652069742e", + "sealed": "59bad668dbf00561dd86add05afe35b269f9997d57e46cee0e42fd69693c3df16b681f3905bbea4135cb379f4a0c730b5dbb3ba06d1231ce396660a16f8cadb8b422d745b932df3c1eed2df9636750551a0333b3d06877582f172f3ec2d437061143699bfc7258aa98e319f23a1f31f88fd15a24a97b46fd2e05c266d31ee96f5e24" + } + ], + "x25519": [ + { + "name": "alice", + "priv": "c63095403fea13cb2c43380599e669ebfb41ab184b04df28a143472e4283aa33", + "pub": "712e68cefc13d0e1778226b7f7aaeb59042225e7a4def3816344da256e031664" + }, + { + "name": "bob", + "priv": "33485a5b40b70730b3c3e468a8262543e5a4d9dc98de06399de66a4d579e02a7", + "pub": "b8540c30e8fb348aed0abc8189cf8025ce09a9c5d74e0681c4e50f8d281da252" + }, + { + "name": "agree", + "shared": "e6a3b1d2ae10c29b51519a71255af4bd20deee697c6ced1a44eadff428439e13" + }, + { + "name": "low-order-0", + "peer": "0000000000000000000000000000000000000000000000000000000000000000", + "peer_rejected": true, + "raised": true + }, + { + "name": "low-order-1", + "peer": "0100000000000000000000000000000000000000000000000000000000000000", + "peer_rejected": true, + "raised": true + } + ], + "ed25519": [ + { + "name": "vector-seed-a", + "seed": "81712c4bef43282ffd12909479bece9da17d404bdbc629bec3a6fdf246f847fb", + "pub": "7ce749eb2966b9b747543236ef5f61b46328a82f92677b82470439db065154e0", + "message": "", + "signature": "b20543ac2e0ba66c1b437de2afda7ca8ca6f9feb2af3e3e7ac3183e388d1786c9c027bfe549639140d0e45e7e850999b3fb992c7c003946c1c5aaeb09892cc0c", + "valid": true + }, + { + "name": "vector-seed-a", + "seed": "81712c4bef43282ffd12909479bece9da17d404bdbc629bec3a6fdf246f847fb", + "pub": "7ce749eb2966b9b747543236ef5f61b46328a82f92677b82470439db065154e0", + "message": "736d6f6c6d61696c2f3120617574680000000000000000000000000000000000000000000000000000000000000000", + "signature": "f7e74a0540e05843b52efb7dee4f3622ab8a88333142f6dd1d5386612f7f0f1410bd0b1f1ff09dea9419922b756920a4c1fb31a95eac3cc55a410d0d6f1dab03", + "valid": true + }, + { + "name": "vector-seed-a", + "seed": "81712c4bef43282ffd12909479bece9da17d404bdbc629bec3a6fdf246f847fb", + "pub": "7ce749eb2966b9b747543236ef5f61b46328a82f92677b82470439db065154e0", + "message": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "signature": "e55b07b43bed9938bdf401d264226d22ea87f85867dc2f3a22edf129088e0a100dda4000c9c2075b946c12a645115911a041493bd3a6c6d6e3892233c7f3e209", + "valid": true + }, + { + "name": "vector-seed-a", + "seed": "81712c4bef43282ffd12909479bece9da17d404bdbc629bec3a6fdf246f847fb", + "pub": "7ce749eb2966b9b747543236ef5f61b46328a82f92677b82470439db065154e0", + "message": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "signature": "e45b07b43bed9938bdf401d264226d22ea87f85867dc2f3a22edf129088e0a100dda4000c9c2075b946c12a645115911a041493bd3a6c6d6e3892233c7f3e209", + "valid": false + }, + { + "name": "vector-seed-b", + "seed": "053a9de5a92ac01208edcd9f9d585ffa1462b60e895567d13a262ebbd5138b5d", + "pub": "7068fc61a59adcb58e856393df939349389af09095fffdfb7374d07be183201e", + "message": "", + "signature": "2dfb4b1e65dfd4bf991ef50be88acddf2d59fe158daf2879bec5641ab80b1e0c542b9ea2bd9a6bc76f2020b76b14dc80ae9f68c62ea58dbd8f5b1990ff069e08", + "valid": true + }, + { + "name": "vector-seed-b", + "seed": "053a9de5a92ac01208edcd9f9d585ffa1462b60e895567d13a262ebbd5138b5d", + "pub": "7068fc61a59adcb58e856393df939349389af09095fffdfb7374d07be183201e", + "message": "736d6f6c6d61696c2f3120617574680000000000000000000000000000000000000000000000000000000000000000", + "signature": "f6d398cd25fec0ba882af13b85c6addcc2f546181fba84f8925e6e196b759897337a2291f4b73c40a062b0a2283ef096ded790154af58e9d4bd39c32b3db2f05", + "valid": true + }, + { + "name": "vector-seed-b", + "seed": "053a9de5a92ac01208edcd9f9d585ffa1462b60e895567d13a262ebbd5138b5d", + "pub": "7068fc61a59adcb58e856393df939349389af09095fffdfb7374d07be183201e", + "message": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "signature": "24f8405160d7f8eca42d9bc5e703bc81e58082b34e5d0f28404e5a3518b500b46d48a9bd64c69f5dde38c978ae06c9ce5dfeabdfacd410753176ea11f171140e", + "valid": true + }, + { + "name": "vector-seed-b", + "seed": "053a9de5a92ac01208edcd9f9d585ffa1462b60e895567d13a262ebbd5138b5d", + "pub": "7068fc61a59adcb58e856393df939349389af09095fffdfb7374d07be183201e", + "message": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "signature": "25f8405160d7f8eca42d9bc5e703bc81e58082b34e5d0f28404e5a3518b500b46d48a9bd64c69f5dde38c978ae06c9ce5dfeabdfacd410753176ea11f171140e", + "valid": false + } + ], + "ed_to_x25519": [ + { + "name": "vector-seed-a", + "seed": "81712c4bef43282ffd12909479bece9da17d404bdbc629bec3a6fdf246f847fb", + "x_priv": "30dc8ba3a49892d4b8a626cd371fd43bff4e5651c2a45f1be16e89d84fa89e68", + "x_pub": "77bc3dae84c9b4085862725a21e0a366c09563d6da8f29c408ac2b6928937910" + }, + { + "name": "vector-seed-b", + "seed": "053a9de5a92ac01208edcd9f9d585ffa1462b60e895567d13a262ebbd5138b5d", + "x_priv": "b03deb6f9f4ab25d15471a355ff4ee23ea98f7d24695c500ed80b6af4b7b9963", + "x_pub": "253d4b5b14df341a5dde486ddd588e292444118ed8eed1fe0738823b82e4243d" + } + ], + "envelope": { + "sender_seed": "89c16df9e4352e706abe701928c230d8bd169cd31633bf4589c2d410cb3ae8cc", + "recipient_seed": "6f845ccc435252d39dd08e964d219258e92c3d6c54af0376fa45d5e55eec0aaf", + "esk": "c31fee505964c44b711cf354b431f9716c644a3dbf8c1d29c5e3cedf884d0a48", + "body": "2d2d2d0a5375626a6563743a20766563746f720a2d2d2d0a68656c6c6f20626f620a", + "time": 1730000000, + "envelope": "534d4f4c01e048814b56d9b82e54fd367d3c980661313cc6a3d81a80315561fc0ac87f81184e49921528d72321669ae1b275229800d8786c1ecdd7d9331bcb2cc64dc27c0399b106b5061e9105d8adf82f6b7464930fa31cb08ba85dba4764ce14cf3ddc32599f43fea73ced76ffa3a3b768e5a127034f5e82d667687369c19f060e8eafb09da0e212683290f4e1a73ce072b94f053c9088652109d3639f7b9aa0d48619626ecefe33855aade6ab8bf9de14ebb7cf07eec0ef9c193ae4537c370183e0c8f7cd7230471b9d8e7389ac654e1b09dc9b0160c875270fdad218f0c9da735bab", + "id": "b05d15a2ab5293164eda564de02a6901", + "unpadded_plaintext_len": 143 + }, + "noise": { + "initiator_eph_priv": "af5f15993914cfd4e308856810d483ab25a383bfb2d708a0e15f80275f1fe6c2", + "responder_eph_priv": "1c21927bb3e579efb69c937a2bf2e299ca1da9c83a62fb7f8ea1a375eb6f1c80", + "server_static_priv": "c7b206a746c9b167da412a6b1a235869e316e9f08dbbc8478430b2998130f79e", + "server_static_pub": "fa111eca8e853320f8e076674143fd4d1cd181bddeda7d9950a65922b9eafc32", + "message1": "b8b73e6f132dab5659270e6496d2c575ae7daf45a0961ae8e19405a12ed1cc2e", + "message2": "0b03ec78fd19cf7b8480881f33c6b4ca1094fac03c87860095c87c6737349c28256e498747bcf2018420d145c378e6605e63b217f92925ae5771dbe387b94cf9c995f7a3a8314fe2e671ca8fa1463e0642c1d7974f326737cadf630b8aac8ed9", + "handshake_hash": "a039471479680a596a8a61b8827afb01853274b03de2870095edb9b7dd4e2c72", + "assert_hash_equal": true, + "initiator_frames": [ + { + "plaintext": "70696e67", + "sealed": "f1885096d9b9383b0bc38b08dff77c005d69f0f0" + }, + { + "plaintext": "7365636f6e64206672616d6520746f20746573742074686520636f756e746572", + "sealed": "4a6481a2f7d0c909d9b321bc097e09a1929aeaf8647751f64d65b5e1f92abe960796dbf1c619bef48845aea257f2c73c" + } + ], + "responder_frames": [ + { + "plaintext": "706f6e67", + "sealed": "a7dda7fe3ef32435b3e72fda49714e9977318f0b" + }, + { + "plaintext": "787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878", + "sealed": "9aa3af13c00a8a0cd81167b48a5443541e0c862297638e4b7e5c71196657bc565aad46c2d147b23f752ac48c29b69699209e252d3e840c13fc466dd0445515dec2d289902c7177a237f9256afe9508a45b64ea12cdb597a8c38b6ce0c43891ec772c21ca360980094268686642eae8e01ccf89650a427297dbab052aabfeb08adbb2087ae303dd168ceb3beffa63c8d3ccd1c5b2687c2a9c0d43eaab237fde648bf8b0f347b4952ff6da2212360b4a2a5b1316e9e776d82961c498e51f35a58c999f080ac1c12d7ac08f6ddd7addb70c37ea6bef42ed2c498362f4fd75a222068035a7f372c4f57e613427193ffd8ed40a2d0765ba62cbfd6cb5103165dc15be7ad2db723a4edc73cefe9f7fe5ff78a8ea06ecbc7c5135e5d810a1bd4e47f0cd4a1b6e8dd88b85236dd4b41296e00b7054139ee4fd4faa5876e01d62" + } + ] + } +} diff --git a/test/widget_test.dart b/test/widget_test.dart index a350d30..640041c 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,19 +1,35 @@ -import "package:flutter/material.dart"; -import "package:flutter_test/flutter_test.dart"; +import "dart:io"; -import "package:flash_mail/presentation/app_widget.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:hive_flutter/hive_flutter.dart"; + +import "package:smol_mail/data/providers/providers.dart"; +import "package:smol_mail/presentation/app_widget.dart"; +import "package:smol_mail/smol/store.dart"; + +late final SmolStore store; void main() { - testWidgets("Counter increments smoke test", (WidgetTester tester) async { - await tester.pumpWidget(const AppWidget()); + // 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(); + final dir = await Directory.systemTemp.createTemp("smol-widget-test"); + Hive.init(dir.path); + store = await SmolStore.open(); + }); - expect(find.text("0"), findsOneWidget); - expect(find.text("1"), findsNothing); - - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - expect(find.text("0"), findsNothing); - expect(find.text("1"), findsOneWidget); + testWidgets("onboarding invites to create or restore an identity", + (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [storeProvider.overrideWithValue(store)], + child: const AppWidget(), + ), + ); + await tester.pumpAndSettle(); + expect(find.text("Create Identity"), findsOneWidget); + expect(find.text("Restore From Seed"), findsOneWidget); }); } From 0ba82370f1aab4c71819424fc9678dd426749da2 Mon Sep 17 00:00:00 2001 From: randogoth Date: Sat, 26 Sep 2026 22:51:29 +0300 Subject: [PATCH 02/22] docs: rewrite README as a brief overview crediting FlashMail origin --- README.md | 60 ++++++++++++++++--------------------------------------- 1 file changed, 17 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 32be7ae..92549c2 100644 --- a/README.md +++ b/README.md @@ -1,67 +1,41 @@ # kirakira -A mobile client for [Smol Mail](../smolmail), the minimalist end-to-end encrypted mail protocol: one Ed25519 identity, five operations, Noise_NX transport, sealed and signed messages. The sibling of the reference CLI client (`../smolmail`) and the browser client (`../gsmol`) — everything they do, in an Android app. +A mobile client for [Smol Mail](https://smol.place), a minimalist end-to-end encrypted mail protocol: one Ed25519 identity, five operations, sealed and signed messages over a Noise_NX transport. Sibling of the reference CLI client (`https://smol.place`) and the browser client (`https://code.randogoth.com/randogoth/gsmol`). -## How it works +kirakira began as [FlashMail](https://github.com/sarthakkimtani/flash-mail), a Flutter UI template for a disposable-email app built on mail.tm. Its networking layer was replaced end to end with a from-scratch Smol Mail implementation (`lib/smol/`: crypto, Noise handshake, framing, client flows), and the UI was redesigned around a Material 3 theme — dark navy and mint green by default, with a matching light theme — built from a single `ColorScheme` and `AppColors` extension rather than hardcoded colors per screen. -``` -app (all crypto, all keys) ⇄ smolmaild (TCP :1961, Noise_NX) -``` +## What it does -There is no bridge and no server-side account: the Noise session, sealing, signing, pinning and trust-on-first-use all run on the device. The seed lives in Hive app storage — the phone's app sandbox is the trust boundary, as with the CLI client's `identity.key` file. - -## First use - -1. Create an identity (or restore from a seed) and back the seed up — it is the only secret. -2. Pin your home server's public key, obtained from the operator through a trusted channel (SPEC.md §4: registration and fetching refuse unpinned servers). -3. Register an address, then fetch. Restoring a seed on a new device? Enter the address alongside the seed — or "Recall" from settings — and it rebinds by RESOLVE + key check, without re-registering. - -## What it implements - -- **Identity** (§2): one 32-byte seed; the X25519 agreement keys are derived from the Ed25519 keypair. Superseded seeds are kept after rotation, since mail sealed to them is readable with nothing else. -- **Addressing** (§3): short `user@host[:1961]` and self-certifying `smol://user@host/key` addresses; base32 fingerprints. -- **Trust** (§4, §8): server keys pinned explicitly; mismatch aborts the handshake; registration and fetching require a pin. -- **Mail** (§5): sealed and signed envelopes with 1 KiB padding, flat frontmatter bodies, sent copies sealed to self. Fetch verifies id and signature before acknowledging — anything unreadable stays on the server. -- **Contacts**: outgoing mail carries a signed `Reply-To` field with the sender's full `smol://` address (an "anonymous" switch omits it); a first-contact claim binds only when its key matches the message's signer, and never over an address already pinned to a different key. The contacts screen shows each bound key with its trust badge and the keys it displaced — the only local record that a contact rotated — and a re-resolve applies §8: a chain-validated rotation is accepted and announced, an unexplained key change is refused until verified out of band. Trust warnings (unpinned sessions, rotations) persist on screen until dismissed, as the spec's §4/§8 messages demand. -- **Rotation** (§7): rotate with a signed certificate; contacts accept the change from the chain. -- **Backup**: settings can export the inbox, sent mail, contacts and server pins to one shareable file (and import it back) — the same JSON shape as gsmol's export, so backups move between the two clients. It never contains the seed, which has its own reveal-and-copy flow. Import never overwrites a pin or contact that already differs locally; malformed entries are skipped and counted. +- Create or restore an identity from a 32-byte seed — that's the only secret. +- 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. ## Layout ``` -lib/smol/crypto.dart SHA-2, HKDF, ChaCha20-Poly1305, X25519, Ed25519, §2 conversions -lib/smol/noise.dart Noise_NX_25519_ChaChaPoly_SHA256 initiator -lib/smol/proto.dart addresses, seal/unseal, frontmatter, rotation, op framing -lib/smol/transport.dart TCP byte pipe (dart:io) -lib/smol/store.dart Hive: identity, pins, contacts, sealed mail, export/import -lib/smol/config.dart deploy-time preset server pin (--dart-define) -lib/smol/client.dart connect/fetch/send/register/rotate flows -lib/presentation/ the UI (Riverpod + auto_route) -test/smol_test.dart byte-exact vectors + protocol cases -test/store_test.dart contact key history, import -test/widget_test.dart UI smoke test -test/e2e_test.dart live round-trip against smolmaild +lib/smol/ the protocol: crypto, Noise handshake, framing, client flows +lib/presentation/ screens, widgets and theme (Riverpod + auto_route) +lib/data/ Riverpod providers +test/ protocol vectors, store tests, a live e2e round-trip ``` -The crypto is pure Dart, mirroring gsmol's dependency-free modules, so `test/vectors.json` — generated from the reference stack (PyNaCl, noiseprotocol) by `../gsmol/test/gen_vectors.py` — pins every operation byte for byte. - ## Run and verify ``` devbox run analyze devbox run test +flutter run ``` -Then `flutter run` with a device or emulator attached. `test/e2e_test.dart` additionally runs a live round-trip (register, send to self, fetch, unseal, drain) against `../smolmail/smolmaild.py` on `127.0.0.1:1961`, and self-skips when no server is listening. - -Not verified here: clicking through the app on a real device — the logic under every button is what the tests exercise, as with gsmol. +`test/e2e_test.dart` runs a live round-trip against `https://smol.place/smolmaild.py` on `127.0.0.1:1961` and skips itself when nothing's listening there. ## Notes and limits -- No server push or notifications in v1 (SPEC.md §13): tap fetch. -- The seed sits in app storage: a compromised device is game over, same as a stolen `identity.key` file for the CLI client. -- Rotation is not revocation (§7): a stolen key can rotate onward and the chain validates. The settings screen surfaces rotations instead of applying them invisibly; out-of-band re-verification is the only defence. +- No server push in v1 — fetch is manual. +- The seed lives in app storage; a compromised device is a compromised identity. +- Rotation isn't revocation — a stolen key can still rotate onward. Settings surfaces rotations for out-of-band verification rather than applying them silently. ## License -Distributed under the MIT License; see LICENSE.md. +MIT — see LICENSE.md. From 854f9ab31d1ed699b68ad842f040e8d6ebb4320c Mon Sep 17 00:00:00 2001 From: randogoth Date: Sat, 26 Sep 2026 22:51:35 +0300 Subject: [PATCH 03/22] docs: add copyright line for the smolmail-client derivative work --- LICENSE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/LICENSE.md b/LICENSE.md index 43ec0c7..76dce47 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,7 @@ MIT License Copyright (c) 2024 Sarthak Kimtani +Copyright (c) 2026 randogoth Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal From f57336b7ea81e6460638be3e4c17d5307c48e7a2 Mon Sep 17 00:00:00 2001 From: randogoth Date: Sun, 27 Sep 2026 00:09:11 +0300 Subject: [PATCH 04/22] feat: add Linux desktop build support --- .metadata | 29 +--- devbox.json | 10 +- devbox.lock | 24 +++ linux/.gitignore | 1 + linux/CMakeLists.txt | 128 ++++++++++++++++ linux/flutter/CMakeLists.txt | 88 +++++++++++ linux/flutter/generated_plugin_registrant.cc | 15 ++ linux/flutter/generated_plugin_registrant.h | 15 ++ linux/flutter/generated_plugins.cmake | 25 ++++ linux/runner/CMakeLists.txt | 26 ++++ linux/runner/main.cc | 6 + linux/runner/my_application.cc | 148 +++++++++++++++++++ linux/runner/my_application.h | 21 +++ 13 files changed, 513 insertions(+), 23 deletions(-) create mode 100644 linux/.gitignore create mode 100644 linux/CMakeLists.txt create mode 100644 linux/flutter/CMakeLists.txt create mode 100644 linux/flutter/generated_plugin_registrant.cc create mode 100644 linux/flutter/generated_plugin_registrant.h create mode 100644 linux/flutter/generated_plugins.cmake create mode 100644 linux/runner/CMakeLists.txt create mode 100644 linux/runner/main.cc create mode 100644 linux/runner/my_application.cc create mode 100644 linux/runner/my_application.h diff --git a/.metadata b/.metadata index 2e239db..2215ee3 100644 --- a/.metadata +++ b/.metadata @@ -1,11 +1,11 @@ # This file tracks properties of this Flutter project. # Used by Flutter tool to assess capabilities and perform upgrades etc. # -# This file should be version controlled. +# This file should be version controlled and should not be manually edited. version: - revision: d3d8effc686d73e0114d71abdcccef63fa1f25d2 - channel: stable + revision: "nixpkgs000000000000000000000000000000000" + channel: "stable" project_type: app @@ -13,26 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: d3d8effc686d73e0114d71abdcccef63fa1f25d2 - base_revision: d3d8effc686d73e0114d71abdcccef63fa1f25d2 - - platform: android - create_revision: d3d8effc686d73e0114d71abdcccef63fa1f25d2 - base_revision: d3d8effc686d73e0114d71abdcccef63fa1f25d2 - - platform: ios - create_revision: d3d8effc686d73e0114d71abdcccef63fa1f25d2 - base_revision: d3d8effc686d73e0114d71abdcccef63fa1f25d2 + create_revision: nixpkgs000000000000000000000000000000000 + base_revision: nixpkgs000000000000000000000000000000000 - platform: linux - create_revision: d3d8effc686d73e0114d71abdcccef63fa1f25d2 - base_revision: d3d8effc686d73e0114d71abdcccef63fa1f25d2 - - platform: macos - create_revision: d3d8effc686d73e0114d71abdcccef63fa1f25d2 - base_revision: d3d8effc686d73e0114d71abdcccef63fa1f25d2 - - platform: web - create_revision: d3d8effc686d73e0114d71abdcccef63fa1f25d2 - base_revision: d3d8effc686d73e0114d71abdcccef63fa1f25d2 - - platform: windows - create_revision: d3d8effc686d73e0114d71abdcccef63fa1f25d2 - base_revision: d3d8effc686d73e0114d71abdcccef63fa1f25d2 + create_revision: nixpkgs000000000000000000000000000000000 + base_revision: nixpkgs000000000000000000000000000000000 # User provided section diff --git a/devbox.json b/devbox.json index 7eb26b3..2c74757 100644 --- a/devbox.json +++ b/devbox.json @@ -4,7 +4,13 @@ "flutter@3.41.9-sdk-links", "nixpkgs#android-tools", "nixpkgs#openjdk17", - "path:./android/nix-android-sdk#android-sdk" + "path:./android/nix-android-sdk#android-sdk", + "nixpkgs#clang", + "nixpkgs#cmake", + "nixpkgs#ninja", + "nixpkgs#pkg-config", + "nixpkgs#gtk3", + "nixpkgs#libsysprof-capture" ], "shell": { "init_hook": [ @@ -14,6 +20,8 @@ "scripts": { "analyze": "flutter analyze", "test": "flutter test", + "run-linux": "flutter run -d linux", + "build-linux": "flutter build linux", "link-android-tools": "if [ -n \"$ANDROID_HOME\" ] && [ ! -d \"$ANDROID_HOME/cmdline-tools/latest\" ] && [ -d \"$ANDROID_HOME/cmdline-tools/8.0\" ]; then ln -s \"$ANDROID_HOME/cmdline-tools/8.0\" \"$ANDROID_HOME/cmdline-tools/latest\"; fi" } }, diff --git a/devbox.lock b/devbox.lock index d61a7b7..6ffe55e 100644 --- a/devbox.lock +++ b/devbox.lock @@ -57,9 +57,33 @@ "last_modified": "1970-01-01T00:00:00Z", "resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#android-tools" }, + "nixpkgs#clang": { + "last_modified": "1970-01-01T00:00:00Z", + "resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#clang" + }, + "nixpkgs#cmake": { + "last_modified": "1970-01-01T00:00:00Z", + "resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#cmake" + }, + "nixpkgs#gtk3": { + "last_modified": "1970-01-01T00:00:00Z", + "resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#gtk3" + }, + "nixpkgs#libsysprof-capture": { + "last_modified": "1970-01-01T00:00:00Z", + "resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#libsysprof-capture" + }, + "nixpkgs#ninja": { + "last_modified": "1970-01-01T00:00:00Z", + "resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#ninja" + }, "nixpkgs#openjdk17": { "last_modified": "1970-01-01T00:00:00Z", "resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#openjdk17" + }, + "nixpkgs#pkg-config": { + "last_modified": "1970-01-01T00:00:00Z", + "resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#pkg-config" } } } diff --git a/linux/.gitignore b/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt new file mode 100644 index 0000000..c1d75e3 --- /dev/null +++ b/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "smol_mail") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.app.smol_mail") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/linux/flutter/CMakeLists.txt b/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..f6f23bf --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); +} diff --git a/linux/flutter/generated_plugin_registrant.h b/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..df8d2f7 --- /dev/null +++ b/linux/flutter/generated_plugins.cmake @@ -0,0 +1,25 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_linux +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/linux/runner/main.cc b/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc new file mode 100644 index 0000000..1f37d8f --- /dev/null +++ b/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "kirakira"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "kirakira"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/linux/runner/my_application.h b/linux/runner/my_application.h new file mode 100644 index 0000000..db16367 --- /dev/null +++ b/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ From f80315e7c4e3af3cc690e8256195aea5b3d333ca Mon Sep 17 00:00:00 2001 From: randogoth Date: Sun, 27 Sep 2026 00:09:18 +0300 Subject: [PATCH 05/22] fix: store Hive data in the app support directory, not ~/Documents --- lib/main.dart | 8 +++++++- pubspec.lock | 2 +- pubspec.yaml | 1 + 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 73e138b..f01ccdb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,7 @@ import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:hive_flutter/hive_flutter.dart"; +import "package:path_provider/path_provider.dart"; import "package:smol_mail/data/providers/providers.dart"; import "package:smol_mail/smol/config.dart"; @@ -9,7 +10,12 @@ import "package:smol_mail/presentation/app_widget.dart"; void main() async { WidgetsFlutterBinding.ensureInitialized(); - await Hive.initFlutter(); + // Hive.initFlutter() defaults to getApplicationDocumentsDirectory(), which + // on Linux resolves to the user's actual ~/Documents folder (XDG_DOCUMENTS_DIR) + // rather than app-private storage — getApplicationSupportDirectory() is the + // one that's actually sandboxed per-app on every platform. + final dataDir = await getApplicationSupportDirectory(); + Hive.init(dataDir.path); final store = await SmolStore.open(); applyPresetServer(store); diff --git a/pubspec.lock b/pubspec.lock index 9496b01..a96a269 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -625,7 +625,7 @@ packages: source: hosted version: "1.9.1" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 diff --git a/pubspec.yaml b/pubspec.yaml index 573249b..378c082 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,6 +18,7 @@ dependencies: flutter_riverpod: ^3.3.2 intl: ^0.20.3 share_plus: ^13.3.0 + path_provider: ^2.1.6 file_picker: ^13.1.0 dev_dependencies: From 7d3c8b423c15870c53641847877103bc0c626903 Mon Sep 17 00:00:00 2001 From: randogoth Date: Sun, 27 Sep 2026 00:09:29 +0300 Subject: [PATCH 06/22] feat: match gsmol's v2 encrypted export format; fix export on desktop --- lib/presentation/screens/settings_screen.dart | 41 ++++++++---- lib/smol/store.dart | 65 +++++++++++++++---- test/store_test.dart | 26 +++++++- 3 files changed, 104 insertions(+), 28 deletions(-) diff --git a/lib/presentation/screens/settings_screen.dart b/lib/presentation/screens/settings_screen.dart index 0d01b8c..4dc1642 100644 --- a/lib/presentation/screens/settings_screen.dart +++ b/lib/presentation/screens/settings_screen.dart @@ -68,19 +68,36 @@ class _SettingsScreenState extends ConsumerState { // it is picked up (SPEC.md §5). This backs up inbox, sent mail, contacts and // pinned servers into one shareable file; never the seed. Future _export() async { - final data = ref.read(storeProvider).exportData(); + final store = ref.read(storeProvider); + final data = store.exportData(); final stamp = DateTime.now().toIso8601String().substring(0, 10); - final file = File("${Directory.systemTemp.path}/smolmail-export-$stamp.json"); - await file.writeAsString(jsonEncode(data)); - await SharePlus.instance.share(ShareParams( - files: [XFile(file.path)], text: "kirakira backup $stamp")); - if (mounted) { - final messages = (data["inbox"] as List).length + (data["sent"] as List).length; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text("exported $messages messages, " - "${(data["contacts"] as Map).length} contacts, " - "${(data["servers"] as Map).length} server keys")), - ); + final fileName = "kirakira-backup-$stamp.json"; + final bytes = Uint8List.fromList(utf8.encode(jsonEncode(data))); + try { + // share_plus has no file-sharing implementation on desktop platforms — + // it throws UnimplementedError for Linux/Windows/macOS — so those save + // straight to a chosen location instead of going through a share sheet. + if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) { + await FilePicker.saveFile(fileName: fileName, bytes: bytes); + } else { + final file = File("${Directory.systemTemp.path}/$fileName"); + await file.writeAsBytes(bytes); + await SharePlus.instance.share(ShareParams( + files: [XFile(file.path)], text: "kirakira backup $stamp")); + } + if (mounted) { + // The export payload is sealed now (v2), so the counts for this + // notice come straight from the store rather than the ciphertext. + final messages = + store.listMessages("inbox").length + store.listMessages("sent").length; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("exported $messages messages, " + "${store.allContacts().length} contacts, " + "${store.allPins().length} server keys")), + ); + } + } catch (err) { + if (mounted) showErrorSnackBar(context, err.toString()); } } diff --git a/lib/smol/store.dart b/lib/smol/store.dart index 788e520..3813aef 100644 --- a/lib/smol/store.dart +++ b/lib/smol/store.dart @@ -301,15 +301,23 @@ class SmolStore { // --- export / import: mail, contacts, pins — never the seed -------------------- - /// The marker matches gsmol's web export, so backups move between the two - /// clients. Deliberately excludes the seed: it has its own reveal-and-copy - /// flow in settings, meant for a password manager, not a shareable file. + /// Label kept as gsmol wrote it originally; the export format version + /// (gsmolExport) is what actually changed between v1 and v2. + static final _exportLabel = utf8Bytes("gsmol/1 export"); + Uint8List _exportKey(Uint8List seed) => + hkdfSha256(seed, Uint8List(0), _exportLabel, 32); + + /// v2 matches gsmol's own current export: the whole payload — mail, + /// contacts, pins — is sealed to a key derived from the identity's seed, so + /// a backup file is only readable by whoever holds that seed. Deliberately + /// excludes the seed itself: it has its own reveal-and-copy flow in + /// settings, meant for a password manager, not a shareable file. Map exportData() { + final seed = this.seed(); + if (seed == null) throw const SmolError("no identity yet"); final state = _load(); final contacts = ((state["contacts"] as Map?) ?? {}).cast(); - return { - "gsmolExport": 1, - "exportedAt": DateTime.now().millisecondsSinceEpoch, + final payload = { "servers": ((state["servers"] as Map?) ?? {}).cast(), "contacts": { for (final entry in contacts.entries) @@ -338,6 +346,15 @@ class SmolStore { } ], }; + final nonce = randomBytes(12); + final ciphertext = aeadEncrypt( + _exportKey(seed), nonce, utf8Bytes(jsonEncode(payload)), Uint8List(0)); + return { + "gsmolExport": 2, + "exportedAt": DateTime.now().millisecondsSinceEpoch, + "nonce": base64Encode(nonce), + "ciphertext": base64Encode(ciphertext), + }; } /// Never overwrites a trust binding that already differs locally — the same @@ -345,15 +362,35 @@ class SmolStore { /// entry is skipped and counted, not fatal: one bad record cannot abort the /// rest of the import. Future importData(Map data) async { - if (data["gsmolExport"] != 1) { - throw const SmolError("not a SmolMail export file"); + Map payload; + if (data["gsmolExport"] == 2) { + final seed = this.seed(); + if (seed == null) { + throw const SmolError("no identity yet — restore it before importing"); + } + try { + final plaintext = aeadDecrypt( + _exportKey(seed), + base64Decode(data["nonce"] as String), + base64Decode(data["ciphertext"] as String), + Uint8List(0), + ); + payload = jsonDecode(utf8.decode(plaintext)) as Map; + } catch (_) { + throw const SmolError("couldn't decrypt — exported by a different " + "identity, or the file is corrupted"); + } + } else if (data["gsmolExport"] == 1) { + payload = data; // pre-encryption shape: fields already sit at the top level + } else { + throw const SmolError("not a gsmol export file"); } final summary = ImportSummary(); _update((state) { final servers = (state["servers"] as Map? ?? {}).cast(); - final incomingPins = data["servers"] is Map ? data["servers"] as Map : null; - if (data["servers"] != null && incomingPins == null) summary.malformed++; + final incomingPins = payload["servers"] is Map ? payload["servers"] as Map : null; + if (payload["servers"] != null && incomingPins == null) summary.malformed++; for (final entry in (incomingPins ?? const {}).entries) { final host = entry.key, key = entry.value; if (host is! String || key is! String || _pinKeyOk(key) != true) { @@ -370,8 +407,8 @@ class SmolStore { state["servers"] = servers; final contacts = (state["contacts"] as Map? ?? {}).cast(); - final incoming = data["contacts"] is Map ? data["contacts"] as Map : null; - if (data["contacts"] != null && incoming == null) summary.malformed++; + final incoming = payload["contacts"] is Map ? payload["contacts"] as Map : null; + if (payload["contacts"] != null && incoming == null) summary.malformed++; for (final entry in (incoming ?? const {}).entries) { final address = entry.key, contact = entry.value; if (address is! String || @@ -399,8 +436,8 @@ class SmolStore { }); for (final folder in ["inbox", "sent"]) { - final rows = data[folder] is List ? data[folder] as List : null; - if (data[folder] != null && rows == null) summary.malformed++; + final rows = payload[folder] is List ? payload[folder] as List : null; + if (payload[folder] != null && rows == null) summary.malformed++; for (final row in rows ?? const []) { try { final map = row as Map; diff --git a/test/store_test.dart b/test/store_test.dart index 74ee0b7..bf8ea19 100644 --- a/test/store_test.dart +++ b/test/store_test.dart @@ -77,9 +77,12 @@ void main() { MailRecord("bb", randomBytes(64), recipient: "bob@example.org", sentAt: 6)); final data = a.exportData(); - expect(data["gsmolExport"], 1); // gsmol-compatible marker + expect(data["gsmolExport"], 2); // sealed to the identity's seed, like gsmol expect(jsonEncode(data).contains(hex(a.seed()!)), isFalse); + // v2 is sealed to the exporting identity's seed — a restore-on-new-device + // scenario, not a transfer to someone else's identity (see the test below). + b.setIdentity(a.seed()!); final summary = await b.importData(data); expect(summary.mailAdded, 2); expect(summary.pinsAdded, 1); @@ -88,7 +91,26 @@ void main() { expect(b.contact("alice@example.org")!.history.length, 1); expect(b.getMessage("inbox", "aa"), isNotNull); expect(b.getMessage("sent", "bb"), isNotNull); - expect(b.seed(), isNull); // never the seed + }); + + test("v2 import refuses a different identity's export", () async { + final a = await freshStore("export-wrong-identity"); + final c = await freshStore("round-trip-wrong-identity"); + a.setIdentity(randomBytes(32)); + a.pinServer("example.org", randomBytes(32)); + final data = a.exportData(); + + c.setIdentity(randomBytes(32)); // a different seed than a's + expect(() => c.importData(data), throwsA(isA())); + }); + + test("v1 legacy export still imports without an identity", () async { + final store = await freshStore("import-legacy-v1"); + final summary = await store.importData({ + "gsmolExport": 1, + "servers": {"example.org": b32encode(randomBytes(32))}, + }); + expect(summary.pinsAdded, 1); }); test("import never overwrites a differing trust binding", () async { From eaaa3f2edef415d02309035f72b7c29f73466c6a Mon Sep 17 00:00:00 2001 From: randogoth Date: Sun, 27 Sep 2026 00:09:38 +0300 Subject: [PATCH 07/22] fix: onboarding restore no longer traps on an abandoned identity or a missing pin --- .../screens/onboarding_screen.dart | 120 ++++++++++++++---- test/dbg_test.dart | 61 --------- test/recall_flow_test.dart | 74 ++++++++++- 3 files changed, 164 insertions(+), 91 deletions(-) delete mode 100644 test/dbg_test.dart diff --git a/lib/presentation/screens/onboarding_screen.dart b/lib/presentation/screens/onboarding_screen.dart index b6b0b3c..5e9498d 100644 --- a/lib/presentation/screens/onboarding_screen.dart +++ b/lib/presentation/screens/onboarding_screen.dart @@ -31,6 +31,10 @@ class _OnboardingScreenState extends ConsumerState { _Step step = _Step.welcome; Uint8List? createdSeed; bool busy = false; + // The register step doubles as "pin a key to finish recalling" when a + // restore's recall can't proceed without one yet — same fields, different + // framing and default action, not the generic "register a new address" copy. + bool recallIntent = false; final seedController = TextEditingController(); final restoreAddressController = TextEditingController(); @@ -57,13 +61,33 @@ class _OnboardingScreenState extends ConsumerState { context, error is SmolError ? error.message : error.toString()); } - void _createIdentity() { + // Reaching onboarding at all means HomeGuard already found no complete + // identity+account, so a seed still sitting in the store here can only be + // an abandoned attempt from earlier in this same flow (wrong seed, failed + // recall, "Back") — safe to replace rather than reject. + // wipe() is fire-and-forget here, like every other store write in this + // screen (setIdentity, pinServer, ...) — Hive updates its in-memory state + // synchronously and persists to disk in the background, so the identity + // check right after is already consistent without awaiting the write. + void _clearAbandonedIdentity() { final client = ref.read(clientProvider); - final fresh = client.createIdentity(); - setState(() { - createdSeed = fresh.seed; - step = _Step.backup; - }); + if (client.identity != null && client.accountAddress() == null) { + ref.read(storeProvider).wipe(); + } + } + + void _createIdentity() { + _clearAbandonedIdentity(); + final client = ref.read(clientProvider); + try { + final fresh = client.createIdentity(); + setState(() { + createdSeed = fresh.seed; + step = _Step.backup; + }); + } on Exception catch (err) { + _showError(err); + } } void _restoreIdentity() { @@ -74,6 +98,7 @@ class _OnboardingScreenState extends ConsumerState { // pinned yet, offline, typo) — recall can always be retried from the // register step or settings afterward. void _submitRestore() { + _clearAbandonedIdentity(); final client = ref.read(clientProvider); try { client.restoreIdentity(seedController.text); @@ -86,6 +111,34 @@ class _OnboardingScreenState extends ConsumerState { setState(() => step = _Step.register); return; } + // The register step has its own address field (it also needs a server + // key, which restore doesn't collect) — carry over what was already + // typed rather than making the user re-enter it. + addressController.text = addressText; + final SmolAddress addr; + try { + addr = parseAddress(addressText); + } on Exception catch (err) { + // A genuine typo, distinct from the merely-unpinned case below — still + // worth an error, but land on the same recall-oriented step to fix it. + _showError(err); + setState(() { + recallIntent = true; + step = _Step.register; + }); + return; + } + // Recall needs the host pinned first (SPEC.md §4). That's the expected, + // common state right after a restore — not an error — so check for it + // up front instead of letting recallAccount fail and surfacing that as + // one: this address just needs a key before its first recall can proceed. + if (ref.read(storeProvider).serverPin(addr.host) == null) { + setState(() { + recallIntent = true; + step = _Step.register; + }); + return; + } () async { try { await client.recallAccount(addressText); @@ -95,7 +148,10 @@ class _OnboardingScreenState extends ConsumerState { } on Exception catch (err) { if (!mounted) return; _showError(err); - setState(() => step = _Step.register); + setState(() { + recallIntent = true; + step = _Step.register; + }); } }(); } @@ -142,7 +198,7 @@ class _OnboardingScreenState extends ConsumerState { @override Widget build(BuildContext context) { return Scaffold( - body: Padding( + body: SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 25), child: switch (step) { _Step.welcome => _welcome(context), @@ -176,7 +232,7 @@ class _OnboardingScreenState extends ConsumerState { children: [ _header(context, "One keypair is the whole identity: an address, a key and a message."), - const Spacer(), + const SizedBox(height: 40), PrimaryButton( onPressed: _createIdentity, child: const Text("Create Identity"), @@ -222,7 +278,7 @@ class _OnboardingScreenState extends ConsumerState { }, child: const Text("Copy seed"), ), - const Spacer(), + const SizedBox(height: 40), PrimaryButton( onPressed: () => setState(() => step = _Step.register), child: const Text("I have backed it up"), @@ -266,7 +322,7 @@ class _OnboardingScreenState extends ConsumerState { text: "Back", onPressed: () => setState(() => step = _Step.welcome), ), - const Spacer(), + const SizedBox(height: 40), ], ); } @@ -275,8 +331,12 @@ class _OnboardingScreenState extends ConsumerState { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _header(context, - "Register an address. Pin the server's public key first — obtain it from the operator through a trusted channel."), + _header( + context, + recallIntent + ? "Pin this server's public key to finish restoring your address." + : "Register an address. Pin the server's public key first — obtain it from the operator through a trusted channel.", + ), const SizedBox(height: 30), TextField( controller: addressController, @@ -297,25 +357,33 @@ class _OnboardingScreenState extends ConsumerState { fillColor: Theme.of(context).extension()!.cardFill, ), ), - const SizedBox(height: 15), - TextField( - controller: tokenController, - decoration: InputDecoration( - labelText: "Invite token (optional)", - filled: true, - fillColor: Theme.of(context).extension()!.cardFill, + if (!recallIntent) ...[ + const SizedBox(height: 15), + TextField( + controller: tokenController, + decoration: InputDecoration( + labelText: "Invite token (optional)", + filled: true, + fillColor: Theme.of(context).extension()!.cardFill, + ), ), - ), - const Spacer(), + ], + const SizedBox(height: 40), PrimaryButton( - onPressed: busy ? () {} : _submitRegistration, + onPressed: busy + ? () {} + : (recallIntent ? _submitRecall : _submitRegistration), child: busy ? const SmallLoadingSpinner() - : const Text("Pin and Register"), + : Text(recallIntent ? "Pin and Recall" : "Pin and Register"), ), TextButton( - onPressed: busy ? () {} : _submitRecall, - child: const Text("Already registered? Recall"), + onPressed: busy + ? () {} + : (recallIntent ? _submitRegistration : _submitRecall), + child: Text(recallIntent + ? "Register a new address instead" + : "Already registered? Recall"), ), const SizedBox(height: 80), ], diff --git a/test/dbg_test.dart b/test/dbg_test.dart deleted file mode 100644 index 9df2990..0000000 --- a/test/dbg_test.dart +++ /dev/null @@ -1,61 +0,0 @@ -import "dart:io"; - -import "package:auto_route/auto_route.dart"; -import "package:flutter/material.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:flutter_test/flutter_test.dart"; -import "package:hive_flutter/hive_flutter.dart"; - -import "package:smol_mail/data/providers/providers.dart"; -import "package:smol_mail/presentation/app_widget.dart"; -import "package:smol_mail/presentation/routes/app_router.gr.dart"; -import "package:smol_mail/smol/client.dart"; -import "package:smol_mail/smol/crypto.dart"; -import "package:smol_mail/smol/proto.dart"; -import "package:smol_mail/smol/store.dart"; - -class StubClient extends SmolClient { - StubClient(super.store); - - @override - Future recallAccount(String addressText) async { - final addr = parseAddress(addressText); - store.setAccount(addr); - return addr; - } -} - -void main() { - late SmolStore store; - setUpAll(() async { - TestWidgetsFlutterBinding.ensureInitialized(); - final dir = await Directory.systemTemp.createTemp("dbg4"); - Hive.init(dir.path); - store = await SmolStore.open(stateBox: "dbg-state", mailBox: "dbg-mail"); - }); - - testWidgets("direct replace", (tester) async { - final seed = randomBytes(32); - await tester.pumpWidget(ProviderScope( - overrides: [ - storeProvider.overrideWithValue(store), - clientProvider.overrideWithValue(StubClient(store)), - ], - child: const AppWidget(), - )); - await tester.pumpAndSettle(); - await tester.tap(find.text("Restore From Seed")); - await tester.pumpAndSettle(); - final fields = find.byType(TextField); - await tester.enterText(fields.at(0), hex(seed)); - await tester.enterText(fields.at(1), "randogoth@smol.place"); - await tester.tap(find.text("Restore")); - await tester.pumpAndSettle(); - expect(store.account(), isNotNull, reason: "recall stub ran"); - final element = tester.element(find.text("Back")); - final router = AutoRouter.of(element); - await router.replace(InboxRoute()); - await tester.pumpAndSettle(); - expect(find.text("Inbox"), findsOneWidget); - }); -} diff --git a/test/recall_flow_test.dart b/test/recall_flow_test.dart index 144d861..7f48832 100644 --- a/test/recall_flow_test.dart +++ b/test/recall_flow_test.dart @@ -33,7 +33,7 @@ class StubClient extends SmolClient { void main() { // Hive box opening is real file IO and must happen outside testWidgets' // fake-async zone — including the per-test stores. - late final SmolStore storeA, storeB; + late final SmolStore storeA, storeB, storeC; setUpAll(() async { TestWidgetsFlutterBinding.ensureInitialized(); final dir = await Directory.systemTemp.createTemp("smol-recall-flow"); @@ -42,6 +42,8 @@ void main() { stateBox: "recall-a-state", mailBox: "recall-a-mail"); storeB = await SmolStore.open( stateBox: "recall-b-state", mailBox: "recall-b-mail"); + storeC = await SmolStore.open( + stateBox: "recall-c-state", mailBox: "recall-c-mail"); }); Widget app(SmolStore store) => ProviderScope( @@ -52,8 +54,9 @@ void main() { child: const AppWidget(), ); - testWidgets("restore with an address recalls and lands in the inbox", - (tester) async { + testWidgets( + "restore with an address, but no pin yet, asks for the server key " + "and then recalls into the inbox", (tester) async { final store = storeA; final seed = randomBytes(32); await tester.pumpWidget(app(store)); @@ -63,13 +66,26 @@ void main() { await tester.tap(find.text("Restore From Seed")); await tester.pumpAndSettle(); - final fields = find.byType(TextField); + var fields = find.byType(TextField); expect(fields, findsNWidgets(2)); await tester.enterText(fields.at(0), hex(seed)); await tester.enterText(fields.at(1), "randogoth@smol.place"); await tester.tap(find.text("Restore")); await tester.pumpAndSettle(); + // Recall needs the server pinned first (SPEC.md §4) — that's the normal + // state right after a restore, so this lands on the register step framed + // for recall (no error, no Invite token field) rather than the inbox yet. + expect(find.text("Inbox"), findsNothing); + expect(find.text("Pin this server's public key to finish restoring your address."), + findsOneWidget); + expect(find.text("Invite token (optional)"), findsNothing); + fields = find.byType(TextField); + expect(fields, findsNWidgets(2)); // address (carried over), server key + await tester.enterText(fields.at(1), b32encode(randomBytes(32))); + await tester.tap(find.text("Pin and Recall")); + await tester.pumpAndSettle(); + expect(find.text("Inbox"), findsOneWidget); expect(store.seed(), seed); expect(store.account()!.user, "randogoth"); @@ -101,4 +117,54 @@ void main() { expect(find.text("Inbox"), findsOneWidget); expect(store.account()!.user, "randogoth"); }); + + testWidgets( + "restoring again after an incomplete attempt replaces the identity " + "instead of refusing it", (tester) async { + final store = storeC; + final abandonedSeed = randomBytes(32); + final realSeed = randomBytes(32); + await tester.pumpWidget(app(store)); + await tester.pumpAndSettle(); + + // First attempt: restore a seed but never finish registering — lands on + // the register step, identity set, no account bound. + await tester.tap(find.text("Restore From Seed")); + await tester.pumpAndSettle(); + var fields = find.byType(TextField); + await tester.enterText(fields.at(0), hex(abandonedSeed)); + await tester.tap(find.text("Restore")); + await tester.pumpAndSettle(); + expect(store.seed(), abandonedSeed); + expect(store.account(), isNull); + + // Simulate returning to onboarding later (e.g. a cold restart). Pumping + // app(store) directly would just rebuild the existing OnboardingScreen + // state in place (still parked on the register step) rather than really + // restarting, so tear the tree down first to force a fresh app state — + // HomeGuard then sends an identity-without-account back to welcome. + await tester.pumpWidget(const SizedBox()); + await tester.pumpWidget(app(store)); + await tester.pumpAndSettle(); + expect(find.text("Create Identity"), findsOneWidget); + + // Restoring a different seed must not throw "identity already exists". + await tester.tap(find.text("Restore From Seed")); + await tester.pumpAndSettle(); + fields = find.byType(TextField); + await tester.enterText(fields.at(0), hex(realSeed)); + await tester.enterText(fields.at(1), "randogoth@smol.place"); + await tester.tap(find.text("Restore")); + await tester.pumpAndSettle(); + + // Lands on the recall-framed register step (no pin yet); pin it and finish. + fields = find.byType(TextField); + expect(fields, findsNWidgets(2)); + await tester.enterText(fields.at(1), b32encode(randomBytes(32))); + await tester.tap(find.text("Pin and Recall")); + await tester.pumpAndSettle(); + + expect(find.text("Inbox"), findsOneWidget); + expect(store.seed(), realSeed); + }); } From c693e6fcb9ce3a2df2e5b5ebb79cd384b0dd7798 Mon Sep 17 00:00:00 2001 From: randogoth Date: Sun, 27 Sep 2026 11:07:11 +0300 Subject: [PATCH 08/22] feat: adopt smolmail protocol 1.1, adaptive nav shell, and server mail retention --- lib/data/providers/providers.dart | 23 +- lib/presentation/routes/app_router.dart | 19 +- lib/presentation/routes/app_router.gr.dart | 103 ++++--- lib/presentation/routes/home_guard.dart | 2 +- .../screens/contact_detail_screen.dart | 57 +++- lib/presentation/screens/contacts_screen.dart | 4 + lib/presentation/screens/home_screen.dart | 86 ++++++ lib/presentation/screens/inbox_screen.dart | 100 +++++-- .../screens/message_detail_screen.dart | 20 +- .../screens/onboarding_screen.dart | 137 ++++----- lib/presentation/screens/settings_screen.dart | 36 ++- lib/presentation/theme/breakpoints.dart | 4 + .../widgets/drawer/app_drawer.dart | 35 --- .../widgets/drawer/drawer_list.dart | 63 ---- .../widgets/drawer/drawer_list_tile.dart | 42 --- .../widgets/drawer/identity_header.dart | 64 ----- .../widgets/message/message_list.dart | 6 +- .../widgets/message/message_tile.dart | 8 +- .../widgets/message/message_view.dart | 32 ++- lib/smol/client.dart | 244 +++++++++++++--- lib/smol/proto.dart | 147 +++++++--- lib/smol/store.dart | 271 ++++++++++++++---- test/e2e_test.dart | 92 +++++- test/recall_flow_test.dart | 86 +++--- test/smol_test.dart | 39 ++- test/store_test.dart | 60 +++- test/vectors.json | 2 +- 27 files changed, 1190 insertions(+), 592 deletions(-) create mode 100644 lib/presentation/screens/home_screen.dart create mode 100644 lib/presentation/theme/breakpoints.dart delete mode 100644 lib/presentation/widgets/drawer/app_drawer.dart delete mode 100644 lib/presentation/widgets/drawer/drawer_list.dart delete mode 100644 lib/presentation/widgets/drawer/drawer_list_tile.dart delete mode 100644 lib/presentation/widgets/drawer/identity_header.dart diff --git a/lib/data/providers/providers.dart b/lib/data/providers/providers.dart index c4d86d1..1f1bcaa 100644 --- a/lib/data/providers/providers.dart +++ b/lib/data/providers/providers.dart @@ -32,18 +32,14 @@ final accountProvider = Provider((ref) { return ref.watch(clientProvider).accountAddress(); }); -class Folder extends Notifier { - @override - String build() => "inbox"; - - void select(String folder) => state = folder; -} - -final folderProvider = NotifierProvider(Folder.new); - -final messagesProvider = Provider>((ref) { +/// One tab (Inbox/Sent/Requests) can be on screen — and simultaneously kept +/// alive off-screen by [TabBarView] for swiping — while another is active, +/// so each is parameterized by its own folder rather than sharing one +/// ambient "current folder" provider. +final messagesForFolderProvider = + Provider.family, String>((ref, folder) { ref.watch(revisionProvider); - return ref.watch(storeProvider).listMessages(ref.watch(folderProvider)); + return ref.watch(storeProvider).listMessages(folder); }); final unreadProvider = Provider((ref) { @@ -51,6 +47,11 @@ final unreadProvider = Provider((ref) { return ref.watch(storeProvider).unreadCount(); }); +final requestsUnreadProvider = Provider((ref) { + ref.watch(revisionProvider); + return ref.watch(storeProvider).requestsUnreadCount(); +}); + final contactsProvider = Provider>((ref) { ref.watch(revisionProvider); return ref.watch(storeProvider).allContacts(); diff --git a/lib/presentation/routes/app_router.dart b/lib/presentation/routes/app_router.dart index 1f20a4d..94dbe81 100644 --- a/lib/presentation/routes/app_router.dart +++ b/lib/presentation/routes/app_router.dart @@ -20,9 +20,14 @@ class AppRouter extends RootStackRouter { guards: [HomeGuard(ref)], ), AutoRoute( - page: InboxRoute.page, - path: "/inbox", + page: HomeRoute.page, + path: "/home", guards: [IdentityGuard(ref)], + children: [ + AutoRoute(page: InboxRoute.page, path: "inbox", initial: true), + AutoRoute(page: ContactsRoute.page, path: "contacts"), + AutoRoute(page: SettingsRoute.page, path: "settings"), + ], ), AutoRoute( page: MessageDetailRoute.page, @@ -34,20 +39,10 @@ class AppRouter extends RootStackRouter { path: "/compose", guards: [IdentityGuard(ref)], ), - AutoRoute( - page: ContactsRoute.page, - path: "/contacts", - guards: [IdentityGuard(ref)], - ), AutoRoute( page: ContactDetailRoute.page, path: "/contact", guards: [IdentityGuard(ref)], ), - AutoRoute( - page: SettingsRoute.page, - path: "/settings", - guards: [IdentityGuard(ref)], - ), ]; } diff --git a/lib/presentation/routes/app_router.gr.dart b/lib/presentation/routes/app_router.gr.dart index d354d14..0a91d9a 100644 --- a/lib/presentation/routes/app_router.gr.dart +++ b/lib/presentation/routes/app_router.gr.dart @@ -10,26 +10,27 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'package:auto_route/auto_route.dart' as _i8; -import 'package:flutter/material.dart' as _i9; +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/inbox_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/message_detail_screen.dart' - as _i5; -import 'package:smol_mail/presentation/screens/onboarding_screen.dart' as _i6; -import 'package:smol_mail/presentation/screens/settings_screen.dart' as _i7; + 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 _i8.PageRouteInfo { +class ComposeRoute extends _i9.PageRouteInfo { ComposeRoute({ - _i9.Key? key, + _i10.Key? key, String? to, String? subject, - List<_i8.PageRouteInfo>? children, + List<_i9.PageRouteInfo>? children, }) : super( ComposeRoute.name, args: ComposeRouteArgs(key: key, to: to, subject: subject), @@ -38,7 +39,7 @@ class ComposeRoute extends _i8.PageRouteInfo { static const String name = 'ComposeRoute'; - static _i8.PageInfo page = _i8.PageInfo( + static _i9.PageInfo page = _i9.PageInfo( name, builder: (data) { final args = data.argsAs( @@ -56,7 +57,7 @@ class ComposeRoute extends _i8.PageRouteInfo { class ComposeRouteArgs { const ComposeRouteArgs({this.key, this.to, this.subject}); - final _i9.Key? key; + final _i10.Key? key; final String? to; @@ -80,11 +81,11 @@ class ComposeRouteArgs { /// generated route for /// [_i2.ContactDetailScreen] -class ContactDetailRoute extends _i8.PageRouteInfo { +class ContactDetailRoute extends _i9.PageRouteInfo { ContactDetailRoute({ - _i9.Key? key, + _i10.Key? key, required String address, - List<_i8.PageRouteInfo>? children, + List<_i9.PageRouteInfo>? children, }) : super( ContactDetailRoute.name, args: ContactDetailRouteArgs(key: key, address: address), @@ -93,7 +94,7 @@ class ContactDetailRoute extends _i8.PageRouteInfo { static const String name = 'ContactDetailRoute'; - static _i8.PageInfo page = _i8.PageInfo( + static _i9.PageInfo page = _i9.PageInfo( name, builder: (data) { final args = data.argsAs(); @@ -105,7 +106,7 @@ class ContactDetailRoute extends _i8.PageRouteInfo { class ContactDetailRouteArgs { const ContactDetailRouteArgs({this.key, required this.address}); - final _i9.Key? key; + final _i10.Key? key; final String address; @@ -127,13 +128,13 @@ class ContactDetailRouteArgs { /// generated route for /// [_i3.ContactsScreen] -class ContactsRoute extends _i8.PageRouteInfo { - const ContactsRoute({List<_i8.PageRouteInfo>? children}) +class ContactsRoute extends _i9.PageRouteInfo { + const ContactsRoute({List<_i9.PageRouteInfo>? children}) : super(ContactsRoute.name, initialChildren: children); static const String name = 'ContactsRoute'; - static _i8.PageInfo page = _i8.PageInfo( + static _i9.PageInfo page = _i9.PageInfo( name, builder: (data) { return const _i3.ContactsScreen(); @@ -142,29 +143,45 @@ class ContactsRoute extends _i8.PageRouteInfo { } /// generated route for -/// [_i4.InboxScreen] -class InboxRoute extends _i8.PageRouteInfo { - const InboxRoute({List<_i8.PageRouteInfo>? children}) - : super(InboxRoute.name, initialChildren: children); +/// [_i4.HomeScreen] +class HomeRoute extends _i9.PageRouteInfo { + const HomeRoute({List<_i9.PageRouteInfo>? children}) + : super(HomeRoute.name, initialChildren: children); - static const String name = 'InboxRoute'; + static const String name = 'HomeRoute'; - static _i8.PageInfo page = _i8.PageInfo( + static _i9.PageInfo page = _i9.PageInfo( name, builder: (data) { - return const _i4.InboxScreen(); + return const _i4.HomeScreen(); }, ); } /// generated route for -/// [_i5.MessageDetailScreen] -class MessageDetailRoute extends _i8.PageRouteInfo { +/// [_i5.InboxScreen] +class InboxRoute extends _i9.PageRouteInfo { + const InboxRoute({List<_i9.PageRouteInfo>? children}) + : super(InboxRoute.name, initialChildren: children); + + static const String name = 'InboxRoute'; + + static _i9.PageInfo page = _i9.PageInfo( + name, + builder: (data) { + return const _i5.InboxScreen(); + }, + ); +} + +/// generated route for +/// [_i6.MessageDetailScreen] +class MessageDetailRoute extends _i9.PageRouteInfo { MessageDetailRoute({ - _i9.Key? key, + _i10.Key? key, required String folder, required String id, - List<_i8.PageRouteInfo>? children, + List<_i9.PageRouteInfo>? children, }) : super( MessageDetailRoute.name, args: MessageDetailRouteArgs(key: key, folder: folder, id: id), @@ -173,11 +190,11 @@ class MessageDetailRoute extends _i8.PageRouteInfo { static const String name = 'MessageDetailRoute'; - static _i8.PageInfo page = _i8.PageInfo( + static _i9.PageInfo page = _i9.PageInfo( name, builder: (data) { final args = data.argsAs(); - return _i5.MessageDetailScreen( + return _i6.MessageDetailScreen( key: args.key, folder: args.folder, id: args.id, @@ -193,7 +210,7 @@ class MessageDetailRouteArgs { required this.id, }); - final _i9.Key? key; + final _i10.Key? key; final String folder; @@ -216,33 +233,33 @@ class MessageDetailRouteArgs { } /// generated route for -/// [_i6.OnboardingScreen] -class OnboardingRoute extends _i8.PageRouteInfo { - const OnboardingRoute({List<_i8.PageRouteInfo>? children}) +/// [_i7.OnboardingScreen] +class OnboardingRoute extends _i9.PageRouteInfo { + const OnboardingRoute({List<_i9.PageRouteInfo>? children}) : super(OnboardingRoute.name, initialChildren: children); static const String name = 'OnboardingRoute'; - static _i8.PageInfo page = _i8.PageInfo( + static _i9.PageInfo page = _i9.PageInfo( name, builder: (data) { - return const _i6.OnboardingScreen(); + return const _i7.OnboardingScreen(); }, ); } /// generated route for -/// [_i7.SettingsScreen] -class SettingsRoute extends _i8.PageRouteInfo { - const SettingsRoute({List<_i8.PageRouteInfo>? children}) +/// [_i8.SettingsScreen] +class SettingsRoute extends _i9.PageRouteInfo { + const SettingsRoute({List<_i9.PageRouteInfo>? children}) : super(SettingsRoute.name, initialChildren: children); static const String name = 'SettingsRoute'; - static _i8.PageInfo page = _i8.PageInfo( + static _i9.PageInfo page = _i9.PageInfo( name, builder: (data) { - return const _i7.SettingsScreen(); + return const _i8.SettingsScreen(); }, ); } diff --git a/lib/presentation/routes/home_guard.dart b/lib/presentation/routes/home_guard.dart index 6f07b86..86ef3ad 100644 --- a/lib/presentation/routes/home_guard.dart +++ b/lib/presentation/routes/home_guard.dart @@ -15,7 +15,7 @@ class HomeGuard extends AutoRouteGuard { void onNavigation(NavigationResolver resolver, StackRouter router) { if (ref.read(identityProvider) != null && ref.read(accountProvider) != null) { - router.replace(InboxRoute()); + router.replace(HomeRoute()); } else { resolver.next(true); } diff --git a/lib/presentation/screens/contact_detail_screen.dart b/lib/presentation/screens/contact_detail_screen.dart index 88b76b5..02b961d 100644 --- a/lib/presentation/screens/contact_detail_screen.dart +++ b/lib/presentation/screens/contact_detail_screen.dart @@ -24,6 +24,34 @@ class ContactDetailScreen extends ConsumerStatefulWidget { class _ContactDetailScreenState extends ConsumerState { bool resolving = false; + bool tokenBusy = false; + + // §5.8: admitting or withdrawing a contact's accept token pushes the + // change to the server right away, since it only takes effect once the + // server holds the changed set. + Future _toggleAccepted(bool currentlyAccepted) async { + final client = ref.read(clientProvider); + setState(() => tokenBusy = true); + try { + if (currentlyAccepted) { + await client.blockContact(widget.address); + } else { + await client.acceptContact(widget.address); + } + ref.read(revisionProvider.notifier).bump(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(currentlyAccepted + ? "${widget.address} blocked; their mail now lands in requests" + : "${widget.address} accepted; their mail now lands in your main tier")), + ); + } + } on SmolError catch (err) { + if (mounted) showErrorSnackBar(context, err.message); + } finally { + if (mounted) setState(() => tokenBusy = false); + } + } Future _reResolve() async { final client = ref.read(clientProvider); @@ -54,7 +82,11 @@ class _ContactDetailScreenState extends ConsumerState { @override Widget build(BuildContext context) { - final contact = ref.watch(storeProvider).contact(widget.address); + ref.watch(revisionProvider); + final store = ref.read(storeProvider); + final contact = store.contact(widget.address); + final accepted = store.accepted(widget.address); + final isAccepted = accepted != null && accepted.active; return Scaffold( appBar: AppBar(title: Text(widget.address)), @@ -69,6 +101,29 @@ class _ContactDetailScreenState extends ConsumerState { ? "verified key" : "key pinned on first use"), const SizedBox(height: 20), + Text( + "Accept token (SPEC.md §5.8)", + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 5), + Text( + isAccepted + ? "This contact's mail lands in your main tier." + : "This contact's mail lands in requests until accepted.", + style: Theme.of(context).textTheme.labelSmall, + ), + const SizedBox(height: 10), + tokenBusy + ? const Center(child: CircularProgressIndicator()) + : TextButton.icon( + onPressed: () => _toggleAccepted(isAccepted), + icon: Icon(isAccepted ? Icons.block : Icons.check_circle_outline, + size: 18), + label: Text(isAccepted ? "Block" : "Accept"), + ), + const SizedBox(height: 20), + const Divider(height: 1), + const SizedBox(height: 20), _field(context, "fingerprint", fingerprint(contact.key)), _field(context, "public key", b32encode(contact.key)), Row( diff --git a/lib/presentation/screens/contacts_screen.dart b/lib/presentation/screens/contacts_screen.dart index 2e39406..46c189f 100644 --- a/lib/presentation/screens/contacts_screen.dart +++ b/lib/presentation/screens/contacts_screen.dart @@ -82,6 +82,7 @@ class ContactsScreen extends ConsumerWidget { itemCount: contacts.length, itemBuilder: (ctx, i) { final (address, contact) = contacts[i]; + final accepted = ref.read(storeProvider).accepted(address); return ListTile( splashColor: Theme.of(context).extension()!.highlight, onTap: () => AutoRouter.of(context) @@ -93,12 +94,15 @@ class ContactsScreen extends ConsumerWidget { ), trailing: Wrap( spacing: 6, + crossAxisAlignment: WrapCrossAlignment.center, children: [ _chip(context, contact.verified ? "verified" : "tofu", ok: contact.verified), if (contact.history.isNotEmpty) _chip(context, "${contact.history.length} previous", ok: false), + if (accepted != null && accepted.active) + _chip(context, "accepted", ok: true), ], ), ); diff --git a/lib/presentation/screens/home_screen.dart b/lib/presentation/screens/home_screen.dart new file mode 100644 index 0000000..8a9a697 --- /dev/null +++ b/lib/presentation/screens/home_screen.dart @@ -0,0 +1,86 @@ +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.gr.dart"; +import "package:smol_mail/presentation/theme/app_colors.dart"; +import "package:smol_mail/presentation/theme/breakpoints.dart"; +import "package:smol_mail/shared/configs/flash_mail_icons.dart"; + +class _Destination { + final Widget icon; + final String label; + + const _Destination({required this.icon, required this.label}); +} + +/// The app's only persistent navigation surface: a bottom [NavigationBar] on +/// phone-width screens, a side [NavigationRail] on desktop-width ones — no +/// hamburger drawer at either width. Mail/Contacts/Settings are switched by +/// tapping only, never by swipe, so a horizontal gesture inside a +/// destination (e.g. the Inbox/Sent/Requests tab bar) never changes section. +@RoutePage() +class HomeScreen extends ConsumerWidget { + const HomeScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final unread = ref.watch(unreadProvider) + ref.watch(requestsUnreadProvider); + final appColors = Theme.of(context).extension()!; + + final mailIcon = Icon(FlashMailIcons.inbox); + final destinations = [ + _Destination( + icon: unread > 0 ? Badge(label: Text("$unread"), child: mailIcon) : mailIcon, + label: "Mail", + ), + const _Destination(icon: Icon(Icons.contacts), label: "Contacts"), + const _Destination(icon: Icon(Icons.settings), label: "Settings"), + ]; + + return AutoTabsRouter.builder( + routes: const [InboxRoute(), ContactsRoute(), SettingsRoute()], + builder: (context, children, tabsRouter) { + final body = IndexedStack(index: tabsRouter.activeIndex, children: children); + final wide = MediaQuery.sizeOf(context).width >= kDesktopBreakpoint; + + if (!wide) { + return Scaffold( + body: body, + bottomNavigationBar: NavigationBar( + selectedIndex: tabsRouter.activeIndex, + onDestinationSelected: tabsRouter.setActiveIndex, + indicatorColor: appColors.highlight, + destinations: [ + for (final d in destinations) + NavigationDestination(icon: d.icon, label: d.label), + ], + ), + ); + } + + return Scaffold( + body: Row( + children: [ + NavigationRail( + selectedIndex: tabsRouter.activeIndex, + onDestinationSelected: tabsRouter.setActiveIndex, + backgroundColor: appColors.cardFill, + useIndicator: true, + indicatorColor: appColors.highlight, + labelType: NavigationRailLabelType.all, + destinations: [ + for (final d in destinations) + NavigationRailDestination(icon: d.icon, label: Text(d.label)), + ], + ), + const VerticalDivider(width: 1), + Expanded(child: body), + ], + ), + ); + }, + ); + } +} diff --git a/lib/presentation/screens/inbox_screen.dart b/lib/presentation/screens/inbox_screen.dart index f22ab0f..30b4c62 100644 --- a/lib/presentation/screens/inbox_screen.dart +++ b/lib/presentation/screens/inbox_screen.dart @@ -4,18 +4,46 @@ 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/widgets/drawer/app_drawer.dart"; import "package:smol_mail/presentation/widgets/image_banner.dart"; import "package:smol_mail/presentation/widgets/message/message_list.dart"; import "package:smol_mail/shared/utils/snackbar.dart"; import "package:smol_mail/smol/errors.dart"; +const _folders = ["inbox", "requests", "sent"]; + /// No server push in v1 (SPEC.md §13): new mail arrives when the user taps -/// fetch, which pulls everything and acknowledges what verifies. +/// fetch, which pulls everything and acknowledges what verifies. One FETCH +/// call populates both Inbox and Requests (they're tiers of the same +/// mailbox), so both — but not the purely local Sent folder — offer fetch. @RoutePage() -class InboxScreen extends ConsumerWidget { +class InboxScreen extends ConsumerStatefulWidget { const InboxScreen({super.key}); + @override + ConsumerState createState() => _InboxScreenState(); +} + +class _InboxScreenState extends ConsumerState + with SingleTickerProviderStateMixin { + late final TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: _folders.length, vsync: this) + ..addListener(() { + // Fires once settled, whether the change came from a tap or a swipe; + // only the AppBar's fetch button depends on which tab is active. + if (!_tabController.indexIsChanging) setState(() {}); + }); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + Future _fetch(BuildContext context, WidgetRef ref) async { final client = ref.read(clientProvider); try { @@ -36,47 +64,63 @@ class InboxScreen extends ConsumerWidget { } } + Widget _folderView(BuildContext context, WidgetRef ref, String folder) { + final messages = ref.watch(messagesForFolderProvider(folder)); + final canFetch = folder != "sent"; + final onRefresh = canFetch ? () => _fetch(context, ref) : () => Future.value(); + + if (messages.isEmpty) { + return RefreshIndicator( + onRefresh: onRefresh, + child: ListView( + children: const [ + ImageBanner( + imgSrc: "assets/images/empty.png", + text: "Nothing here yet!", + ), + ], + ), + ); + } + return MessageList(folder: folder, onRefresh: onRefresh); + } + @override - Widget build(BuildContext context, WidgetRef ref) { - final messages = ref.watch(messagesProvider); - final folder = ref.watch(folderProvider); + Widget build(BuildContext context) { + final unread = ref.watch(unreadProvider); + final requestsUnread = ref.watch(requestsUnreadProvider); + final canFetch = _folders[_tabController.index] != "sent"; return Scaffold( appBar: AppBar( - title: Text(folder == "inbox" ? "Inbox" : "Sent"), + title: const Text("kirakira"), actions: [ - if (folder == "inbox") + if (canFetch) IconButton( tooltip: "Fetch", onPressed: () => _fetch(context, ref), icon: const Icon(Icons.cloud_download), ), ], + bottom: TabBar( + controller: _tabController, + tabs: [ + Tab(text: unread > 0 ? "Inbox ($unread)" : "Inbox"), + Tab(text: requestsUnread > 0 ? "Requests ($requestsUnread)" : "Requests"), + const Tab(text: "Sent"), + ], + ), ), - drawer: const AppDrawer(), floatingActionButton: FloatingActionButton( onPressed: () => AutoRouter.of(context).push(ComposeRoute()), child: const Icon(Icons.edit), ), - body: messages.isEmpty - ? RefreshIndicator( - onRefresh: () => folder == "inbox" - ? _fetch(context, ref) - : Future.value(), - child: ListView( - children: const [ - ImageBanner( - imgSrc: "assets/images/empty.png", - text: "Nothing here yet!", - ), - ], - ), - ) - : MessageList( - onRefresh: () => folder == "inbox" - ? _fetch(context, ref) - : Future.value(), - ), + body: TabBarView( + controller: _tabController, + children: [ + for (final folder in _folders) _folderView(context, ref, folder), + ], + ), ); } } diff --git a/lib/presentation/screens/message_detail_screen.dart b/lib/presentation/screens/message_detail_screen.dart index 4e2ffe4..4467084 100644 --- a/lib/presentation/screens/message_detail_screen.dart +++ b/lib/presentation/screens/message_detail_screen.dart @@ -10,9 +10,10 @@ import "package:smol_mail/presentation/widgets/message/message_view.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/store.dart"; -/// Fetched mail is acknowledged off the server during fetch, so deleting here -/// removes only the local sealed copy. +/// Deleting removes the local copy, and the server's too if "leave mail on +/// server" left one there to remove (settings; SPEC.md §10). @RoutePage() class MessageDetailScreen extends ConsumerWidget { final String folder; @@ -24,8 +25,14 @@ class MessageDetailScreen extends ConsumerWidget { required this.id, }); - Future _delete(BuildContext context, WidgetRef ref) async { - await ref.read(storeProvider).deleteMessage(folder, id); + Future _delete( + BuildContext context, WidgetRef ref, MailRecord record) async { + try { + await ref.read(clientProvider).deleteMessage(folder, record); + } on SmolError catch (err) { + if (context.mounted) showErrorSnackBar(context, err.message); + return; // keep the local copy if a needed server delete failed + } ref.read(revisionProvider.notifier).bump(); if (context.mounted) { AutoRouter.of(context).pop(); @@ -109,8 +116,8 @@ class MessageDetailScreen extends ConsumerWidget { ), actions: [ IconButton( - tooltip: "Delete local copy", - onPressed: () => _delete(context, ref), + tooltip: "Delete", + onPressed: () => _delete(context, ref, record), icon: const Icon(Icons.delete), ), ], @@ -118,6 +125,7 @@ class MessageDetailScreen extends ConsumerWidget { body: SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), child: MessageView( + folder: folder, record: record, opened: opened, onNameSender: opened.sender == null diff --git a/lib/presentation/screens/onboarding_screen.dart b/lib/presentation/screens/onboarding_screen.dart index 5e9498d..38ac6e8 100644 --- a/lib/presentation/screens/onboarding_screen.dart +++ b/lib/presentation/screens/onboarding_screen.dart @@ -29,12 +29,17 @@ enum _Step { welcome, backup, restore, register } class _OnboardingScreenState extends ConsumerState { _Step step = _Step.welcome; - Uint8List? createdSeed; + Uint8List? createdMaster; bool busy = false; // The register step doubles as "pin a key to finish recalling" when a // restore's recall can't proceed without one yet — same fields, different // framing and default action, not the generic "register a new address" copy. bool recallIntent = false; + // Set alongside recallIntent when we got here from Restore rather than from + // "Already registered? Recall": there is no well-defined "register a new + // address instead" fallback for a restored master until its rotation index + // is known, so that escape hatch is hidden in this case (§2). + bool restoreIntent = false; final seedController = TextEditingController(); final restoreAddressController = TextEditingController(); @@ -62,11 +67,11 @@ class _OnboardingScreenState extends ConsumerState { } // Reaching onboarding at all means HomeGuard already found no complete - // identity+account, so a seed still sitting in the store here can only be - // an abandoned attempt from earlier in this same flow (wrong seed, failed + // identity+account, so a master still sitting in the store here can only be + // an abandoned attempt from earlier in this same flow (wrong master, failed // recall, "Back") — safe to replace rather than reject. // wipe() is fire-and-forget here, like every other store write in this - // screen (setIdentity, pinServer, ...) — Hive updates its in-memory state + // screen (setMaster, pinServer, ...) — Hive updates its in-memory state // synchronously and persists to disk in the background, so the identity // check right after is already consistent without awaiting the write. void _clearAbandonedIdentity() { @@ -80,9 +85,9 @@ class _OnboardingScreenState extends ConsumerState { _clearAbandonedIdentity(); final client = ref.read(clientProvider); try { - final fresh = client.createIdentity(); + final master = client.createIdentity(); setState(() { - createdSeed = fresh.seed; + createdMaster = master; step = _Step.backup; }); } on Exception catch (err) { @@ -94,74 +99,49 @@ class _OnboardingScreenState extends ConsumerState { setState(() => step = _Step.restore); } - // Restoring must succeed on its own even if recall fails (server not - // pinned yet, offline, typo) — recall can always be retried from the - // register step or settings afterward. - void _submitRestore() { - _clearAbandonedIdentity(); - final client = ref.read(clientProvider); - try { - client.restoreIdentity(seedController.text); - } on Exception catch (err) { - _showError(err); - return; - } + // §2: a master alone does not say which rotation index a server bound, so + // restoring resolves the address and walks indices to find it — restore + // always ends in a recall, never a bare local step. + Future _submitRestore() async { final addressText = restoreAddressController.text.trim(); if (addressText.isEmpty) { - setState(() => step = _Step.register); + _showError(const SmolError("enter the address this master was registered under")); return; } + _clearAbandonedIdentity(); // The register step has its own address field (it also needs a server // key, which restore doesn't collect) — carry over what was already // typed rather than making the user re-enter it. addressController.text = addressText; - final SmolAddress addr; + final client = ref.read(clientProvider); + setState(() => busy = true); try { - addr = parseAddress(addressText); + await client.restoreAndRecall(seedController.text, addressText); + if (!mounted) return; + ref.read(revisionProvider.notifier).bump(); + AutoRouter.of(context).replace(HomeRoute()); } on Exception catch (err) { - // A genuine typo, distinct from the merely-unpinned case below — still - // worth an error, but land on the same recall-oriented step to fix it. + if (!mounted) return; + // Most often the host just isn't pinned yet (SPEC.md §4) — the expected + // state right after a restore, not a dead end — so land on the + // recall-framed register step to collect a key and retry. _showError(err); setState(() { recallIntent = true; + restoreIntent = true; step = _Step.register; }); - return; + } finally { + if (mounted) setState(() => busy = false); } - // Recall needs the host pinned first (SPEC.md §4). That's the expected, - // common state right after a restore — not an error — so check for it - // up front instead of letting recallAccount fail and surfacing that as - // one: this address just needs a key before its first recall can proceed. - if (ref.read(storeProvider).serverPin(addr.host) == null) { - setState(() { - recallIntent = true; - step = _Step.register; - }); - return; - } - () async { - try { - await client.recallAccount(addressText); - if (!mounted) return; - ref.read(revisionProvider.notifier).bump(); - AutoRouter.of(context).replace(InboxRoute()); - } on Exception catch (err) { - if (!mounted) return; - _showError(err); - setState(() { - recallIntent = true; - step = _Step.register; - }); - } - }(); } Future _submitRegistration() async { await _submit(recall: false); } - // Restoring a seed on a new device knows the identity but not the address it - // was registered under; recall binds it without re-REGISTER. + // Recall binds a restored or already-created identity to its registered + // address without re-REGISTER. Future _submitRecall() async { await _submit(recall: true); } @@ -178,7 +158,9 @@ class _OnboardingScreenState extends ConsumerState { if (serverKey.isNotEmpty) { client.pinServer(address.host, serverKey); } - if (recall) { + if (recall && restoreIntent) { + await client.restoreAndRecall(seedController.text, address.short); + } else if (recall) { await client.recallAccount(address.short); } else { await client.registerAccount(address.short, @@ -186,7 +168,7 @@ class _OnboardingScreenState extends ConsumerState { } if (mounted) { ref.read(revisionProvider.notifier).bump(); - AutoRouter.of(context).replace(InboxRoute()); + AutoRouter.of(context).replace(HomeRoute()); } } catch (err) { _showError(err); @@ -248,20 +230,21 @@ class _OnboardingScreenState extends ConsumerState { } Widget _backup(BuildContext context) { - final seed = createdSeed!; - final seedHex = hex(seed); + final master = createdMaster!; + final masterHex = hex(master); + final publicKey = ref.read(clientProvider).identity!.publicKey; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _header(context, "Back up this seed; it is the only secret."), + _header(context, "Back up this master secret; it is the only secret."), const SizedBox(height: 20), Text( - "fingerprint:\n${fingerprint(ed25519PublicKey(seed))}", + "fingerprint:\n${fingerprint(publicKey)}", style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 20), SelectableText( - seedHex, + masterHex, style: Theme.of(context) .textTheme .bodySmall! @@ -269,14 +252,14 @@ class _OnboardingScreenState extends ConsumerState { ), TextButton( onPressed: () async { - await Clipboard.setData(ClipboardData(text: seedHex)); + await Clipboard.setData(ClipboardData(text: masterHex)); if (mounted) { ScaffoldMessenger.of(this.context).showSnackBar( - const SnackBar(content: Text("Seed copied to clipboard")), + const SnackBar(content: Text("Master secret copied to clipboard")), ); } }, - child: const Text("Copy seed"), + child: const Text("Copy master secret"), ), const SizedBox(height: 40), PrimaryButton( @@ -292,7 +275,7 @@ class _OnboardingScreenState extends ConsumerState { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _header(context, "Enter the 32-byte seed as 64 hex characters."), + _header(context, "Enter the 32-byte master secret as 64 hex characters."), const SizedBox(height: 20), TextField( controller: seedController, @@ -306,7 +289,7 @@ class _OnboardingScreenState extends ConsumerState { TextField( controller: restoreAddressController, decoration: InputDecoration( - labelText: "Address (if already registered)", + labelText: "Address this master was registered under", hintText: "alice@example.org", filled: true, fillColor: Theme.of(context).extension()!.cardFill, @@ -314,13 +297,13 @@ class _OnboardingScreenState extends ConsumerState { ), const SizedBox(height: 30), PrimaryButton( - onPressed: _submitRestore, - child: const Text("Restore"), + onPressed: busy ? () {} : _submitRestore, + child: busy ? const SmallLoadingSpinner() : const Text("Restore"), ), const SizedBox(height: 5), SecondaryButton( text: "Back", - onPressed: () => setState(() => step = _Step.welcome), + onPressed: busy ? () {} : () => setState(() => step = _Step.welcome), ), const SizedBox(height: 40), ], @@ -377,14 +360,18 @@ class _OnboardingScreenState extends ConsumerState { ? const SmallLoadingSpinner() : Text(recallIntent ? "Pin and Recall" : "Pin and Register"), ), - TextButton( - onPressed: busy - ? () {} - : (recallIntent ? _submitRegistration : _submitRecall), - child: Text(recallIntent - ? "Register a new address instead" - : "Already registered? Recall"), - ), + // Restoring a master has no well-defined "register instead" fallback + // until its rotation index is resolved (§2), so that escape hatch is + // only offered from the fresh-identity path. + if (!restoreIntent) + TextButton( + onPressed: busy + ? () {} + : (recallIntent ? _submitRegistration : _submitRecall), + child: Text(recallIntent + ? "Register a new address instead" + : "Already registered? Recall"), + ), const SizedBox(height: 80), ], ); diff --git a/lib/presentation/screens/settings_screen.dart b/lib/presentation/screens/settings_screen.dart index 4dc1642..d9562d0 100644 --- a/lib/presentation/screens/settings_screen.dart +++ b/lib/presentation/screens/settings_screen.dart @@ -88,8 +88,9 @@ class _SettingsScreenState extends ConsumerState { if (mounted) { // The export payload is sealed now (v2), so the counts for this // notice come straight from the store rather than the ciphertext. - final messages = - store.listMessages("inbox").length + store.listMessages("sent").length; + final messages = store.listMessages("inbox").length + + store.listMessages("requests").length + + store.listMessages("sent").length; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text("exported $messages messages, " "${store.allContacts().length} contacts, " @@ -207,7 +208,7 @@ class _SettingsScreenState extends ConsumerState { final account = ref.watch(accountProvider); if (me == null) return Scaffold(appBar: AppBar(title: const Text("Settings"))); - final seedHex = hex(me.seed); + final masterHex = hex(store.master()!); final share = account?.uri(me.publicKey); final pins = store.allPins(); @@ -249,12 +250,12 @@ class _SettingsScreenState extends ConsumerState { context, borderColor: Theme.of(context).colorScheme.error, children: [ - Text("seed", style: Theme.of(context).textTheme.labelSmall), + Text("master secret", style: Theme.of(context).textTheme.labelSmall), Row( children: [ Expanded( child: SelectableText( - seedShown ? seedHex : "•" * 64, + seedShown ? masterHex : "•" * 64, style: Theme.of(context).textTheme.bodySmall, ), ), @@ -266,12 +267,12 @@ class _SettingsScreenState extends ConsumerState { IconButton( icon: const Icon(Icons.copy, size: 18), onPressed: () => - Clipboard.setData(ClipboardData(text: seedHex)), + Clipboard.setData(ClipboardData(text: masterHex)), ), ], ), Text( - "back this seed up; it is the only secret", + "back this master secret up; it is the only secret", style: Theme.of(context) .textTheme .labelSmall! @@ -279,12 +280,31 @@ class _SettingsScreenState extends ConsumerState { ), const SizedBox(height: 10), Text( - "retired keys: ${store.identities().length - 1} (kept to read old mail)", + "rotations: ${store.rotations()} (superseded keys kept to read old mail)", + style: Theme.of(context).textTheme.labelSmall), + const SizedBox(height: 4), + Text( + "accepted correspondents: ${store.allAccepted().where((e) => e.$2.active).length}" + "${store.syncOk() ? "" : " (not yet pushed to the server — accept them again)"}", style: Theme.of(context).textTheme.labelSmall), ], ), const SizedBox(height: 30), const Divider(height: 1), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text("Leave mail on server"), + subtitle: const Text( + "Fetch keeps a copy on the server instead of deleting it. " + "Delete a message here to remove it from both."), + value: store.leaveOnServer(), + onChanged: (value) { + store.setLeaveOnServer(value); + setState(() {}); + }, + ), + const SizedBox(height: 10), + const Divider(height: 1), const SizedBox(height: 20), Text("pinned servers", style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 10), diff --git a/lib/presentation/theme/breakpoints.dart b/lib/presentation/theme/breakpoints.dart new file mode 100644 index 0000000..dc2f7bc --- /dev/null +++ b/lib/presentation/theme/breakpoints.dart @@ -0,0 +1,4 @@ +/// Below this logical width the home shell shows a bottom [NavigationBar] +/// (phone); at or above it, a side [NavigationRail] (desktop). Matches +/// Material 3's compact/medium width class boundary. +const double kDesktopBreakpoint = 600; diff --git a/lib/presentation/widgets/drawer/app_drawer.dart b/lib/presentation/widgets/drawer/app_drawer.dart deleted file mode 100644 index 56a1f04..0000000 --- a/lib/presentation/widgets/drawer/app_drawer.dart +++ /dev/null @@ -1,35 +0,0 @@ -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:flutter/material.dart"; - -import "package:smol_mail/data/providers/providers.dart"; -import "package:smol_mail/smol/proto.dart"; -import "package:smol_mail/presentation/widgets/drawer/identity_header.dart"; -import "package:smol_mail/presentation/widgets/drawer/drawer_list.dart"; -import "package:smol_mail/presentation/theme/app_colors.dart"; - -class AppDrawer extends ConsumerWidget { - const AppDrawer({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final identity = ref.watch(identityProvider); - final account = ref.watch(accountProvider); - final address = account?.short ?? ""; - final fp = identity == null ? "" : fingerprint(identity.publicKey); - - return Drawer( - backgroundColor: Theme.of(context).extension()!.cardFill, - child: Padding( - padding: const EdgeInsets.only(top: 60), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - IdentityHeader(address: address, fingerprint: fp), - const SizedBox(height: 50), - const DrawerList(), - ], - ), - ), - ); - } -} diff --git a/lib/presentation/widgets/drawer/drawer_list.dart b/lib/presentation/widgets/drawer/drawer_list.dart deleted file mode 100644 index 19b5c96..0000000 --- a/lib/presentation/widgets/drawer/drawer_list.dart +++ /dev/null @@ -1,63 +0,0 @@ -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.gr.dart"; -import "package:auto_route/auto_route.dart"; -import "package:smol_mail/shared/configs/flash_mail_icons.dart"; -import "package:smol_mail/presentation/widgets/drawer/drawer_list_tile.dart"; - -class DrawerList extends ConsumerWidget { - const DrawerList({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final unread = ref.watch(unreadProvider); - - return Padding( - padding: const EdgeInsets.only(left: 25), - child: Column( - children: [ - DrawerListTile( - color: Theme.of(context).primaryColor, - icon: FlashMailIcons.inbox, - title: "Inbox${unread > 0 ? " ($unread)" : ""}", - onTap: () { - ref.read(folderProvider.notifier).select("inbox"); - ref.read(revisionProvider.notifier).bump(); - Navigator.of(context).pop(); - }, - ), - DrawerListTile( - color: Theme.of(context).primaryColor, - icon: Icons.outgoing_mail, - title: "Sent", - onTap: () { - ref.read(folderProvider.notifier).select("sent"); - ref.read(revisionProvider.notifier).bump(); - Navigator.of(context).pop(); - }, - ), - DrawerListTile( - color: Theme.of(context).primaryColor, - icon: Icons.contacts, - title: "Contacts", - onTap: () { - Navigator.of(context).pop(); - AutoRouter.of(context).push(ContactsRoute()); - }, - ), - DrawerListTile( - color: Theme.of(context).primaryColor, - icon: Icons.settings, - title: "Settings", - onTap: () { - Navigator.of(context).pop(); - AutoRouter.of(context).push(SettingsRoute()); - }, - ), - ], - ), - ); - } -} diff --git a/lib/presentation/widgets/drawer/drawer_list_tile.dart b/lib/presentation/widgets/drawer/drawer_list_tile.dart deleted file mode 100644 index fb67c08..0000000 --- a/lib/presentation/widgets/drawer/drawer_list_tile.dart +++ /dev/null @@ -1,42 +0,0 @@ -import "package:flutter/material.dart"; - -import "package:smol_mail/presentation/theme/app_colors.dart"; - -class DrawerListTile extends StatelessWidget { - final Color color; - final IconData icon; - final String title; - final void Function() onTap; - - const DrawerListTile({ - super.key, - required this.color, - required this.icon, - required this.title, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - final appColors = Theme.of(context).extension()!; - return Theme( - data: Theme.of(context).copyWith( - splashColor: appColors.highlight, - highlightColor: appColors.cardFill, - ), - child: ListTile( - onTap: onTap, - leading: Icon(icon, color: color, size: 18), - title: Text( - title, - style: TextStyle( - fontFamily: "Inter", - color: color, - fontSize: 18, - fontWeight: FontWeight.w900, - ), - ), - ), - ); - } -} diff --git a/lib/presentation/widgets/drawer/identity_header.dart b/lib/presentation/widgets/drawer/identity_header.dart deleted file mode 100644 index 3128c49..0000000 --- a/lib/presentation/widgets/drawer/identity_header.dart +++ /dev/null @@ -1,64 +0,0 @@ -import "package:flutter/material.dart"; - -import "package:smol_mail/shared/utils/format.dart"; - -/// Identity summary in the drawer: the address is what gets shared, the -/// fingerprint is what gets verified. -class IdentityHeader extends StatelessWidget { - final String address; - final String fingerprint; - - const IdentityHeader({ - super.key, - required this.address, - required this.fingerprint, - }); - - @override - Widget build(BuildContext context) { - return Column( - children: [ - CircleAvatar( - backgroundColor: Theme.of(context).primaryColor, - radius: 35, - child: Text( - address.isEmpty ? "?" : address[0].toUpperCase(), - style: - Theme.of(context).textTheme.bodyLarge!.copyWith(fontSize: 30), - ), - ), - const SizedBox(height: 20), - InkWell( - onTap: () => copyText(context, address), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text( - address.isEmpty ? "(not registered)" : address, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w900, - ), - ), - ), - ), - const SizedBox(height: 5), - InkWell( - onTap: () => copyText(context, fingerprint), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text( - "fp: $fingerprint", - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontSize: 14, - fontWeight: FontWeight.w700, - ), - ), - ), - ), - ], - ); - } -} diff --git a/lib/presentation/widgets/message/message_list.dart b/lib/presentation/widgets/message/message_list.dart index 21b457b..fe32b71 100644 --- a/lib/presentation/widgets/message/message_list.dart +++ b/lib/presentation/widgets/message/message_list.dart @@ -5,21 +5,23 @@ import "package:smol_mail/data/providers/providers.dart"; import "package:smol_mail/presentation/widgets/message/message_tile.dart"; class MessageList extends ConsumerWidget { + final String folder; final Future Function() onRefresh; const MessageList({ super.key, + required this.folder, required this.onRefresh, }); @override Widget build(BuildContext context, WidgetRef ref) { - final messages = ref.watch(messagesProvider); + final messages = ref.watch(messagesForFolderProvider(folder)); return RefreshIndicator( onRefresh: onRefresh, child: ListView.builder( - itemBuilder: (ctx, i) => MessageTile(record: messages[i]), + itemBuilder: (ctx, i) => MessageTile(folder: folder, record: messages[i]), itemCount: messages.length, ), ); diff --git a/lib/presentation/widgets/message/message_tile.dart b/lib/presentation/widgets/message/message_tile.dart index e2c854c..f2fb1a5 100644 --- a/lib/presentation/widgets/message/message_tile.dart +++ b/lib/presentation/widgets/message/message_tile.dart @@ -11,13 +11,13 @@ import "package:smol_mail/smol/proto.dart"; import "package:smol_mail/smol/store.dart"; class MessageTile extends ConsumerWidget { + final String folder; final MailRecord record; - const MessageTile({super.key, required this.record}); + const MessageTile({super.key, required this.folder, required this.record}); @override Widget build(BuildContext context, WidgetRef ref) { - final folder = ref.watch(folderProvider); final store = ref.watch(storeProvider); final opened = ref.read(clientProvider).describe(record); final appColors = Theme.of(context).extension()!; @@ -31,7 +31,7 @@ class MessageTile extends ConsumerWidget { } else { who = "?"; } - final unread = folder == "inbox" && !store.isRead(record.id); + final unread = folder != "sent" && !store.isRead(record.id); final errorText = opened.error != null ? "" : null; final subject = errorText ?? (opened.subject.isEmpty ? "(no subject)" : opened.subject); @@ -44,7 +44,7 @@ class MessageTile extends ConsumerWidget { splashColor: appColors.highlight, focusColor: appColors.cardFill, onTap: () { - if (folder == "inbox") { + if (folder != "sent") { store.markRead(record.id); ref.read(revisionProvider.notifier).bump(); } diff --git a/lib/presentation/widgets/message/message_view.dart b/lib/presentation/widgets/message/message_view.dart index cb6853a..e52c2e9 100644 --- a/lib/presentation/widgets/message/message_view.dart +++ b/lib/presentation/widgets/message/message_view.dart @@ -15,12 +15,14 @@ import "package:smol_mail/smol/store.dart"; /// The opened message: trust row (fingerprint and how the sender's key is /// known), frontmatter fields, and the plain body — smol mail has no HTML. class MessageView extends ConsumerWidget { + final String folder; final MailRecord record; final OpenedRecord opened; final void Function(Uint8List senderKey)? onNameSender; const MessageView({ super.key, + required this.folder, required this.record, required this.opened, this.onNameSender, @@ -38,18 +40,19 @@ class MessageView extends ConsumerWidget { ); } - final folder = ref.watch(folderProvider); final store = ref.watch(storeProvider); final client = ref.read(clientProvider); // Sent copies carry no signature from a third party; the trust row then // just says what they are. final isSent = folder == "sent"; + final isRequest = folder == "requests"; final senderKey = opened.sender; final known = senderKey == null ? null : store.addressForKey(senderKey); final contact = known == null ? null : store.contact(known); final verifiedSender = contact?.verified ?? false; final replyTo = isSent ? null : client.replyAddress(opened); + final accepted = known == null ? null : store.accepted(known); final subject = opened.subject.isEmpty ? "(no subject)" : opened.subject; @@ -69,6 +72,10 @@ class MessageView extends ConsumerWidget { : "key bound to no address", alert: known == null, ), + if (isRequest) ...[ + const SizedBox(height: 10), + _badge(context, "arrived without an accept token (SPEC.md §5.8)", alert: true), + ], if (!isSent && senderKey != null) ...[ const SizedBox(height: 10), _keyBlock(context, "${fingerprint(senderKey)}\n${b32encode(senderKey)}"), @@ -78,7 +85,7 @@ class MessageView extends ConsumerWidget { isSent ? (record.recipient ?? "") : (known ?? "unknown sender")), _field(context, "Date", formatTime(opened.time)), for (final entry in opened.fields.entries) - if (entry.key != "Subject" && entry.key != "Reply-To") + if (entry.key != "subject" && entry.key != "reply-to" && entry.key != "accept") _field(context, entry.key, entry.value), if (!isSent && replyTo != null) Row( @@ -114,6 +121,27 @@ class MessageView extends ConsumerWidget { child: const Text("Name this sender"), ), ), + if (isRequest && known != null && !(accepted?.active ?? false)) + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + icon: const Icon(Icons.check_circle_outline, size: 18), + label: const Text("Accept sender"), + onPressed: () async { + try { + await client.acceptContact(known); + ref.read(revisionProvider.notifier).bump(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("$known accepted")), + ); + } + } catch (err) { + if (context.mounted) showErrorSnackBar(context, err.toString()); + } + }, + ), + ), const SizedBox(height: 10), const Divider(height: 1), const SizedBox(height: 20), diff --git a/lib/smol/client.dart b/lib/smol/client.dart index ffa283f..9390108 100644 --- a/lib/smol/client.dart +++ b/lib/smol/client.dart @@ -36,7 +36,7 @@ class OpenedRecord { const OpenedRecord(this.id, {this.sender, this.time, this.fields = const {}, this.body = "", this.error}); - String get subject => error == null ? (fields["Subject"] ?? "") : ""; + String get subject => error == null ? (fields["subject"] ?? "") : ""; } class SmolClient { @@ -58,6 +58,8 @@ class SmolClient { SmolIdentity? get identity => store.identity(); + Uint8List? get master => store.master(); + SmolAddress? accountAddress() { final account = store.account(); if (account == null) return null; @@ -91,25 +93,51 @@ class SmolClient { // --- identity setup ------------------------------------------------------------ - SmolIdentity createIdentity() { - final fresh = newIdentity(); - store.setIdentity(fresh.seed); + /// A fresh master secret at rotation index 0. Only this local step; nothing + /// is sent until [registerAccount]. + Uint8List createIdentity() { + final fresh = randomBytes(keyLen); + store.setMaster(fresh); return fresh; } - SmolIdentity restoreIdentity(String seedHex) { - Uint8List seed; + /// §2: a master alone does not say which rotation index a server has bound, + /// so restoring resolves the address and walks indices 0..[maxChain] until + /// one derives the key RESOLVE returned. Also binds "account" locally, like + /// [recallAccount] — restoring on a new device knows the identity but not + /// the address it was registered under. + Future restoreAndRecall(String masterHex, String addressText) async { + Uint8List master; try { - seed = unhex(seedHex.trim()); + master = unhex(masterHex.trim()); } on Exception { - throw const SmolError("seed must be 64 hex characters"); + throw const SmolError("master must be 64 hex characters"); } - if (seed.length != keyLen) { - throw SmolError("seed is ${seed.length} bytes, expected $keyLen"); + if (master.length != keyLen) { + throw SmolError("master is ${master.length} bytes, expected $keyLen"); } - final restored = identityFromSeed(seed); - store.setIdentity(seed); - return restored; + final addr = parseAddress(addressText); + final opened = await connect(addr, requirePin: true); + Uint8List current; + try { + current = (await resolveOp(opened.session, addr.user)).identity; + } finally { + opened.session.wire.close(); + } + int? found; + for (var n = 0; n <= maxChain; n++) { + if (timingSafeEqual(identityFromSeed(identitySeed(master, n)).publicKey, current)) { + found = n; + break; + } + } + if (found == null) { + throw SmolError("the key bound to ${addr.short} is not derived from " + "this master within $maxChain rotations"); + } + store.restoreMaster(master, found); + store.setAccount(addr); + return addr; } void pinServer(String host, String keyB32) { @@ -126,7 +154,7 @@ class SmolClient { final addr = parseAddress(addressText); final opened = await connect(addr, requirePin: true); try { - await registerOp(opened.session, addr.user, me, + await registerOp(opened.session, opened.serverStatic, addr.user, me, RegisterOptions(token: token)); } finally { opened.session.wire.close(); @@ -134,7 +162,7 @@ class SmolClient { store.setAccount(addr); } - /// Restoring a seed brings back the identity, not the memory of what + /// Restoring a master brings back the identity, not the memory of what /// address a *different device* registered it under — "account" is /// local-only state, never asked of the server. This binds it without /// REGISTER: RESOLVE the address and require it name this exact key, so a @@ -162,46 +190,178 @@ class SmolClient { Future fetch() async { final me = identity; + final master = this.master; final addr = accountAddress(); - if (me == null) throw const SmolError("no identity yet"); + if (me == null || master == null) throw const SmolError("no identity yet"); if (addr == null) { throw const SmolError("not registered; register an address first"); } var stored = 0; final rejected = []; + // §10: acknowledging (deleting) is the default; "leave mail on server" + // pages forward by cursor instead, so already-fetched mail is never + // re-downloaded even though it isn't deleted (store.storeIfNew also + // dedupes, as a second line of defense). + final leaveOnServer = store.leaveOnServer(); + var (afterTime, afterId) = store.cursor(); final opened = await connect(addr, requirePin: true); try { - await authenticate(opened.session, opened.handshakeHash, addr.user, me); + final (sync, tokens) = store.tokenSet(master); + await authenticate(opened.session, opened.handshakeHash, addr.user, me, + sync: sync, tokens: tokens); while (true) { - final records = await fetchOp(opened.session); + final records = await fetchOp(opened.session, afterTime, afterId); if (records.isEmpty) break; final acked = []; for (final record in records) { + afterTime = record.receivedAt; + afterId = record.id; + OpenedMessage msg; try { if (!timingSafeEqual(messageId(record.envelope), record.id)) { throw const SmolError("id does not match the envelope"); } - unseal(store.identities(), record.envelope); + msg = unseal(store.identities(), record.envelope); } on SmolError catch (err) { // Left on the server rather than destroyed, so a client-side bug // cannot lose mail. rejected.add("${hex(record.id)}: ${err.message}"); continue; } - final fresh = await store.storeIfNew("inbox", MailRecord(hex(record.id), record.envelope, - receivedAt: record.receivedAt)); - if (fresh != null) stored++; + final fresh = await store.storeIfNew( + "inbox", + MailRecord(hex(record.id), record.envelope, + receivedAt: record.receivedAt, + tier: record.isRequest ? tierRequests : tierMain, + keptOnServer: leaveOnServer)); + if (fresh != null) { + stored++; + _learnToken(msg); + } acked.add(record.id); } - if (acked.isEmpty) break; - await deleteOp(opened.session, acked); + if (leaveOnServer) { + // Persisted per batch, so an interrupted fetch resumes here rather + // than re-paging from the start next time. + store.setCursor(afterTime, afterId); + } else if (acked.isNotEmpty) { + await deleteOp(opened.session, acked); + } } } finally { opened.session.wire.close(); } + if (!leaveOnServer) { + // Everything acknowledged is deleted, so the next fetch starts fresh; a + // record left on the server (rejected above) simply resurfaces then. + store.setCursor(0, Uint8List(idLen)); + } return FetchSummary(stored, rejected); } + // --- delete ------------------------------------------------------------------ + + /// Deletes a message locally, and from the server too if it might still be + /// sitting there (only possible when "leave mail on server" was on when it + /// was fetched — §10). Sent copies are local-only; there is nothing + /// server-side to remove for them (§5.6). Throws, leaving the local copy in + /// place, if a needed server-side delete fails — otherwise a message could + /// look gone locally while silently persisting on the server. + Future deleteMessage(String folder, MailRecord record) async { + if (folder != "sent" && record.keptOnServer) { + final me = identity; + final addr = accountAddress(); + if (me == null || addr == null) { + throw const SmolError( + "not registered; cannot reach the server to delete this message"); + } + final opened = await connect(addr, requirePin: true); + try { + await authenticate(opened.session, opened.handshakeHash, addr.user, me, + sync: 0, tokens: const []); + await deleteOp(opened.session, [unhex(record.id)]); + } finally { + opened.session.wire.close(); + } + } + await store.deleteMessage(folder, record.id); + } + + // §5.8: an Accept field is bound to the signer of the message that carried + // it, which unseal() has already verified. + void _learnToken(OpenedMessage msg) { + final parsed = parseFrontmatter(utf8.decode(msg.body, allowMalformed: true)); + final raw = parsed.fields["accept"]; + if (raw == null) return; + Uint8List token; + try { + token = b32decode(raw); + } on SmolError { + return; + } + if (token.length != tokenLen) return; + final address = _addressOfSigner(msg.sender, parsed.fields["reply-to"]); + if (address == null) return; // no address to send to, so no use for a token + store.learnToken(address, token); + } + + /// The address we know a signer by: a contact, or the Reply-To it signed + /// for itself. Naming a mailbox is not trusting a key, so nothing is + /// pinned here (§5.7, §8). + String? _addressOfSigner(Uint8List sender, String? replyTo) { + final known = store.addressForKey(sender); + if (known != null) return known; + if (replyTo == null) return null; + try { + final parsed = parseAddress(replyTo); + if (parsed.identity != null && timingSafeEqual(parsed.identity!, sender)) { + return parsed.short; + } + } on SmolError { + // malformed claim: no address to learn a token under + } + return null; + } + + // --- accept tokens (§5.8) -------------------------------------------------------- + + /// Admit a contact to the main tier; their token travels in our next + /// message to them. Pushes the change to the server right away, since an + /// accept or a block only takes effect once it holds the changed set. + Future acceptContact(String address) async { + final key = store.contact(address)?.key; + if (key == null) throw SmolError("no key for $address yet"); + store.accept(address, key); + store.setSyncOk(true); + return _pushTokens(); + } + + /// Withdraw a contact's accept token; their mail lands in requests from + /// their next message on. + Future blockContact(String address) async { + store.block(address); + return _pushTokens(); + } + + Future _pushTokens() async { + final me = identity; + final master = this.master; + final addr = accountAddress(); + if (me == null || master == null) throw const SmolError("no identity yet"); + if (addr == null) { + _warn("not registered; the set will be pushed with your first fetch"); + return 0; + } + final opened = await connect(addr, requirePin: true); + try { + final (sync, tokens) = store.tokenSet(master); + return await authenticate(opened.session, opened.handshakeHash, addr.user, me, + sync: sync, tokens: tokens); + } finally { + opened.session.wire.close(); + } + } + // --- compose and send ----------------------------------------------------------- // Prefer a key we already trust; fall back to RESOLVE with trust on first @@ -227,7 +387,8 @@ class SmolClient { Future send(String toText, String subject, String body, {String? replyTo, bool anonymous = false}) async { final me = identity; - if (me == null) throw const SmolError("no identity yet"); + final master = this.master; + if (me == null || master == null) throw const SmolError("no identity yet"); final addr = parseAddress(toText); final recipient = await resolveRecipient(addr); final account = accountAddress(); @@ -237,12 +398,21 @@ class SmolClient { if (account != null && !anonymous) { fields["Reply-To"] = account.uri(me.publicKey); } + // §5.8: hand an accepted correspondent the token for our own mailbox, so + // a first reply from them reaches our main tier. + final accepted = store.accepted(addr.short); + if (accepted != null && accepted.active) { + fields["Accept"] = b32encode(tokenFor(master, accepted.identity)); + } final bodyBytes = utf8Bytes(buildFrontmatter( fields, "${body.replaceFirst(RegExp(r"\s+$"), "")}\n")); final envelope = seal(me, recipient, bodyBytes); + // §5.8: our token for their mailbox, if they have given us one. + final held = store.tokenFrom(addr.short); + final mac = held == null ? null : acceptMac(held, messageId(envelope)); final opened = await connect(addr, requirePin: false); try { - await sendOp(opened.session, envelope); + await sendOp(opened.session, envelope, mac: mac); } finally { opened.session.wire.close(); } @@ -293,7 +463,7 @@ class SmolClient { /// full smol:// URI whose key matches the signer (§5.7); anything else is /// ordinary text. SmolAddress? replyAddress(OpenedRecord opened) { - final claim = opened.fields["Reply-To"]; + final claim = opened.fields["reply-to"]; if (claim == null || opened.sender == null) return null; try { final parsed = parseAddress(claim); @@ -370,7 +540,7 @@ class SmolClient { if (timingSafeEqual(known.key, resolved.identity)) { return RefreshOutcome("${addr.short}: key unchanged", false); } - if (walkChain(known.key, resolved.identity, resolved.chain)) { + if (walkChain(addr.user, known.key, resolved.identity, resolved.chain)) { store.saveContact(addr.short, resolved.identity, known.verified); return RefreshOutcome( "${addr.short} rotated its key; a signed chain confirms it.\n" @@ -395,25 +565,27 @@ class SmolClient { // --- rotation ----------------------------------------------------------------- - // §7: rotate to a fresh seed and rebind the account with a signed - // certificate. The old seed is kept by the store, since mail sealed to it - // stays readable with nothing else. + // §7: rotate to the next index's derived key and rebind the account with a + // signed certificate. The superseded key stays derivable from the master, + // since mail sealed to it stays readable with nothing else. Future rotateIdentity() async { final me = identity; + final master = this.master; final addr = accountAddress(); - if (me == null || addr == null) { + if (me == null || master == null || addr == null) { throw const SmolError("rotate needs a registered account"); } - final fresh = newIdentity(); - final cert = makeCert(me, fresh.seed); + final freshSeed = identitySeed(master, store.rotations() + 1); + final fresh = identityFromSeed(freshSeed); + final cert = makeCert(addr.user, me, freshSeed); final opened = await connect(addr, requirePin: true); try { - await registerOp(opened.session, addr.user, fresh, + await registerOp(opened.session, opened.serverStatic, addr.user, fresh, RegisterOptions(cert: cert)); } finally { opened.session.wire.close(); } - store.rotateIdentity(fresh.seed); + store.advanceRotation(); _openedCache.clear(); return fresh; } diff --git a/lib/smol/proto.dart b/lib/smol/proto.dart index 881963f..c253723 100644 --- a/lib/smol/proto.dart +++ b/lib/smol/proto.dart @@ -1,6 +1,6 @@ -// Smol Mail protocol, version 1 (../smolmail SPEC.md): addresses, sealed and -// signed envelopes, body frontmatter, key rotation, and the framed request -// and response bodies of the five operations. +// Smol Mail protocol, version 1.1 (../smolmail SPEC.md): addresses, sealed +// and signed envelopes, body frontmatter, key rotation, accept tokens, and +// the framed request and response bodies of the five operations. import "dart:math"; import "dart:typed_data"; @@ -10,9 +10,11 @@ import "package:smol_mail/smol/errors.dart"; import "package:smol_mail/smol/noise.dart"; const defaultPort = 1961; -const keyLen = 32, sigLen = 64, certLen = 136, idLen = 16; +const keyLen = 32, sigLen = 64, certLen = 200, idLen = 32, tokenLen = 32; const maxFrame = 1 << 20, noisePayload = 65535 - 16, padTo = 1024; const envelopeHeader = 69, payloadHeader = 45, maxChain = 16; +const maxSkew = 86400; // §5.3: how far ahead of our clock a payload may be dated +const flagRequests = 0x01; // §6.1: set when a FETCH record missed an accept token const _frontmatterMax = 4096, _frontmatterKeys = 64; const opAuth = 0x00, opResolve = 0x01, opSend = 0x02, opFetch = 0x03, @@ -32,6 +34,10 @@ final _label = ( msg: utf8Bytes("smolmail/1 msg"), id: utf8Bytes("smolmail/1 id"), rotate: utf8Bytes("smolmail/1 rotate"), + identity: utf8Bytes("smolmail/1 identity"), + accept: utf8Bytes("smolmail/1 accept"), + mac: utf8Bytes("smolmail/1 mac"), + register: utf8Bytes("smolmail/1 register"), ); // --- encoding helpers --------------------------------------------------------- @@ -139,11 +145,44 @@ SmolIdentity identityFromSeed(Uint8List seed) { SmolIdentity newIdentity() => identityFromSeed(randomBytes(keyLen)); +// §2: the only secret a user holds. Everything else — every rotation index's +// signing seed, and the accept key — is derived from it with HKDF. +Uint8List identitySeed(Uint8List master, int index) => + hkdfSha256(master, Uint8List(0), concat([_label.identity, u32be(index)])); + +Uint8List acceptKeyFor(Uint8List master) => + hkdfSha256(master, Uint8List(0), _label.accept); + +// §5.8: the token this account issues to one correspondent, independent of +// the rotation index so it survives the owner's key rotation. +Uint8List tokenFor(Uint8List master, Uint8List correspondentIdentity) => + hmacSha256(acceptKeyFor(master), correspondentIdentity); + +// §5.8: what a sender attaches to SEND to reach the recipient's main tier. +Uint8List acceptMac(Uint8List token, Uint8List id) => + hmacSha256(token, concat([_label.mac, id])); + // --- addressing (§3) ----------------------------------------------------------- final _address = RegExp(r"^(?[a-z0-9._-]{1,63})@(?[^/:]+)(?::(?\d+))?$"); +const _separators = "._-"; + +// §3: alphanumeric at both ends, never two separators in a row. +bool validUsername(String name) { + if (name.isEmpty) return false; + if (_separators.contains(name[0]) || _separators.contains(name[name.length - 1])) { + return false; + } + for (var i = 0; i < name.length - 1; i++) { + if (_separators.contains(name[i]) && _separators.contains(name[i + 1])) { + return false; + } + } + return true; +} + class SmolAddress { final String user; final String host; @@ -179,8 +218,9 @@ SmolAddress parseAddress(String text) { if (m == null) throw SmolError("'$text' is not a valid address"); final user = m.namedGroup("user")!; final host = m.namedGroup("host")!; - if ("._-".contains(user[0]) || "._-".contains(user[user.length - 1])) { - throw SmolError("$user may not begin or end with a separator"); + if (!validUsername(user)) { + throw SmolError("$user must begin and end with a letter or digit " + "and may not contain two separators in a row"); } final portText = m.namedGroup("port"); final port = portText != null ? int.parse(portText) : defaultPort; @@ -189,8 +229,9 @@ SmolAddress parseAddress(String text) { // --- message format (§5) ------------------------------------------------------- -Uint8List messageId(List envelope) => - sha256(concat([_label.id, envelope])).sublist(0, idLen); +// §5.4: derived from the envelope so no sender can choose it; used whole, +// nothing truncates it. +Uint8List messageId(List envelope) => sha256(concat([_label.id, envelope])); class OpenedMessage { final Uint8List sender; @@ -288,6 +329,9 @@ OpenedMessage unseal(List identities, Uint8List envelope) { signature)) { throw const SmolError("signature does not verify"); } + if (when > nowSeconds() + maxSkew) { + throw const SmolError("payload is dated in the future"); + } return OpenedMessage(sender, when, body, messageId(envelope)); } @@ -304,7 +348,8 @@ class Frontmatter { // A flat `Key: value` block, deliberately not YAML. Any malformed line // invalidates the whole block, which is then returned as ordinary body text: -// frontmatter fails closed toward display, never toward silent discard. +// frontmatter fails closed toward display, never toward silent discard. Keys +// are compared case-insensitively (§5.5), so they are kept lowercased. Frontmatter parseFrontmatter(String text) { if (!text.startsWith("---\n")) return Frontmatter(const {}, text); final lines = text.split("\n"); @@ -327,7 +372,7 @@ Frontmatter parseFrontmatter(String text) { return Frontmatter(const {}, text); } // first occurrence wins - fields.putIfAbsent(head, () => line.substring(colon + 1).trim()); + fields.putIfAbsent(head.toLowerCase(), () => line.substring(colon + 1).trim()); } return Frontmatter(fields, rest); } @@ -343,35 +388,45 @@ String buildFrontmatter(Map fields, String body) { // --- key rotation (§7) --------------------------------------------------------- -Uint8List makeCert(SmolIdentity oldIdentity, Uint8List newSeed, [int? when]) { - final newPub = ed25519PublicKey(newSeed); +// §7: old_pub 32 || new_pub 32 || time 8 || sig_old 64 || sig_new 64. Both +// keys sign, so the old key alone cannot hand the username to a key nobody +// controls; the username is covered but not carried, so a verifier always +// supplies the one it is checking. +Uint8List makeCert( + String username, SmolIdentity oldIdentity, Uint8List newSeed, [int? when]) { + final newIdentity = identityFromSeed(newSeed); final time = i64be(BigInt.from(when ?? nowSeconds())); + final signed = concat( + [_label.rotate, utf8Bytes(username), oldIdentity.publicKey, newIdentity.publicKey, time]); return concat([ oldIdentity.publicKey, - newPub, + newIdentity.publicKey, time, - ed25519Sign(oldIdentity.seed, - concat([_label.rotate, oldIdentity.publicKey, newPub, time])), + ed25519Sign(oldIdentity.seed, signed), + ed25519Sign(newIdentity.seed, signed), ]); } // Accept a key change only when a signed chain leads from the key we hold to -// the one the server now returns (§7). -bool walkChain(Uint8List pinned, Uint8List current, List chain) { +// the one the server now returns, both keys signing each link (§7). +bool walkChain( + String username, Uint8List pinned, Uint8List current, List chain) { if (timingSafeEqual(pinned, current)) return true; if (chain.isEmpty || chain.length > maxChain) return false; var key = pinned; var started = false; for (final cert in chain) { final old = cert.sublist(0, 32), next = cert.sublist(32, 64); - final when = cert.sublist(64, 72), sig = cert.sublist(72); + final when = cert.sublist(64, 72); + final sigOld = cert.sublist(72, 136), sigNew = cert.sublist(136, 200); if (!started) { if (!timingSafeEqual(old, key)) continue; // a link predating the key we hold started = true; } else if (!timingSafeEqual(old, key)) { return false; // the chain is not continuous } - if (!ed25519Verify(old, concat([_label.rotate, old, next, when]), sig)) { + final signed = concat([_label.rotate, utf8Bytes(username), old, next, when]); + if (!ed25519Verify(old, signed, sigOld) || !ed25519Verify(next, signed, sigNew)) { return false; } key = next; @@ -482,18 +537,27 @@ void expectOk(int status, String what) { } // §4 session authentication: sign the handshake hash, which binds the -// signature to this session's server ephemeral and cannot be replayed. -Future authenticate( - Session session, Uint8List handshakeHash, String username, SmolIdentity identity) async { +// signature to this session's server ephemeral and cannot be replayed, and +// push the accept token set (§5.8). `sync = 0` leaves the server's stored set +// untouched and `tokens` MUST then be empty; `sync = 1` replaces it exactly. +// Returns the number of accept tokens the server now holds. +Future authenticate(Session session, Uint8List handshakeHash, String username, + SmolIdentity identity, {required int sync, List tokens = const []}) async { final name = utf8Bytes(username); if (name.length > 255) throw const SmolError("username too long"); + if (tokens.length > 0xffff) throw const SmolError("too many accept tokens for one AUTH"); final body = concat([ Uint8List.fromList([name.length]), name, identity.publicKey, ed25519Sign(identity.seed, concat([_label.auth, handshakeHash])), + Uint8List.fromList([sync]), + u16be(tokens.length), + ...tokens, ]); - expectOk((await session.call(opAuth, body)).status, "authentication"); + final response = await session.call(opAuth, body); + expectOk(response.status, "authentication"); + return Reader(response.body).u16(); } class Resolved { @@ -516,8 +580,14 @@ Future resolveOp(Session session, String user) async { List.generate(r.u8(), (_) => r.take(certLen))); } -Future sendOp(Session session, Uint8List envelope) async { - final response = await session.call(opSend, envelope); +// §5.8: [mac] is the sender's proof of an accept token, 0 or 32 bytes. +Future sendOp(Session session, Uint8List envelope, {Uint8List? mac}) async { + final macBytes = mac ?? Uint8List(0); + if (macBytes.isNotEmpty && macBytes.length != tokenLen) { + throw const SmolError("accept MAC must be $tokenLen bytes"); + } + final body = concat([Uint8List.fromList([macBytes.length]), macBytes, envelope]); + final response = await session.call(opSend, body); expectOk(response.status, "sending"); return response.body.length == idLen ? response.body @@ -527,19 +597,27 @@ Future sendOp(Session session, Uint8List envelope) async { class FetchedRecord { final Uint8List id; final int receivedAt; + final int flags; final Uint8List envelope; - const FetchedRecord(this.id, this.receivedAt, this.envelope); + const FetchedRecord(this.id, this.receivedAt, this.flags, this.envelope); + + // §6.1: bit 0 is set when the message arrived without a matching accept token. + bool get isRequest => flags & flagRequests != 0; } -Future> fetchOp(Session session) async { - final response = await session.call(opFetch); +// §6.1: pages forward from a cursor; an all-zero id starts at the beginning. +Future> fetchOp( + Session session, int afterReceivedAt, Uint8List afterId) async { + final body = concat([i64be(BigInt.from(afterReceivedAt)), afterId]); + final response = await session.call(opFetch, body); expectOk(response.status, "fetching"); final r = Reader(response.body); return List.generate(r.u16(), (_) { final id = r.take(idLen); final receivedAt = r.i64(); - return FetchedRecord(id, receivedAt, r.take(r.u32())); + final flags = r.u8(); + return FetchedRecord(id, receivedAt, flags, r.take(r.u32())); }); } @@ -558,9 +636,13 @@ class RegisterOptions { const RegisterOptions({this.token = "", this.cert}); } -Future registerOp( - Session session, String username, SmolIdentity identity, - [RegisterOptions opts = const RegisterOptions()]) async { +// §6.1: the signature is proof of possession, bound to the server that will +// store the binding so it cannot be replayed to another server. +Uint8List registerSigned(Uint8List serverStatic, String username, Uint8List identity) => + concat([_label.register, serverStatic, utf8Bytes(username), identity]); + +Future registerOp(Session session, Uint8List serverStatic, String username, + SmolIdentity identity, [RegisterOptions opts = const RegisterOptions()]) async { final name = utf8Bytes(username); final tokenBytes = utf8Bytes(opts.token); final cert = opts.cert ?? Uint8List(0); @@ -571,6 +653,7 @@ Future registerOp( Uint8List.fromList([name.length]), name, identity.publicKey, + ed25519Sign(identity.seed, registerSigned(serverStatic, username, identity.publicKey)), Uint8List.fromList([tokenBytes.length]), tokenBytes, Uint8List.fromList([cert.length]), diff --git a/lib/smol/store.dart b/lib/smol/store.dart index 3813aef..d80391e 100644 --- a/lib/smol/store.dart +++ b/lib/smol/store.dart @@ -1,7 +1,7 @@ // Device state: identity, pins, contacts and read markers in one JSON blob; // sealed envelopes in a second Hive box, opened only on demand, so nothing at -// rest is plaintext (the seed excepted — the device's app storage is the trust -// boundary, like gsmol's browser profile). +// rest is plaintext (the master secret excepted — the device's app storage is +// the trust boundary, like gsmol's browser profile). import "dart:convert"; import "dart:typed_data"; @@ -42,14 +42,36 @@ class StoredContact { } class MailRecord { - final String id; // hex of the 16-byte message id + final String id; // hex of the 32-byte message id final Uint8List envelope; final int? receivedAt; final String? recipient; // sent copies only final int? sentAt; + final int tier; // §5.8: tierMain or tierRequests; meaningless for sent copies + + /// Whether "leave mail on server" was on when this was fetched, so a + /// manual delete still has a server-side copy to remove. Always false for + /// sent copies, which never had one (§5.6). + final bool keptOnServer; const MailRecord(this.id, this.envelope, - {this.receivedAt, this.recipient, this.sentAt}); + {this.receivedAt, + this.recipient, + this.sentAt, + this.tier = tierMain, + this.keptOnServer = false}); +} + +const tierMain = 0, tierRequests = 1; + +/// A correspondent admitted to this mailbox's main tier (§5.8). The identity +/// is frozen at acceptance because the token is derived from it: a contact's +/// later rotation must not change the token they already hold. +class AcceptedContact { + final Uint8List identity; + final bool active; + + const AcceptedContact(this.identity, this.active); } class ImportSummary { @@ -87,49 +109,66 @@ class SmolStore { _state.put(_stateKey, next); } - // --- identity -------------------------------------------------------------- + // --- identity (§2) ----------------------------------------------------------- - Uint8List? seed() { - final raw = _load()["seed"]; + /// The 32-byte master secret, or null before the user creates or restores + /// one. Every signing key is derived from it plus the rotation index. + Uint8List? master() { + final raw = _load()["master"]; return raw == null ? null : unhex(raw as String); } + /// The rotation index (§7) of the identity currently in use. + int rotations() => (_load()["rotations"] as int?) ?? 0; + /// The active identity, or null before the user creates or restores one. SmolIdentity? identity() { - final s = seed(); - return s == null ? null : identityFromSeed(s); + final m = master(); + return m == null ? null : identityFromSeed(identitySeed(m, rotations())); } - void setIdentity(Uint8List newSeed) { - if (seed() != null) { - throw const SmolIdentityExistsException(); + /// Whether the accepted-correspondent set held here may replace the + /// server's on the next AUTH — false right after a restore from the master + /// alone, whose empty set must not erase the server's (§4). + bool syncOk() => (_load()["syncOk"] as bool?) ?? true; + + void setSyncOk(bool ok) => _update((state) => state..["syncOk"] = ok); + + void _bindMaster(Uint8List newMaster, int rotations, bool syncOk) { + if (master() != null) throw const SmolIdentityExistsException(); + _update((state) => state + ..["master"] = hex(newMaster) + ..["rotations"] = rotations + ..["syncOk"] = syncOk); + setCursor(0, Uint8List(idLen)); + } + + /// A fresh identity: rotation index 0, and an empty accepted set is + /// already complete, so it may sync. + void setMaster(Uint8List newMaster) => _bindMaster(newMaster, 0, true); + + /// §2: recovering a master alone does not recover which correspondents were + /// accepted, so that set must not overwrite the server's until rebuilt. + void restoreMaster(Uint8List newMaster, int rotationIndex) => + _bindMaster(newMaster, rotationIndex, false); + + // Rotation (§7): only the index advances; the superseded key stays + // derivable from the master, so nothing has to be archived. + void advanceRotation() { + final current = rotations(); + if (master() == null) throw const SmolNoIdentityException(); + if (current >= maxChain) { + throw SmolError("the rotation chain is full at $maxChain links"); } - _update((state) => state..["seed"] = hex(newSeed)); + _update((state) => state..["rotations"] = current + 1); } - // Rotation (§7): the old seed is retained, since mail sealed to a - // superseded key is readable with nothing else. - void rotateIdentity(Uint8List newSeed) { - final old = seed(); - if (old == null) throw const SmolNoIdentityException(); - _update((state) { - final retired = (state["retired"] as List? ?? []) - ..add({"seed": hex(old), "at": DateTime.now().millisecondsSinceEpoch}); - state["retired"] = retired; - state["seed"] = hex(newSeed); - return state; - }); - } - - /// §7: seeds rotated away from are retained, since mail sealed to a - /// superseded key is readable with nothing else. + /// §7: every key rotated away from is re-derivable from the master, since + /// mail sealed to a superseded key is readable with nothing else. List identities() { - final s = seed(); - if (s == null) return const []; - final retired = (_load()["retired"] as List? ?? const []) - .whereType() - .map((entry) => identityFromSeed(unhex(entry["seed"] as String))); - return [identityFromSeed(s), ...retired]; + final m = master(); + if (m == null) return const []; + return [for (var n = rotations(); n >= 0; n--) identityFromSeed(identitySeed(m, n))]; } // --- account and server pins ------------------------------------------------- @@ -176,6 +215,31 @@ class SmolStore { return [for (final e in servers.entries) (e.key, b32decode(e.value))]; } + // --- FETCH behavior -------------------------------------------------------- + + /// When true, FETCH does not acknowledge (delete) what it retrieves — + /// mail stays on the server until explicitly deleted. Defaults to the + /// original behavior: fetched mail is acknowledged immediately. + bool leaveOnServer() => (_load()["leaveOnServer"] as bool?) ?? false; + + void setLeaveOnServer(bool value) => + _update((state) => state..["leaveOnServer"] = value); + + // --- FETCH cursor (§6.1) ------------------------------------------------------- + + (int, Uint8List) cursor() { + final state = _load(); + final afterId = state["afterId"] as String?; + return ( + (state["afterTime"] as int?) ?? 0, + afterId == null ? Uint8List(idLen) : unhex(afterId), + ); + } + + void setCursor(int afterTime, Uint8List afterId) => _update((state) => state + ..["afterTime"] = afterTime + ..["afterId"] = hex(afterId)); + // --- contacts ------------------------------------------------------------------ StoredContact? contact(String address) { @@ -233,6 +297,82 @@ class SmolStore { return [for (final entry in contacts.entries) (entry.key, contact(entry.key)!)]; } + // --- accept tokens (§5.8) -------------------------------------------------------- + + AcceptedContact? accepted(String address) { + final a = ((_load()["accepted"] as Map?) ?? {})[address]; + if (a is! Map) return null; + return AcceptedContact(b32decode(a["identity"] as String), a["active"] as bool); + } + + /// Admit a contact to the main tier. The identity is frozen at acceptance — + /// re-accepting after a block must not change which key the token is + /// derived from (§5.8). + void accept(String address, Uint8List identity) { + _update((state) { + final accepted = (state["accepted"] as Map? ?? {}).cast(); + final previous = accepted[address]; + accepted[address] = { + "identity": previous?["identity"] ?? b32encode(identity), + "active": true, + "addedAt": previous?["addedAt"] ?? DateTime.now().millisecondsSinceEpoch, + }; + state["accepted"] = accepted; + return state; + }); + } + + /// Withdraw a contact's accept token; their mail lands in the requests tier + /// from their next message on. Throws if the contact was never accepted. + void block(String address) { + final accepted = (_load()["accepted"] as Map? ?? {}).cast(); + if (!accepted.containsKey(address)) { + throw SmolError("$address was never accepted"); + } + _update((state) { + final accepted = (state["accepted"] as Map? ?? {}).cast(); + accepted[address] = {...accepted[address]!, "active": false}; + state["accepted"] = accepted; + return state; + }); + } + + List<(String, AcceptedContact)> allAccepted() { + final accepted = ((_load()["accepted"] as Map?) ?? {}).cast(); + return [for (final e in accepted.entries) (e.key, this.accepted(e.key)!)]; + } + + /// §4: the tokens to push with AUTH, and whether to push at all. A client + /// that cannot vouch for its own set — one restored from the master alone — + /// must not replace the server's with an incomplete one. + (int, List) tokenSet(Uint8List master) { + if (!syncOk()) return (0, const []); + final active = allAccepted().where((e) => e.$2.active).toList() + ..sort((a, b) => a.$1.compareTo(b.$1)); + return (1, [for (final e in active) tokenFor(master, e.$2.identity)]); + } + + /// A token received from a correspondent, filed under the address that + /// issued it: an address outlives the keys behind it, so the token keeps + /// working across the issuer's rotations (§5.8). + Uint8List? tokenFrom(String address) { + final raw = ((_load()["tokens"] as Map?) ?? {})[address]; + if (raw is! Map) return null; + return b32decode(raw["token"] as String); + } + + void learnToken(String address, Uint8List token) { + _update((state) { + final tokens = (state["tokens"] as Map? ?? {}).cast(); + tokens[address] = { + "token": b32encode(token), + "seenAt": DateTime.now().millisecondsSinceEpoch, + }; + state["tokens"] = tokens; + return state; + }); + } + // --- read markers --------------------------------------------------------------- void markRead(String idHex) { @@ -255,6 +395,8 @@ class SmolStore { "receivedAt": record.receivedAt, "recipient": record.recipient, "sentAt": record.sentAt, + "tier": record.tier, + "keptOnServer": record.keptOnServer, }; MailRecord _mapToRecord(Map map) => MailRecord( @@ -263,10 +405,18 @@ class SmolStore { receivedAt: map["receivedAt"] as int?, recipient: map["recipient"] as String?, sentAt: map["sentAt"] as int?, + tier: (map["tier"] as int?) ?? tierMain, + keptOnServer: (map["keptOnServer"] as bool?) ?? false, ); static String mailKey(String folder, String id) => "$folder/$id"; + // "requests" is a view over the same physical "inbox" records, filtered by + // tier (§5.8) — not a separate folder, so a message keeps one identity + // regardless of which tier it arrived in. + static String _physicalFolder(String folder) => + folder == "requests" ? "inbox" : folder; + Future storeMessage(String folder, MailRecord record) => _mail.put(mailKey(folder, record.id), _recordToMap(record)); @@ -279,12 +429,17 @@ class SmolStore { } List listMessages(String folder) { - final prefix = "$folder/"; + final physical = _physicalFolder(folder); + final prefix = "$physical/"; + final wantTier = folder == "requests" ? tierRequests : tierMain; final rows = []; for (final key in _mail.keys.cast()) { if (!key.startsWith(prefix)) continue; final row = _mail.get(key); - if (row is Map) rows.add(_mapToRecord(row)); + if (row is! Map) continue; + final record = _mapToRecord(row); + if (physical == "inbox" && record.tier != wantTier) continue; + rows.add(record); } rows.sort((a, b) => (b.receivedAt ?? b.sentAt ?? 0).compareTo(a.receivedAt ?? a.sentAt ?? 0)); @@ -292,29 +447,29 @@ class SmolStore { } MailRecord? getMessage(String folder, String id) { - final row = _mail.get(mailKey(folder, id)); + final row = _mail.get(mailKey(_physicalFolder(folder), id)); return row is Map ? _mapToRecord(row) : null; } Future deleteMessage(String folder, String id) => - _mail.delete(mailKey(folder, id)); + _mail.delete(mailKey(_physicalFolder(folder), id)); // --- export / import: mail, contacts, pins — never the seed -------------------- /// Label kept as gsmol wrote it originally; the export format version /// (gsmolExport) is what actually changed between v1 and v2. static final _exportLabel = utf8Bytes("gsmol/1 export"); - Uint8List _exportKey(Uint8List seed) => - hkdfSha256(seed, Uint8List(0), _exportLabel, 32); + Uint8List _exportKey(Uint8List master) => + hkdfSha256(master, Uint8List(0), _exportLabel, 32); /// v2 matches gsmol's own current export: the whole payload — mail, - /// contacts, pins — is sealed to a key derived from the identity's seed, so - /// a backup file is only readable by whoever holds that seed. Deliberately - /// excludes the seed itself: it has its own reveal-and-copy flow in - /// settings, meant for a password manager, not a shareable file. + /// contacts, pins — is sealed to a key derived from the identity's master, + /// so a backup file is only readable by whoever holds that master. + /// Deliberately excludes the master itself: it has its own reveal-and-copy + /// flow in settings, meant for a password manager, not a shareable file. Map exportData() { - final seed = this.seed(); - if (seed == null) throw const SmolError("no identity yet"); + final master = this.master(); + if (master == null) throw const SmolError("no identity yet"); final state = _load(); final contacts = ((state["contacts"] as Map?) ?? {}).cast(); final payload = { @@ -329,11 +484,13 @@ class SmolStore { } }, "inbox": [ - for (final row in listMessages("inbox")) + for (final row in [...listMessages("inbox"), ...listMessages("requests")]) { "id": row.id, "receivedAt": row.receivedAt, "envelope": base64Encode(row.envelope), + "tier": row.tier, + "keptOnServer": row.keptOnServer, } ], "sent": [ @@ -348,7 +505,7 @@ class SmolStore { }; final nonce = randomBytes(12); final ciphertext = aeadEncrypt( - _exportKey(seed), nonce, utf8Bytes(jsonEncode(payload)), Uint8List(0)); + _exportKey(master), nonce, utf8Bytes(jsonEncode(payload)), Uint8List(0)); return { "gsmolExport": 2, "exportedAt": DateTime.now().millisecondsSinceEpoch, @@ -364,13 +521,13 @@ class SmolStore { Future importData(Map data) async { Map payload; if (data["gsmolExport"] == 2) { - final seed = this.seed(); - if (seed == null) { + final master = this.master(); + if (master == null) { throw const SmolError("no identity yet — restore it before importing"); } try { final plaintext = aeadDecrypt( - _exportKey(seed), + _exportKey(master), base64Decode(data["nonce"] as String), base64Decode(data["ciphertext"] as String), Uint8List(0), @@ -447,6 +604,8 @@ class SmolStore { receivedAt: folder == "inbox" ? map["receivedAt"] as int? : null, recipient: folder == "sent" ? map["recipient"] as String? : null, sentAt: folder == "sent" ? map["sentAt"] as int? : null, + tier: (map["tier"] as int?) ?? tierMain, + keptOnServer: (map["keptOnServer"] as bool?) ?? false, ); if (await storeIfNew(folder, record) != null) summary.mailAdded++; } on Exception { @@ -481,6 +640,14 @@ class SmolStore { } return count; } + + int requestsUnreadCount() { + var count = 0; + for (final row in listMessages("requests")) { + if (!isRead(row.id)) count++; + } + return count; + } } /// The store throws these typed errors so the UI can tell "no identity yet" diff --git a/test/e2e_test.dart b/test/e2e_test.dart index 4db7129..f137bfe 100644 --- a/test/e2e_test.dart +++ b/test/e2e_test.dart @@ -1,13 +1,15 @@ // End-to-end against a live reference server: register an address on a -// locally running smolmaild, send a sealed message to ourselves, fetch it back, -// and check the server is drained afterwards. Skips when nothing listens on -// 127.0.0.1:1961, so `devbox run test` does not depend on a server. +// locally running smolmaild, send sealed messages to ourselves, fetch them +// back, exercise the accept-token round trip (§5.8) between the requests and +// main tiers, and check the server is drained afterwards. Skips when nothing +// listens on 127.0.0.1:1961, so `devbox run test` does not depend on a server. // // To run it: (cd ../smolmail && uv run smolmaild.py keygen --key server.key && // uv run smolmaild.py serve --key server.key --db mail.db) // then `devbox run test`. import "dart:io"; +import "dart:typed_data"; import "package:flutter_test/flutter_test.dart"; import "package:hive_flutter/hive_flutter.dart"; @@ -47,7 +49,8 @@ void main() { final warnings = []; client.onWarning = warnings.add; - final me = client.createIdentity(); + final master = client.createIdentity(); + final me = client.identity!; final user = "e2e${hex(randomBytes(4))}"; final address = parseAddress("$user@$host"); final learned = await client.connect(address, requirePin: false); @@ -67,13 +70,16 @@ void main() { expect(summary.stored, 2); expect(summary.rejected, isEmpty); - final inbox = store.listMessages("inbox"); - expect(inbox.length, 2); + // §5.8: we have not accepted ourselves as a correspondent yet, so this + // unsolicited self-mail lands in the requests tier, not the main one. + expect(store.listMessages("inbox"), isEmpty); + final requests = store.listMessages("requests"); + expect(requests.length, 2); // receivedAt has second granularity, so the order of the two is not // guaranteed; assert on the pair, then open the one we care about. - final subjects = inbox.map((m) => client.describe(m).subject).toSet(); + final subjects = requests.map((m) => client.describe(m).subject).toSet(); expect(subjects, {"hello e2e", "second"}); - final hello = inbox.firstWhere( + final hello = requests.firstWhere( (m) => client.describe(m).subject == "hello e2e"); final opened = client.describe(hello); expect(opened.error, isNull); @@ -84,9 +90,27 @@ void main() { final again = await client.fetch(); expect(again.stored, 0); + // Accept ourselves as a correspondent (§5.8): the change is pushed to the + // server right away. This next message carries our own Accept field, but + // no MAC yet — we cannot know our own token before receiving and parsing + // a message that carries it — so it still lands in requests. + await client.acceptContact(address.short); + await client.send(address.short, "third", "still unsolicited"); + expect((await client.fetch()).stored, 1); + expect(store.listMessages("requests").length, 3); + expect(store.listMessages("inbox"), isEmpty); + + // Having now learned our own token from that message's Accept field, the + // next one carries a matching MAC and reaches the main tier. + await client.send(address.short, "fourth", "now accepted"); + expect((await client.fetch()).stored, 1); + final mainTier = store.listMessages("inbox"); + expect(mainTier.length, 1); + expect(client.describe(mainTier.single).subject, "fourth"); + // The sent copy is sealed to ourselves and readable (§5.6). final sent = store.listMessages("sent"); - expect(sent.length, 2); + expect(sent.length, 4); expect(client.describe(sent.first).error, isNull); // A restored seed can rebind the address without REGISTER; the address @@ -96,13 +120,13 @@ void main() { expect( () => client.recallAccount("nobody@$host"), throwsA(isA())); - // Restore on a second device: same seed, fresh store, no pin. Unpinned + // Restore on a second device: same master, fresh store, no pin. Unpinned // recall is refused; re-registering a taken name is refused; recall with // the operator-supplied key then binds the account without REGISTER. final restored = await SmolStore.open( stateBox: "e2e-restore-state", mailBox: "e2e-restore-mail"); final secondDevice = SmolClient(restored); - restored.setIdentity(me.seed); + restored.setMaster(master); expect( secondDevice.recallAccount(address.short), throwsA(isA())); restored.pinServer(host, learned.serverStatic); @@ -118,4 +142,50 @@ void main() { expect(outcome.message, contains("key unchanged")); expect(store.contact(address.short)!.history, isEmpty); }, timeout: const Timeout(Duration(minutes: 2))); + + test("leave mail on server keeps mail until deleted, with dedupe on refetch", + () async { + if (!await serverUp()) { + markTestSkipped("no smolmaild on $host:$port"); + return; + } + final dir = await Directory.systemTemp.createTemp("smol-e2e-keep"); + Hive.init(dir.path); + final store = + await SmolStore.open(stateBox: "e2e-keep-state", mailBox: "e2e-keep-mail"); + final client = SmolClient(store); + + client.createIdentity(); + final user = "e2ekeep${hex(randomBytes(4))}"; + final address = parseAddress("$user@$host"); + final learned = await client.connect(address, requirePin: false); + store.pinServer(host, learned.serverStatic); + learned.session.wire.close(); + await client.registerAccount(address.short); + + store.setLeaveOnServer(true); + await client.send(address.short, "kept", "stays on the server until deleted"); + + final first = await client.fetch(); + expect(first.stored, 1); + final record = store.listMessages("requests").single; + expect(record.keptOnServer, isTrue); + expect(client.describe(record).subject, "kept"); + + // Re-fetching from scratch must not duplicate it locally (storeIfNew's + // dedupe), even though the server still has it (nothing was deleted). + store.setCursor(0, Uint8List(idLen)); + final second = await client.fetch(); + expect(second.stored, 0); + expect(store.listMessages("requests").length, 1); + + // Deleting removes it from the server too: a further full re-page after + // deletion must come back empty rather than resurrecting it. + await client.deleteMessage("requests", record); + expect(store.listMessages("requests"), isEmpty); + store.setCursor(0, Uint8List(idLen)); + final third = await client.fetch(); + expect(third.stored, 0); + expect(store.listMessages("requests"), isEmpty); + }, timeout: const Timeout(Duration(minutes: 2))); } diff --git a/test/recall_flow_test.dart b/test/recall_flow_test.dart index 7f48832..147ebbc 100644 --- a/test/recall_flow_test.dart +++ b/test/recall_flow_test.dart @@ -1,4 +1,4 @@ -// Regression tests for the onboarding recall flows: restoring a seed and +// Regression tests for the onboarding recall flows: restoring a master and // recalling the registered address must land the user in the inbox. The // client's network operations are stubbed; what is under test is the UI and // router flow itself. @@ -14,14 +14,28 @@ import "package:smol_mail/data/providers/providers.dart"; import "package:smol_mail/presentation/app_widget.dart"; import "package:smol_mail/smol/client.dart"; import "package:smol_mail/smol/crypto.dart"; +import "package:smol_mail/smol/errors.dart"; import "package:smol_mail/smol/proto.dart"; import "package:smol_mail/smol/store.dart"; -/// A [SmolClient] whose recall completes instantly, so the test exercises -/// the flow rather than the network. +/// A [SmolClient] whose network operations complete instantly, so the test +/// exercises the flow rather than the network. [restoreAndRecall] still +/// enforces the pin gate (SPEC.md §4), since that gate is exactly what the +/// restore flow's UX is regression-tested against. class StubClient extends SmolClient { StubClient(super.store); + @override + Future restoreAndRecall(String masterHex, String addressText) async { + final addr = parseAddress(addressText); + if (store.serverPin(addr.host) == null) { + throw SmolError("no pinned key for ${addr.host}"); + } + store.restoreMaster(unhex(masterHex.trim()), 0); + store.setAccount(addr); + return addr; + } + @override Future recallAccount(String addressText) async { final addr = parseAddress(addressText); @@ -58,7 +72,7 @@ void main() { "restore with an address, but no pin yet, asks for the server key " "and then recalls into the inbox", (tester) async { final store = storeA; - final seed = randomBytes(32); + final master = randomBytes(32); await tester.pumpWidget(app(store)); await tester.pumpAndSettle(); expect(find.text("Create Identity"), findsOneWidget); @@ -68,7 +82,7 @@ void main() { var fields = find.byType(TextField); expect(fields, findsNWidgets(2)); - await tester.enterText(fields.at(0), hex(seed)); + await tester.enterText(fields.at(0), hex(master)); await tester.enterText(fields.at(1), "randogoth@smol.place"); await tester.tap(find.text("Restore")); await tester.pumpAndSettle(); @@ -80,6 +94,9 @@ void main() { expect(find.text("Pin this server's public key to finish restoring your address."), findsOneWidget); expect(find.text("Invite token (optional)"), findsNothing); + // Restoring has no well-defined "register a new address instead" escape + // hatch until the rotation index is known (§2). + expect(find.text("Register a new address instead"), findsNothing); fields = find.byType(TextField); expect(fields, findsNWidgets(2)); // address (carried over), server key await tester.enterText(fields.at(1), b32encode(randomBytes(32))); @@ -87,84 +104,71 @@ void main() { await tester.pumpAndSettle(); expect(find.text("Inbox"), findsOneWidget); - expect(store.seed(), seed); + expect(store.master(), master); expect(store.account()!.user, "randogoth"); }); - testWidgets("restore without an address recalls from the register step", + testWidgets("restore requires an address before it will submit", (tester) async { final store = storeB; - final seed = randomBytes(32); await tester.pumpWidget(app(store)); await tester.pumpAndSettle(); await tester.tap(find.text("Restore From Seed")); await tester.pumpAndSettle(); - var fields = find.byType(TextField); - await tester.enterText(fields.at(0), hex(seed)); + final fields = find.byType(TextField); + await tester.enterText(fields.at(0), hex(randomBytes(32))); await tester.tap(find.text("Restore")); await tester.pumpAndSettle(); - // The register step: address, server key, optional invite token. - fields = find.byType(TextField); - expect(fields, findsNWidgets(3)); - await tester.enterText(fields.at(0), "randogoth@smol.place"); - await tester.enterText(fields.at(1), b32encode(randomBytes(32))); - await tester.tap(find.text("Already registered? Recall")); - await tester.pumpAndSettle(); - - expect(find.text("Inbox"), findsOneWidget); - expect(store.account()!.user, "randogoth"); + // Nothing was submitted, so nothing was persisted; still on this step. + expect(find.text("Restore"), findsOneWidget); + expect(find.text("Inbox"), findsNothing); + expect(store.master(), isNull); }); testWidgets( - "restoring again after an incomplete attempt replaces the identity " + "restoring after an abandoned Create Identity attempt replaces it " "instead of refusing it", (tester) async { final store = storeC; - final abandonedSeed = randomBytes(32); - final realSeed = randomBytes(32); + final realMaster = randomBytes(32); await tester.pumpWidget(app(store)); await tester.pumpAndSettle(); - // First attempt: restore a seed but never finish registering — lands on - // the register step, identity set, no account bound. - await tester.tap(find.text("Restore From Seed")); + // First attempt: create a fresh identity but never finish registering — + // lands on the backup step, master set, no account bound. + await tester.tap(find.text("Create Identity")); await tester.pumpAndSettle(); - var fields = find.byType(TextField); - await tester.enterText(fields.at(0), hex(abandonedSeed)); - await tester.tap(find.text("Restore")); - await tester.pumpAndSettle(); - expect(store.seed(), abandonedSeed); + expect(store.master(), isNotNull); expect(store.account(), isNull); // Simulate returning to onboarding later (e.g. a cold restart). Pumping // app(store) directly would just rebuild the existing OnboardingScreen - // state in place (still parked on the register step) rather than really - // restarting, so tear the tree down first to force a fresh app state — - // HomeGuard then sends an identity-without-account back to welcome. + // state in place rather than really restarting, so tear the tree down + // first to force a fresh app state — HomeGuard then sends an + // identity-without-account back to welcome. await tester.pumpWidget(const SizedBox()); await tester.pumpWidget(app(store)); await tester.pumpAndSettle(); expect(find.text("Create Identity"), findsOneWidget); - // Restoring a different seed must not throw "identity already exists". + // Restoring a different master must not throw "identity already exists". await tester.tap(find.text("Restore From Seed")); await tester.pumpAndSettle(); - fields = find.byType(TextField); - await tester.enterText(fields.at(0), hex(realSeed)); + final fields = find.byType(TextField); + await tester.enterText(fields.at(0), hex(realMaster)); await tester.enterText(fields.at(1), "randogoth@smol.place"); await tester.tap(find.text("Restore")); await tester.pumpAndSettle(); // Lands on the recall-framed register step (no pin yet); pin it and finish. - fields = find.byType(TextField); - expect(fields, findsNWidgets(2)); - await tester.enterText(fields.at(1), b32encode(randomBytes(32))); + expect(find.byType(TextField), findsNWidgets(2)); + await tester.enterText(find.byType(TextField).at(1), b32encode(randomBytes(32))); await tester.tap(find.text("Pin and Recall")); await tester.pumpAndSettle(); expect(find.text("Inbox"), findsOneWidget); - expect(store.seed(), realSeed); + expect(store.master(), realMaster); }); } diff --git a/test/smol_test.dart b/test/smol_test.dart index be77744..12ab9e7 100644 --- a/test/smol_test.dart +++ b/test/smol_test.dart @@ -163,24 +163,28 @@ void main() { }); test("frontmatter parses and fails closed (§5.5)", () { - const spec = - "---\nSubject: Re: the thing\nIn-Reply-To: 4f2a1c9e8b7d6a5f3e2d1c0b9a8f7e6d\nX-Mood: cautiously optimistic\n---\nBody text starts here."; + const inReplyTo = + "4f2a1c9e8b7d6a5f3e2d1c0b9a8f7e6d5c4b3a291807f6e5d4c3b2a1908f7e6d5"; + final spec = + "---\nSubject: Re: the thing\nIn-Reply-To: $inReplyTo\nX-Mood: cautiously optimistic\n---\nBody text starts here."; final parsed = parseFrontmatter(spec); - expect(parsed.fields["Subject"], "Re: the thing"); - expect(parsed.fields["In-Reply-To"], "4f2a1c9e8b7d6a5f3e2d1c0b9a8f7e6d"); + expect(parsed.fields["subject"], "Re: the thing"); + expect(parsed.fields["in-reply-to"], inReplyTo); expect(parsed.body, "Body text starts here."); // a malformed line invalidates the whole block, which fails closed toward display expect(parseFrontmatter("---\nno colon here\n---\nrest").body, "---\nno colon here\n---\nrest"); expect(parseFrontmatter("---\nSubject: x\nno end").body, "---\nSubject: x\nno end"); - expect(parseFrontmatter("---\nA: 1\nA: 2\n---\ntext").fields["A"], "1"); + expect(parseFrontmatter("---\nA: 1\nA: 2\n---\ntext").fields["a"], "1"); + // keys compare case-insensitively; the first occurrence wins (§5.5) + expect(parseFrontmatter("---\nSubject: x\nsubject: y\n---\ntext").fields["subject"], "x"); expect(buildFrontmatter(const {}, "---\nactual body"), "---\n---\n---\nactual body"); expect(buildFrontmatter({"Subject": "hi"}, "there"), "---\nSubject: hi\n---\nthere"); expect(buildFrontmatter(const {}, "plain"), "plain"); expect( - parseFrontmatter("---\n${"X: y\n" * 65}---\nbody").fields["Subject"], + parseFrontmatter("---\n${"X: y\n" * 65}---\nbody").fields["subject"], isNull); }); @@ -195,6 +199,9 @@ void main() { expect(parsed.identity, key); expect(parsed.user, "bob"); expect(() => parseAddress("-bob@h"), throwsA(isA())); + // §3: never two separators in a row + expect(() => parseAddress("a..b@h"), throwsA(isA())); + expect(parseAddress("a.b_c@h").user, "a.b_c"); // §3: fingerprints are the first 20 base32 characters in groups of four final b32 = b32encode(key); expect( @@ -210,27 +217,31 @@ void main() { }); test("rotation chains validate, break and oversize per §7", () { + const username = "alice"; final old = identityFromSeed(randomBytes(32)); final mid = randomBytes(32); final fresh = randomBytes(32); final when = nowSeconds(); final chain = [ - makeCert(old, mid, when), - makeCert(identityFromSeed(mid), fresh, when), + makeCert(username, old, mid, when), + makeCert(username, identityFromSeed(mid), fresh, when), ]; - expect(walkChain(old.publicKey, ed25519PublicKey(fresh), chain), isTrue); - expect(walkChain(old.publicKey, old.publicKey, []), isTrue); + expect(walkChain(username, old.publicKey, ed25519PublicKey(fresh), chain), isTrue); + expect(walkChain(username, old.publicKey, old.publicKey, []), isTrue); expect( - walkChain(old.publicKey, ed25519PublicKey(fresh), chain.sublist(1)), + walkChain(username, old.publicKey, ed25519PublicKey(fresh), chain.sublist(1)), isFalse); final forged = List.from(chain); - forged[1] = makeCert(identityFromSeed(mid), randomBytes(32), when); + forged[1] = makeCert(username, identityFromSeed(mid), randomBytes(32), when); expect( - walkChain(old.publicKey, ed25519PublicKey(fresh), forged), isFalse); + walkChain(username, old.publicKey, ed25519PublicKey(fresh), forged), isFalse); expect( - walkChain(old.publicKey, ed25519PublicKey(fresh), + walkChain(username, old.publicKey, ed25519PublicKey(fresh), List.filled(17, chain[0])), isFalse); + // a chain signed for a different username must not validate (§7) + expect( + walkChain("bob", old.publicKey, ed25519PublicKey(fresh), chain), isFalse); expect(chain[0].length, certLen); }); diff --git a/test/store_test.dart b/test/store_test.dart index bf8ea19..7d6abe8 100644 --- a/test/store_test.dart +++ b/test/store_test.dart @@ -63,11 +63,11 @@ void main() { expect(saved.key, identity.publicKey); }); - test("export never contains the seed and round-trips through import", + test("export never contains the master secret and round-trips through import", () async { final a = await freshStore("export"); final b = await freshStore("round-trip"); - a.setIdentity(randomBytes(32)); + a.setMaster(randomBytes(32)); a.pinServer("example.org", randomBytes(32)); a.saveContact("alice@example.org", randomBytes(32), true); a.saveContact("alice@example.org", randomBytes(32), false); // history grows @@ -77,12 +77,12 @@ void main() { MailRecord("bb", randomBytes(64), recipient: "bob@example.org", sentAt: 6)); final data = a.exportData(); - expect(data["gsmolExport"], 2); // sealed to the identity's seed, like gsmol - expect(jsonEncode(data).contains(hex(a.seed()!)), isFalse); + expect(data["gsmolExport"], 2); // sealed to the identity's master, like gsmol + expect(jsonEncode(data).contains(hex(a.master()!)), isFalse); - // v2 is sealed to the exporting identity's seed — a restore-on-new-device + // v2 is sealed to the exporting identity's master — a restore-on-new-device // scenario, not a transfer to someone else's identity (see the test below). - b.setIdentity(a.seed()!); + b.setMaster(a.master()!); final summary = await b.importData(data); expect(summary.mailAdded, 2); expect(summary.pinsAdded, 1); @@ -96,14 +96,58 @@ void main() { test("v2 import refuses a different identity's export", () async { final a = await freshStore("export-wrong-identity"); final c = await freshStore("round-trip-wrong-identity"); - a.setIdentity(randomBytes(32)); + a.setMaster(randomBytes(32)); a.pinServer("example.org", randomBytes(32)); final data = a.exportData(); - c.setIdentity(randomBytes(32)); // a different seed than a's + c.setMaster(randomBytes(32)); // a different master than a's expect(() => c.importData(data), throwsA(isA())); }); + test("accept tokens: accept/block gate the sync set, tiers split the inbox view", + () async { + final store = await freshStore("accept-tokens"); + final master = randomBytes(32); + store.setMaster(master); + final alice = randomBytes(32); + store.saveContact("alice@example.org", alice, true); + + // No one accepted yet: an empty set is already complete, so it may sync. + var (sync, tokens) = store.tokenSet(master); + expect(sync, 1); + expect(tokens, isEmpty); + + store.accept("alice@example.org", alice); + (sync, tokens) = store.tokenSet(master); + expect(sync, 1); + expect(tokens, [tokenFor(master, alice)]); + expect(store.accepted("alice@example.org")!.active, isTrue); + + store.block("alice@example.org"); + expect(store.accepted("alice@example.org")!.active, isFalse); + expect(store.tokenSet(master).$2, isEmpty); + // Re-accepting keeps the identity frozen at the original acceptance, + // so the token a correspondent already holds keeps working. + store.accept("alice@example.org", randomBytes(32)); + expect(store.accepted("alice@example.org")!.identity, alice); + + expect(() => store.block("bob@example.org"), throwsA(isA())); + + // A restored master must not silently replace the server's set. + store.setSyncOk(false); + (sync, tokens) = store.tokenSet(master); + expect(sync, 0); + expect(tokens, isEmpty); + + await store.storeIfNew( + "inbox", MailRecord("aa", randomBytes(64), receivedAt: 1, tier: tierMain)); + await store.storeIfNew("inbox", + MailRecord("bb", randomBytes(64), receivedAt: 2, tier: tierRequests)); + expect(store.listMessages("inbox").map((r) => r.id), ["aa"]); + expect(store.listMessages("requests").map((r) => r.id), ["bb"]); + expect(store.getMessage("requests", "bb"), isNotNull); + }); + test("v1 legacy export still imports without an identity", () async { final store = await freshStore("import-legacy-v1"); final summary = await store.importData({ diff --git a/test/vectors.json b/test/vectors.json index 61fb897..7acb656 100644 --- a/test/vectors.json +++ b/test/vectors.json @@ -190,7 +190,7 @@ "body": "2d2d2d0a5375626a6563743a20766563746f720a2d2d2d0a68656c6c6f20626f620a", "time": 1730000000, "envelope": "534d4f4c01e048814b56d9b82e54fd367d3c980661313cc6a3d81a80315561fc0ac87f81184e49921528d72321669ae1b275229800d8786c1ecdd7d9331bcb2cc64dc27c0399b106b5061e9105d8adf82f6b7464930fa31cb08ba85dba4764ce14cf3ddc32599f43fea73ced76ffa3a3b768e5a127034f5e82d667687369c19f060e8eafb09da0e212683290f4e1a73ce072b94f053c9088652109d3639f7b9aa0d48619626ecefe33855aade6ab8bf9de14ebb7cf07eec0ef9c193ae4537c370183e0c8f7cd7230471b9d8e7389ac654e1b09dc9b0160c875270fdad218f0c9da735bab", - "id": "b05d15a2ab5293164eda564de02a6901", + "id": "b05d15a2ab5293164eda564de02a69019aa97895ff988f3d9fa2be5126516553", "unpadded_plaintext_len": 143 }, "noise": { From 52947e153d886cc718e3631904ba374ae4bc8637 Mon Sep 17 00:00:00 2001 From: randogoth Date: Sun, 27 Sep 2026 17:39:29 +0300 Subject: [PATCH 09/22] feat: adopt bug logo and scale onboarding header with screen width --- assets/images/bug_logo.png | Bin 0 -> 31934 bytes .../screens/onboarding_screen.dart | 30 +++++++++++------- pubspec.yaml | 6 ++-- 3 files changed, 21 insertions(+), 15 deletions(-) create mode 100644 assets/images/bug_logo.png diff --git a/assets/images/bug_logo.png b/assets/images/bug_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..280131637b0da68dfc1163617a0cdb6badd95d00 GIT binary patch literal 31934 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 literal 0 HcmV?d00001 diff --git a/lib/presentation/screens/onboarding_screen.dart b/lib/presentation/screens/onboarding_screen.dart index 38ac6e8..1c26d9b 100644 --- a/lib/presentation/screens/onboarding_screen.dart +++ b/lib/presentation/screens/onboarding_screen.dart @@ -192,20 +192,26 @@ class _OnboardingScreenState extends ConsumerState { ); } - Widget _header(BuildContext context, String title) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 60), - Row( + Widget _header(BuildContext context, String title) => LayoutBuilder( + builder: (context, constraints) { + final logoWidth = + (constraints.maxWidth * 0.5).clamp(48.0, 200.0).toDouble(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Image(image: AssetImage("assets/images/logo-small.png")), - const SizedBox(width: 10), - Text("kirakira", style: Theme.of(context).textTheme.titleLarge), + SizedBox( + width: logoWidth, + child: const Image( + image: AssetImage("assets/images/bug_logo.png"), + fit: BoxFit.contain), + ), + Text("kirakira", + style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 40), + Text(title, style: Theme.of(context).textTheme.titleMedium), ], - ), - const SizedBox(height: 40), - Text(title, style: Theme.of(context).textTheme.titleMedium), - ], + ); + }, ); Widget _welcome(BuildContext context) { diff --git a/pubspec.yaml b/pubspec.yaml index 378c082..52af655 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,8 +34,8 @@ flutter_launcher_icons: android: "launcher_icon" ios: false min_sdk_android: 20 - image_path: assets/images/icon.png - adaptive_icon_foreground: assets/images/icon.png + image_path: assets/images/bug_logo.png + adaptive_icon_foreground: assets/images/bug_logo.png adaptive_icon_background: "#01111d" flutter_native_splash: @@ -50,7 +50,7 @@ flutter_native_splash: flutter: uses-material-design: true assets: - - assets/images/logo-small.png + - assets/images/bug_logo.png - assets/images/empty.png - assets/images/error.png - assets/images/avatar.png From d6f4c434fa3c67b6dba7df5526d3016c40c6e330 Mon Sep 17 00:00:00 2001 From: randogoth Date: Mon, 28 Sep 2026 23:49:39 +0300 Subject: [PATCH 10/22] fix: read back sent copies after the sec 5.6 upstream fix, pinned at 2d65d66 --- android/gradlew.bat | 90 ++ lib/data/providers/providers.dart | 2 +- lib/main.dart | 3 +- lib/native/client.dart | 211 +++ lib/native/ffi.dart | 518 +++++++ lib/native/library.dart | 13 + .../screens/contact_detail_screen.dart | 7 +- lib/presentation/screens/contacts_screen.dart | 2 +- .../screens/message_detail_screen.dart | 3 +- .../screens/onboarding_screen.dart | 13 +- lib/presentation/screens/settings_screen.dart | 16 +- .../widgets/message/message_tile.dart | 3 +- .../widgets/message/message_view.dart | 7 +- lib/smol/address.dart | 48 + lib/smol/client.dart | 608 ++++----- lib/smol/config.dart | 14 +- lib/smol/crypto.dart | 435 ------ lib/smol/noise.dart | 118 -- lib/smol/proto.dart | 663 --------- lib/smol/store.dart | 857 ++++-------- lib/smol/transport.dart | 115 -- lib/smol/ui.dart | 24 + native/Cargo.lock | 738 ++++++++++ native/Cargo.toml | 14 + native/src/bin/dnsprobe.rs | 17 + native/src/ffi.rs | 1205 +++++++++++++++++ native/src/lib.rs | 450 ++++++ test/e2e_test.dart | 107 +- test/ffi_smoke_test.dart | 19 + test/interop_fumi_test.dart | 77 ++ test/native_binding_test.dart | 127 ++ test/recall_flow_test.dart | 31 +- test/smol_test.dart | 317 ----- test/store_test.dart | 241 +--- test/vectors.json | 226 ---- test/widget_test.dart | 2 +- 36 files changed, 4223 insertions(+), 3118 deletions(-) create mode 100755 android/gradlew.bat create mode 100644 lib/native/client.dart create mode 100644 lib/native/ffi.dart create mode 100644 lib/native/library.dart create mode 100644 lib/smol/address.dart delete mode 100644 lib/smol/crypto.dart delete mode 100644 lib/smol/noise.dart delete mode 100644 lib/smol/proto.dart delete mode 100644 lib/smol/transport.dart create mode 100644 lib/smol/ui.dart create mode 100644 native/Cargo.lock create mode 100644 native/Cargo.toml create mode 100644 native/src/bin/dnsprobe.rs create mode 100644 native/src/ffi.rs create mode 100644 native/src/lib.rs create mode 100644 test/ffi_smoke_test.dart create mode 100644 test/interop_fumi_test.dart create mode 100644 test/native_binding_test.dart delete mode 100644 test/smol_test.dart delete mode 100644 test/vectors.json diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100755 index 0000000..aec9973 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/lib/data/providers/providers.dart b/lib/data/providers/providers.dart index 1f1bcaa..c222a18 100644 --- a/lib/data/providers/providers.dart +++ b/lib/data/providers/providers.dart @@ -1,7 +1,7 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:smol_mail/smol/address.dart"; import "package:smol_mail/smol/client.dart"; -import "package:smol_mail/smol/proto.dart"; import "package:smol_mail/smol/store.dart"; /// Overridden in main() with the Hive-backed store opened at boot. diff --git a/lib/main.dart b/lib/main.dart index f01ccdb..4c2beb1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,4 @@ + import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:hive_flutter/hive_flutter.dart"; @@ -16,7 +17,7 @@ void main() async { // one that's actually sandboxed per-app on every platform. final dataDir = await getApplicationSupportDirectory(); Hive.init(dataDir.path); - final store = await SmolStore.open(); + final store = await SmolStore.open(dbPath: "${dataDir.path}/smol-mail.db"); applyPresetServer(store); runApp( diff --git a/lib/native/client.dart b/lib/native/client.dart new file mode 100644 index 0000000..f445693 --- /dev/null +++ b/lib/native/client.dart @@ -0,0 +1,211 @@ +// The async surface over the raw ABI: every operation runs on a short-lived +// isolate via Isolate.run, because the Rust side blocks — a fetch can hold a +// thread for seconds, which must never be the UI thread. Handles are plain +// addresses (ints) and the master is bytes, so both cross isolates freely, +// and fumi-core's `Send + Sync` store is safe to call from any of them. +// +// Error codes cross in two ranges: 0-10 are wire statuses, 64 up are local +// failures (see ffi.dart) — recovery paths branch on the range. After a +// smolPanic the store's lock is poisoned and every later call panics; the +// UI treats that as "close and reopen the store", not an unsolvable error. + +import "dart:isolate"; +import "dart:typed_data"; + +import "package:smol_mail/native/ffi.dart"; + +class FumiNative { + FumiNative(this.dbPath); + + /// The SQLite file the store owns; one store per process. + final String dbPath; + + int? _store; + int? _account; + int? _flag; + + /// The 32-byte master, held by the app (Hive in kirakira), never by the + /// library. + Uint8List? _master; + + int? get store => _store; + + /// The account handle, when a master is set; readers need it for describe. + int? get account => _account; + + /// Binds the account locally, as register and restore do at their end. + void setAccount(String address) => + SmolFfi.open().setAccount(_store!, address); + + Future _io(T Function(SmolFfi ffi) op) => Isolate.run(() { + final ffi = SmolFfi.open(); + return op(ffi); + }); + + Future open() async { + if (_store != null) return; + _store = await _io((ffi) => ffi.openStore(dbPath)); + _flag = await _io((ffi) => ffi.newFlag()); + } + + /// Drops the identity from memory the honest way Dart allows: the + /// master's bytes are overwritten before the reference goes, and the + /// account handle is freed. What persists is the caller's business + /// (the store wipes its own copy). + void clearMaster() { + _master?.fillRange(0, _master!.length, 0); + _master = null; + final old = _account; + if (old != null) { + SmolFfi.open().freeAccount(old); + } + _account = null; + } + + /// Sets the master and rebuilds the account handle synchronously: the + /// rebuild is a few key derivations (microseconds native), not a network + /// operation, so it never needs an isolate — and widget tests can settle + /// it inside their fake-async zones. + void setMaster(Uint8List master, {int? rotations}) { + _master = master; + _rebuildAccount(rotations: rotations); + } + + /// Rebuilds the account handle from the master and the store's rotation + /// index — required after register, restore and rotate, which all change + /// the index the account stands at. + void _rebuildAccount({int? rotations}) { + final ffi = SmolFfi.open(); + final index = rotations ?? ffi.rotations(_store!); + final old = _account; + if (old != null) { + ffi.freeAccount(old); + } + _account = ffi.newAccount(_master!, index); + } + + int get _s => _store!; + int get _a => _account!; + + Future close() async { + final store = _store; + final account = _account; + final flag = _flag; + _store = null; + _account = null; + _flag = null; + if (account != null) await _io((ffi) => ffi.freeAccount(account)); + if (flag != null) await _io((ffi) => ffi.freeFlag(flag)); + if (store != null) await _io((ffi) => ffi.closeStore(store)); + } + + // --- identity and pins ------------------------------------------------------ + + Future accountPk() => _io((ffi) => ffi.accountPk(_a)); + + Future accountAddress() => _io((ffi) => ffi.accountAddress(_s)); + + Future rotations() => _io((ffi) => ffi.rotations(_s)); + + Future pinServer(String host, String keyB32) => + _io((ffi) => ffi.pinServer(_s, host, keyB32)); + + Future serverPin(String host) => + _io((ffi) => ffi.serverPin(_s, host)); + + Future unpinServer(String host) => + _io((ffi) => ffi.unpinServer(_s, host)); + + Future> pins() => _io((ffi) => ffi.pins(_s)); + + Future syncOk() => _io((ffi) => ffi.syncOk(_s)); + + Future saveContact(String address, String keyB32, + {required bool verified}) => + _io((ffi) => ffi.saveContact(_s, address, keyB32, verified: verified)); + + // --- account lifecycle ------------------------------------------------------ + + Future register(String address, + {String? invite, String? dial, int timeout = 30}) => + _io((ffi) { + ffi.register(_s, _a, address, + invite: invite, dial: dial, timeout: timeout); + }).then((_) => _rebuildAccount()); + + Future restore(String address, {String? dial, int timeout = 30}) => + _io((ffi) => + ffi.restore(_s, _master!, address, dial: dial, timeout: timeout)) + .then((_) => _rebuildAccount()); + + Future> rotate({String? dial, int timeout = 30}) => + _io((ffi) => ffi.rotate(_s, _a, dial: dial, timeout: timeout)) + .then((out) { + _rebuildAccount(); + return out; + }); + + // --- mail ------------------------------------------------------------------- + + /// Raises the cancellation flag; the running fetch stops between + /// envelopes and returns a partial summary. + void cancelFetch() => SmolFfi.open().setFlag(_flag ?? 0, true); + + Future> fetch( + {bool keep = false, + bool reset = false, + String? dial, + int timeout = 30}) => + _io((ffi) { + ffi.setFlag(_flag!, false); + return ffi.fetch(_s, _a, + keep: keep, reset: reset, dial: dial, timeout: timeout, + cancel: _flag); + }); + + Future> send(String to, String body, + {String? subject, String? replyTo, String? dial, int timeout = 30}) => + _io((ffi) => ffi.send(_s, _a, to, body, + subject: subject, replyTo: replyTo, dial: dial)); + + Future delete(List idsHex, {String? dial, int timeout = 30}) => + _io((ffi) => ffi.delete(_s, _a, idsHex, dial: dial, timeout: timeout)); + + /// The reader's delete: local removal plus seen-marking, no server round + /// trip. Use [delete] for the server-side DELETE. + Future deleteLocal(String folder, List idsHex) => + _io((ffi) => ffi.deleteLocal(_s, folder, idsHex)); + + Future> mail(String folder) => + _io((ffi) => ffi.mail(_s, folder)); + + Future> describe(String idHex) => + _io((ffi) => ffi.describe(_s, _a, idHex)); + + // --- contacts --------------------------------------------------------------- + + Future> contacts() => _io((ffi) => ffi.contacts(_s)); + + Future> contact(String address) => + _io((ffi) => ffi.contact(_s, address)); + + Future> resolve(String address, + {String? dial, int timeout = 30}) => + _io((ffi) => ffi.resolve(_s, address, dial: dial, timeout: timeout)); + + Future importContact(String uri) => + _io((ffi) => ffi.importContact(_s, uri)); + + Future accept(String address, {String? dial, int timeout = 30}) => + _io((ffi) => ffi.accept(_s, _a, address, dial: dial, timeout: timeout)); + + Future block(String address, {String? dial, int timeout = 30}) => + _io((ffi) => ffi.block(_s, _a, address, dial: dial, timeout: timeout)); + + // --- backups ---------------------------------------------------------------- + + Future exportBackup() => _io((ffi) => ffi.exportBackup(_s, _master!)); + + Future importBackup(String text) => + _io((ffi) => ffi.importBackup(_s, _master!, text)); +} diff --git a/lib/native/ffi.dart b/lib/native/ffi.dart new file mode 100644 index 0000000..32a7d69 --- /dev/null +++ b/lib/native/ffi.dart @@ -0,0 +1,518 @@ +// Raw FFI bindings for the C ABI in native/src/ffi.rs: one lookup per entry +// point, no logic. Handles are opaque pointers at this layer; the typed +// methods accept plain addresses (ints) so they cross isolates freely. +// Every string this ABI returns is freed with smolFreeString — Dart's GC +// runs no Rust destructor. + +import "dart:convert"; +import "dart:ffi"; +import "dart:typed_data"; + +import "package:ffi/ffi.dart"; + +import "package:smol_mail/native/library.dart"; + +// Stable error codes, mirrored from native/src/ffi.rs. The space is split +// like the wire's: 0-10 are protocol status codes, verbatim, and local +// failures live from 64 up, so a recovery path can be picked by range — +// RATE_LIMITED (8) retries on the live session it arrived on, while +// HANDSHAKE_REFUSED (71) means there is no session to retry on. +const smolOk = 0; +const smolMalformed = 1; +const smolBadVersion = 2; +const smolUnknownUser = 3; +const smolAuthRequired = 4; +const smolAuthFailed = 5; +const smolQuotaExceeded = 6; +const smolTooLarge = 7; +const smolRateLimited = 8; +const smolNotPermitted = 9; +const smolInternalError = 10; +/// Unassigned status byte; the raw byte is in the payload's "status". +const smolUnknownStatus = 11; + +const smolNotPinned = 64; +const smolPinMismatch = 65; +const smolKeyChanged = 66; +const smolNotRegistered = 67; +const smolChainLimit = 68; +const smolSchemaVersion = 69; +const smolUnreachable = 70; +const smolHandshakeRefused = 71; +const smolStorage = 72; +const smolNoise = 73; +const smolIo = 74; +const smolOther = 75; +const smolPanic = 76; + +/// What a failed native call reports: the decision code plus the structured +/// payload the ABI carries (host, known, offered — keys as base32). +class NativeSmolException implements Exception { + final int code; + final String message; + final Map details; + + const NativeSmolException(this.code, this.message, this.details); + + @override + String toString() => message; +} + +/// The raw ABI. Opened per isolate — handles are plain addresses, so the +/// same store can be called from any isolate, which is what the async +/// wrapper relies on. +class SmolFfi { + SmolFfi._(this._lib); + + final DynamicLibrary _lib; + + static SmolFfi? _cached; + + factory SmolFfi.open() => _cached ??= SmolFfi._(openSmolLibrary()); + + Pointer _h(int address) => Pointer.fromAddress(address); + + late final Pointer Function() _lastError = _lib + .lookupFunction Function(), Pointer Function()>( + "smol_last_error"); + late final void Function(Pointer) _freeString = _lib.lookupFunction< + Void Function(Pointer), + void Function(Pointer)>("smol_free_string"); + + late final Pointer Function(Pointer) _storeOpen = _lib + .lookupFunction Function(Pointer), + Pointer Function(Pointer)>("smol_store_open"); + late final Pointer Function() _storeMemory = _lib.lookupFunction< + Pointer Function(), + Pointer Function()>("smol_store_memory"); + late final void Function(Pointer) _storeFree = _lib.lookupFunction< + Void Function(Pointer), + void Function(Pointer)>("smol_store_free"); + late final Pointer Function(Pointer, int) _accountNew = _lib + .lookupFunction< + Pointer Function(Pointer, Uint32), + Pointer Function( + Pointer, int)>("smol_account_new"); + late final void Function(Pointer) _accountFree = _lib.lookupFunction< + Void Function(Pointer), + void Function(Pointer)>("smol_account_free"); + late final Pointer Function(Pointer) _accountPk = _lib + .lookupFunction Function(Pointer), + Pointer Function(Pointer)>("smol_account_pk"); + late final Pointer Function() _flagNew = _lib.lookupFunction< + Pointer Function(), + Pointer Function()>("smol_flag_new"); + late final void Function(Pointer, int) _flagSet = _lib.lookupFunction< + Void Function(Pointer, Int32), + void Function(Pointer, int)>("smol_flag_set"); + late final void Function(Pointer) _flagFree = _lib.lookupFunction< + Void Function(Pointer), + void Function(Pointer)>("smol_flag_free"); + + late final int Function(Pointer, Pointer, Pointer) + _pinServer = _lib.lookupFunction< + Int32 Function(Pointer, Pointer, Pointer), + int Function( + Pointer, Pointer, Pointer)>("smol_pin_server"); + late final Pointer Function(Pointer, Pointer) + _serverPin = _lib.lookupFunction< + Pointer Function(Pointer, Pointer), + Pointer Function(Pointer, Pointer)>( + "smol_server_pin"); + late final Pointer Function(Pointer) _storeAccount = _lib + .lookupFunction Function(Pointer), + Pointer Function(Pointer)>("smol_store_account"); + late final int Function(Pointer) _rotations = _lib.lookupFunction< + Int32 Function(Pointer), + int Function(Pointer)>("smol_rotations"); + late final int Function(Pointer, Pointer, Pointer, + Pointer, Pointer, int) _register = _lib.lookupFunction< + Int32 Function(Pointer, Pointer, Pointer, Pointer, + Pointer, Uint64), + int Function(Pointer, Pointer, Pointer, Pointer, + Pointer, int) + >("smol_register"); + late final int Function(Pointer, Pointer, Pointer, + Pointer, int) _restore = _lib.lookupFunction< + Int32 Function(Pointer, Pointer, Pointer, + Pointer, Uint64), + int Function(Pointer, Pointer, Pointer, + Pointer, int)>("smol_restore"); + late final Pointer Function(Pointer, Pointer, + Pointer, int) _rotate = _lib.lookupFunction< + Pointer Function( + Pointer, Pointer, Pointer, Uint64), + Pointer Function(Pointer, Pointer, Pointer, + int)>("smol_rotate"); + late final Pointer Function(Pointer, Pointer, int, int, + Pointer, int, Pointer) _fetch = _lib.lookupFunction< + Pointer Function(Pointer, Pointer, Int32, Int32, + Pointer, Uint64, Pointer), + Pointer Function(Pointer, Pointer, int, int, + Pointer, int, Pointer)>("smol_fetch"); + late final Pointer Function( + Pointer, + Pointer, + Pointer, + Pointer, + Pointer, + Pointer, + int, + int, + Pointer, + int) _send = _lib.lookupFunction< + Pointer Function( + Pointer, + Pointer, + Pointer, + Pointer, + Pointer, + Pointer, + Int32, + Int32, + Pointer, + Uint64), + Pointer Function(Pointer, Pointer, Pointer, + Pointer, Pointer, Pointer, int, int, + Pointer, int)>("smol_send"); + late final int Function(Pointer, Pointer, Pointer, + Pointer, int) _delete = _lib.lookupFunction< + Int32 Function( + Pointer, Pointer, Pointer, Pointer, Uint64), + int Function(Pointer, Pointer, Pointer, + Pointer, int)>("smol_delete"); + late final Pointer Function(Pointer, Pointer) _mail = _lib + .lookupFunction Function(Pointer, Pointer), + Pointer Function( + Pointer, Pointer)>("smol_mail"); + late final Pointer Function(Pointer, Pointer, + Pointer) _describe = _lib.lookupFunction< + Pointer Function(Pointer, Pointer, Pointer), + Pointer Function( + Pointer, Pointer, Pointer)>("smol_describe"); + late final Pointer Function(Pointer) _contacts = _lib + .lookupFunction Function(Pointer), + Pointer Function(Pointer)>("smol_contacts"); + late final Pointer Function(Pointer, Pointer) _contact = + _lib.lookupFunction< + Pointer Function(Pointer, Pointer), + Pointer Function(Pointer, Pointer)>( + "smol_contact"); + late final Pointer Function(Pointer, Pointer, + Pointer, Pointer, int) _accept = _lib.lookupFunction< + Pointer Function( + Pointer, Pointer, Pointer, Pointer, Uint64), + Pointer Function(Pointer, Pointer, Pointer, + Pointer, int)>("smol_accept"); + late final int Function(Pointer, Pointer, Pointer, + Pointer, int) _block = _lib.lookupFunction< + Int32 Function( + Pointer, Pointer, Pointer, Pointer, Uint64), + int Function(Pointer, Pointer, Pointer, + Pointer, int)>("smol_block"); + late final Pointer Function( + Pointer, Pointer, Pointer, int) _resolve = _lib + .lookupFunction< + Pointer Function( + Pointer, Pointer, Pointer, Uint64), + Pointer Function(Pointer, Pointer, + Pointer, int)>("smol_resolve"); + late final int Function(Pointer, Pointer) _accountSet = _lib + .lookupFunction, Pointer), + int Function(Pointer, Pointer)>("smol_account_set"); + late final int Function(Pointer, Pointer, Pointer, int) + _contactSave = _lib.lookupFunction< + Int32 Function( + Pointer, Pointer, Pointer, Int32), + int Function(Pointer, Pointer, Pointer, + int)>("smol_contact_save"); + late final int Function(Pointer, Pointer) _unpinServer = _lib + .lookupFunction, Pointer), + int Function(Pointer, Pointer)>("smol_unpin_server"); + late final Pointer Function(Pointer) _pins = _lib.lookupFunction< + Pointer Function(Pointer), + Pointer Function(Pointer)>("smol_pins"); + late final int Function(Pointer) _syncOk = _lib.lookupFunction< + Int32 Function(Pointer), + int Function(Pointer)>("smol_sync_ok"); + late final int Function(Pointer, Pointer, Pointer) + _deleteLocal = _lib.lookupFunction< + Int32 Function(Pointer, Pointer, Pointer), + int Function( + Pointer, Pointer, Pointer)>("smol_delete_local"); + late final Pointer Function(Pointer) _parseAddress = _lib + .lookupFunction Function(Pointer), + Pointer Function(Pointer)>("smol_parse_address"); + late final int Function(Pointer, Pointer) _importContact = _lib + .lookupFunction, Pointer), + int Function(Pointer, Pointer)>("smol_import_contact"); + late final Pointer Function(Pointer, Pointer) + _exportBackup = _lib.lookupFunction< + Pointer Function(Pointer, Pointer), + Pointer Function( + Pointer, Pointer)>("smol_export_backup"); + late final Pointer Function(Pointer, Pointer, + Pointer) _importBackup = _lib.lookupFunction< + Pointer Function( + Pointer, Pointer, Pointer), + Pointer Function( + Pointer, Pointer, Pointer)>( + "smol_import_backup"); + + // --- helpers over the raw surface ------------------------------------------ + + /// Throws when a status call returned non-zero, decoding the error slot. + /// The slot is thread-local: this only works because it runs inside the + /// same Isolate.run closure that made the failing call — after the + /// closure returns, the slot on that thread is gone. + Never _fail() { + final slot = _lastError(); + if (slot == nullptr) { + throw const NativeSmolException(smolOther, "the native call failed", {}); + } + final text = take(slot); + final Map details; + try { + details = jsonDecode(text) as Map; + } on FormatException { + throw NativeSmolException(smolOther, text, {}); + } + throw NativeSmolException( + details["code"] as int? ?? smolOther, + details["message"] as String? ?? "the native call failed", + details); + } + + /// Reads one returned buffer into a String and frees the buffer — the + /// ownership rule every caller owes a returned pointer. + String take(Pointer ptr) { + if (ptr == nullptr) _fail(); + final text = ptr.toDartString(); + _freeString(ptr); + return text; + } + + Pointer _text(String text) => text.toNativeUtf8(); + + Pointer _bytes(Uint8List bytes) { + final buf = malloc(32); + buf.asTypedList(32).setAll(0, bytes); + return buf; + } + + // --- typed surface: handles cross as ints ---------------------------------- + + int openStore(String path) { + final ptr = _storeOpen(_text(path)); + if (ptr == nullptr) _fail(); + return ptr.address; + } + + int openMemoryStore() { + final ptr = _storeMemory(); + if (ptr == nullptr) _fail(); + return ptr.address; + } + + void closeStore(int store) => _storeFree(_h(store)); + + int newAccount(Uint8List master, int index) { + final buf = _bytes(master); + final ptr = _accountNew(buf, index); + malloc.free(buf); + if (ptr == nullptr) _fail(); + return ptr.address; + } + + void freeAccount(int account) => _accountFree(_h(account)); + + String accountPk(int account) => take(_accountPk(_h(account))); + + int newFlag() => _flagNew().address; + + void setFlag(int flag, bool value) => _flagSet(_h(flag), value ? 1 : 0); + + void freeFlag(int flag) => _flagFree(_h(flag)); + + void pinServer(int store, String host, String keyB32) { + if (_pinServer(_h(store), _text(host), _text(keyB32)) != smolOk) _fail(); + } + + void unpinServer(int store, String host) { + if (_unpinServer(_h(store), _text(host)) != smolOk) _fail(); + } + + /// Every pin: [{host, key}] with keys as base32. + List pins(int store) => + jsonDecode(take(_pins(_h(store)))) as List; + + /// Whether the local accept-token set may replace the server's (sec 4). + bool syncOk(int store) => _syncOk(_h(store)) == 1; + + void saveContact(int store, String address, String keyB32, + {required bool verified}) => + _contactSave( + _h(store), _text(address), _text(keyB32), verified ? 1 : 0); + + /// Null when nothing is pinned. + String? serverPin(int store, String host) { + final text = take(_serverPin(_h(store), _text(host))); + return text.isEmpty ? null : text; + } + + /// Null when not registered. + String? accountAddress(int store) { + final text = take(_storeAccount(_h(store))); + return text.isEmpty ? null : text; + } + + int rotations(int store) => _rotations(_h(store)); + + /// Binds the account locally, as register and restore do at their end. + void setAccount(int store, String address) { + if (_accountSet(_h(store), _text(address)) != smolOk) _fail(); + } + + void register(int store, int account, String address, + {String? invite, String? dial, int timeout = 30}) { + if (_register( + _h(store), + _h(account), + _text(address), + invite == null ? nullptr : _text(invite), + dial == null ? nullptr : _text(dial), + timeout) != + smolOk) { + _fail(); + } + } + + void restore(int store, Uint8List master, String address, + {String? dial, int timeout = 30}) { + final buf = _bytes(master); + final code = _restore(_h(store), buf, _text(address), + dial == null ? nullptr : _text(dial), timeout); + malloc.free(buf); + if (code != smolOk) _fail(); + } + + Map rotate(int store, int account, + {String? dial, int timeout = 30}) => + jsonDecode(take(_rotate(_h(store), _h(account), + dial == null ? nullptr : _text(dial), timeout))) + as Map; + + Map fetch(int store, int account, + {bool keep = false, + bool reset = false, + String? dial, + int timeout = 30, + int? cancel}) { + final text = take(_fetch(_h(store), _h(account), keep ? 1 : 0, reset ? 1 : 0, + dial == null ? nullptr : _text(dial), timeout, + cancel == null ? nullptr : _h(cancel))); + return jsonDecode(text) as Map; + } + + Map send(int store, int account, String to, String body, + {String? subject, + String? replyTo, + bool anonymous = false, + bool noPad = false, + String? dial, + int timeout = 30}) { + final text = take(_send( + _h(store), + _h(account), + _text(to), + subject == null ? nullptr : _text(subject), + _text(body), + replyTo == null ? nullptr : _text(replyTo), + anonymous ? 1 : 0, + noPad ? 1 : 0, + dial == null ? nullptr : _text(dial), + timeout)); + return jsonDecode(text) as Map; + } + + /// Ids are 64 hex characters everywhere — the same shape `mail` and + /// `describe` return, so no caller hex-decodes solely to delete. + void delete(int store, int account, List ids, + {String? dial, int timeout = 30}) { + if (_delete(_h(store), _h(account), _text(jsonEncode(ids)), + dial == null ? nullptr : _text(dial), timeout) != + smolOk) { + _fail(); + } + } + + List mail(int store, String folder) => + jsonDecode(take(_mail(_h(store), _text(folder)))) as List; + + Map describe(int store, int account, String idHex) => + jsonDecode(take(_describe(_h(store), _h(account), _text(idHex)))) + as Map; + + List contacts(int store) => + jsonDecode(take(_contacts(_h(store)))) as List; + + Map contact(int store, String address) => + jsonDecode(take(_contact(_h(store), _text(address)))) + as Map; + + int accept(int store, int account, String address, + {String? dial, int timeout = 30}) => + (jsonDecode(take(_accept(_h(store), _h(account), _text(address), + dial == null ? nullptr : _text(dial), timeout))) + as Map)["held"] as int; + + void block(int store, int account, String address, + {String? dial, int timeout = 30}) { + if (_block(_h(store), _h(account), _text(address), + dial == null ? nullptr : _text(dial), timeout) != + smolOk) { + _fail(); + } + } + + Map resolve(int store, String address, + {String? dial, int timeout = 30}) { + final text = take(_resolve(_h(store), _text(address), + dial == null ? nullptr : _text(dial), timeout)); + return jsonDecode(text) as Map; + } + + /// The reader's delete: removes locally and marks seen, so a kept + /// server copy is not re-stored by the next fetch. + void deleteLocal(int store, String folder, List ids) { + if (_deleteLocal(_h(store), _text(folder), _text(jsonEncode(ids))) != + smolOk) { + _fail(); + } + } + + /// One address for the UI: {user, host, port, short, scheme}, or a + /// NativeSmolException when it does not parse. + Map parseAddress(String text) { + return jsonDecode(take(_parseAddress(_text(text)))) + as Map; + } + + void importContact(int store, String uri) { + if (_importContact(_h(store), _text(uri)) != smolOk) _fail(); + } + + String exportBackup(int store, Uint8List master) { + final buf = _bytes(master); + final text = take(_exportBackup(_h(store), buf)); + malloc.free(buf); + return text; + } + + String importBackup(int store, Uint8List master, String text) { + final buf = _bytes(master); + final summary = take(_importBackup(_h(store), buf, _text(text))); + malloc.free(buf); + return summary; + } +} diff --git a/lib/native/library.dart b/lib/native/library.dart new file mode 100644 index 0000000..30d26e3 --- /dev/null +++ b/lib/native/library.dart @@ -0,0 +1,13 @@ +// Opens the native library (lib/smol -> fumi-core) the app links against. +// Android bundles the cdylib under its plain soname; Linux loads the built +// artifact from the checkout in dev/test and the bundle dir in release. + +import "dart:ffi"; +import "dart:io"; + +DynamicLibrary openSmolLibrary() { + if (Platform.isAndroid) { + return DynamicLibrary.open("libsmol_mail_native.so"); + } + return DynamicLibrary.open("native/target/release/libsmol_mail_native.so"); +} diff --git a/lib/presentation/screens/contact_detail_screen.dart b/lib/presentation/screens/contact_detail_screen.dart index 02b961d..27dc40e 100644 --- a/lib/presentation/screens/contact_detail_screen.dart +++ b/lib/presentation/screens/contact_detail_screen.dart @@ -5,8 +5,9 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; 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/smol/errors.dart"; -import "package:smol_mail/smol/proto.dart"; +import "package:smol_mail/smol/ui.dart"; /// The full key, the address to hand out, and any key this one replaced — /// the row can only afford a fingerprint, and §8 turns on the user being able @@ -125,7 +126,7 @@ class _ContactDetailScreenState extends ConsumerState { const Divider(height: 1), const SizedBox(height: 20), _field(context, "fingerprint", fingerprint(contact.key)), - _field(context, "public key", b32encode(contact.key)), + _field(context, "public key", contact.key), Row( children: [ Expanded( @@ -162,7 +163,7 @@ class _ContactDetailScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(fingerprint(entry.key)), - Text(b32encode(entry.key), + Text(entry.key, style: Theme.of(context).textTheme.labelSmall), Text( "replaced ${formatTime(entry.until ~/ 1000)}", diff --git a/lib/presentation/screens/contacts_screen.dart b/lib/presentation/screens/contacts_screen.dart index 46c189f..9a04b98 100644 --- a/lib/presentation/screens/contacts_screen.dart +++ b/lib/presentation/screens/contacts_screen.dart @@ -7,7 +7,7 @@ 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/proto.dart"; +import "package:smol_mail/smol/ui.dart"; /// Contacts: the addresses bound to keys, with the trust badge and the number /// of keys each has replaced — the §8 surface for noticing rotations. diff --git a/lib/presentation/screens/message_detail_screen.dart b/lib/presentation/screens/message_detail_screen.dart index 4467084..edc4dba 100644 --- a/lib/presentation/screens/message_detail_screen.dart +++ b/lib/presentation/screens/message_detail_screen.dart @@ -1,4 +1,3 @@ -import "dart:typed_data"; import "package:auto_route/auto_route.dart"; import "package:flutter/material.dart"; @@ -40,7 +39,7 @@ class MessageDetailScreen extends ConsumerWidget { } Future _nameSender(BuildContext context, WidgetRef ref, - Uint8List senderKey) async { + String senderKey) async { final client = ref.read(clientProvider); final controller = TextEditingController(); final address = await showDialog( diff --git a/lib/presentation/screens/onboarding_screen.dart b/lib/presentation/screens/onboarding_screen.dart index 1c26d9b..75e62dc 100644 --- a/lib/presentation/screens/onboarding_screen.dart +++ b/lib/presentation/screens/onboarding_screen.dart @@ -8,9 +8,9 @@ import "package:smol_mail/presentation/routes/app_router.gr.dart"; import "package:smol_mail/presentation/widgets/primary_button.dart"; import "package:smol_mail/presentation/widgets/secondary_button.dart"; import "package:smol_mail/presentation/widgets/small_loading_spinner.dart"; -import "package:smol_mail/smol/crypto.dart"; +import "package:smol_mail/smol/address.dart"; import "package:smol_mail/smol/errors.dart"; -import "package:smol_mail/smol/proto.dart"; +import "package:smol_mail/smol/ui.dart"; import "package:smol_mail/presentation/theme/app_colors.dart"; import "package:smol_mail/shared/utils/snackbar.dart"; @@ -49,6 +49,10 @@ class _OnboardingScreenState extends ConsumerState { @override void dispose() { + // The backup step's copy of the master is overwritten, not left to the + // garbage collector — the honest zeroization Dart allows. + createdMaster?.fillRange(0, createdMaster!.length, 0); + createdMaster = null; seedController.dispose(); restoreAddressController.dispose(); addressController.dispose(); @@ -81,11 +85,12 @@ class _OnboardingScreenState extends ConsumerState { } } - void _createIdentity() { + Future _createIdentity() async { _clearAbandonedIdentity(); final client = ref.read(clientProvider); try { - final master = client.createIdentity(); + final master = await client.createIdentity(); + if (!mounted) return; setState(() { createdMaster = master; step = _Step.backup; diff --git a/lib/presentation/screens/settings_screen.dart b/lib/presentation/screens/settings_screen.dart index d9562d0..a9b5746 100644 --- a/lib/presentation/screens/settings_screen.dart +++ b/lib/presentation/screens/settings_screen.dart @@ -12,9 +12,8 @@ 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/crypto.dart"; import "package:smol_mail/smol/errors.dart"; -import "package:smol_mail/smol/proto.dart"; +import "package:smol_mail/smol/ui.dart"; /// Identity card, server pins, rotation and the wipe. The seed is the only /// secret; revealing it is always an explicit action. @@ -69,10 +68,11 @@ class _SettingsScreenState extends ConsumerState { // pinned servers into one shareable file; never the seed. Future _export() async { final store = ref.read(storeProvider); - final data = store.exportData(); + // exportData is the sealed gsmol container itself; the file body is its + // JSON, already sealed to the master-derived key. + final bytes = Uint8List.fromList(utf8.encode(await store.exportData())); final stamp = DateTime.now().toIso8601String().substring(0, 10); final fileName = "kirakira-backup-$stamp.json"; - final bytes = Uint8List.fromList(utf8.encode(jsonEncode(data))); try { // share_plus has no file-sharing implementation on desktop platforms — // it throws UnimplementedError for Linux/Windows/macOS — so those save @@ -108,8 +108,8 @@ class _SettingsScreenState extends ConsumerState { final path = files.isEmpty ? null : files.single.path; if (path == null) return; try { - final data = jsonDecode(File(path).readAsStringSync()) as Map; - final summary = await ref.read(storeProvider).importData(data); + final summary = + await ref.read(storeProvider).importData(File(path).readAsStringSync()); ref.read(revisionProvider.notifier).bump(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -165,7 +165,7 @@ class _SettingsScreenState extends ConsumerState { ref.read(revisionProvider.notifier).bump(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text("rotated; new key ${b32encode(fresh.publicKey)}")), + SnackBar(content: Text("rotated; new key ${fresh.publicKey}")), ); } } catch (err) { @@ -313,7 +313,7 @@ class _SettingsScreenState extends ConsumerState { contentPadding: EdgeInsets.zero, title: Text(host), subtitle: Text( - b32encode(key), + key, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.labelSmall, ), diff --git a/lib/presentation/widgets/message/message_tile.dart b/lib/presentation/widgets/message/message_tile.dart index f2fb1a5..de73b4a 100644 --- a/lib/presentation/widgets/message/message_tile.dart +++ b/lib/presentation/widgets/message/message_tile.dart @@ -7,7 +7,6 @@ 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/avatar_color.dart"; import "package:smol_mail/shared/utils/format.dart"; -import "package:smol_mail/smol/proto.dart"; import "package:smol_mail/smol/store.dart"; class MessageTile extends ConsumerWidget { @@ -27,7 +26,7 @@ class MessageTile extends ConsumerWidget { who = record.recipient ?? ""; } else if (opened.sender != null) { final known = store.addressForKey(opened.sender!); - who = known ?? "unknown · ${b32encode(opened.sender!).substring(0, 4)}"; + who = known ?? "unknown · ${opened.sender!.substring(0, 4)}"; } else { who = "?"; } diff --git a/lib/presentation/widgets/message/message_view.dart b/lib/presentation/widgets/message/message_view.dart index e52c2e9..4c1566b 100644 --- a/lib/presentation/widgets/message/message_view.dart +++ b/lib/presentation/widgets/message/message_view.dart @@ -1,4 +1,3 @@ -import "dart:typed_data"; import "package:auto_route/auto_route.dart"; import "package:flutter/material.dart"; @@ -9,8 +8,8 @@ import "package:smol_mail/presentation/routes/app_router.gr.dart"; import "package:smol_mail/shared/utils/format.dart"; import "package:smol_mail/shared/utils/snackbar.dart"; import "package:smol_mail/smol/client.dart"; -import "package:smol_mail/smol/proto.dart"; import "package:smol_mail/smol/store.dart"; +import "package:smol_mail/smol/ui.dart"; /// The opened message: trust row (fingerprint and how the sender's key is /// known), frontmatter fields, and the plain body — smol mail has no HTML. @@ -18,7 +17,7 @@ class MessageView extends ConsumerWidget { final String folder; final MailRecord record; final OpenedRecord opened; - final void Function(Uint8List senderKey)? onNameSender; + final void Function(String senderKey)? onNameSender; const MessageView({ super.key, @@ -78,7 +77,7 @@ class MessageView extends ConsumerWidget { ], if (!isSent && senderKey != null) ...[ const SizedBox(height: 10), - _keyBlock(context, "${fingerprint(senderKey)}\n${b32encode(senderKey)}"), + _keyBlock(context, "${fingerprint(senderKey)}\n$senderKey"), ], const SizedBox(height: 18), _field(context, isSent ? "To" : "From", diff --git a/lib/smol/address.dart b/lib/smol/address.dart new file mode 100644 index 0000000..44be381 --- /dev/null +++ b/lib/smol/address.dart @@ -0,0 +1,48 @@ +// Address forms (SPEC.md §3): parsing goes through the native library, so +// the username rules are fumi's, not a reimplementation that can drift. + +import "package:smol_mail/native/ffi.dart"; +import "package:smol_mail/smol/errors.dart"; + +/// One parsed address. [identity] carries the self-certifying key a +/// smol:// URI binds; a short form has none until it is resolved. +class SmolAddress { + final String user; + final String host; + final int port; + final String? identity; + final bool rns; + + const SmolAddress(this.user, this.host, this.port, + {this.identity, this.rns = false}); + + /// The short form a contact list shows; the default port is elided. + String get short => rns + ? "smol+rns://$user@$host" + : "$user@$host${port == defaultPort ? "" : ":$port"}"; + + /// The self-certifying form: the key inside makes the address verifiable. + String uri(String publicKey) => rns + ? "smol+rns://$user@$host/$publicKey" + : "smol://$user@$host${port == defaultPort ? "" : ":$port"}/$publicKey"; +} + +const defaultPort = 1961; + +/// Parses any of the four address forms. Throws [SmolError] on anything +/// else — the onboarding and compose fields validate through this. +SmolAddress parseAddress(String text) { + final Map parsed; + try { + parsed = SmolFfi.open().parseAddress(text.trim()); + } on NativeSmolException catch (err) { + throw SmolError(err.message); + } + return SmolAddress( + parsed["user"] as String, + parsed["host"] as String, + parsed["port"] as int, + identity: parsed["identity"] as String?, + rns: parsed["scheme"] == "rns", + ); +} diff --git a/lib/smol/client.dart b/lib/smol/client.dart index 9390108..1ea6a7a 100644 --- a/lib/smol/client.dart +++ b/lib/smol/client.dart @@ -1,15 +1,19 @@ -// App-level client: the flows of gsmol's app.js — connect with pinning, fetch -// with verification and acknowledgment, send with sent copies, contacts and -// rotation — on top of the pure protocol modules. +// The app-level client: the same flows the screens have always called — +// connect with pinning, fetch with verification and acknowledgment, send +// with sent copies, contacts and rotation — now as one thin layer over +// fumi-core through the native binding. Every network operation runs on an +// isolate; the reads the UI makes per frame stay synchronous. -import "dart:convert"; +import "dart:io"; +import "dart:math"; import "dart:typed_data"; -import "package:smol_mail/smol/crypto.dart"; +import "package:smol_mail/native/client.dart"; +import "package:smol_mail/native/ffi.dart"; + +import "package:smol_mail/smol/address.dart"; import "package:smol_mail/smol/errors.dart"; -import "package:smol_mail/smol/proto.dart"; import "package:smol_mail/smol/store.dart"; -import "package:smol_mail/smol/transport.dart"; class RefreshOutcome { final String message; @@ -27,7 +31,7 @@ class FetchSummary { class OpenedRecord { final String id; - final Uint8List? sender; + final String? sender; // base32 final int? time; final Map fields; final String body; @@ -50,10 +54,41 @@ class SmolClient { void _warn(String message) => onWarning?.call(message); - // Opening an envelope costs an X25519 agreement and an Ed25519 - // verification, and an envelope's plaintext never changes — so the result - // is kept. Failures are cached too, so one bad message is not retried on - // every render. Rotation clears it, since the key set grew. + FumiNative get _native => store.native; + + SmolFfi get _ffi => SmolFfi.open(); + + /// Resolves the host the way that works everywhere this app runs: the + /// platform resolver. On Android the native getaddrinfo that fumi-core's + /// connect would use can be dead for app processes while this path works, + /// so the facade resolves here and hands the IP across as a dial hint — + /// the hostname keeps every identity role. IP literals dial themselves. + Future _dial(String host) async { + final parsed = InternetAddress.tryParse(host); + if (parsed != null) return parsed.address; + try { + final addresses = await InternetAddress.lookup(host); + if (addresses.isEmpty) { + throw SmolError("could not resolve $host"); + } + return addresses.first.address; + } on SocketException catch (err) { + throw SmolError("could not resolve $host: ${err.message}"); + } catch (err) { + if (err is SmolError) rethrow; + throw SmolError("could not resolve $host"); + } + } + + /// The account host's dial hint, for the ops that connect home. + Future _accountDial() async { + final addr = accountAddress(); + if (addr == null) return ""; + return _dial(addr.host); + } + + // Opening an envelope is microseconds native; the results never change, so + // they are kept like the Dart core kept them. Failures cache too. final _openedCache = {}; SmolIdentity? get identity => store.identity(); @@ -61,397 +96,211 @@ class SmolClient { Uint8List? get master => store.master(); SmolAddress? accountAddress() { - final account = store.account(); - if (account == null) return null; - final suffix = account.port == defaultPort ? "" : ":${account.port}"; - return parseAddress("${account.user}@${account.host}$suffix"); - } - - // §4: registration and fetching demand a pinned key; sending to a recipient - // whose key we already hold tolerates an unpinned server. - Future connect(SmolAddress addr, - {required bool requirePin}) async { - final pinned = store.serverPin(addr.host); - if (requirePin && pinned == null) { - throw SmolError("no pinned key for ${addr.host}. Obtain it from the " - "operator through a trusted channel, then pin it in settings."); - } - final wire = await TcpWire.connect(addr.host, addr.port); - try { - final opened = await openSession(wire, addr.host, pinned: pinned); - if (pinned == null) { - _warn("${addr.host} is not pinned; its key is " - "${b32encode(opened.serverStatic)}.\n" - "RESOLVE results from this session are UNVERIFIED (SPEC.md §8)."); - } - return opened; - } catch (_) { - wire.close(); - rethrow; - } + final text = _ffi.accountAddress(_native.store!); + if (text == null) return null; + return parseAddress(text); } // --- identity setup ------------------------------------------------------------ - /// A fresh master secret at rotation index 0. Only this local step; nothing - /// is sent until [registerAccount]. - Uint8List createIdentity() { - final fresh = randomBytes(keyLen); + /// A fresh master secret at rotation index 0. Only this local step; + /// nothing is sent until [registerAccount]. + Future createIdentity() async { + final fresh = _randomMaster(); store.setMaster(fresh); return fresh; } + Uint8List _randomMaster() { + // The platform CSPRNG: 32 random bytes are the whole identity. + final rng = Random.secure(); + return Uint8List.fromList( + List.generate(32, (_) => rng.nextInt(256))); + } + /// §2: a master alone does not say which rotation index a server has bound, - /// so restoring resolves the address and walks indices 0..[maxChain] until - /// one derives the key RESOLVE returned. Also binds "account" locally, like - /// [recallAccount] — restoring on a new device knows the identity but not - /// the address it was registered under. + /// so restoring resolves the address and walks indices until one derives + /// the key RESOLVE returned, then binds the account locally. Future restoreAndRecall(String masterHex, String addressText) async { Uint8List master; try { - master = unhex(masterHex.trim()); + master = _unhex(masterHex.trim()); } on Exception { throw const SmolError("master must be 64 hex characters"); } - if (master.length != keyLen) { - throw SmolError("master is ${master.length} bytes, expected $keyLen"); + if (master.length != 32) { + throw SmolError("master is ${master.length} bytes, expected 32"); } final addr = parseAddress(addressText); - final opened = await connect(addr, requirePin: true); - Uint8List current; - try { - current = (await resolveOp(opened.session, addr.user)).identity; - } finally { - opened.session.wire.close(); - } - int? found; - for (var n = 0; n <= maxChain; n++) { - if (timingSafeEqual(identityFromSeed(identitySeed(master, n)).publicKey, current)) { - found = n; - break; - } - } - if (found == null) { - throw SmolError("the key bound to ${addr.short} is not derived from " - "this master within $maxChain rotations"); - } - store.restoreMaster(master, found); - store.setAccount(addr); + store.restoreMaster(master, 0); + await _restore(addr); + master.fillRange(0, master.length, 0); return addr; } - void pinServer(String host, String keyB32) { - final key = b32decode(keyB32); - if (key.length != keyLen) { - throw SmolError("server key is ${key.length} bytes, expected $keyLen"); + Future _restore(SmolAddress addr) async { + // fumi's restore would tolerate an unpinned server (sec 8 trust on + // first use), but this app's onboarding teaches the pin up front: + // 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) == null) { + throw SmolError("no pinned key for ${addr.host}. Obtain it from the " + "operator through a trusted channel, then pin it in settings."); + } + try { + // restore resolves, walks the rotation indices and writes the account + // state; the account handle is rebuilt inside the binding. + await _native.restore(addr.short, dial: await _dial(addr.host)); + } on NativeSmolException catch (err) { + throw SmolError(err.message); } - store.pinServer(host.trim().toLowerCase(), key); } + void pinServer(String host, String keyB32) => store.pinServer(host, keyB32); + Future registerAccount(String addressText, {String token = ""}) async { - final me = identity; - if (me == null) throw const SmolError("no identity yet"); final addr = parseAddress(addressText); - final opened = await connect(addr, requirePin: true); try { - await registerOp(opened.session, opened.serverStatic, addr.user, me, - RegisterOptions(token: token)); - } finally { - opened.session.wire.close(); + await _native.register(addr.short, + invite: token.isEmpty ? null : token, dial: await _dial(addr.host)); + } on NativeSmolException catch (err) { + throw SmolError(err.message); } - store.setAccount(addr); } - /// Restoring a master brings back the identity, not the memory of what - /// address a *different device* registered it under — "account" is - /// local-only state, never asked of the server. This binds it without - /// REGISTER: RESOLVE the address and require it name this exact key, so a - /// typo or someone else's address cannot misfile fetch and Reply-To. + /// Recall binds the identity to its registered address without + /// re-REGISTER — the same resolve-and-walk a restore does (§2). Future recallAccount(String addressText) async { - final me = identity; - if (me == null) throw const SmolError("no identity yet"); final addr = parseAddress(addressText); - final opened = await connect(addr, requirePin: true); - Uint8List current; - try { - current = (await resolveOp(opened.session, addr.user)).identity; - } finally { - opened.session.wire.close(); - } - if (!timingSafeEqual(current, me.publicKey)) { - throw SmolError( - "${addr.short} resolves to a different key — not this identity"); - } - store.setAccount(addr); + await _restore(addr); return addr; } - // --- fetch ---------------------------------------------------------------------- + // --- fetch ----------------------------------------------------------------- - Future fetch() async { - final me = identity; - final master = this.master; - final addr = accountAddress(); - if (me == null || master == null) throw const SmolError("no identity yet"); - if (addr == null) { + Future fetch({bool reset = false}) async { + if (master == null) throw const SmolError("no identity yet"); + if (accountAddress() == null) { throw const SmolError("not registered; register an address first"); } - var stored = 0; - final rejected = []; - // §10: acknowledging (deleting) is the default; "leave mail on server" - // pages forward by cursor instead, so already-fetched mail is never - // re-downloaded even though it isn't deleted (store.storeIfNew also - // dedupes, as a second line of defense). - final leaveOnServer = store.leaveOnServer(); - var (afterTime, afterId) = store.cursor(); - final opened = await connect(addr, requirePin: true); + final Map summary; try { - final (sync, tokens) = store.tokenSet(master); - await authenticate(opened.session, opened.handshakeHash, addr.user, me, - sync: sync, tokens: tokens); - while (true) { - final records = await fetchOp(opened.session, afterTime, afterId); - if (records.isEmpty) break; - final acked = []; - for (final record in records) { - afterTime = record.receivedAt; - afterId = record.id; - OpenedMessage msg; - try { - if (!timingSafeEqual(messageId(record.envelope), record.id)) { - throw const SmolError("id does not match the envelope"); - } - msg = unseal(store.identities(), record.envelope); - } on SmolError catch (err) { - // Left on the server rather than destroyed, so a client-side bug - // cannot lose mail. - rejected.add("${hex(record.id)}: ${err.message}"); - continue; - } - final fresh = await store.storeIfNew( - "inbox", - MailRecord(hex(record.id), record.envelope, - receivedAt: record.receivedAt, - tier: record.isRequest ? tierRequests : tierMain, - keptOnServer: leaveOnServer)); - if (fresh != null) { - stored++; - _learnToken(msg); - } - acked.add(record.id); - } - if (leaveOnServer) { - // Persisted per batch, so an interrupted fetch resumes here rather - // than re-paging from the start next time. - store.setCursor(afterTime, afterId); - } else if (acked.isNotEmpty) { - await deleteOp(opened.session, acked); - } - } - } finally { - opened.session.wire.close(); + summary = await _native.fetch( + keep: store.leaveOnServer(), reset: reset, dial: await _accountDial()); + } on NativeSmolException catch (err) { + throw SmolError(err.message); } - if (!leaveOnServer) { - // Everything acknowledged is deleted, so the next fetch starts fresh; a - // record left on the server (rejected above) simply resurfaces then. - store.setCursor(0, Uint8List(idLen)); + _openedCache.clear(); + if (summary["cancelled"] == true) { + _warn("fetch cancelled; partial results kept"); } - return FetchSummary(stored, rejected); + return FetchSummary( + summary["stored"] as int, + [ + for (final r in (summary["rejected"] as List).cast>()) + "${r[0]}: ${r[1]}", + ], + ); } + /// Raises the cancellation flag; a running fetch stops between envelopes + /// and returns a partial summary. + void cancelFetch() => _native.cancelFetch(); + // --- delete ------------------------------------------------------------------ /// Deletes a message locally, and from the server too if it might still be /// sitting there (only possible when "leave mail on server" was on when it - /// was fetched — §10). Sent copies are local-only; there is nothing - /// server-side to remove for them (§5.6). Throws, leaving the local copy in - /// place, if a needed server-side delete fails — otherwise a message could - /// look gone locally while silently persisting on the server. + /// was fetched — §10). Sent copies are local-only (§5.6). Future deleteMessage(String folder, MailRecord record) async { if (folder != "sent" && record.keptOnServer) { - final me = identity; - final addr = accountAddress(); - if (me == null || addr == null) { + if (accountAddress() == null) { throw const SmolError( "not registered; cannot reach the server to delete this message"); } - final opened = await connect(addr, requirePin: true); try { - await authenticate(opened.session, opened.handshakeHash, addr.user, me, - sync: 0, tokens: const []); - await deleteOp(opened.session, [unhex(record.id)]); - } finally { - opened.session.wire.close(); + await _native.delete([record.id], dial: await _accountDial()); + } on NativeSmolException catch (err) { + throw SmolError(err.message); } } await store.deleteMessage(folder, record.id); - } - - // §5.8: an Accept field is bound to the signer of the message that carried - // it, which unseal() has already verified. - void _learnToken(OpenedMessage msg) { - final parsed = parseFrontmatter(utf8.decode(msg.body, allowMalformed: true)); - final raw = parsed.fields["accept"]; - if (raw == null) return; - Uint8List token; - try { - token = b32decode(raw); - } on SmolError { - return; - } - if (token.length != tokenLen) return; - final address = _addressOfSigner(msg.sender, parsed.fields["reply-to"]); - if (address == null) return; // no address to send to, so no use for a token - store.learnToken(address, token); - } - - /// The address we know a signer by: a contact, or the Reply-To it signed - /// for itself. Naming a mailbox is not trusting a key, so nothing is - /// pinned here (§5.7, §8). - String? _addressOfSigner(Uint8List sender, String? replyTo) { - final known = store.addressForKey(sender); - if (known != null) return known; - if (replyTo == null) return null; - try { - final parsed = parseAddress(replyTo); - if (parsed.identity != null && timingSafeEqual(parsed.identity!, sender)) { - return parsed.short; - } - } on SmolError { - // malformed claim: no address to learn a token under - } - return null; + _openedCache.remove(record.id); } // --- accept tokens (§5.8) -------------------------------------------------------- /// Admit a contact to the main tier; their token travels in our next - /// message to them. Pushes the change to the server right away, since an - /// accept or a block only takes effect once it holds the changed set. + /// message to them. Pushes the changed set to the server right away. Future acceptContact(String address) async { - final key = store.contact(address)?.key; - if (key == null) throw SmolError("no key for $address yet"); - store.accept(address, key); - store.setSyncOk(true); - return _pushTokens(); + try { + return await _native.accept(address, dial: await _accountDial()); + } on NativeSmolException catch (err) { + throw SmolError(err.message); + } } /// Withdraw a contact's accept token; their mail lands in requests from /// their next message on. Future blockContact(String address) async { - store.block(address); - return _pushTokens(); - } - - Future _pushTokens() async { - final me = identity; - final master = this.master; - final addr = accountAddress(); - if (me == null || master == null) throw const SmolError("no identity yet"); - if (addr == null) { - _warn("not registered; the set will be pushed with your first fetch"); - return 0; - } - final opened = await connect(addr, requirePin: true); try { - final (sync, tokens) = store.tokenSet(master); - return await authenticate(opened.session, opened.handshakeHash, addr.user, me, - sync: sync, tokens: tokens); - } finally { - opened.session.wire.close(); + await _native.block(address, dial: await _accountDial()); + return 0; + } on NativeSmolException catch (err) { + throw SmolError(err.message); } } // --- compose and send ----------------------------------------------------------- - // Prefer a key we already trust; fall back to RESOLVE with trust on first - // use. - Future resolveRecipient(SmolAddress addr) async { - if (addr.identity != null) { - store.saveContact(addr.short, addr.identity!, true); - return addr.identity!; - } - final known = store.contact(addr.short); - if (known != null) return known.key; - final opened = await connect(addr, requirePin: false); - Uint8List current; - try { - current = (await resolveOp(opened.session, addr.user)).identity; - } finally { - opened.session.wire.close(); - } - store.saveContact(addr.short, current, false); - return current; - } - + /// Sends one message (§5, §6.1): recipient selection prefers a key we + /// already trust, then RESOLVE with trust on first use; an accepted + /// correspondent gets our token and a §5.6 sent copy is kept. Future send(String toText, String subject, String body, {String? replyTo, bool anonymous = false}) async { - final me = identity; - final master = this.master; - if (me == null || master == null) throw const SmolError("no identity yet"); + if (master == null) throw const SmolError("no identity yet"); final addr = parseAddress(toText); - final recipient = await resolveRecipient(addr); - final account = accountAddress(); - final fields = {"Subject": subject, "In-Reply-To": replyTo ?? ""}; - // A signed Reply-To lets a first-time recipient name and answer us - // (§5.5 allows unknown keys); "anonymous" omits it. - if (account != null && !anonymous) { - fields["Reply-To"] = account.uri(me.publicKey); - } - // §5.8: hand an accepted correspondent the token for our own mailbox, so - // a first reply from them reaches our main tier. - final accepted = store.accepted(addr.short); - if (accepted != null && accepted.active) { - fields["Accept"] = b32encode(tokenFor(master, accepted.identity)); - } - final bodyBytes = utf8Bytes(buildFrontmatter( - fields, "${body.replaceFirst(RegExp(r"\s+$"), "")}\n")); - final envelope = seal(me, recipient, bodyBytes); - // §5.8: our token for their mailbox, if they have given us one. - final held = store.tokenFrom(addr.short); - final mac = held == null ? null : acceptMac(held, messageId(envelope)); - final opened = await connect(addr, requirePin: false); + final Map sent; try { - await sendOp(opened.session, envelope, mac: mac); - } finally { - opened.session.wire.close(); + sent = await _native.send(addr.short, body, + subject: subject.isEmpty ? null : subject, + replyTo: replyTo, + dial: await _dial(addr.host)); + } on NativeSmolException catch (err) { + throw SmolError(err.message); + } + if (sent["warning"] != null) { + _warn(sent["warning"] as String); } - // §5.6: the ephemeral is gone, so keep a copy sealed to ourselves. - await store.storeMessage("sent", MailRecord(hex(messageId(envelope)), - seal(me, me.publicKey, bodyBytes), - recipient: addr.short, sentAt: nowSeconds())); return addr.short; } // --- reading ----------------------------------------------------------------- - // What is known about a sender changes as the user binds addresses to keys, - // so this layer sits over the cached envelope and is recomputed per call — - // it is a map lookup, not crypto. + /// Opens one sealed message: the described view the reader shows. Sync — + /// one unseal is microseconds native, and the old Dart core did the same + /// work at fifty times the cost. OpenedRecord describe(MailRecord row) { - final opened = _openEnvelope(row); - if (opened.error != null) return opened; - return OpenedRecord( - row.id, - sender: opened.sender, - time: opened.time, - fields: opened.fields, - body: opened.body, - ); - } - - OpenedRecord _openEnvelope(MailRecord row) { var entry = _openedCache[row.id]; if (entry == null) { + if (_native.account == null) { + // No account handle: no identity to unseal with. + entry = OpenedRecord(row.id, error: "no identity yet"); + _openedCache[row.id] = entry; + return entry; + } try { - final opened = unseal(store.identities(), row.envelope); - final parsed = parseFrontmatter(utf8.decode(opened.body, allowMalformed: true)); - entry = OpenedRecord(row.id, - sender: opened.sender, - time: opened.time, - fields: parsed.fields, - body: parsed.body); - } on SmolError catch (err) { + final described = + _ffi.describe(_native.store!, _native.account!, row.id); + entry = OpenedRecord( + row.id, + sender: described["sender"] as String, + time: described["time"] as int, + fields: (described["fields"] as Map).cast(), + body: described["text"] as String, + ); + } on NativeSmolException catch (err) { entry = OpenedRecord(row.id, error: err.message); } _openedCache[row.id] = entry; @@ -467,8 +316,7 @@ class SmolClient { if (claim == null || opened.sender == null) return null; try { final parsed = parseAddress(claim); - if (parsed.identity != null && - timingSafeEqual(parsed.identity!, opened.sender!)) { + if (parsed.identity != null && parsed.identity == opened.sender) { return parsed; } } on SmolError { @@ -481,38 +329,36 @@ class SmolClient { /// address carries its own key (verified); a short address is resolved and /// the result kept on first use. Anything that binds a different key is /// refused. - Future nameSender(String text, Uint8List senderKey) async { + Future nameSender(String text, String senderKey) async { final addr = parseAddress(text.trim()); - Uint8List key; - var verified = true; if (addr.identity == null) { - final opened = await connect(addr, requirePin: false); - try { - key = (await resolveOp(opened.session, addr.user)).identity; - } finally { - opened.session.wire.close(); + await refreshContact(addr.short); + final known = store.contact(addr.short); + if (known == null) { + throw const SmolError("could not resolve that address"); } - verified = false; // trust on first use, as with any RESOLVE - } else { - key = addr.identity!; + if (known.key != senderKey) { + throw const SmolError( + "that address carries a different key than this message's sender"); + } + return; } - if (!timingSafeEqual(key, senderKey)) { + if (addr.identity != senderKey) { throw const SmolError( "that address carries a different key than this message's sender"); } - store.saveContact(addr.short, senderKey, verified); + await store.saveContact(addr.short, senderKey, verified: true); } /// A signed Reply-To is the sender's own claim, so it saves as verified — - /// but never over an address already pinned to a different key (§8: a key - /// change without a rotation chain needs out-of-band confirmation). - Future saveReplyAddress(SmolAddress addr, Uint8List senderKey) async { + /// but never over an address already pinned to a different key (§8). + Future saveReplyAddress(SmolAddress addr, String senderKey) async { final existing = store.contact(addr.short); - if (existing != null && !timingSafeEqual(existing.key, senderKey)) { + if (existing != null && existing.key != senderKey) { throw SmolError("${addr.short} is already known with a different key — " "verify out of band before replying"); } - store.saveContact(addr.short, senderKey, true); + await store.saveContact(addr.short, senderKey, verified: true); } // Re-resolve a contact and apply §8: a valid rotation chain is accepted and @@ -520,74 +366,64 @@ class SmolClient { Future refreshContact(String address) async { final addr = parseAddress(address); if (addr.identity != null) { - throw const SmolError("that address already carries a key; use import instead"); + throw const SmolError( + "that address already carries a key; use import instead"); } final known = store.contact(addr.short); - final opened = await connect(addr, requirePin: false); - Resolved resolved; + final Map resolved; try { - resolved = await resolveOp(opened.session, addr.user); - } finally { - opened.session.wire.close(); + resolved = await _native.resolve(addr.short, dial: await _dial(addr.host)); + } on NativeSmolException catch (err) { + throw SmolError(err.message); } + final change = resolved["change"] as String; if (known == null) { - store.saveContact(addr.short, resolved.identity, false); return RefreshOutcome( - "${addr.short} pinned (trust on first use" - "${opened.pinned ? "" : ", UNVERIFIED server"})", - !opened.pinned); + "${addr.short} pinned (trust on first use)", change == "newUnverified"); } - if (timingSafeEqual(known.key, resolved.identity)) { - return RefreshOutcome("${addr.short}: key unchanged", false); + switch (change) { + case "none": + return RefreshOutcome("${addr.short}: key unchanged", false); + case "rotated": + _warn("${addr.short} rotated its key; a signed chain confirms it.\n" + "now ${resolved["key"]}"); + return RefreshOutcome( + "${addr.short} rotated its key; a signed chain confirms it.\n" + "now ${resolved["key"]}", + true); + default: + return RefreshOutcome( + "${addr.short} presents a different key with no valid rotation chain.\n" + "Verify out of band, then import the new smol:// address.", + true); } - if (walkChain(addr.user, known.key, resolved.identity, resolved.chain)) { - store.saveContact(addr.short, resolved.identity, known.verified); - return RefreshOutcome( - "${addr.short} rotated its key; a signed chain confirms it.\n" - "now ${b32encode(resolved.identity)}", - true); - } - return RefreshOutcome( - "${addr.short} presents a different key with no valid rotation chain.\n" - "Verify out of band, then import the new smol:// address.", - true); } - // Bind a smol:// address to the key it carries (§8's strong path); the - // displaced key, if any, lands in the contact's history. - void importContact(String text) { + // Bind a smol:// address to the key it carries (§8's strong path). + Future importContact(String text) async { final addr = parseAddress(text.trim()); if (addr.identity == null) { throw const SmolError("import needs a smol:// address carrying a key"); } - store.saveContact(addr.short, addr.identity!, true); + await store.saveContact(addr.short, addr.identity!, verified: true); } // --- rotation ----------------------------------------------------------------- - // §7: rotate to the next index's derived key and rebind the account with a - // signed certificate. The superseded key stays derivable from the master, - // since mail sealed to it stays readable with nothing else. + /// §7: rotate to the next index's derived key and rebind the account with + /// a signed certificate. The superseded key stays derivable from the + /// master, since mail sealed to it stays readable with nothing else. Future rotateIdentity() async { - final me = identity; - final master = this.master; - final addr = accountAddress(); - if (me == null || master == null || addr == null) { + if (identity == null || master == null || accountAddress() == null) { throw const SmolError("rotate needs a registered account"); } - final freshSeed = identitySeed(master, store.rotations() + 1); - final fresh = identityFromSeed(freshSeed); - final cert = makeCert(addr.user, me, freshSeed); - final opened = await connect(addr, requirePin: true); try { - await registerOp(opened.session, opened.serverStatic, addr.user, fresh, - RegisterOptions(cert: cert)); - } finally { - opened.session.wire.close(); + await _native.rotate(dial: await _accountDial()); + } on NativeSmolException catch (err) { + throw SmolError(err.message); } - store.advanceRotation(); _openedCache.clear(); - return fresh; + return identity!; } // A full wipe: every secret and every stored envelope. The UI must confirm. @@ -595,4 +431,14 @@ class SmolClient { await store.wipe(); _openedCache.clear(); } + + Uint8List _unhex(String text) { + if (text.length % 2 != 0) { + throw const SmolError("odd-length hex string"); + } + return Uint8List.fromList([ + for (var i = 0; i < text.length; i += 2) + int.parse(text.substring(i, i + 2), radix: 16), + ]); + } } diff --git a/lib/smol/config.dart b/lib/smol/config.dart index 102c73d..aae3fee 100644 --- a/lib/smol/config.dart +++ b/lib/smol/config.dart @@ -1,7 +1,3 @@ -import "package:smol_mail/smol/proto.dart"; -import "package:smol_mail/smol/errors.dart"; -import "package:smol_mail/smol/store.dart"; - // Deploy-time configuration, empty by default (mirrors gsmol's config.js). // A release may bake in a server key with: // flutter build apk --dart-define=SMOL_PRESET_SERVER=example.org \ @@ -12,15 +8,15 @@ import "package:smol_mail/smol/store.dart"; // This only seeds the first run — once written it is an ordinary pin, // removable in settings like any other, and never overwrites a host the user // (or a previous install) already pinned. + +import "package:smol_mail/smol/store.dart"; + const _presetHost = String.fromEnvironment("SMOL_PRESET_SERVER"); const _presetKey = String.fromEnvironment("SMOL_PRESET_SERVER_KEY"); void applyPresetServer(SmolStore store) { if (_presetHost.isEmpty || _presetKey.isEmpty) return; if (store.serverPin(_presetHost) != null) return; - try { - store.pinServer(_presetHost, b32decode(_presetKey)); - } on SmolError { - // malformed preset: leave unpinned rather than block boot - } + // A malformed preset leaves the host unpinned rather than blocking boot. + store.pinServer(_presetHost, _presetKey); } diff --git a/lib/smol/crypto.dart b/lib/smol/crypto.dart deleted file mode 100644 index 345ce64..0000000 --- a/lib/smol/crypto.dart +++ /dev/null @@ -1,435 +0,0 @@ -// Smol Mail primitives (SPEC.md §1): SHA-2, HMAC/HKDF-SHA256, ChaCha20-Poly1305, -// X25519, Ed25519, and the §2 key conversions between the two curves. Pure -// Dart rather than PointyCastle so the byte-exact vectors from the reference -// client (test/vectors.json) can pin every operation. - -import "dart:convert"; -import "dart:math"; -import "dart:typed_data"; - -import "package:crypto/crypto.dart" as hashes; - -import "package:smol_mail/smol/errors.dart"; - -// --- bytes -------------------------------------------------------------------- - -Uint8List concat(List> parts) { - final out = Uint8List(parts.fold(0, (n, p) => n + p.length)); - var off = 0; - for (final p in parts) { - out.setRange(off, off + p.length, p); - off += p.length; - } - return out; -} - -Uint8List utf8Bytes(String text) => Uint8List.fromList(utf8.encode(text)); - -String hex(List bytes) => - bytes.map((b) => b.toRadixString(16).padLeft(2, "0")).join(); - -Uint8List unhex(String text) { - if (text.length.isOdd) throw ArgumentError("odd-length hex string: $text"); - final out = Uint8List(text.length ~/ 2); - for (var i = 0; i < out.length; i++) { - out[i] = int.parse(text.substring(i * 2, i * 2 + 2), radix: 16); - } - return out; -} - -BigInt leBytesToBigInt(Uint8List bytes) { - var n = BigInt.zero; - for (var i = bytes.length - 1; i >= 0; i--) { - n = (n << 8) | BigInt.from(bytes[i]); - } - return n; -} - -Uint8List bigIntToLeBytes(BigInt value, int length) { - final out = Uint8List(length); - var v = value; - for (var i = 0; i < length; i++) { - out[i] = (v & BigInt.from(0xff)).toInt(); - v >>= 8; - } - return out; -} - -Uint8List randomBytes(int n) { - final out = Uint8List(n); - final rng = Random.secure(); - for (var i = 0; i < n; i++) { - out[i] = rng.nextInt(256); - } - return out; -} - -bool timingSafeEqual(List a, List b) { - if (a.length != b.length) return false; - var diff = 0; - for (var i = 0; i < a.length; i++) { - diff |= a[i] ^ b[i]; - } - return diff == 0; -} - -// --- SHA-256 / SHA-512 / HMAC-SHA256 / HKDF (RFC 2104, RFC 5869) --------------- - -Uint8List sha256(List message) => - Uint8List.fromList(hashes.sha256.convert(message).bytes); - -Uint8List sha512(List message) => - Uint8List.fromList(hashes.sha512.convert(message).bytes); - -Uint8List hmacSha256(List key, List message) => - Uint8List.fromList(hashes.Hmac(hashes.sha256, key).convert(message).bytes); - -Uint8List hkdfSha256(List ikm, List salt, List info, - [int length = 32]) { - final prk = hmacSha256(salt, ikm); - var out = []; - var block = []; - var counter = 1; - while (out.length < length) { - block = hmacSha256(prk, concat([block, info, [counter]])); - out.addAll(block); - counter++; - } - return Uint8List.fromList(out.sublist(0, length)); -} - -// --- ChaCha20-Poly1305 AEAD (RFC 8439) ----------------------------------------- - -const _mask32 = 0xFFFFFFFF; - -int _rotl32(int x, int n) => ((x << n) | (x >>> (32 - n))) & _mask32; - -Uint8List _chachaBlock(Uint8List key, int counter, Uint8List nonce) { - final state = Uint32List(16); - state.setAll(0, [0x61707865, 0x3320646e, 0x79622d32, 0x6b206574]); - final kview = ByteData.view(key.buffer, key.offsetInBytes, key.length); - for (var i = 0; i < 8; i++) { - state[4 + i] = kview.getUint32(i * 4, Endian.little); - } - state[12] = counter & _mask32; - final nview = ByteData.view(nonce.buffer, nonce.offsetInBytes, nonce.length); - for (var i = 0; i < 3; i++) { - state[13 + i] = nview.getUint32(i * 4, Endian.little); - } - final x = Uint32List.fromList(state); - void qr(int a, int b, int c, int d) { - x[a] = (x[a] + x[b]) & _mask32; - x[d] = _rotl32(x[d] ^ x[a], 16); - x[c] = (x[c] + x[d]) & _mask32; - x[b] = _rotl32(x[b] ^ x[c], 12); - x[a] = (x[a] + x[b]) & _mask32; - x[d] = _rotl32(x[d] ^ x[a], 8); - x[c] = (x[c] + x[d]) & _mask32; - x[b] = _rotl32(x[b] ^ x[c], 7); - } - - for (var i = 0; i < 10; i++) { - qr(0, 4, 8, 12); - qr(1, 5, 9, 13); - qr(2, 6, 10, 14); - qr(3, 7, 11, 15); - qr(0, 5, 10, 15); - qr(1, 6, 11, 12); - qr(2, 7, 8, 13); - qr(3, 4, 9, 14); - } - final out = Uint8List(64); - final view = ByteData.view(out.buffer); - for (var i = 0; i < 16; i++) { - view.setUint32(i * 4, (x[i] + state[i]) & _mask32, Endian.little); - } - return out; -} - -Uint8List _chacha20Xor(Uint8List key, int counter, Uint8List nonce, Uint8List data) { - final out = Uint8List(data.length); - for (var off = 0; off < data.length; off += 64) { - final stream = _chachaBlock(key, counter + off ~/ 64, nonce); - final n = min(64, data.length - off); - for (var i = 0; i < n; i++) { - out[off + i] = data[off + i] ^ stream[i]; - } - } - return out; -} - -// Poly1305 over BigInt; correctness over speed, messages here stay small. -Uint8List _poly1305(Uint8List key, List message) { - final p = (BigInt.one << 130) - BigInt.from(5); - final r = leBytesToBigInt(key.sublist(0, 16)) & - BigInt.parse("0x0ffffffc0ffffffc0ffffffc0fffffff"); - final s = leBytesToBigInt(key.sublist(16, 32)); - var acc = BigInt.zero; - for (var off = 0; off < message.length; off += 16) { - final block = message.sublist(off, min(off + 16, message.length)); - acc = (acc + leBytesToBigInt(Uint8List.fromList(block)) + - (BigInt.one << (8 * block.length))) * - r % - p; - } - return bigIntToLeBytes((acc + s) & ((BigInt.one << 128) - BigInt.one), 16); -} - -Uint8List _pad16(int n) => Uint8List((16 - (n % 16)) % 16); - -Uint8List _le64(int n) => bigIntToLeBytes(BigInt.from(n), 8); - -Uint8List aeadEncrypt(Uint8List key, Uint8List nonce, Uint8List plaintext, - Uint8List aad) { - final polyKey = _chachaBlock(key, 0, nonce).sublist(0, 32); - final ciphertext = _chacha20Xor(key, 1, nonce, plaintext); - final mac = _poly1305(polyKey, - concat([aad, _pad16(aad.length), ciphertext, _pad16(ciphertext.length), _le64(aad.length), _le64(ciphertext.length)])); - return concat([ciphertext, mac]); -} - -Uint8List aeadDecrypt(Uint8List key, Uint8List nonce, Uint8List sealed, Uint8List aad) { - if (sealed.length < 16) { - throw const SmolError("ciphertext shorter than the Poly1305 tag"); - } - final ciphertext = sealed.sublist(0, sealed.length - 16); - final polyKey = _chachaBlock(key, 0, nonce).sublist(0, 32); - final expect = _poly1305(polyKey, - concat([aad, _pad16(aad.length), ciphertext, _pad16(ciphertext.length), _le64(aad.length), _le64(ciphertext.length)])); - if (!timingSafeEqual(expect, sealed.sublist(sealed.length - 16))) { - throw const SmolError("decryption failed: bad Poly1305 tag"); - } - return _chacha20Xor(key, 1, nonce, ciphertext); -} - -// --- X25519 (RFC 7748) --------------------------------------------------------- - -final BigInt _p = (BigInt.one << 255) - BigInt.from(19); -final BigInt _mask255 = (BigInt.one << 255) - BigInt.one; - -BigInt _mod(BigInt value, [BigInt? p]) { - final m = p ?? _p; - return ((value % m) + m) % m; -} - -BigInt _powMod(BigInt base, BigInt exponent, [BigInt? p]) { - final m = p ?? _p; - var out = BigInt.one; - base = _mod(base, m); - while (exponent > BigInt.zero) { - if (exponent & BigInt.one == BigInt.one) out = out * base % m; - base = base * base % m; - exponent >>= 1; - } - return out; -} - -Uint8List clampScalar(Uint8List scalar) { - final k = Uint8List.fromList(scalar); - k[0] &= 248; - k[31] &= 127; - k[31] |= 64; - return k; -} - -BigInt _x25519Raw(Uint8List scalar, Uint8List u) { - final k = leBytesToBigInt(clampScalar(scalar)); - final x1 = leBytesToBigInt(u) & _mask255; - const a24 = 121665; - var x2 = BigInt.one, z2 = BigInt.zero, x3 = x1, z3 = BigInt.one; - var swap = BigInt.zero; - for (var t = 254; t >= 0; t--) { - final kt = (k >> t) & BigInt.one; - swap ^= kt; - if (swap == BigInt.one) { - var tmp = x2; - x2 = x3; - x3 = tmp; - tmp = z2; - z2 = z3; - z3 = tmp; - } - swap = kt; - final a = _mod(x2 + z2), aa = a * a % _p; - final b = _mod(x2 - z2), bb = b * b % _p; - final e = _mod(aa - bb); - final c = _mod(x3 + z3), d = _mod(x3 - z3); - final da = d * a % _p, cb = c * b % _p; - final sum = _mod(da + cb), diff = _mod(da - cb); - x3 = sum * sum % _p; - z3 = x1 * diff * diff % _p; - x2 = aa * bb % _p; - z2 = e * _mod(aa + BigInt.from(a24) * e) % _p; - } - if (swap == BigInt.one) { - var tmp = x2; - x2 = x3; - x3 = tmp; - tmp = z2; - z2 = z3; - z3 = tmp; - } - return x2 * _powMod(z2, _p - BigInt.two) % _p; -} - -// §2's low-order rejection: a clamped scalar is a multiple of 8, so any -// low-order peer point yields an all-zero shared secret — rejecting the zero -// output rejects all of them. -Uint8List x25519(Uint8List scalar, Uint8List peerPublic) { - final shared = bigIntToLeBytes(_x25519Raw(scalar, peerPublic), 32); - if (shared.every((b) => b == 0)) { - throw const SmolError("rejected low-order key agreement point"); - } - return shared; -} - -Uint8List x25519Base(Uint8List scalar) => bigIntToLeBytes( - _x25519Raw(scalar, unhex("0900000000000000000000000000000000000000000000000000000000000000")), - 32); - -// --- Ed25519 (RFC 8032) -------------------------------------------------------- - -final BigInt _l = (BigInt.one << 252) + - BigInt.parse("27742317777372353535851937790883648493"); -final BigInt _d = _mod(-BigInt.from(121665) * _powMod(BigInt.from(121666), _p - BigInt.two)); -final _Point _b = _Point.fromAffine( - BigInt.parse( - "15112221349535400772501151409588531511454012693041857206046113283949847762202"), - _mod(BigInt.from(4) * _powMod(BigInt.from(5), _p - BigInt.two))); - -class _Point { - final BigInt x, y, z, t; - - const _Point(this.x, this.y, this.z, this.t); - - _Point.fromAffine(BigInt x, BigInt y) - : this(x, y, BigInt.one, _mod(x * y)); -} - -final _Point _identity = _Point( - BigInt.zero, BigInt.one, BigInt.one, BigInt.zero); - -_Point _pointAdd(_Point p, _Point q) { - final a = _mod(p.y - p.x) * _mod(q.y - q.x) % _p; - final b = _mod(p.y + p.x) * _mod(q.y + q.x) % _p; - final c = BigInt.two * p.t * q.t % _p * _d % _p; - final d = BigInt.two * p.z * q.z % _p; - final e = _mod(b - a), f = _mod(d - c), g = _mod(d + c); - final h = b + a; - return _Point(e * f % _p, g * h % _p, f * g % _p, e * h % _p); -} - -_Point _pointDouble(_Point p) { - final a = p.x * p.x % _p; - final b = p.y * p.y % _p; - final c = BigInt.two * p.z * p.z % _p; - final d = _p - a; // a = -1 on this curve, so d = -A - final e = _mod(_mod(p.x + p.y) * _mod(p.x + p.y) - a - b); - final g = _mod(d + b); - final f = _mod(g - c); - final h = _mod(d - b); - return _Point(e * f % _p, g * h % _p, f * g % _p, e * h % _p); -} - -_Point _scalarMult(BigInt scalar, _Point point) { - var result = _identity; - for (var t = 254; t >= 0; t--) { - result = _pointDouble(result); - if ((scalar >> t) & BigInt.one == BigInt.one) result = _pointAdd(result, point); - } - return result; -} - -Uint8List _encodePoint(_Point p) { - final zInv = _powMod(p.z, _p - BigInt.two); - final x = p.x * zInv % _p, y = p.y * zInv % _p; - final out = bigIntToLeBytes(y, 32); - out[31] |= (x & BigInt.one).toInt() << 7; - return out; -} - -_Point _decodePoint(Uint8List bytes) { - if (bytes.length != 32) { - throw const SmolError("Ed25519 public key must be 32 bytes"); - } - final sign = bytes[31] >> 7; - final y = leBytesToBigInt(bytes) & _mask255; - if (y >= _p) { - throw const SmolError("non-canonical Ed25519 public key"); - } - final u = _mod(y * y - BigInt.one), v = _mod(_d * y * y + BigInt.one); - final v2 = v * v % _p, v3 = v2 * v % _p, v4 = v2 * v2 % _p; - var x = u * v3 % _p * _powMod(u * v4 % _p * v3 % _p, (_p - BigInt.from(5)) ~/ BigInt.from(8)) % _p; - if (_mod(v * x % _p * x) != u) { - if (_mod(v * x % _p * x) == _mod(-u)) { - x = x * _powMod(BigInt.two, (_p - BigInt.one) ~/ BigInt.from(4)) % _p; - } else { - throw const SmolError("not a point on the Ed25519 curve"); - } - } - if (x == BigInt.zero && sign == 1) { - throw const SmolError("invalid sign bit on x = 0"); - } - if ((x & BigInt.one).toInt() != sign) x = _p - x; - return _Point.fromAffine(x, y); -} - -BigInt _seedToScalar(Uint8List seed) { - final h = sha512(seed); - return leBytesToBigInt(clampScalar(h.sublist(0, 32))); -} - -Uint8List ed25519PublicKey(Uint8List seed) { - if (seed.length != 32) { - throw const SmolError("identity seed must be 32 bytes"); - } - return _encodePoint(_scalarMult(_seedToScalar(seed), _Point.fromAffine(_b.x, _b.y))); -} - -Uint8List ed25519Sign(Uint8List seed, List message) { - final h = sha512(seed); - final a = leBytesToBigInt(clampScalar(h.sublist(0, 32))); - final publicKey = - _encodePoint(_scalarMult(a, _Point.fromAffine(_b.x, _b.y))); - final r = leBytesToBigInt(sha512(concat([h.sublist(32), message]))) % _l; - final rEnc = _encodePoint(_scalarMult(r, _Point.fromAffine(_b.x, _b.y))); - final k = leBytesToBigInt(sha512(concat([rEnc, publicKey, message]))) % _l; - return concat([rEnc, bigIntToLeBytes((r + k * a) % _l, 32)]); -} - -bool ed25519Verify(Uint8List publicKey, List message, Uint8List signature) { - if (signature.length != 64) return false; - try { - final decodedPk = _decodePoint(publicKey); - final decodedR = _decodePoint(signature.sublist(0, 32)); - final a = _Point.fromAffine(decodedPk.x, decodedPk.y); - final r = _Point.fromAffine(decodedR.x, decodedR.y); - final s = leBytesToBigInt(signature.sublist(32, 64)); - if (s >= _l) return false; - final k = leBytesToBigInt(sha512(concat([signature.sublist(0, 32), publicKey, message]))) % _l; - final lhs = _scalarMult(s, _Point.fromAffine(_b.x, _b.y)); - final rhs = _pointAdd(_scalarMult(k, a), r); - return lhs.x * rhs.z % _p == rhs.x * lhs.z % _p && - lhs.y * rhs.z % _p == rhs.y * lhs.z % _p; - } on Exception { - return false; - } -} - -// --- §2 conversions between the identity key and X25519 ------------------------- - -Uint8List ed25519ToX25519(Uint8List publicKey) { - final y = leBytesToBigInt(publicKey) & _mask255; - if (y >= _p) { - throw const SmolError("non-canonical Ed25519 public key"); - } - if (_mod(BigInt.one - y) == BigInt.zero) { - throw const SmolError("identity element has no X25519 image"); - } - return bigIntToLeBytes( - _mod(BigInt.one + y) * _powMod(BigInt.one - y, _p - BigInt.two) % _p, 32); -} - -Uint8List ed25519SeedToX25519(Uint8List seed) => - clampScalar(sha512(seed).sublist(0, 32)); - diff --git a/lib/smol/noise.dart b/lib/smol/noise.dart deleted file mode 100644 index fa5d46b..0000000 --- a/lib/smol/noise.dart +++ /dev/null @@ -1,118 +0,0 @@ -// Noise_NX_25519_ChaChaPoly_SHA256 initiator (SPEC.md §4), rev-34 semantics. -// The initiator is anonymous; the responder's static key arrives encrypted in -// message two, which is what server pinning checks. - -import "dart:typed_data"; - -import "package:smol_mail/smol/crypto.dart"; -import "package:smol_mail/smol/errors.dart"; - -const String _protocol = "Noise_NX_25519_ChaChaPoly_SHA256"; // exactly 32 bytes, so h = name - -Uint8List _prologue() => utf8Bytes("smolmail/1"); - -// Noise's ChaChaPoly nonce: 4 zero bytes then the counter as u64 LE. -Uint8List _nonce(int n) { - final out = Uint8List(12); - ByteData.view(out.buffer).setUint64(4, n, Endian.little); - return out; -} - -/// One direction of the post-handshake transport; tests substitute a -/// passthrough so framing guards can be exercised without crypto. -abstract class SessionCipher { - Uint8List encrypt(Uint8List plaintext); - - Uint8List decrypt(Uint8List sealed); -} - -// The key is unique per session, so the counter starting at zero is safe. -class CipherState implements SessionCipher { - final Uint8List key; - int counter = 0; - - CipherState(this.key); - - @override - Uint8List encrypt(Uint8List plaintext) { - final sealed = aeadEncrypt(key, _nonce(counter), plaintext, Uint8List(0)); - counter++; - return sealed; - } - - @override - Uint8List decrypt(Uint8List sealed) { - final plaintext = aeadDecrypt(key, _nonce(counter), sealed, Uint8List(0)); - counter++; - return plaintext; - } -} - -class NxResult { - final CipherState send, recv; - final Uint8List serverStatic; - final Uint8List handshakeHash; - - const NxResult(this.send, this.recv, this.serverStatic, this.handshakeHash); -} - -class NxInitiator { - late Uint8List h; - late Uint8List ck; - Uint8List? key; - late Uint8List esk; - late Uint8List epk; - - NxInitiator() { - h = utf8Bytes(_protocol); - ck = Uint8List.fromList(h); - mixHash(_prologue()); - } - - void mixHash(Uint8List data) { - h = sha256(concat([h, data])); - } - - void mixKey(Uint8List ikm) { - final okm = hkdfSha256(ikm, ck, Uint8List(0), 64); - ck = okm.sublist(0, 32); - key = okm.sublist(32); - } - - // Message one is just our ephemeral public key. No key is set yet, so the - // empty payload travels in the clear — and is still mixed into h. - Uint8List writeMessage1([Uint8List? esk]) { - this.esk = esk ?? randomBytes(32); - epk = x25519Base(this.esk); - mixHash(epk); - mixHash(Uint8List(0)); - return Uint8List.fromList(epk); - } - - // Message two: e (plaintext), ee, then the responder's static and the - // (empty) payload as AEAD ciphertexts chained through h. Each MixKey - // restarts the nonce at zero. - NxResult readMessage2(Uint8List message) { - if (message.length != 32 + 48 + 16) { - throw SmolError("unexpected NX message length ${message.length}"); - } - final re = message.sublist(0, 32); - mixHash(re); - mixKey(x25519(esk, re)); - final serverStatic = decryptAndHash(message.sublist(32, 80)); - mixKey(x25519(esk, serverStatic)); // es - final payload = decryptAndHash(message.sublist(80)); - if (payload.isNotEmpty) throw const SmolError("unexpected payload in handshake"); - final handshakeHash = h; - // Split(): two transport keys from the final chaining key, zero-length ikm - final okm = hkdfSha256(Uint8List(0), ck, Uint8List(0), 64); - return NxResult(CipherState(okm.sublist(0, 32)), CipherState(okm.sublist(32)), - serverStatic, handshakeHash); - } - - Uint8List decryptAndHash(Uint8List sealed) { - final plaintext = aeadDecrypt(key!, _nonce(0), sealed, h); - mixHash(sealed); - return plaintext; - } -} diff --git a/lib/smol/proto.dart b/lib/smol/proto.dart deleted file mode 100644 index c253723..0000000 --- a/lib/smol/proto.dart +++ /dev/null @@ -1,663 +0,0 @@ -// Smol Mail protocol, version 1.1 (../smolmail SPEC.md): addresses, sealed -// and signed envelopes, body frontmatter, key rotation, accept tokens, and -// the framed request and response bodies of the five operations. - -import "dart:math"; -import "dart:typed_data"; - -import "package:smol_mail/smol/crypto.dart"; -import "package:smol_mail/smol/errors.dart"; -import "package:smol_mail/smol/noise.dart"; - -const defaultPort = 1961; -const keyLen = 32, sigLen = 64, certLen = 200, idLen = 32, tokenLen = 32; -const maxFrame = 1 << 20, noisePayload = 65535 - 16, padTo = 1024; -const envelopeHeader = 69, payloadHeader = 45, maxChain = 16; -const maxSkew = 86400; // §5.3: how far ahead of our clock a payload may be dated -const flagRequests = 0x01; // §6.1: set when a FETCH record missed an accept token -const _frontmatterMax = 4096, _frontmatterKeys = 64; - -const opAuth = 0x00, opResolve = 0x01, opSend = 0x02, opFetch = 0x03, - opDelete = 0x04, opRegister = 0x05; - -const _statusNames = { - 0: "ok", 1: "malformed", 2: "bad version", 3: "unknown user", - 4: "auth required", 5: "auth failed", 6: "quota exceeded", 7: "too large", - 8: "rate limited", 9: "not permitted", 10: "internal error", -}; - -String statusName(int status) => _statusNames[status] ?? "$status"; - -final _label = ( - auth: utf8Bytes("smolmail/1 auth"), - seal: utf8Bytes("smolmail/1 seal"), - msg: utf8Bytes("smolmail/1 msg"), - id: utf8Bytes("smolmail/1 id"), - rotate: utf8Bytes("smolmail/1 rotate"), - identity: utf8Bytes("smolmail/1 identity"), - accept: utf8Bytes("smolmail/1 accept"), - mac: utf8Bytes("smolmail/1 mac"), - register: utf8Bytes("smolmail/1 register"), -); - -// --- encoding helpers --------------------------------------------------------- - -const _b32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; - -String b32encode(List bytes) { - var out = ""; - var value = 0, bits = 0; - for (final b in bytes) { - value = (value << 8) | b; - bits += 8; - while (bits >= 5) { - bits -= 5; - out += _b32[(value >>> bits) & 31]; - } - } - if (bits > 0) out += _b32[(value << (5 - bits)) & 31]; - return out.toLowerCase(); -} - -Uint8List b32decode(String text) { - final out = []; - var value = 0, bits = 0; - final clean = text.trim().toUpperCase().replaceAll(RegExp(r"=+$"), ""); - for (final ch in clean.split("")) { - final idx = _b32.indexOf(ch); - if (idx < 0) throw SmolError("invalid base32 character '$ch'"); - value = (value << 5) | idx; - bits += 5; - if (bits >= 8) { - bits -= 8; - out.add((value >>> bits) & 0xff); - } - } - return Uint8List.fromList(out); -} - -// §3: the first 20 base32 characters of the identity, in groups of four. -String fingerprint(Uint8List identity) { - final s = b32encode(identity).substring(0, 20); - return RegExp(".{4}").allMatches(s).map((m) => m[0]).join(" "); -} - -Uint8List u16be(int n) => Uint8List.fromList([(n >> 8) & 0xff, n & 0xff]); - -Uint8List u32be(int n) => Uint8List.fromList( - [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]); - -// Dart 3.2's ByteData has no setBigInt, so write big-endian manually. -Uint8List i64be(BigInt n) { - final out = Uint8List(8); - for (var i = 0; i < 8; i++) { - out[i] = ((n >> (8 * (7 - i))) & BigInt.from(0xff)).toInt(); - } - return out; -} - -int nowSeconds() => DateTime.now().millisecondsSinceEpoch ~/ 1000; - -// Fail-closed reader; every parse raises rather than reading past the end. -class Reader { - final Uint8List buf; - int pos = 0; - - Reader(this.buf); - - Uint8List take(int n) { - if (n < 0 || pos + n > buf.length) throw const SmolError("truncated message"); - final out = buf.sublist(pos, pos + n); - pos += n; - return out; - } - - int u8() => take(1)[0]; - - int u16() { - final b = take(2); - return (b[0] << 8) | b[1]; - } - - int u32() => ByteData.view(take(4).buffer).getUint32(0); - - int i64() => ByteData.view(take(8).buffer).getInt64(0); - - int get left => buf.length - pos; -} - -// --- identity ----------------------------------------------------------------- - -// §2: an Ed25519 keypair with the X25519 agreement keys derived from it. -class SmolIdentity { - final Uint8List seed; - final Uint8List publicKey; - - const SmolIdentity(this.seed, this.publicKey); -} - -SmolIdentity identityFromSeed(Uint8List seed) { - if (seed.length != keyLen) { - throw const SmolError("identity seed must be $keyLen bytes"); - } - return SmolIdentity(seed, ed25519PublicKey(seed)); -} - -SmolIdentity newIdentity() => identityFromSeed(randomBytes(keyLen)); - -// §2: the only secret a user holds. Everything else — every rotation index's -// signing seed, and the accept key — is derived from it with HKDF. -Uint8List identitySeed(Uint8List master, int index) => - hkdfSha256(master, Uint8List(0), concat([_label.identity, u32be(index)])); - -Uint8List acceptKeyFor(Uint8List master) => - hkdfSha256(master, Uint8List(0), _label.accept); - -// §5.8: the token this account issues to one correspondent, independent of -// the rotation index so it survives the owner's key rotation. -Uint8List tokenFor(Uint8List master, Uint8List correspondentIdentity) => - hmacSha256(acceptKeyFor(master), correspondentIdentity); - -// §5.8: what a sender attaches to SEND to reach the recipient's main tier. -Uint8List acceptMac(Uint8List token, Uint8List id) => - hmacSha256(token, concat([_label.mac, id])); - -// --- addressing (§3) ----------------------------------------------------------- - -final _address = - RegExp(r"^(?[a-z0-9._-]{1,63})@(?[^/:]+)(?::(?\d+))?$"); - -const _separators = "._-"; - -// §3: alphanumeric at both ends, never two separators in a row. -bool validUsername(String name) { - if (name.isEmpty) return false; - if (_separators.contains(name[0]) || _separators.contains(name[name.length - 1])) { - return false; - } - for (var i = 0; i < name.length - 1; i++) { - if (_separators.contains(name[i]) && _separators.contains(name[i + 1])) { - return false; - } - } - return true; -} - -class SmolAddress { - final String user; - final String host; - final int port; - - /// The key carried by a `smol://` address; null for short addresses. - final Uint8List? identity; - - const SmolAddress(this.user, this.host, this.port, this.identity); - - String get short => - "$user@$host${port == defaultPort ? "" : ":$port"}"; - - String uri(Uint8List key) => - "smol://$user@$host${port == defaultPort ? "" : ":$port"}/${b32encode(key)}"; -} - -SmolAddress parseAddress(String text) { - text = text.trim(); - Uint8List? identity; - if (text.startsWith("smol://")) { - final rest = text.substring("smol://".length); - final slash = rest.lastIndexOf("/"); - if (slash < 0) throw SmolError("$text: smol:// address carries no key"); - identity = b32decode(rest.substring(slash + 1)); - if (identity.length != keyLen) { - throw SmolError( - "$text: key is ${identity.length} bytes, expected $keyLen"); - } - text = rest.substring(0, slash); - } - final m = _address.firstMatch(text.toLowerCase()); - if (m == null) throw SmolError("'$text' is not a valid address"); - final user = m.namedGroup("user")!; - final host = m.namedGroup("host")!; - if (!validUsername(user)) { - throw SmolError("$user must begin and end with a letter or digit " - "and may not contain two separators in a row"); - } - final portText = m.namedGroup("port"); - final port = portText != null ? int.parse(portText) : defaultPort; - return SmolAddress(user, host, port, identity); -} - -// --- message format (§5) ------------------------------------------------------- - -// §5.4: derived from the envelope so no sender can choose it; used whole, -// nothing truncates it. -Uint8List messageId(List envelope) => sha256(concat([_label.id, envelope])); - -class OpenedMessage { - final Uint8List sender; - final int time; - final Uint8List body; - final Uint8List id; - - const OpenedMessage(this.sender, this.time, this.body, this.id); -} - -/// Options for [seal]; [esk] and [pad] exist so tests can pin them, mirroring -/// the spec's fixed-ephemeral vectors. -class SealOptions { - final Uint8List? esk; - final bool pad; - - const SealOptions({this.esk, this.pad = true}); -} - -// §5.2 and §5.3. The ephemeral key is thrown away after sealing, so the sender -// cannot decrypt what they sent. -Uint8List seal(SmolIdentity identity, Uint8List recipient, Uint8List body, - [int? when, SealOptions opts = const SealOptions()]) { - final esk = opts.esk ?? randomBytes(keyLen); - final epk = x25519Base(esk); - final key = hkdfSha256(x25519(esk, ed25519ToX25519(recipient)), - concat([epk, recipient]), _label.seal); - final header = concat([ - Uint8List.fromList([1]), - identity.publicKey, - i64be(BigInt.from(when ?? nowSeconds())), - u32be(body.length), - ]); - var plaintext = concat([ - header, - body, - ed25519Sign(identity.seed, concat([_label.msg, recipient, epk, header, body])), - ]); - if (opts.pad) { - plaintext = concat( - [plaintext, Uint8List((padTo - plaintext.length % padTo) % padTo)]); - } - final aad = concat([utf8Bytes("SMOL"), Uint8List.fromList([1]), recipient, epk]); - return concat([aad, aeadEncrypt(key, Uint8List(12), plaintext, aad)]); -} - -// Inverse of seal(); throws unless the signature and the recipient both check -// out. [identities] may include retired keys, per §7. -OpenedMessage unseal(List identities, Uint8List envelope) { - if (envelope.length < envelopeHeader + 16) { - throw const SmolError("envelope too short"); - } - final magic = utf8Bytes("SMOL"); - for (var i = 0; i < 4; i++) { - if (magic[i] != envelope[i]) { - throw const SmolError("not a Smol Mail envelope"); - } - } - if (envelope[4] != 1) { - throw SmolError("unsupported envelope version ${envelope[4]}"); - } - final to = envelope.sublist(5, 37), epk = envelope.sublist(37, 69); - final sealed = envelope.sublist(69); - SmolIdentity? me; - for (final i in identities) { - if (timingSafeEqual(i.publicKey, to)) { - me = i; - break; - } - } - if (me == null) { - throw SmolError( - "addressed to ${b32encode(to).substring(0, 16)}…, not one of our keys"); - } - final key = hkdfSha256( - x25519(ed25519SeedToX25519(me.seed), epk), concat([epk, to]), _label.seal); - Uint8List plaintext; - try { - plaintext = aeadDecrypt(key, Uint8List(12), sealed, envelope.sublist(0, envelopeHeader)); - } on SmolError { - throw const SmolError("decryption failed: wrong key or corrupt envelope"); - } - final r = Reader(plaintext); - if (r.u8() != 1) throw const SmolError("unsupported payload version"); - final sender = r.take(keyLen); - final when = r.i64(); - final bodyLen = r.u32(); - if (bodyLen > r.left) { - throw const SmolError("payload body length exceeds the payload"); - } - final body = r.take(bodyLen); - final signature = r.take(sigLen); // trailing bytes are padding - if (!ed25519Verify(sender, - concat([_label.msg, to, epk, plaintext.sublist(0, payloadHeader), body]), - signature)) { - throw const SmolError("signature does not verify"); - } - if (when > nowSeconds() + maxSkew) { - throw const SmolError("payload is dated in the future"); - } - return OpenedMessage(sender, when, body, messageId(envelope)); -} - -// --- body frontmatter (§5.5) --------------------------------------------------- - -final _fmKey = RegExp(r"^[A-Za-z0-9-]{1,64}$"); - -class Frontmatter { - final Map fields; - final String body; - - const Frontmatter(this.fields, this.body); -} - -// A flat `Key: value` block, deliberately not YAML. Any malformed line -// invalidates the whole block, which is then returned as ordinary body text: -// frontmatter fails closed toward display, never toward silent discard. Keys -// are compared case-insensitively (§5.5), so they are kept lowercased. -Frontmatter parseFrontmatter(String text) { - if (!text.startsWith("---\n")) return Frontmatter(const {}, text); - final lines = text.split("\n"); - final close = lines.indexOf("---", 1); - if (close < 0) return Frontmatter(const {}, text); - final block = lines.sublist(1, close); - final rest = lines.sublist(close + 1).join("\n"); - var encoded = 0; - for (final line in block) { - encoded += utf8Bytes(line).length + 1; - } - if (block.length > _frontmatterKeys || encoded > _frontmatterMax) { - return Frontmatter(const {}, text); - } - final fields = {}; - for (final line in block) { - final colon = line.indexOf(":"); - final head = colon < 0 ? "" : line.substring(0, colon); - if (colon < 0 || !_fmKey.hasMatch(head)) { - return Frontmatter(const {}, text); - } - // first occurrence wins - fields.putIfAbsent(head.toLowerCase(), () => line.substring(colon + 1).trim()); - } - return Frontmatter(fields, rest); -} - -// Emit a block only when needed, including to escape a body that genuinely -// begins with `---` (§5.5). -String buildFrontmatter(Map fields, String body) { - final entries = fields.entries.where((e) => e.value.isNotEmpty).toList(); - if (entries.isEmpty && !body.startsWith("---\n")) return body; - final block = entries.map((e) => "${e.key}: ${e.value}\n").join(); - return "---\n$block---\n$body"; -} - -// --- key rotation (§7) --------------------------------------------------------- - -// §7: old_pub 32 || new_pub 32 || time 8 || sig_old 64 || sig_new 64. Both -// keys sign, so the old key alone cannot hand the username to a key nobody -// controls; the username is covered but not carried, so a verifier always -// supplies the one it is checking. -Uint8List makeCert( - String username, SmolIdentity oldIdentity, Uint8List newSeed, [int? when]) { - final newIdentity = identityFromSeed(newSeed); - final time = i64be(BigInt.from(when ?? nowSeconds())); - final signed = concat( - [_label.rotate, utf8Bytes(username), oldIdentity.publicKey, newIdentity.publicKey, time]); - return concat([ - oldIdentity.publicKey, - newIdentity.publicKey, - time, - ed25519Sign(oldIdentity.seed, signed), - ed25519Sign(newIdentity.seed, signed), - ]); -} - -// Accept a key change only when a signed chain leads from the key we hold to -// the one the server now returns, both keys signing each link (§7). -bool walkChain( - String username, Uint8List pinned, Uint8List current, List chain) { - if (timingSafeEqual(pinned, current)) return true; - if (chain.isEmpty || chain.length > maxChain) return false; - var key = pinned; - var started = false; - for (final cert in chain) { - final old = cert.sublist(0, 32), next = cert.sublist(32, 64); - final when = cert.sublist(64, 72); - final sigOld = cert.sublist(72, 136), sigNew = cert.sublist(136, 200); - if (!started) { - if (!timingSafeEqual(old, key)) continue; // a link predating the key we hold - started = true; - } else if (!timingSafeEqual(old, key)) { - return false; // the chain is not continuous - } - final signed = concat([_label.rotate, utf8Bytes(username), old, next, when]); - if (!ed25519Verify(old, signed, sigOld) || !ed25519Verify(next, signed, sigNew)) { - return false; - } - key = next; - } - return started && timingSafeEqual(key, current); -} - -// --- framing and operations (§4, §6) ------------------------------------------- - -/// An ordered byte pipe (TCP socket, or an in-memory queue in tests). -abstract class Wire { - void send(Uint8List bytes); - - void close(); - - Future readExact(int n); -} - -// One Noise session: application frames split across u16-prefixed Noise -// messages, requests and responses as in §6.1. -class Session { - final Wire wire; - final SessionCipher send, recv; - - Session(this.wire, this.send, this.recv); - - Future _readNoise() async { - final head = await wire.readExact(2); - final length = (head[0] << 8) | head[1]; - if (length < 16) throw SmolError("server sent a $length-byte Noise message"); - return recv.decrypt(await wire.readExact(length)); - } - - Future call(int op, [Uint8List? body]) async { - final payload = body ?? Uint8List(0); - final frame = concat([u32be(1 + payload.length), Uint8List.fromList([op]), payload]); - if (frame.length > maxFrame + 4) { - throw const SmolError("request exceeds the maximum frame size"); - } - for (var off = 0; off < frame.length; off += noisePayload) { - final packet = send.encrypt(frame.sublist(off, min(off + noisePayload, frame.length))); - wire.send(concat([u16be(packet.length), packet])); - } - var length = -1; - var have = []; - while (length < 0 || have.length < 4 + length) { - have.addAll(await _readNoise()); - if (length < 0 && have.length >= 4) { - length = (have[0] << 24) | (have[1] << 16) | (have[2] << 8) | have[3]; - // §6.1: the shortest response is a type byte and a status byte. - if (length < 2 || length > maxFrame) { - throw SmolError("server sent a frame of length $length"); - } - } - } - final payloadOut = Uint8List.fromList(have.sublist(4, 4 + length)); - // §6.1: a response reuses the request's type byte. A mismatch means the - // session desynchronised, which must not be mistaken for a status. - if (payloadOut[0] != op) { - throw SmolError( - "server answered op 0x${payloadOut[0].toRadixString(16)}, expected 0x${op.toRadixString(16)}"); - } - return Response(payloadOut[1], payloadOut.sublist(2)); - } -} - -class Response { - final int status; - final Uint8List body; - - const Response(this.status, this.body); -} - -class OpenedSession { - final Session session; - final Uint8List serverStatic; - final bool pinned; - final Uint8List handshakeHash; - - const OpenedSession(this.session, this.serverStatic, this.pinned, this.handshakeHash); -} - -// Handshake plus §4 pinning. Returns the session, the server's static key as -// revealed by the handshake, and whether that key was already pinned. -Future openSession(Wire wire, String host, - {Uint8List? pinned}) async { - final nx = NxInitiator(); - final m1 = nx.writeMessage1(); - wire.send(concat([u16be(m1.length), m1])); - final head = await wire.readExact(2); - final result = nx.readMessage2(await wire.readExact((head[0] << 8) | head[1])); - if (pinned != null && !timingSafeEqual(pinned, result.serverStatic)) { - throw SmolError("$host presented a different key than the one pinned\n" - " pinned: ${b32encode(pinned)}\n" - " presented: ${b32encode(result.serverStatic)}"); - } - return OpenedSession( - Session(wire, result.send, result.recv), - result.serverStatic, - pinned != null, - result.handshakeHash); -} - -void expectOk(int status, String what) { - if (status != 0) { - throw SmolError("$what failed: ${statusName(status)} ($status)"); - } -} - -// §4 session authentication: sign the handshake hash, which binds the -// signature to this session's server ephemeral and cannot be replayed, and -// push the accept token set (§5.8). `sync = 0` leaves the server's stored set -// untouched and `tokens` MUST then be empty; `sync = 1` replaces it exactly. -// Returns the number of accept tokens the server now holds. -Future authenticate(Session session, Uint8List handshakeHash, String username, - SmolIdentity identity, {required int sync, List tokens = const []}) async { - final name = utf8Bytes(username); - if (name.length > 255) throw const SmolError("username too long"); - if (tokens.length > 0xffff) throw const SmolError("too many accept tokens for one AUTH"); - final body = concat([ - Uint8List.fromList([name.length]), - name, - identity.publicKey, - ed25519Sign(identity.seed, concat([_label.auth, handshakeHash])), - Uint8List.fromList([sync]), - u16be(tokens.length), - ...tokens, - ]); - final response = await session.call(opAuth, body); - expectOk(response.status, "authentication"); - return Reader(response.body).u16(); -} - -class Resolved { - final Uint8List identity; - final List chain; - - const Resolved(this.identity, this.chain); -} - -// RESOLVE, returning the current key and its rotation chain (§6.1). -Future resolveOp(Session session, String user) async { - final name = utf8Bytes(user); - if (name.length > 255) throw const SmolError("username too long"); - final response = - await session.call(opResolve, concat([Uint8List.fromList([name.length]), name])); - expectOk(response.status, "resolving $user"); - final r = Reader(response.body); - return Resolved( - r.take(keyLen), - List.generate(r.u8(), (_) => r.take(certLen))); -} - -// §5.8: [mac] is the sender's proof of an accept token, 0 or 32 bytes. -Future sendOp(Session session, Uint8List envelope, {Uint8List? mac}) async { - final macBytes = mac ?? Uint8List(0); - if (macBytes.isNotEmpty && macBytes.length != tokenLen) { - throw const SmolError("accept MAC must be $tokenLen bytes"); - } - final body = concat([Uint8List.fromList([macBytes.length]), macBytes, envelope]); - final response = await session.call(opSend, body); - expectOk(response.status, "sending"); - return response.body.length == idLen - ? response.body - : messageId(envelope); -} - -class FetchedRecord { - final Uint8List id; - final int receivedAt; - final int flags; - final Uint8List envelope; - - const FetchedRecord(this.id, this.receivedAt, this.flags, this.envelope); - - // §6.1: bit 0 is set when the message arrived without a matching accept token. - bool get isRequest => flags & flagRequests != 0; -} - -// §6.1: pages forward from a cursor; an all-zero id starts at the beginning. -Future> fetchOp( - Session session, int afterReceivedAt, Uint8List afterId) async { - final body = concat([i64be(BigInt.from(afterReceivedAt)), afterId]); - final response = await session.call(opFetch, body); - expectOk(response.status, "fetching"); - final r = Reader(response.body); - return List.generate(r.u16(), (_) { - final id = r.take(idLen); - final receivedAt = r.i64(); - final flags = r.u8(); - return FetchedRecord(id, receivedAt, flags, r.take(r.u32())); - }); -} - -Future deleteOp(Session session, List ids) async { - if (ids.length > 0xffff) throw const SmolError("too many ids for one DELETE"); - final body = concat([u16be(ids.length), ...ids]); - final response = await session.call(opDelete, body); - expectOk(response.status, "acknowledging"); - return Reader(response.body).u16(); -} - -class RegisterOptions { - final String token; - final Uint8List? cert; - - const RegisterOptions({this.token = "", this.cert}); -} - -// §6.1: the signature is proof of possession, bound to the server that will -// store the binding so it cannot be replayed to another server. -Uint8List registerSigned(Uint8List serverStatic, String username, Uint8List identity) => - concat([_label.register, serverStatic, utf8Bytes(username), identity]); - -Future registerOp(Session session, Uint8List serverStatic, String username, - SmolIdentity identity, [RegisterOptions opts = const RegisterOptions()]) async { - final name = utf8Bytes(username); - final tokenBytes = utf8Bytes(opts.token); - final cert = opts.cert ?? Uint8List(0); - if (name.length > 255 || tokenBytes.length > 255 || cert.length > 255) { - throw const SmolError("REGISTER field too long"); - } - final body = concat([ - Uint8List.fromList([name.length]), - name, - identity.publicKey, - ed25519Sign(identity.seed, registerSigned(serverStatic, username, identity.publicKey)), - Uint8List.fromList([tokenBytes.length]), - tokenBytes, - Uint8List.fromList([cert.length]), - cert, - ]); - expectOk((await session.call(opRegister, body)).status, "registering $username"); -} diff --git a/lib/smol/store.dart b/lib/smol/store.dart index d80391e..9ace821 100644 --- a/lib/smol/store.dart +++ b/lib/smol/store.dart @@ -1,74 +1,46 @@ -// Device state: identity, pins, contacts and read markers in one JSON blob; -// sealed envelopes in a second Hive box, opened only on demand, so nothing at -// rest is plaintext (the master secret excepted — the device's app storage is -// the trust boundary, like gsmol's browser profile). +// Local state, split by owner: fumi's SQLite store owns everything the +// protocol defines (mail, contacts, pins, accepted, tokens, seen ids), and +// this Hive layer owns only what the app owns — the master secret, read +// marks and UI settings. Reads are synchronous FFI calls (a query plus one +// JSON parse, microseconds); network operations stay on the client, where +// they run through isolates. +import "dart:async"; import "dart:convert"; +import "dart:io"; import "dart:typed_data"; -import "package:hive_flutter/hive_flutter.dart"; +import "package:hive/hive.dart"; + +import "package:smol_mail/native/client.dart"; +import "package:smol_mail/native/ffi.dart"; -import "package:smol_mail/smol/crypto.dart"; import "package:smol_mail/smol/errors.dart"; -import "package:smol_mail/smol/proto.dart"; +import "package:smol_mail/smol/ui.dart"; -const _stateBox = "smol"; -const _mailBox = "mail"; -const _stateKey = "state"; +const tierMain = 0, tierRequests = 1; -class StoredAccount { - final String user; - final String host; - final int port; - - const StoredAccount(this.user, this.host, this.port); -} - -/// A key this contact replaced, per §7/§8 — the only local record that a -/// rotation happened, kept so the user can notice such changes. +/// A displaced key a contact no longer uses, with when it stopped being +/// current (epoch ms) — the record §8 turns on the user being able to see. class ContactHistoryEntry { - final Uint8List key; - final int until; // epoch ms of the displacement + final String key; + final int until; const ContactHistoryEntry(this.key, this.until); } class StoredContact { - final Uint8List key; + final String key; final bool verified; final List history; - const StoredContact(this.key, this.verified, [this.history = const []]); + const StoredContact(this.key, this.verified, this.history); } -class MailRecord { - final String id; // hex of the 32-byte message id - final Uint8List envelope; - final int? receivedAt; - final String? recipient; // sent copies only - final int? sentAt; - final int tier; // §5.8: tierMain or tierRequests; meaningless for sent copies - - /// Whether "leave mail on server" was on when this was fetched, so a - /// manual delete still has a server-side copy to remove. Always false for - /// sent copies, which never had one (§5.6). - final bool keptOnServer; - - const MailRecord(this.id, this.envelope, - {this.receivedAt, - this.recipient, - this.sentAt, - this.tier = tierMain, - this.keptOnServer = false}); -} - -const tierMain = 0, tierRequests = 1; - -/// A correspondent admitted to this mailbox's main tier (§5.8). The identity -/// is frozen at acceptance because the token is derived from it: a contact's -/// later rotation must not change the token they already hold. +/// A correspondent admitted to the mailbox's main tier (§5.8). The identity +/// is frozen at acceptance because the token is derived from it. class AcceptedContact { - final Uint8List identity; + final String identity; final bool active; const AcceptedContact(this.identity, this.active); @@ -84,584 +56,303 @@ class ImportSummary { "$pinsAdded server keys ($pinsConflicted conflicted), $malformed malformed"; } +class MailRecord { + final String id; // hex of the 32-byte message id + final String envelope; // sealed, base64 — plaintext is never at rest + final int? receivedAt; + final String? recipient; // sent copies only + final int? sentAt; + final int tier; // §5.8: tierMain or tierRequests; meaningless for sent + final bool keptOnServer; + + const MailRecord(this.id, this.envelope, + {this.receivedAt, + this.recipient, + this.sentAt, + this.tier = tierMain, + this.keptOnServer = false}); +} + +/// The public half of the identity; the master never leaves the store +/// except through the reveal-and-copy flow in settings. +class SmolIdentity { + final String publicKey; // base32 + + const SmolIdentity(this.publicKey); +} + class SmolStore { - final Box _state; - final Box _mail; + final Box _meta; + final Box _read; + final FumiNative _native; - SmolStore(this._state, this._mail); + SmolStore._(this._meta, this._read, this._native); - /// [stateBox]/[mailBox] exist so tests can hold several isolated stores - /// in one process; production always uses the defaults. + /// The SQLite file fumi's store owns. One store per process. + late final String dbPath = _native.dbPath; + + /// Opens both layers. [dbPath] is the SQLite file fumi's store owns; the + /// box names exist so tests can hold several isolated stores in one + /// process. static Future open( - {String stateBox = _stateBox, String mailBox = _mailBox}) async { - final state = await Hive.openBox(stateBox); - final mail = await Hive.openBox(mailBox); - return SmolStore(state, mail); - } - - Map _load() { - final blob = _state.get(_stateKey); - return blob is Map ? blob : {}; - } - - void _update(Map Function(Map state) fn) { - final next = fn(_load()); - _state.put(_stateKey, next); - } - - // --- identity (§2) ----------------------------------------------------------- - - /// The 32-byte master secret, or null before the user creates or restores - /// one. Every signing key is derived from it plus the rotation index. - Uint8List? master() { - final raw = _load()["master"]; - return raw == null ? null : unhex(raw as String); - } - - /// The rotation index (§7) of the identity currently in use. - int rotations() => (_load()["rotations"] as int?) ?? 0; - - /// The active identity, or null before the user creates or restores one. - SmolIdentity? identity() { - final m = master(); - return m == null ? null : identityFromSeed(identitySeed(m, rotations())); - } - - /// Whether the accepted-correspondent set held here may replace the - /// server's on the next AUTH — false right after a restore from the master - /// alone, whose empty set must not erase the server's (§4). - bool syncOk() => (_load()["syncOk"] as bool?) ?? true; - - void setSyncOk(bool ok) => _update((state) => state..["syncOk"] = ok); - - void _bindMaster(Uint8List newMaster, int rotations, bool syncOk) { - if (master() != null) throw const SmolIdentityExistsException(); - _update((state) => state - ..["master"] = hex(newMaster) - ..["rotations"] = rotations - ..["syncOk"] = syncOk); - setCursor(0, Uint8List(idLen)); - } - - /// A fresh identity: rotation index 0, and an empty accepted set is - /// already complete, so it may sync. - void setMaster(Uint8List newMaster) => _bindMaster(newMaster, 0, true); - - /// §2: recovering a master alone does not recover which correspondents were - /// accepted, so that set must not overwrite the server's until rebuilt. - void restoreMaster(Uint8List newMaster, int rotationIndex) => - _bindMaster(newMaster, rotationIndex, false); - - // Rotation (§7): only the index advances; the superseded key stays - // derivable from the master, so nothing has to be archived. - void advanceRotation() { - final current = rotations(); - if (master() == null) throw const SmolNoIdentityException(); - if (current >= maxChain) { - throw SmolError("the rotation chain is full at $maxChain links"); + {required String dbPath, + String stateBox = "smol-state", + String readBox = "smol-read"}) async { + final native = FumiNative(dbPath); + await native.open(); + final store = SmolStore._( + await Hive.openBox(stateBox), await Hive.openBox(readBox), native); + final master = store.master(); + if (master != null) { + // The rotation index sits in the native store; read it through the + // synchronous ABI, not the async wrapper. + native.setMaster(master, + rotations: SmolFfi.open().rotations(native.store!)); } - _update((state) => state..["rotations"] = current + 1); + return store; } - /// §7: every key rotated away from is re-derivable from the master, since - /// mail sealed to a superseded key is readable with nothing else. - List identities() { - final m = master(); - if (m == null) return const []; - return [for (var n = rotations(); n >= 0; n--) identityFromSeed(identitySeed(m, n))]; + /// The native binding this store fronts; the client drives its network + /// operations, the store its reads. + FumiNative get native => _native; + + SmolFfi get _ffi => SmolFfi.open(); + + // --- identity --------------------------------------------------------------- + + SmolIdentity? identity() { + if (master() == null) return null; + return SmolIdentity(_ffi.accountPk(_native.account!)); } - // --- account and server pins ------------------------------------------------- - - StoredAccount? account() { - final a = _load()["account"]; - if (a is! Map) return null; - return StoredAccount( - a["user"] as String, a["host"] as String, a["port"] as int); + /// The master, as the one mutable buffer that owns it — wipe overwrites + /// these bytes rather than leaving them to the garbage collector, which + /// is the honest version of zeroization Dart allows. Hive keeps the + /// durable copy as bytes too; a hex string could never be scrubbed. + Uint8List? master() { + final stored = _meta.get("master"); + if (stored == null) return null; + if (stored is Uint8List) return stored; + // The pre-swap app stored hex; convert once. Two alpha testers, so this + // shim retires when their stores have moved. + final bytes = unhex(stored as String); + _meta.put("master", bytes); + return bytes; } - void setAccount(SmolAddress address) { - _update((state) => state - ..["account"] = { - "user": address.user, - "host": address.host, - "port": address.port, - }); + /// A fresh identity: the master at rotation index 0. Only this local step; + /// nothing is sent until registration. + void setMaster(Uint8List fresh) { + // Hive's in-memory state updates synchronously and persists in the + // background, so the store is consistent without awaiting the write. + unawaited(_meta.put("master", fresh)); + _native.setMaster(fresh, rotations: 0); } - Uint8List? serverPin(String host) { - final raw = ((_load()["servers"] as Map?) ?? {})[host]; - return raw == null ? null : b32decode(raw as String); + /// The master restored from a backup, already at the rotation index the + /// server bound. + void restoreMaster(Uint8List master, int index) { + unawaited(_meta.put("master", master)); + _native.setMaster(master, rotations: index); } - void pinServer(String host, Uint8List key) { - _update((state) { - final servers = (state["servers"] as Map? ?? {}).cast(); - servers[host] = b32encode(key); - state["servers"] = servers; - return state; - }); + int rotations() => _ffi.rotations(_native.store!); + + // --- settings --------------------------------------------------------------- + + bool leaveOnServer() => _meta.get("leaveOnServer") == true; + + Future setLeaveOnServer(bool value) async => + _meta.put("leaveOnServer", value); + + bool syncOk() => _ffi.syncOk(_native.store!); + + // --- mail ------------------------------------------------------------------- + + List listMessages(String folder) { + final rows = _ffi.mail(_native.store!, folder); + return [ + for (final row in rows.cast>()) + MailRecord( + row["id"] as String, + row["envelope"] as String, + receivedAt: folder == "sent" ? null : row["at"] as int, + recipient: row["recipient"] as String?, + sentAt: folder == "sent" ? row["at"] as int : null, + tier: (row["tier"] as int?) ?? tierMain, + keptOnServer: row["kept"] as bool? ?? false, + ), + ]..sort((a, b) => (b.receivedAt ?? b.sentAt ?? 0) + .compareTo(a.receivedAt ?? a.sentAt ?? 0)); } - void unpinServer(String host) { - _update((state) { - (state["servers"] as Map?)?.remove(host); - return state; - }); - } - - List<(String, Uint8List)> allPins() { - final servers = ((_load()["servers"] as Map?) ?? {}).cast(); - return [for (final e in servers.entries) (e.key, b32decode(e.value))]; - } - - // --- FETCH behavior -------------------------------------------------------- - - /// When true, FETCH does not acknowledge (delete) what it retrieves — - /// mail stays on the server until explicitly deleted. Defaults to the - /// original behavior: fetched mail is acknowledged immediately. - bool leaveOnServer() => (_load()["leaveOnServer"] as bool?) ?? false; - - void setLeaveOnServer(bool value) => - _update((state) => state..["leaveOnServer"] = value); - - // --- FETCH cursor (§6.1) ------------------------------------------------------- - - (int, Uint8List) cursor() { - final state = _load(); - final afterId = state["afterId"] as String?; - return ( - (state["afterTime"] as int?) ?? 0, - afterId == null ? Uint8List(idLen) : unhex(afterId), - ); - } - - void setCursor(int afterTime, Uint8List afterId) => _update((state) => state - ..["afterTime"] = afterTime - ..["afterId"] = hex(afterId)); - - // --- contacts ------------------------------------------------------------------ - - StoredContact? contact(String address) { - final c = ((_load()["contacts"] as Map?) ?? {})[address]; - if (c is! Map) return null; - final history = ((c["history"] as List?) ?? const []) - .whereType() - .map((e) => ContactHistoryEntry( - b32decode(e["key"] as String), e["until"] as int)) - .toList(); - return StoredContact(b32decode(c["key"] as String), - c["verified"] as bool, history); - } - - // A key that displaces another is kept in the history (§8): it is the - // only local record that the contact rotated. Re-saving the same key is - // not a rotation and must not add an entry. - void saveContact(String address, Uint8List key, bool verified) { - _update((state) { - final contacts = - (state["contacts"] as Map? ?? {}).cast(); - final wanted = b32encode(key); - final previous = contacts[address]; - final history = ((previous?["history"] as List?) ?? const []) - .whereType() - .toList(); - if (previous != null && previous["key"] != wanted) { - history.add({ - "key": previous["key"], - "until": DateTime.now().millisecondsSinceEpoch, - }); - } - contacts[address] = { - "key": wanted, - "verified": verified, - "seenAt": DateTime.now().millisecondsSinceEpoch, - if (history.isNotEmpty) "history": history, - }; - state["contacts"] = contacts; - return state; - }); - } - - String? addressForKey(Uint8List key) { - final contacts = ((_load()["contacts"] as Map?) ?? {}).cast(); - final wanted = b32encode(key); - for (final entry in contacts.entries) { - if (entry.value["key"] == wanted) return entry.key; + /// One message by folder and id, for the reader screen's deep link. + MailRecord? getMessage(String folder, String id) { + for (final row in listMessages(folder)) { + if (row.id == id) return row; } return null; } + bool isRead(String id) => _read.get(id) == true; + + void markRead(String id) => unawaited(_read.put(id, true)); + + int unreadCount() => _unread("inbox"); + + int requestsUnreadCount() => _unread("requests"); + + int _unread(String folder) { + final rows = _ffi.mail(_native.store!, folder); + return rows.cast>() + .where((row) => _read.get(row["id"] as String) != true) + .length; + } + + /// The reader's delete: local removal with the id marked seen (§10), so a + /// still-kept server copy is not re-stored by the next fetch. + Future deleteMessage(String folder, String id) async => + _native.deleteLocal(folder, [id]); + + // --- contacts --------------------------------------------------------------- + List<(String, StoredContact)> allContacts() { - final contacts = ((_load()["contacts"] as Map?) ?? {}).cast(); - return [for (final entry in contacts.entries) (entry.key, contact(entry.key)!)]; + final rows = _ffi.contacts(_native.store!); + return [ + for (final row in rows.cast>()) + ( + row["address"] as String, + StoredContact( + row["key"] as String, + row["verified"] as bool, + [ + for (final entry in (row["history"] as List).cast>()) + ContactHistoryEntry( + entry["key"] as String, entry["until"] as int), + ], + ) + ), + ]; } - // --- accept tokens (§5.8) -------------------------------------------------------- + StoredContact? contact(String address) { + final row = _ffi.contact(_native.store!, address); + if ((row["key"] as String).isEmpty) return null; + return StoredContact( + row["key"] as String, + row["verified"] as bool, + [ + for (final entry in (row["history"] as List).cast>()) + ContactHistoryEntry(entry["key"] as String, entry["until"] as int), + ], + ); + } + /// The acceptance state: main tier, blocked, or never accepted. AcceptedContact? accepted(String address) { - final a = ((_load()["accepted"] as Map?) ?? {})[address]; - if (a is! Map) return null; - return AcceptedContact(b32decode(a["identity"] as String), a["active"] as bool); - } - - /// Admit a contact to the main tier. The identity is frozen at acceptance — - /// re-accepting after a block must not change which key the token is - /// derived from (§5.8). - void accept(String address, Uint8List identity) { - _update((state) { - final accepted = (state["accepted"] as Map? ?? {}).cast(); - final previous = accepted[address]; - accepted[address] = { - "identity": previous?["identity"] ?? b32encode(identity), - "active": true, - "addedAt": previous?["addedAt"] ?? DateTime.now().millisecondsSinceEpoch, - }; - state["accepted"] = accepted; - return state; - }); - } - - /// Withdraw a contact's accept token; their mail lands in the requests tier - /// from their next message on. Throws if the contact was never accepted. - void block(String address) { - final accepted = (_load()["accepted"] as Map? ?? {}).cast(); - if (!accepted.containsKey(address)) { - throw SmolError("$address was never accepted"); - } - _update((state) { - final accepted = (state["accepted"] as Map? ?? {}).cast(); - accepted[address] = {...accepted[address]!, "active": false}; - state["accepted"] = accepted; - return state; - }); + final row = _ffi.contact(_native.store!, address); + final active = row["active"] as bool?; + final acceptedKey = row["acceptedKey"] as String?; + if (active == null || acceptedKey == null) return null; + return AcceptedContact(acceptedKey, active); } List<(String, AcceptedContact)> allAccepted() { - final accepted = ((_load()["accepted"] as Map?) ?? {}).cast(); - return [for (final e in accepted.entries) (e.key, this.accepted(e.key)!)]; + final rows = _ffi.contacts(_native.store!); + return [ + for (final row in rows.cast>()) + if (row["active"] != null && row["acceptedKey"] != null) + ( + row["address"] as String, + AcceptedContact( + row["acceptedKey"] as String, row["active"] as bool) + ), + ]; } - /// §4: the tokens to push with AUTH, and whether to push at all. A client - /// that cannot vouch for its own set — one restored from the master alone — - /// must not replace the server's with an incomplete one. - (int, List) tokenSet(Uint8List master) { - if (!syncOk()) return (0, const []); - final active = allAccepted().where((e) => e.$2.active).toList() - ..sort((a, b) => a.$1.compareTo(b.$1)); - return (1, [for (final e in active) tokenFor(master, e.$2.identity)]); - } + /// Binds an address to a key. A different key displaces the old one into + /// the contact's history (§8). + Future saveContact(String address, String keyB32, + {required bool verified}) async => + _native.saveContact(address, keyB32, verified: verified); - /// A token received from a correspondent, filed under the address that - /// issued it: an address outlives the keys behind it, so the token keeps - /// working across the issuer's rotations (§5.8). - Uint8List? tokenFrom(String address) { - final raw = ((_load()["tokens"] as Map?) ?? {})[address]; - if (raw is! Map) return null; - return b32decode(raw["token"] as String); - } - - void learnToken(String address, Uint8List token) { - _update((state) { - final tokens = (state["tokens"] as Map? ?? {}).cast(); - tokens[address] = { - "token": b32encode(token), - "seenAt": DateTime.now().millisecondsSinceEpoch, - }; - state["tokens"] = tokens; - return state; - }); - } - - // --- read markers --------------------------------------------------------------- - - void markRead(String idHex) { - _update((state) { - final read = (state["read"] as Map? ?? {}).cast(); - read[idHex] = true; - state["read"] = read; - return state; - }); - } - - bool isRead(String idHex) => - ((_load()["read"] as Map?) ?? {})[idHex] == true; - - // --- sealed mail ------------------------------------------------------------ - - Map _recordToMap(MailRecord record) => { - "id": record.id, - "envelope": record.envelope, - "receivedAt": record.receivedAt, - "recipient": record.recipient, - "sentAt": record.sentAt, - "tier": record.tier, - "keptOnServer": record.keptOnServer, - }; - - MailRecord _mapToRecord(Map map) => MailRecord( - map["id"] as String, - (map["envelope"] as Uint8List), - receivedAt: map["receivedAt"] as int?, - recipient: map["recipient"] as String?, - sentAt: map["sentAt"] as int?, - tier: (map["tier"] as int?) ?? tierMain, - keptOnServer: (map["keptOnServer"] as bool?) ?? false, - ); - - static String mailKey(String folder, String id) => "$folder/$id"; - - // "requests" is a view over the same physical "inbox" records, filtered by - // tier (§5.8) — not a separate folder, so a message keeps one identity - // regardless of which tier it arrived in. - static String _physicalFolder(String folder) => - folder == "requests" ? "inbox" : folder; - - Future storeMessage(String folder, MailRecord record) => - _mail.put(mailKey(folder, record.id), _recordToMap(record)); - - /// Returns null when the id already exists, so fetch can leave server - /// state alone. - Future storeIfNew(String folder, MailRecord record) async { - if (_mail.containsKey(mailKey(folder, record.id))) return null; - await storeMessage(folder, record); - return record; - } - - List listMessages(String folder) { - final physical = _physicalFolder(folder); - final prefix = "$physical/"; - final wantTier = folder == "requests" ? tierRequests : tierMain; - final rows = []; - for (final key in _mail.keys.cast()) { - if (!key.startsWith(prefix)) continue; - final row = _mail.get(key); - if (row is! Map) continue; - final record = _mapToRecord(row); - if (physical == "inbox" && record.tier != wantTier) continue; - rows.add(record); + /// The address a key is known by, if any — naming a mailbox is not + /// trusting a key, so nothing is bound here. + String? addressForKey(String keyB32) { + for (final (address, contact) in allContacts()) { + if (contact.key == keyB32) return address; } - rows.sort((a, b) => - (b.receivedAt ?? b.sentAt ?? 0).compareTo(a.receivedAt ?? a.sentAt ?? 0)); - return rows; + return null; } - MailRecord? getMessage(String folder, String id) { - final row = _mail.get(mailKey(_physicalFolder(folder), id)); - return row is Map ? _mapToRecord(row) : null; + // --- pins ------------------------------------------------------------------- + + /// Pins a server's static key (§4): sync, because it is one local write. + void pinServer(String host, String keyB32) => + _ffi.pinServer(_native.store!, host, keyB32); + + String? serverPin(String host) => _ffi.serverPin(_native.store!, host); + + List<(String, String)> allPins() { + final rows = _ffi.pins(_native.store!); + return [ + for (final row in rows.cast>()) + (row["host"] as String, row["key"] as String), + ]; } - Future deleteMessage(String folder, String id) => - _mail.delete(mailKey(_physicalFolder(folder), id)); + Future unpinServer(String host) async => _native.unpinServer(host); -// --- export / import: mail, contacts, pins — never the seed -------------------- + // --- backups ---------------------------------------------------------------- - /// Label kept as gsmol wrote it originally; the export format version - /// (gsmolExport) is what actually changed between v1 and v2. - static final _exportLabel = utf8Bytes("gsmol/1 export"); - Uint8List _exportKey(Uint8List master) => - hkdfSha256(master, Uint8List(0), _exportLabel, 32); + /// The gsmol backup container: sealed mail, contacts and pins — never the + /// master — as one JSON file body. + Future exportData() => _native.exportBackup(); - /// v2 matches gsmol's own current export: the whole payload — mail, - /// contacts, pins — is sealed to a key derived from the identity's master, - /// so a backup file is only readable by whoever holds that master. - /// Deliberately excludes the master itself: it has its own reveal-and-copy - /// flow in settings, meant for a password manager, not a shareable file. - Map exportData() { - final master = this.master(); - if (master == null) throw const SmolError("no identity yet"); - final state = _load(); - final contacts = ((state["contacts"] as Map?) ?? {}).cast(); - final payload = { - "servers": ((state["servers"] as Map?) ?? {}).cast(), - "contacts": { - for (final entry in contacts.entries) - entry.key: { - "key": entry.value["key"], - "verified": entry.value["verified"], - if ((entry.value["history"] as List?)?.isNotEmpty == true) - "history": entry.value["history"], - } - }, - "inbox": [ - for (final row in [...listMessages("inbox"), ...listMessages("requests")]) - { - "id": row.id, - "receivedAt": row.receivedAt, - "envelope": base64Encode(row.envelope), - "tier": row.tier, - "keptOnServer": row.keptOnServer, - } - ], - "sent": [ - for (final row in listMessages("sent")) - { - "id": row.id, - "recipient": row.recipient, - "sentAt": row.sentAt, - "envelope": base64Encode(row.envelope), - } - ], - }; - final nonce = randomBytes(12); - final ciphertext = aeadEncrypt( - _exportKey(master), nonce, utf8Bytes(jsonEncode(payload)), Uint8List(0)); - return { - "gsmolExport": 2, - "exportedAt": DateTime.now().millisecondsSinceEpoch, - "nonce": base64Encode(nonce), - "ciphertext": base64Encode(ciphertext), - }; - } - - /// Never overwrites a trust binding that already differs locally — the same - /// rule refreshContact()/saveReplyAddress() apply elsewhere. A malformed - /// entry is skipped and counted, not fatal: one bad record cannot abort the - /// rest of the import. - Future importData(Map data) async { - Map payload; - if (data["gsmolExport"] == 2) { - final master = this.master(); - if (master == null) { - throw const SmolError("no identity yet — restore it before importing"); - } - try { - final plaintext = aeadDecrypt( - _exportKey(master), - base64Decode(data["nonce"] as String), - base64Decode(data["ciphertext"] as String), - Uint8List(0), - ); - payload = jsonDecode(utf8.decode(plaintext)) as Map; - } catch (_) { - throw const SmolError("couldn't decrypt — exported by a different " - "identity, or the file is corrupted"); - } - } else if (data["gsmolExport"] == 1) { - payload = data; // pre-encryption shape: fields already sit at the top level - } else { + Future importData(String text) async { + final summary = await _native.importBackup(text); + final Map parsed; + try { + parsed = _decodeSummary(summary); + } on Exception { throw const SmolError("not a gsmol export file"); } - final summary = ImportSummary(); - - _update((state) { - final servers = (state["servers"] as Map? ?? {}).cast(); - final incomingPins = payload["servers"] is Map ? payload["servers"] as Map : null; - if (payload["servers"] != null && incomingPins == null) summary.malformed++; - for (final entry in (incomingPins ?? const {}).entries) { - final host = entry.key, key = entry.value; - if (host is! String || key is! String || _pinKeyOk(key) != true) { - summary.malformed++; - continue; - } - if (!servers.containsKey(host)) { - servers[host] = key; - summary.pinsAdded++; - } else if (servers[host] != key) { - summary.pinsConflicted++; - } - } - state["servers"] = servers; - - final contacts = (state["contacts"] as Map? ?? {}).cast(); - final incoming = payload["contacts"] is Map ? payload["contacts"] as Map : null; - if (payload["contacts"] != null && incoming == null) summary.malformed++; - for (final entry in (incoming ?? const {}).entries) { - final address = entry.key, contact = entry.value; - if (address is! String || - contact is! Map || - contact["key"] is! String || - _pinKeyOk(contact["key"] as String) != true) { - summary.malformed++; - continue; - } - if (!contacts.containsKey(address)) { - contacts[address] = { - "key": contact["key"], - "verified": contact["verified"] == true, - "seenAt": DateTime.now().millisecondsSinceEpoch, - if (contact["history"] is List && (contact["history"] as List).isNotEmpty) - "history": contact["history"], - }; - summary.contactsAdded++; - } else if (contacts[address]!["key"] != contact["key"]) { - summary.contactsConflicted++; - } - } - state["contacts"] = contacts; - return state; - }); - - for (final folder in ["inbox", "sent"]) { - final rows = payload[folder] is List ? payload[folder] as List : null; - if (payload[folder] != null && rows == null) summary.malformed++; - for (final row in rows ?? const []) { - try { - final map = row as Map; - final record = MailRecord( - map["id"] as String, - base64Decode(map["envelope"] as String), - receivedAt: folder == "inbox" ? map["receivedAt"] as int? : null, - recipient: folder == "sent" ? map["recipient"] as String? : null, - sentAt: folder == "sent" ? map["sentAt"] as int? : null, - tier: (map["tier"] as int?) ?? tierMain, - keptOnServer: (map["keptOnServer"] as bool?) ?? false, - ); - if (await storeIfNew(folder, record) != null) summary.mailAdded++; - } on Exception { - summary.malformed++; - } on TypeError { - summary.malformed++; - } - } - } - return summary; + final out = ImportSummary(); + out.pinsAdded = parsed["pinsAdded"] as int; + out.pinsConflicted = parsed["pinsConflicted"] as int; + out.contactsAdded = parsed["contactsAdded"] as int; + out.contactsConflicted = parsed["contactsConflicted"] as int; + out.mailAdded = parsed["mailAdded"] as int; + out.malformed = parsed["malformed"] as int; + return out; } - // A pin key must decode to exactly 32 bytes of base32. - bool _pinKeyOk(String key) { - try { - return b32decode(key).length == keyLen; - } on SmolError { - return false; - } - } + Map _decodeSummary(String text) => + jsonDecode(text) as Map; - /// Remove every secret and every stored envelope; the UI must confirm first. + // --- teardown --------------------------------------------------------------- + + /// A full wipe: every secret, envelope and mark. The UI must confirm. + /// The master's bytes are overwritten before their references go — Dart + /// cannot promise zeroed immutable strings, so the master is only ever + /// held as this one mutable buffer. Future wipe() async { - await _state.delete(_stateKey); - await _mail.clear(); - } - - int unreadCount() { - var count = 0; - for (final row in listMessages("inbox")) { - if (!isRead(row.id)) count++; + master()?.fillRange(0, 32, 0); + _native.clearMaster(); + await _meta.delete("master"); + await _read.clear(); + await _meta.delete("master"); + await _native.close(); + try { + final db = File(dbPath); + if (await db.exists()) await db.delete(); + for (final suffix in ["-wal", "-shm"]) { + final side = File("$dbPath$suffix"); + if (await side.exists()) await side.delete(); + } + } on FileSystemException { + // A wipe must not fail on files the store never created. } - return count; - } - - int requestsUnreadCount() { - var count = 0; - for (final row in listMessages("requests")) { - if (!isRead(row.id)) count++; - } - return count; + await _native.open(); } } - -/// The store throws these typed errors so the UI can tell "no identity yet" -/// and "an identity already exists" apart without string matching. -class SmolIdentityExistsException implements Exception { - const SmolIdentityExistsException(); - - @override - String toString() => "an identity already exists; rotate it instead"; -} - -class SmolNoIdentityException implements Exception { - const SmolNoIdentityException(); - - @override - String toString() => "no identity to rotate"; -} diff --git a/lib/smol/transport.dart b/lib/smol/transport.dart deleted file mode 100644 index c768957..0000000 --- a/lib/smol/transport.dart +++ /dev/null @@ -1,115 +0,0 @@ -// The byte pipe to a smolmaild server: raw TCP (dart:io), framed elsewhere. -// Mobile is this app's only target platform, so dart:io is fine here. - -import "dart:async"; -import "dart:io"; -import "dart:typed_data"; - -import "package:smol_mail/smol/errors.dart"; -import "package:smol_mail/smol/proto.dart"; - -/// Buffers the socket's stream and serves exact-length reads, so protocol -/// code never sees a partial frame. -class TcpWire implements Wire { - final Socket _socket; - final _chunks = []; - final _waiters = <_ReadRequest>[]; - int _buffered = 0; - Object? _closed; - late final StreamSubscription _subscription; - - TcpWire(this._socket) { - _subscription = _socket.listen(_onData, - onError: (Object error) => _fail(error), - onDone: () => _fail(const SmolError("server closed the connection"))); - } - - static Future connect(String host, int port) async { - try { - // Mobile networks routinely need longer than a LAN handshake; 30s keeps - // flaky handovers from surfacing as user-facing timeouts. - return TcpWire(await Socket.connect(host, port, - timeout: const Duration(seconds: 30))); - } on SocketException catch (error) { - throw SmolError("cannot reach $host:$port (${error.message})"); - } - } - - void _onData(Uint8List data) { - _chunks.add(data); - _buffered += data.length; - _wake(); - } - - void _wake() { - _waiters.removeWhere((w) { - if (_closed != null) { - w.completer.completeError(_closed!); - return true; - } - if (_buffered >= w.need) { - w.completer.complete(); - return true; - } - return false; - }); - } - - void _fail(Object error) { - _closed = error; - for (final w in _waiters) { - w.completer.completeError(error); - } - _waiters.clear(); - } - - @override - void send(Uint8List bytes) { - if (_closed != null) throw _closed!; - _socket.add(bytes); - } - - @override - void close() { - _subscription.cancel(); - _socket.destroy(); - _fail(const SmolError("connection closed")); - } - - @override - Future readExact(int n) async { - if (_closed != null) throw _closed!; - if (_buffered < n) { - final request = _ReadRequest(n); - _waiters.add(request); - try { - await request.completer.future; - } finally { - _waiters.remove(request); - } - if (_closed != null) throw _closed!; - } - final out = Uint8List(n); - var off = 0; - while (off < n) { - final chunk = _chunks.first; - final take = chunk.length < n - off ? chunk.length : n - off; - out.setRange(off, off + take, chunk); - if (take == chunk.length) { - _chunks.removeAt(0); - } else { - _chunks[0] = Uint8List.sublistView(chunk, take); - } - off += take; - _buffered -= take; - } - return out; - } -} - -class _ReadRequest { - final int need; - final completer = Completer(); - - _ReadRequest(this.need); -} diff --git a/lib/smol/ui.dart b/lib/smol/ui.dart new file mode 100644 index 0000000..dc19f1d --- /dev/null +++ b/lib/smol/ui.dart @@ -0,0 +1,24 @@ +// Display helpers for the keys and ids the native library hands back as +// base32 and hex — formatting only, no protocol meaning. + +import "dart:typed_data"; + +/// Lowercase hex, as the message ids cross the ABI. +String hex(List bytes) => + bytes.map((b) => b.toRadixString(16).padLeft(2, "0")).join(); + +/// The compact human check for a key: the first 20 base32 characters in +/// groups of four, the same shape every smol client shows. +String fingerprint(String keyB32) { + final s = keyB32.substring(0, 20); + return RegExp(".{4}").allMatches(s).map((m) => m[0]).join(" "); +} + +/// The inverse of [hex]; throws on anything that is not even-length hex. +Uint8List unhex(String text) { + if (text.length % 2 != 0) { + throw FormatException("odd-length hex string"); + } + return Uint8List.fromList( + [for (var i = 0; i < text.length; i += 2) int.parse(text.substring(i, i + 2), radix: 16)]); +} diff --git a/native/Cargo.lock b/native/Cargo.lock new file mode 100644 index 0000000..209a64d --- /dev/null +++ b/native/Cargo.lock @@ -0,0 +1,738 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "fumi-core" +version = "0.1.0" +source = "git+ssh://git@code.randogoth.com:2222/randogoth/fumi.git?rev=2d65d66#2d65d66012fa40121e4dc3d8d15db9ffc486569a" +dependencies = [ + "chacha20poly1305", + "data-encoding", + "ed25519-dalek", + "hkdf", + "hmac", + "rand_core", + "rusqlite", + "serde", + "serde_json", + "sha2", + "snow", + "x25519-dalek", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core", +] + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "smol_mail_native" +version = "0.1.0" +dependencies = [ + "data-encoding", + "fumi-core", + "rand_core", + "serde", + "serde_json", +] + +[[package]] +name = "snow" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "850948bee068e713b8ab860fe1adc4d109676ab4c3b621fd8147f06b261f2f85" +dependencies = [ + "aes-gcm", + "blake2", + "chacha20poly1305", + "curve25519-dalek", + "rand_core", + "rustc_version", + "sha2", + "subtle", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core", + "serde", + "zeroize", +] + +[[package]] +name = "zerocopy" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/native/Cargo.toml b/native/Cargo.toml new file mode 100644 index 0000000..adb0e95 --- /dev/null +++ b/native/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "smol_mail_native" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["lib", "cdylib"] + +[dependencies] +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" +data-encoding = "2" diff --git a/native/src/bin/dnsprobe.rs b/native/src/bin/dnsprobe.rs new file mode 100644 index 0000000..408e224 --- /dev/null +++ b/native/src/bin/dnsprobe.rs @@ -0,0 +1,17 @@ +// Device probe: reproduce the app's DNS failure in isolation. Resolves +// the given host exactly the way fumi-core's connect does — Rust std's +// getaddrinfo — and prints what came back. + +use std::net::ToSocketAddrs; + +fn main() { + let host = std::env::args().nth(1).unwrap_or_else(|| "example.com".into()); + let target = format!("{host}:1961"); + match target.to_socket_addrs() { + Ok(addrs) => { + let ips: Vec = addrs.map(|a| a.to_string()).collect(); + println!("resolved {host}: {ips:?}"); + } + Err(err) => println!("FAILED {host}: {err}"), + } +} diff --git a/native/src/ffi.rs b/native/src/ffi.rs new file mode 100644 index 0000000..04cb17e --- /dev/null +++ b/native/src/ffi.rs @@ -0,0 +1,1205 @@ +//! The C ABI over fumi-core: what the Dart binding links against. +//! +//! Boundary contract (agreed with the library's maintainer): +//! - every entry point catches panics and maps them to code 24, so a panic +//! can never unwind into Dart; +//! - `Error` is a decision enum, so it crosses as stable status codes plus +//! one JSON error slot (`smol_last_error`) carrying the structured +//! payload — the UI must never parse `Display` prose; +//! - ownership is explicit: `Store`, `Account` and cancel flags are opaque +//! handles with free functions, and every string the ABI hands back is +//! freed by `smol_free_string` — Dart's GC runs no Rust destructor. +//! +//! Compound results cross as JSON strings (camelCase), which suits the +//! poll-based UI: a list is one allocation, not a callback per row. +//! +//! Conventions every caller relies on: +//! - message ids are 64 hex characters everywhere, including the delete +//! batch, which takes one JSON array of them; +//! - `at` and `time` are protocol times in epoch seconds; `until` (contact +//! key history) is epoch milliseconds, the gsmol format's unit; `active` +//! is an accepted-state flag (1/0), not a timestamp; +//! - the error slot is thread-local, so it is only readable from the thread +//! that made the failing call — on the Dart side, inside the same +//! Isolate.run closure, never after it returns; +//! - after PANIC the store's lock is poisoned and every later call on the +//! handle returns PANIC; the UI treats that as "close and reopen the +//! store", not as an unsolvable error. + +use std::cell::RefCell; +use std::ffi::{c_char, CStr, CString}; +use std::sync::atomic::AtomicBool; + +use fumi_core::account::Account; +use fumi_core::address::Address; +use fumi_core::client::{self, FetchOptions, SendDraft, TrustChange}; +use fumi_core::crypto::{b32, unb32, KEY_LEN}; +use fumi_core::error::Error; +use fumi_core::store::Store; +use fumi_core::transport::{CERT_LEN, ID_LEN, TIER_MAIN, TIER_REQUESTS}; +use serde::Serialize; + +// --- stable error codes ------------------------------------------------------ +// +// The space is split the way the wire splits it (sec 12, RNS.md sec 13.5): +// 0-10 are the protocol's status codes, verbatim, and every purely local +// failure lives from 64 up — a server-issued RATE_LIMITED (8) arrives over a +// live session and is retried on it, while a local HANDSHAKE_REFUSED (71) +// means there is no session to retry on. The ranges must stay disjoint so +// the poll loop can pick a recovery path by code alone. + +pub const OK: i32 = 0; +pub const MALFORMED: i32 = 1; +pub const BAD_VERSION: i32 = 2; +pub const UNKNOWN_USER: i32 = 3; +pub const AUTH_REQUIRED: i32 = 4; +pub const AUTH_FAILED: i32 = 5; +pub const QUOTA_EXCEEDED: i32 = 6; +pub const TOO_LARGE: i32 = 7; +pub const RATE_LIMITED: i32 = 8; +pub const NOT_PERMITTED: i32 = 9; +pub const INTERNAL_ERROR: i32 = 10; +/// An unassigned status byte the server sent; the raw byte is in the payload. +pub const UNKNOWN_STATUS: i32 = 11; + +pub const NOT_PINNED: i32 = 64; +pub const PIN_MISMATCH: i32 = 65; +pub const KEY_CHANGED: i32 = 66; +pub const NOT_REGISTERED: i32 = 67; +pub const CHAIN_LIMIT: i32 = 68; +pub const SCHEMA_VERSION: i32 = 69; +pub const UNREACHABLE: i32 = 70; +pub const HANDSHAKE_REFUSED: i32 = 71; +pub const STORAGE: i32 = 72; +pub const NOISE: i32 = 73; +pub const IO: i32 = 74; +pub const OTHER: i32 = 75; +pub const PANIC: i32 = 76; + +fn code_of(error: &Error) -> i32 { + match error { + Error::Malformed(_) => MALFORMED, + Error::BadVersion(_) => BAD_VERSION, + Error::UnknownUser(_) => UNKNOWN_USER, + Error::AuthRequired(_) => AUTH_REQUIRED, + Error::AuthFailed(_) => AUTH_FAILED, + Error::QuotaExceeded(_) => QUOTA_EXCEEDED, + Error::TooLarge(_) => TOO_LARGE, + Error::RateLimited(_) => RATE_LIMITED, + Error::NotPermitted(_) => NOT_PERMITTED, + Error::InternalError(_) => INTERNAL_ERROR, + Error::UnknownStatus(_, _) => UNKNOWN_STATUS, + Error::NotPinned { .. } => NOT_PINNED, + Error::PinMismatch { .. } => PIN_MISMATCH, + Error::KeyChanged { .. } => KEY_CHANGED, + Error::NotRegistered => NOT_REGISTERED, + Error::ChainLimit { .. } => CHAIN_LIMIT, + Error::SchemaVersion { .. } => SCHEMA_VERSION, + Error::Unreachable { .. } => UNREACHABLE, + Error::HandshakeRefused { .. } => HANDSHAKE_REFUSED, + Error::Storage(_) => STORAGE, + Error::Noise(_) => NOISE, + Error::Io(_) => IO, + Error::Other(_) => OTHER, + } +} + +/// The structured payload of the last failure on this thread: code, message, +/// and the fields the UI decides on (keys as base32). +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ErrorJson { + code: i32, + message: String, + #[serde(skip_serializing_if = "Option::is_none")] + host: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pinned: Option, + #[serde(skip_serializing_if = "Option::is_none")] + presented: Option, + #[serde(skip_serializing_if = "Option::is_none")] + address: Option, + #[serde(skip_serializing_if = "Option::is_none")] + known: Option, + #[serde(skip_serializing_if = "Option::is_none")] + offered: Option, + /// The raw byte behind an UNKNOWN_STATUS (code 11). + #[serde(skip_serializing_if = "Option::is_none")] + status: Option, +} + +impl ErrorJson { + fn of(error: &Error) -> ErrorJson { + let message = error.to_string(); + let (host, pinned, presented, address, known, offered, status) = match error { + Error::UnknownStatus(status, _) => { + (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) + } + Error::PinMismatch { + host, + pinned, + presented, + } => ( + Some(host.clone()), + Some(b32(pinned)), + Some(b32(presented)), + None, + None, + None, + None, + ), + Error::KeyChanged { + address, + known, + offered, + } => ( + None, + None, + None, + Some(address.clone()), + Some(b32(known)), + Some(b32(offered)), + None, + ), + _ => (None, None, None, None, None, None, None), + }; + ErrorJson { + code: code_of(error), + message, + host, + pinned, + presented, + address, + known, + offered, + status, + } + } +} + +thread_local! { + static LAST_ERROR: RefCell> = const { RefCell::new(None) }; +} + +/// Runs `op` under the panic guard, recording the result. On success the +/// continuation builds the return value; on failure the error slot is filled +/// and the status code is returned for the Dart side to match on. +fn guarded(op: impl FnOnce() -> Result) -> Result { + LAST_ERROR.with(|slot| *slot.borrow_mut() = None); + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(op)) { + Ok(Ok(value)) => Ok(value), + Ok(Err(error)) => { + let code = code_of(&error); + let json = serde_json::to_string(&ErrorJson::of(&error)) + .unwrap_or_else(|_| format!("{{\"code\":{OTHER},\"message\":\"error\"}}")); + LAST_ERROR.with(|slot| *slot.borrow_mut() = Some(json)); + Err(code) + } + Err(_) => { + LAST_ERROR.with(|slot| { + *slot.borrow_mut() = Some(format!( + "{{\"code\":{PANIC},\"message\":\"the native library panicked; the store is still consistent\"}}" + )) + }); + Err(PANIC) + } + } +} + +/// Every entry point returns an i32 status: 0 ok, otherwise the error code. +fn status(op: impl FnOnce() -> Result<(), Error>) -> i32 { + match guarded(op) { + Ok(()) => OK, + Err(code) => code, + } +} + +/// String-returning entry points: a returned pointer is success (freed by +/// `smol_free_string`), null is failure with the error slot set. +fn json_out(op: impl FnOnce() -> Result) -> *mut c_char { + match guarded(op) { + Ok(text) => CString::new(text).map(CString::into_raw).unwrap_or(std::ptr::null_mut()), + Err(_) => std::ptr::null_mut(), + } +} + +/// The ABI keeps every input string alive for the duration of the call, so +/// the borrowed lifetime is the call's. +fn text(ptr: *const c_char) -> Result<&'static str, Error> { + if ptr.is_null() { + return Ok(""); + } + unsafe { CStr::from_ptr(ptr) } + .to_str() + .map_err(|_| Error::Other("input is not UTF-8".into())) +} + +fn opt_text(ptr: *const c_char) -> Result, Error> { + if ptr.is_null() { + Ok(None) + } else { + text(ptr).map(Some) + } +} + +fn master_of(ptr: *const u8) -> Result<[u8; KEY_LEN], Error> { + if ptr.is_null() { + return Err(Error::Other("master pointer is null".into())); + } + let bytes = unsafe { std::slice::from_raw_parts(ptr, KEY_LEN) }; + bytes + .try_into() + .map_err(|_| Error::Other("master must be 32 bytes".into())) +} + + +fn ser(value: &T) -> Result { + serde_json::to_string(value) + .map_err(|e| Error::Other(format!("serialization failed: {e}"))) +} + +fn parse_address(raw: &str) -> Result { + Address::parse(raw) +} + +/// Parses an address and applies the caller's dial hint: the IP to dial when +/// the host resolved DNS itself, with the hostname keeping every identity +/// role — pins, proof-of-possession, display. +fn dial_addr(raw: *const c_char, dial: *const c_char) -> Result { + let addr = parse_address(text(raw)?)?; + match opt_text(dial)? { + Some(ip) => Ok(addr.with_dial(ip)), + None => Ok(addr), + } +} + +fn change_name(change: &TrustChange) -> &'static str { + match change { + TrustChange::New { unverified: false } => "new", + TrustChange::New { unverified: true } => "newUnverified", + TrustChange::Rotated => "rotated", + TrustChange::None => "none", + } +} + +// --- the ABI ----------------------------------------------------------------- + +/// The JSON error slot: what the last failed call on this thread reports. +/// Null when the last call succeeded. +#[no_mangle] +pub extern "C" fn smol_last_error() -> *mut c_char { + LAST_ERROR.with(|slot| { + match slot.borrow_mut().take() { + Some(json) => CString::new(json).map(CString::into_raw).unwrap_or(std::ptr::null_mut()), + None => std::ptr::null_mut(), + } + }) +} + +/// Frees any string this ABI returned. Strings not freed leak — Dart's GC +/// runs no Rust destructor. +#[no_mangle] +pub extern "C" fn smol_free_string(ptr: *mut c_char) { + if !ptr.is_null() { + unsafe { drop(CString::from_raw(ptr)) }; + } +} + +#[no_mangle] +pub extern "C" fn smol_store_open(path: *const c_char) -> *mut Store { + match guarded(|| Store::open(std::path::Path::new(text(path)?))) { + Ok(store) => Box::into_raw(Box::new(store)), + Err(_) => std::ptr::null_mut(), + } +} + +#[no_mangle] +pub extern "C" fn smol_store_memory() -> *mut Store { + match guarded(|| Store::open_in_memory()) { + Ok(store) => Box::into_raw(Box::new(store)), + Err(_) => std::ptr::null_mut(), + } +} + +#[no_mangle] +pub extern "C" fn smol_store_free(ptr: *mut Store) { + if !ptr.is_null() { + unsafe { drop(Box::from_raw(ptr)) }; + } +} + +#[no_mangle] +pub extern "C" fn smol_account_new(master: *const u8, index: u32) -> *mut Account { + match guarded(|| Account::new(master_of(master)?, index)) { + Ok(account) => Box::into_raw(Box::new(account)), + Err(_) => std::ptr::null_mut(), + } +} + +#[no_mangle] +pub extern "C" fn smol_account_free(ptr: *mut Account) { + if !ptr.is_null() { + unsafe { drop(Box::from_raw(ptr)) }; + } +} + +#[no_mangle] +pub extern "C" fn smol_account_pk(ptr: *mut Account) -> *mut c_char { + match guarded(|| { + let account = unsafe { ptr.as_ref().ok_or_else(|| Error::Other("account handle is null".into()))? }; + Ok(b32(&account.me().pk())) + }) { + Ok(text) => CString::new(text).map(CString::into_raw).unwrap_or(std::ptr::null_mut()), + Err(_) => std::ptr::null_mut(), + } +} + +#[no_mangle] +pub extern "C" fn smol_flag_new() -> *mut AtomicBool { + Box::into_raw(Box::new(AtomicBool::new(false))) +} + +#[no_mangle] +pub extern "C" fn smol_flag_set(ptr: *mut AtomicBool, value: i32) { + if let Some(flag) = unsafe { ptr.as_ref() } { + flag.store(value != 0, std::sync::atomic::Ordering::Relaxed); + } +} + +#[no_mangle] +pub extern "C" fn smol_flag_free(ptr: *mut AtomicBool) { + if !ptr.is_null() { + unsafe { drop(Box::from_raw(ptr)) }; + } +} + +#[no_mangle] +pub extern "C" fn smol_pin_server( + store: *mut Store, + host: *const c_char, + key_b32: *const c_char, +) -> 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)?, &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) -> *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)?)? + .map(|key| b32(&key)) + .unwrap_or_default()) + }) { + Ok(text) => CString::new(text).map(CString::into_raw).unwrap_or(std::ptr::null_mut()), + Err(_) => std::ptr::null_mut(), + } +} + +/// Saves a contact binding: the settings and reader flows that name a key +/// for an address (import, name-sender, save-reply-address). +#[no_mangle] +pub extern "C" fn smol_contact_save( + store: *mut Store, + address: *const c_char, + key_b32: *const c_char, + verified: i32, +) -> 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("contact key must decode to 32 bytes".into()))?; + store.save_contact(text(address)?, &key, verified != 0) + }) +} + +/// 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) -> i32 { + status(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + store.unpin_server(text(host)?) + }) +} + +/// Every pin as JSON: [{host, key}]. +#[no_mangle] +pub extern "C" fn smol_pins(store: *mut Store) -> *mut c_char { + json_out(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + #[derive(Serialize)] + struct Out { + host: String, + key: String, + } + let rows = store + .pins()? + .into_iter() + .map(|(host, key)| Out { host, key: b32(&key) }) + .collect::>(); + ser(&rows) + }) +} + +/// Whether the local accept-token set may replace the server's (sec 4): +/// 1 yes, 0 no, -1 error. +#[no_mangle] +pub extern "C" fn smol_sync_ok(store: *mut Store) -> i32 { + match guarded(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + Ok(store.sync_ok()?) + }) { + Ok(ok) => ok as i32, + Err(_) => -1, + } +} + +/// The registered address, short form. Empty string: not registered. +#[no_mangle] +pub extern "C" fn smol_store_account(store: *mut Store) -> *mut c_char { + match guarded(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + Ok(store.account()?.map(|addr| addr.short()).unwrap_or_default()) + }) { + Ok(text) => CString::new(text).map(CString::into_raw).unwrap_or(std::ptr::null_mut()), + Err(_) => std::ptr::null_mut(), + } +} + +/// Binds the account locally: what register and restore do at their end, +/// exposed for hosts and test harnesses that need to set the state directly. +#[no_mangle] +pub extern "C" fn smol_account_set(store: *mut Store, address: *const c_char) -> i32 { + status(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + store.set_account(&parse_address(text(address)?)?) + }) +} + +/// The rotation index the account is at; -1 on error. +#[no_mangle] +pub extern "C" fn smol_rotations(store: *mut Store) -> i32 { + match guarded(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + Ok(store.rotations()? as i32) + }) { + Ok(index) => index, + Err(_) => -1, + } +} + +#[no_mangle] +pub extern "C" fn smol_register( + store: *mut Store, + account: *mut Account, + address: *const c_char, + invite: *const c_char, + dial: *const c_char, + timeout: u64, +) -> i32 { + status(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + let account = unsafe { account.as_ref().ok_or_else(|| Error::Other("account handle is null".into()))? }; + let addr = dial_addr(address, dial)?; + client::register(store, &addr, account, opt_text(invite)?, timeout) + }) +} + +/// Recovers local state from the master alone; the rotation index lands in +/// the store, so the account handle must be rebuilt afterwards. +#[no_mangle] +pub extern "C" fn smol_restore( + store: *mut Store, + master: *const u8, + address: *const c_char, + dial: *const c_char, + timeout: u64, +) -> i32 { + status(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + let addr = dial_addr(address, dial)?; + client::restore(store, &master_of(master)?, &addr, timeout)?; + Ok(()) + }) +} + +#[no_mangle] +pub extern "C" fn smol_rotate( + store: *mut Store, + account: *mut Account, + dial: *const c_char, + timeout: u64, +) -> *mut c_char { + json_out(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + let account = unsafe { account.as_ref().ok_or_else(|| Error::Other("account handle is null".into()))? }; + let rotated = client::rotate(store, account, opt_text(dial)?, timeout)?; + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Out<'a> { + new_pk: String, + cert: &'a str, + } + Ok(ser(&Out { + new_pk: b32(&rotated.new_pk), + cert: &hex(&rotated.cert), + })?) + }) +} + +#[no_mangle] +pub extern "C" fn smol_fetch( + store: *mut Store, + account: *mut Account, + keep: i32, + reset: i32, + dial: *const c_char, + timeout: u64, + cancel: *mut AtomicBool, +) -> *mut c_char { + json_out(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + let account = unsafe { account.as_ref().ok_or_else(|| Error::Other("account handle is null".into()))? }; + let cancel = unsafe { cancel.as_ref() }; + let summary = client::fetch_with( + store, + account, + FetchOptions { + keep: keep != 0, + reset: reset != 0, + dial: opt_text(dial)?, + timeout, + cancel, + }, + )?; + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Out { + total: u32, + stored: u32, + rejected: Vec<(String, String)>, + acknowledged: bool, + cancelled: bool, + } + Ok(ser(&Out { + total: summary.total, + stored: summary.stored, + rejected: summary + .rejected + .iter() + .map(|r| (hex(&r.id), r.reason.clone())) + .collect(), + acknowledged: summary.acknowledged, + cancelled: summary.cancelled, + })?) + }) +} + +#[no_mangle] +pub extern "C" fn smol_send( + store: *mut Store, + account: *mut Account, + to: *const c_char, + subject: *const c_char, + body: *const c_char, + reply_to: *const c_char, + anonymous: i32, + no_pad: i32, + dial: *const c_char, + timeout: u64, +) -> *mut c_char { + json_out(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + let account = unsafe { account.as_ref().ok_or_else(|| Error::Other("account handle is null".into()))? }; + let addr = dial_addr(to, dial)?; + let draft = SendDraft { + address: &addr, + text: text(body)?.to_string(), + subject: opt_text(subject)?, + reply_to: opt_text(reply_to)?, + headers: &[], + anonymous: anonymous != 0, + no_pad: no_pad != 0, + }; + let sent = client::send(store, account, &draft, timeout)?; + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Out { + id: String, + bytes: usize, + token_used: bool, + change: &'static str, + warning: Option, + } + Ok(ser(&Out { + id: hex(&sent.id), + bytes: sent.bytes, + token_used: sent.token_used, + change: change_name(&sent.change), + warning: sent.warning, + })?) + }) +} + +/// Deletes on the server by message id: one JSON array of 64-hex-character +/// ids — the same shape `smol_mail` and `smol_describe` speak, so no caller +/// hex-decodes solely to delete. A missing or empty array is a caller bug +/// and surfaces as an error rather than silent success. +#[no_mangle] +pub extern "C" fn smol_delete( + store: *mut Store, + account: *mut Account, + ids_json: *const c_char, + dial: *const c_char, + timeout: u64, +) -> i32 { + status(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + let account = unsafe { account.as_ref().ok_or_else(|| Error::Other("account handle is null".into()))? }; + let ids: Vec = serde_json::from_str(text(ids_json)?) + .map_err(|_| Error::Other("delete takes a JSON array of hex message ids".into()))?; + if ids.is_empty() { + return Err(Error::Other("delete was called with no message ids".into())); + } + let mut parsed = Vec::with_capacity(ids.len()); + for id in &ids { + parsed.push( + unhex(id).and_then(|bytes| <[u8; ID_LEN]>::try_from(bytes).ok()).ok_or_else( + || Error::Other("message ids must be 64 hex characters".into()), + )?, + ); + } + client::delete(store, account, &parsed, opt_text(dial)?, timeout)?; + Ok(()) + }) +} + +/// One folder as JSON: "inbox", "requests", "sent" or "all". +#[no_mangle] +pub extern "C" fn smol_mail(store: *mut Store, folder: *const c_char) -> *mut c_char { + json_out(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + let folder = text(folder)?; + // Only the inbox folders have a tier; sent and all rows omit the + // field rather than fabricate one a badge could mislabel. + let tier = match folder { + "inbox" => Some(TIER_MAIN), + "requests" => Some(TIER_REQUESTS), + _ => None, + }; + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Row { + id: String, + envelope: String, + at: i64, + #[serde(skip_serializing_if = "Option::is_none")] + tier: Option, + kept: bool, + recipient: Option, + } + let rows: Vec = store + .mail(folder)? + .into_iter() + .map(|row| Row { + id: hex(&row.id), + envelope: data_encoding::BASE64.encode(&row.envelope), + at: row.at, + tier, + kept: row.kept, + recipient: row.recipient, + }) + .collect(); + Ok(ser(&rows)?) + }) +} + +/// Removes messages locally — the reader's delete, distinct from the +/// server delete above. Each id is also marked seen (sec 10), so a still-kept +/// server copy is not re-stored on the next fetch: deleting locally must not +/// resurrect the message. +#[no_mangle] +pub extern "C" fn smol_delete_local( + store: *mut Store, + folder: *const c_char, + ids_json: *const c_char, +) -> i32 { + status(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + let folder = text(folder)?; + let table = match folder { + "sent" => "sent", + "inbox" | "requests" | "all" => "inbox", + _ => return Err(Error::Other("folder must be inbox, requests, sent or all".into())), + }; + let ids: Vec = serde_json::from_str(text(ids_json)?) + .map_err(|_| Error::Other("delete takes a JSON array of hex message ids".into()))?; + if ids.is_empty() { + return Err(Error::Other("delete was called with no message ids".into())); + } + for id in &ids { + let id: [u8; ID_LEN] = unhex(id) + .and_then(|bytes| bytes.try_into().ok()) + .ok_or_else(|| Error::Other("message ids must be 64 hex characters".into()))?; + store.delete_local(table, &id)?; + } + Ok(()) + }) +} + +/// Parses one address for the UI: {user, host, port, short, scheme}. Null +/// plus the error slot when it does not parse — the onboarding's field +/// validation and the compose screen's recipient check both live here. +#[no_mangle] +pub extern "C" fn smol_parse_address(raw: *const c_char) -> *mut c_char { + json_out(|| { + let addr = parse_address(text(raw)?)?; + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Out { + user: String, + host: String, + port: u16, + short: String, + scheme: &'static str, + /// The self-certifying key a smol:// URI carries, when present. + identity: Option, + } + ser(&Out { + user: addr.user.clone(), + host: addr.host.clone(), + port: addr.port, + short: addr.short(), + scheme: match addr.scheme { + fumi_core::address::Scheme::Tcp => "tcp", + fumi_core::address::Scheme::Rns => "rns", + }, + identity: addr.identity.map(|key| b32(&key)), + }) + }) +} + +/// Opens one sealed message: the described view the reader shows. +#[no_mangle] +pub extern "C" fn smol_describe( + store: *mut Store, + account: *mut Account, + id_hex: *const c_char, +) -> *mut c_char { + json_out(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + let account = unsafe { account.as_ref().ok_or_else(|| Error::Other("account handle is null".into()))? }; + let id: [u8; ID_LEN] = unhex(text(id_hex)?) + .and_then(|bytes| bytes.try_into().ok()) + .ok_or_else(|| Error::Other("message id must be 64 hex characters".into()))?; + let stored = store + .mail("all")? + .into_iter() + .chain(store.mail("sent")?) + .find(|row| row.id == id) + .ok_or_else(|| Error::Other("no such message".into()))?; + let described = client::describe(store, account, &stored)?; + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Out { + sender: String, + time: i64, + subject: String, + fields: std::collections::BTreeMap, + text: String, + from: String, + } + Ok(ser(&Out { + sender: b32(&described.sender), + time: described.time, + subject: described.subject, + fields: described.fields, + text: described.text, + from: described.from, + })?) + }) +} + +#[no_mangle] +pub extern "C" fn smol_contacts(store: *mut Store) -> *mut c_char { + json_out(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct History { + key: String, + until: i64, + } + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Out { + address: String, + key: String, + verified: bool, + active: Option, + /// The identity frozen at acceptance (sec 5.8), which the token + /// is derived from — it can differ from the current key after a + /// rotation. + accepted_key: Option, + history: Vec, + } + let mut rows = Vec::new(); + for (address, key, verified, active) in store.contact_rows()? { + let history = store + .history(&address)? + .into_iter() + .map(|(key, until)| History { key: b32(&key), until }) + .collect(); + let accepted_key = store + .accepted_identity(&address)? + .map(|identity| b32(&identity)); + rows.push(Out { + address, + key: b32(&key), + verified, + active: active.map(|a| a != 0), + accepted_key, + history, + }); + } + Ok(ser(&rows)?) + }) +} + +/// One contact by address: {key, verified, history}. Empty object: unknown. +#[no_mangle] +pub extern "C" fn smol_contact(store: *mut Store, address: *const c_char) -> *mut c_char { + json_out(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + let address = text(address)?; + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Out { + key: String, + verified: bool, + active: Option, + accepted_key: Option, + history: Vec<(String, i64)>, + } + let Some((key, verified)) = store.contact(address)? else { + return Ok(ser(&Out { + key: String::new(), + verified: false, + active: None, + accepted_key: None, + history: Vec::new(), + })?); + }; + Ok(ser(&Out { + key: b32(&key), + verified, + active: store.accepted_state(address)?, + accepted_key: store + .accepted_identity(address)? + .map(|identity| b32(&identity)), + history: store + .history(address)? + .into_iter() + .map(|(key, until)| (b32(&key), until)) + .collect(), + })?) + }) +} + +#[no_mangle] +pub extern "C" fn smol_accept( + store: *mut Store, + account: *mut Account, + address: *const c_char, + dial: *const c_char, + timeout: u64, +) -> *mut c_char { + json_out(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + let account = unsafe { account.as_ref().ok_or_else(|| Error::Other("account handle is null".into()))? }; + let addr = parse_address(text(address)?)?; + let identity = store + .contact(&addr.short())? + .ok_or_else(|| Error::Other("no key for that address; resolve first".into()))? + .0; + store.accept(&addr.short(), &identity)?; + match client::push_tokens(store, account, opt_text(dial)?, timeout)? { + client::Pushed::Held(held) => Ok(format!("{{\"held\":{held}}}")), + client::Pushed::NotRegistered => Ok("{\"held\":0}".to_string()), + } + }) +} + +#[no_mangle] +pub extern "C" fn smol_block( + store: *mut Store, + account: *mut Account, + address: *const c_char, + dial: *const c_char, + timeout: u64, +) -> i32 { + status(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + let account = unsafe { account.as_ref().ok_or_else(|| Error::Other("account handle is null".into()))? }; + let addr = parse_address(text(address)?)?; + store.block(&addr.short())?; + client::push_tokens(store, account, opt_text(dial)?, timeout)?; + Ok(()) + }) +} + +/// Resolves a contact and applies the sec 8 trust rules; a key change +/// without a chain fails with KEY_CHANGED carrying known and offered. +#[no_mangle] +pub extern "C" fn smol_resolve( + store: *mut Store, + address: *const c_char, + dial: *const c_char, + timeout: u64, +) -> *mut c_char { + json_out(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + let addr = dial_addr(address, dial)?; + let mut session = client::connect(store, &addr, true, timeout)?; + let (key, change) = { + let transport = session.transport(); + client::trust_key(store, transport, &addr)? + }; + session.close(); + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Out { + key: String, + change: &'static str, + } + Ok(ser(&Out { + key: b32(&key), + change: change_name(&change), + })?) + }) +} + +#[no_mangle] +pub extern "C" fn smol_import_contact(store: *mut Store, uri: *const c_char) -> i32 { + status(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + let addr = parse_address(text(uri)?)?; + let key = addr + .identity + .ok_or_else(|| Error::Other("import needs a self-certifying address carrying a key".into()))?; + store.save_contact(&addr.short(), &key, true)?; + Ok(()) + }) +} + +#[no_mangle] +pub extern "C" fn smol_export_backup(store: *mut Store, master: *const u8) -> *mut c_char { + json_out(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + fumi_core::export::export(store, &master_of(master)?) + }) +} + +#[no_mangle] +pub extern "C" fn smol_import_backup( + store: *mut Store, + master: *const u8, + backup: *const c_char, +) -> *mut c_char { + json_out(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + let summary = fumi_core::export::import(store, &master_of(master)?, text(backup)?)?; + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Out { + pins_added: usize, + pins_conflicted: usize, + contacts_added: usize, + contacts_conflicted: usize, + mail_added: usize, + malformed: usize, + } + ser(&Out { + pins_added: summary.pins_added, + pins_conflicted: summary.pins_conflicted, + contacts_added: summary.contacts_added, + contacts_conflicted: summary.contacts_conflicted, + mail_added: summary.mail_added, + malformed: summary.malformed, + }) + }) +} + +/// The smoke entry point from the spike: a full store round-trip through the +/// cdylib. 0 = round-trip held. +#[no_mangle] +pub extern "C" fn smol_smoke() -> i32 { + let addr = match Address::parse("alice@example.org") { + Ok(addr) => addr, + Err(_) => return 1, + }; + let store = match Store::open_in_memory() { + Ok(store) => store, + Err(_) => return 2, + }; + if store.set_account(&addr).is_err() { + return 3; + } + match store.account() { + Ok(Some(back)) if back.short() == addr.short() => 0, + Ok(Some(_)) => 4, + Ok(None) => 5, + Err(_) => 6, + } +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +fn unhex(text: &str) -> Option> { + if text.len() % 2 != 0 { + return None; + } + (0..text.len() / 2) + .map(|i| u8::from_str_radix(&text[i * 2..i * 2 + 2], 16).ok()) + .collect() +} + +// CERT_LEN is part of the rotation result's shape; keep the import honest. +const _: () = assert!(CERT_LEN == 200); + +#[cfg(test)] +mod tests { + use super::*; + + /// Reads one returned buffer into an owned String and frees the buffer — + /// the ownership rule every caller of this ABI owes a returned pointer. + fn take(ptr: *mut c_char) -> String { + assert!(!ptr.is_null(), "a null return means the call failed"); + let text = unsafe { CStr::from_ptr(ptr) }.to_str().unwrap().to_string(); + smol_free_string(ptr); + text + } + + #[test] + fn smoke_through_the_abi() { + assert_eq!(smol_smoke(), 0); + } + + #[test] + fn local_delete_and_address_parse() { + let store = smol_store_memory(); + let s = unsafe { &*store }; + let id = [5u8; ID_LEN]; + s.store_inbox(&id, b"sealed", 9, false, true).unwrap(); + assert_eq!(s.mail("all").unwrap().len(), 1); + + // Local removal marks the id seen, so a refetch cannot re-store it. + let ids = CString::new(format!("[\"{}\"]", hex(&id))).unwrap(); + let code = smol_delete_local(store, c"inbox".as_ptr(), ids.as_ptr()); + assert_eq!(code, OK); + assert!(s.mail("all").unwrap().is_empty()); + assert!(s.seen(&id).unwrap()); + // Caller bugs surface, same as the server delete. + assert_eq!( + smol_delete_local(store, c"inbox".as_ptr(), c"[]".as_ptr()), + OTHER + ); + + // Address parsing for the UI: full shape, or an error with the slot. + let parsed = smol_parse_address(c"alice@example.org:1961".as_ptr()); + let parsed = take(parsed); + let json: serde_json::Value = serde_json::from_str(&parsed).unwrap(); + assert_eq!(json["user"], "alice"); + assert_eq!(json["host"], "example.org"); + assert_eq!(json["port"], 1961); + // The default port is elided in the short form, as everywhere else. + assert_eq!(json["short"], "alice@example.org"); + assert_eq!(json["scheme"], "tcp"); + assert!(smol_parse_address(c"not an address".as_ptr()).is_null()); + + smol_store_free(store); + } + + #[test] + fn delete_surfaces_caller_bugs() { + let store = smol_store_memory(); + let master = [7u8; KEY_LEN]; + let account = smol_account_new(master.as_ptr(), 0); + // An empty array, a non-JSON argument and a wrong-length id are all + // caller bugs: errors, never silent success. + assert_eq!(smol_delete(store, account, c"[]".as_ptr(), std::ptr::null(), 5), OTHER); + assert_eq!(smol_delete(store, account, c"not json".as_ptr(), std::ptr::null(), 5), OTHER); + assert_eq!( + smol_delete(store, account, c"[\"abcd\"]".as_ptr(), std::ptr::null(), 5), + OTHER + ); + smol_account_free(account); + smol_store_free(store); + } + + #[test] + fn handles_round_trip_and_errors_carry_codes() { + let store = smol_store_memory(); + assert!(!store.is_null()); + let master = [5u8; KEY_LEN]; + let account = smol_account_new(master.as_ptr(), 0); + assert!(!account.is_null()); + let pk = take(smol_account_pk(account)); + assert_eq!(pk.len(), 52); + + // No pin: NotPinned with the host in the slot, by code, not prose. + let code = smol_register(store, account, c"nobody@127.0.0.1:19619".as_ptr(), std::ptr::null(), std::ptr::null(), 5); + assert_eq!(code, NOT_PINNED); + let slot = take(smol_last_error()); + 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"); + + // Strings returned on success must be freed without double-free. + assert_eq!(take(smol_store_account(store)), ""); + + let flag = smol_flag_new(); + smol_flag_set(flag, 1); + smol_flag_free(flag); + smol_account_free(account); + smol_store_free(store); + } + + #[test] + fn mail_and_contacts_render_as_json() { + let store = smol_store_memory(); + let s = unsafe { &*store }; + 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(); + + let folder = take(smol_mail(store, c"all".as_ptr())); + let rows: serde_json::Value = serde_json::from_str(&folder).unwrap(); + assert_eq!(rows[0]["id"], hex(&[3u8; ID_LEN])); + assert_eq!(rows[0]["kept"], true); + + let contacts = take(smol_contacts(store)); + let list: serde_json::Value = serde_json::from_str(&contacts).unwrap(); + assert_eq!(list[0]["address"], "alice@example.org"); + assert_eq!(list[0]["verified"], true); + assert_eq!(list[0]["history"][0]["until"], 500); + + smol_store_free(store); + } +} diff --git a/native/src/lib.rs b/native/src/lib.rs new file mode 100644 index 0000000..faec893 --- /dev/null +++ b/native/src/lib.rs @@ -0,0 +1,450 @@ +// C ABI over fumi-core for the Dart FFI binding; the app links the cdylib. +// One smoke function for the spike; the real surface lives in `ffi`. + +pub mod ffi; + +#[cfg(test)] +mod tests { + use fumi_core::{account::Identity, message}; + use rand_core::{OsRng, RngCore}; + use std::time::Instant; + + #[test] + fn store_round_trip() { + assert_eq!(crate::ffi::smol_smoke(), 0); + } + + /// Spike benchmark: seal/unseal throughput with a 2 KiB body, to compare + /// against the Dart core's numbers (test/perf_smoke_test.dart). Print + /// only; the assertions pin correctness, not speed. + #[test] + fn envelope_perf() { + let mut seed = [0u8; 32]; + OsRng.fill_bytes(&mut seed); + let sender = Identity::from_seed(seed); + OsRng.fill_bytes(&mut seed); + let recipient = Identity::from_seed(seed); + let body = vec![b'x'; 2048]; + const N: usize = 100; + + let t0 = Instant::now(); + let envelopes: Vec> = (0..N) + .map(|i| message::seal(&sender, &recipient.pk(), &body, i as i64, true).unwrap()) + .collect(); + let seal_dt = t0.elapsed(); + + let t1 = Instant::now(); + for (i, envelope) in envelopes.iter().enumerate() { + let opened = message::unseal(&[recipient.clone()], envelope, 0).unwrap(); + assert_eq!(opened.body.len(), 2048); + assert_eq!(opened.sender, sender.pk()); + assert_eq!(opened.time, i as i64); + } + let open_dt = t1.elapsed(); + + println!( + "rust: seal {N} in {seal_dt:?} ({:.0} us/msg), unseal {N} in {open_dt:?} ({:.0} us/msg)", + seal_dt.as_micros() as f64 / N as f64, + open_dt.as_micros() as f64 / N as f64, + ); + } + + /// Spike round-trip against a live bunshin on 127.0.0.1:19619; ignored + /// because it needs the server (bunshin serve --key ... --port 19619). + /// Covers the embeddable path end to end: pin, REGISTER, SEND, FETCH. + #[test] + #[ignore] + fn bunshin_round_trip() { + use fumi_core::account::Account; + use fumi_core::address::Address; + use fumi_core::client::{fetch, register, send, SendDraft}; + use fumi_core::crypto::unb32; + use fumi_core::store::Store; + use fumi_core::transport::KEY_LEN; + use rand_core::{OsRng, RngCore}; + + let server: [u8; KEY_LEN] = unb32( + "g5ttsssqjq6j37vso3h7otn33zrk6o6r7c6jjm7guxbbijoa5nqq", + ) + .unwrap() + .try_into() + .unwrap(); + + let mut master = [0u8; 32]; + let mut identity = || { + OsRng.fill_bytes(&mut master); + (Store::open_in_memory().unwrap(), Account::new(master, 0).unwrap()) + }; + let (alice_store, alice) = identity(); + let (bob_store, bob) = identity(); + + // Random usernames: the server persists registrations, so fixed ones + // would collide on the second run of this test. + let mut suffix = [0u8; 2]; + OsRng.fill_bytes(&mut suffix); + 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", &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(); + + let draft = SendDraft { + address: &bob_addr, + text: "spike: hello over fumi".into(), + subject: Some("spike"), + reply_to: None, + headers: &[], + anonymous: false, + no_pad: false, + }; + let sent = send(&alice_store, &alice, &draft, 5).unwrap(); + assert_eq!(sent.warning, None); + + let fetched = fetch(&bob_store, &bob, false, false, 5).unwrap(); + assert_eq!(fetched.stored, 1); + // First contact without an accept token lands in the requests tier + // (sec 5.8), not the inbox. + let stored = &bob_store.mail("requests").unwrap()[0]; + let opened = fumi_core::client::describe(&bob_store, &bob, stored).unwrap(); + assert_eq!(opened.text, "spike: hello over fumi"); + assert_eq!(opened.subject, "spike"); + assert_eq!(opened.sender, alice.keys()[0].pk()); + assert_eq!(opened.from, alice_addr.short()); + + // sec 5.6, the cross-recipient case self-sends mask: the sent copy + // is sealed to the sender's own key, so alice can read back what she + // sent to bob even though bob's envelope used a discarded ephemeral. + let sent = &alice_store.mail("sent").unwrap()[0]; + let reread = + fumi_core::client::describe(&alice_store, &alice, sent).unwrap(); + assert_eq!(reread.text, "spike: hello over fumi"); + assert_eq!(reread.subject, "spike"); + } + + /// Spike: cancellation and resume, against the same live bunshin. Four + /// ~400 KiB envelopes exceed the server's 512 KiB fetch budget, so each + /// page carries one message and the between-page cancel check is + /// reachable. Phase A is deterministic (flag pre-set); phase B cancels + /// from a watcher thread the moment the first message commits — which is + /// also the concurrent-reader check, since the store is read while the + /// fetch holds it. + #[test] + #[ignore] + fn bunshin_cancel_and_resume() { + use fumi_core::account::Account; + use fumi_core::address::Address; + use fumi_core::client::{fetch, fetch_with, register, send, FetchOptions, SendDraft}; + use fumi_core::crypto::unb32; + use fumi_core::store::Store; + use fumi_core::transport::KEY_LEN; + use rand_core::{OsRng, RngCore}; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; + + let server: [u8; KEY_LEN] = unb32( + "g5ttsssqjq6j37vso3h7otn33zrk6o6r7c6jjm7guxbbijoa5nqq", + ) + .unwrap() + .try_into() + .unwrap(); + + let mut master = [0u8; 32]; + let mut identity = || { + OsRng.fill_bytes(&mut master); + (Store::open_in_memory().unwrap(), Account::new(master, 0).unwrap()) + }; + let (alice_store, alice) = identity(); + let bob_store = Arc::new(Store::open_in_memory().unwrap()); + let mut bob_master = [0u8; 32]; + OsRng.fill_bytes(&mut bob_master); + let bob = Account::new(bob_master, 0).unwrap(); + + let mut suffix = [0u8; 2]; + OsRng.fill_bytes(&mut suffix); + 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", &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(); + + let big = "x".repeat(400 * 1024); + for i in 0..4 { + let draft = SendDraft { + address: &bob_addr, + text: format!("{i}\n{big}"), + subject: None, + reply_to: None, + headers: &[], + anonymous: false, + no_pad: false, + }; + send(&alice_store, &alice, &draft, 5).unwrap(); + } + + // Phase A: the flag is checked before the first page, so nothing is + // fetched and nothing is acknowledged. + let cancel = AtomicBool::new(true); + let a = fetch_with( + &bob_store, + &bob, + FetchOptions { + keep: false, + reset: false, + dial: None, + timeout: 5, + cancel: Some(&cancel), + }, + ) + .unwrap(); + assert!(a.cancelled); + assert_eq!(a.stored, 0); + + // Phase B: a reader thread watches the store fill and raises the + // flag after the first commit; the fetch stops between pages. + cancel.store(false, Ordering::Relaxed); + let flag = Arc::new(AtomicBool::new(false)); + let thread_flag = Arc::clone(&flag); + let watcher_store = Arc::clone(&bob_store); + let watcher = std::thread::spawn(move || { + while watcher_store.mail("all").unwrap().len() < 1 { + std::thread::sleep(std::time::Duration::from_millis(1)); + } + thread_flag.store(true, Ordering::Relaxed); + }); + let b = fetch_with( + &bob_store, + &bob, + FetchOptions { + keep: false, + reset: false, + dial: None, + timeout: 5, + cancel: Some(&flag), + }, + ) + .unwrap(); + assert!(b.cancelled, "watcher should have raised the flag mid-fetch"); + assert!(b.stored >= 1 && b.stored < 4); + watcher.join().unwrap(); + + // Resume: an uncancelled fetch delivers the rest, and the seen-id + // set keeps the acknowledged pages from coming back as duplicates. + let c = fetch(&bob_store, &bob, false, false, 5).unwrap(); + assert_eq!(b.stored + c.stored, 4); + assert_eq!(bob_store.mail("all").unwrap().len(), 4); + } + + /// The Android regression: a hostname the native resolver cannot + /// resolve (the device's getaddrinfo is dead for app processes), pinned + /// by name, dialed by the IP the app resolved itself. Registration must + /// succeed and the account must keep the hostname identity. + #[test] + #[ignore] + fn bunshin_dial_hint_routes_by_ip() { + use fumi_core::client::register; + use fumi_core::crypto::unb32; + use fumi_core::store::Store; + use fumi_core::transport::KEY_LEN; + use rand_core::{OsRng, RngCore}; + + let server: [u8; KEY_LEN] = unb32( + "g5ttsssqjq6j37vso3h7otn33zrk6o6r7c6jjm7guxbbijoa5nqq", + ) + .unwrap() + .try_into() + .unwrap(); + let mut master = [0u8; 32]; + OsRng.fill_bytes(&mut master); + let store = Store::open_in_memory().unwrap(); + let account = fumi_core::account::Account::new(master, 0).unwrap(); + + let mut suffix = [0u8; 2]; + OsRng.fill_bytes(&mut suffix); + let run = fumi_core::crypto::b32(&suffix); + let host = format!("unresolvable-{run}.invalid"); + let addr = + fumi_core::address::Address::parse(&format!("u{run}@{host}:19619")) + .unwrap() + .with_dial("127.0.0.1"); + 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")); + } + + /// Spike: the error variants the onboarding routes on. No pin and a + /// wrong pin are distinct and matchable — the two branches that decide + /// whether the register step frames itself as "pin the key" (§4) or + /// aborts. Needs the live bunshin but changes no server state. + #[test] + #[ignore] + fn bunshin_pin_errors() { + use fumi_core::address::Address; + use fumi_core::client::connect; + use fumi_core::error::Error; + use fumi_core::store::Store; + use fumi_core::transport::KEY_LEN; + use rand_core::{OsRng, RngCore}; + + let addr = Address::parse("nobody@127.0.0.1:19619").unwrap(); + + let unpinned = Store::open_in_memory().unwrap(); + match connect(&unpinned, &addr, true, 5) { + Err(Error::NotPinned { host }) => assert_eq!(host, "127.0.0.1"), + Err(err) => panic!("expected NotPinned, got {err}"), + Ok(_) => panic!("expected NotPinned, connected"), + } + + 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", &wrong).unwrap(); + match connect(&mismatched, &addr, true, 5) { + Err(Error::PinMismatch { .. }) => {} + Err(err) => panic!("expected PinMismatch, got {err}"), + Ok(_) => panic!("expected PinMismatch, connected"), + } + } + + /// Spike, bright path: a real rotation validates through the chain and + /// surfaces as `TrustChange::Rotated` — a warning, not an error. Carol + /// registers, Alice learns her key by sending, Carol rotates, and + /// Alice's next resolve walks the chain the server returns. + #[test] + #[ignore] + fn bunshin_rotation_bright() { + use fumi_core::account::Account; + use fumi_core::address::Address; + use fumi_core::client::{connect, register, rotate, send, trust_key, SendDraft}; + use fumi_core::crypto::unb32; + use fumi_core::store::Store; + use fumi_core::transport::KEY_LEN; + use rand_core::{OsRng, RngCore}; + + let server: [u8; KEY_LEN] = unb32( + "g5ttsssqjq6j37vso3h7otn33zrk6o6r7c6jjm7guxbbijoa5nqq", + ) + .unwrap() + .try_into() + .unwrap(); + + let mut carol_master = [0u8; 32]; + OsRng.fill_bytes(&mut carol_master); + let mut alice_master = [0u8; 32]; + OsRng.fill_bytes(&mut alice_master); + let carol_store = Store::open_in_memory().unwrap(); + let alice_store = Store::open_in_memory().unwrap(); + let carol = Account::new(carol_master, 0).unwrap(); + let alice = Account::new(alice_master, 0).unwrap(); + + let mut suffix = [0u8; 2]; + OsRng.fill_bytes(&mut suffix); + 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", &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(); + + let draft = SendDraft { + address: &carol_addr, + text: "before the rotation".into(), + subject: None, + reply_to: None, + headers: &[], + anonymous: false, + no_pad: false, + }; + let sent = send(&alice_store, &alice, &draft, 5).unwrap(); + assert!(matches!( + sent.change, + fumi_core::client::TrustChange::New { unverified: false } + )); + + rotate(&carol_store, &carol, None, 5).unwrap(); + let carol_next = Account::new(carol_master, 1).unwrap(); + + let mut session = connect(&alice_store, &carol_addr, true, 5).unwrap(); + let (pk, change) = { + let transport = session.transport(); + trust_key(&alice_store, transport, &carol_addr).unwrap() + }; + session.close(); + assert!(matches!(change, fumi_core::client::TrustChange::Rotated)); + assert_eq!(pk, carol_next.me().pk()); + } + + /// Spike, dark twin: RESOLVE returns a key with no chain to the one we + /// hold — the state a hijacked or re-registered username produces, and + /// the branch the onboarding must treat as terminal until the user + /// verifies out of band. A mock transport stands in for the server, so + /// this needs no live bunshin. + #[test] + fn keychanged_dark_twin() { + use fumi_core::address::Address; + use fumi_core::client::trust_key; + use fumi_core::error::Error; + use fumi_core::store::Store; + use fumi_core::transport::{ + Response, Transport, TransportBindValues, KEY_LEN, OP_RESOLVE, + }; + use rand_core::{OsRng, RngCore}; + + struct MockResolve { + offered: [u8; KEY_LEN], + bind: TransportBindValues, + } + impl Transport for MockResolve { + fn request(&mut self, op: u8, _body: &[u8]) -> Result { + assert_eq!(op, OP_RESOLVE); + let mut body = Vec::with_capacity(KEY_LEN + 1); + body.extend_from_slice(&self.offered); + body.push(0); // no chain at all + Ok(Response { status: 0, body }) + } + fn bind(&self) -> &TransportBindValues { + &self.bind + } + fn pinned(&self) -> bool { + true + } + fn close(&mut self) {} + } + + let addr = Address::parse("dark@127.0.0.1:19619").unwrap(); + let mut known = [0u8; KEY_LEN]; + OsRng.fill_bytes(&mut known); + let mut offered = [0u8; KEY_LEN]; + OsRng.fill_bytes(&mut offered); + + let store = Store::open_in_memory().unwrap(); + store.save_contact(&addr.short(), &known, false).unwrap(); + let mut mock = MockResolve { + offered, + bind: TransportBindValues { + h: [0u8; 32], + server_static: [0u8; 32], + }, + }; + match trust_key(&store, &mut mock, &addr) { + Err(Error::KeyChanged { + address, + known: k, + offered: o, + }) => { + assert_eq!(address, addr.short()); + assert_eq!(k, known); + assert_eq!(o, offered); + } + Err(err) => panic!("expected KeyChanged, got {err}"), + Ok(_) => panic!("expected KeyChanged, trusted the new key"), + } + } +} diff --git a/test/e2e_test.dart b/test/e2e_test.dart index f137bfe..2c67ea3 100644 --- a/test/e2e_test.dart +++ b/test/e2e_test.dart @@ -1,26 +1,32 @@ -// End-to-end against a live reference server: register an address on a -// locally running smolmaild, send sealed messages to ourselves, fetch them -// back, exercise the accept-token round trip (§5.8) between the requests and -// main tiers, and check the server is drained afterwards. Skips when nothing -// listens on 127.0.0.1:1961, so `devbox run test` does not depend on a server. +// End-to-end against a live server: register an address, send sealed mail to +// ourselves, fetch it back, exercise the accept-token round trip (§5.8) +// between the requests and main tiers, restore onto a second store, and +// check the server is drained afterwards. Skips when nothing listens on +// 127.0.0.1:1961, so `devbox run test` does not depend on a server. // -// To run it: (cd ../smolmail && uv run smolmaild.py keygen --key server.key && -// uv run smolmaild.py serve --key server.key --db mail.db) -// then `devbox run test`. +// The server key is pinned directly: on this machine the bunshin.service +// static key is a documented, operator-supplied value — exactly the trusted +// channel SPEC.md §4 asks a pin to come from. import "dart:io"; +import "dart:math"; 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/client.dart"; -import "package:smol_mail/smol/crypto.dart"; import "package:smol_mail/smol/errors.dart"; -import "package:smol_mail/smol/proto.dart"; import "package:smol_mail/smol/store.dart"; +import "package:smol_mail/smol/ui.dart"; const host = "127.0.0.1"; const port = 1961; +const serverKey = "lm2gqd7e5q67xq3isc5hx6jfj2q7a7xvq4l7trctxipudujovmgq"; + +Uint8List randomBytes(int n) => + Uint8List.fromList(List.generate(n, (_) => Random.secure().nextInt(256))); Future serverUp() async { try { @@ -33,35 +39,31 @@ Future serverUp() async { } } +Future freshStore(String tag, String dir) => SmolStore.open( + dbPath: "$dir/$tag.db", stateBox: "$tag-state", readBox: "$tag-read"); + void main() { test("register, send to self, fetch, unseal, drain", () async { if (!await serverUp()) { - markTestSkipped("no smolmaild on $host:$port"); + markTestSkipped("no server on $host:$port"); return; } final dir = await Directory.systemTemp.createTemp("smol-e2e"); Hive.init(dir.path); - final store = await SmolStore.open(); + final store = await freshStore("main", dir.path); final client = SmolClient(store); - // First contact is trust-on-first-use: learn the key the handshake reveals, - // then pin it — the flow a user with an operator-supplied key skips. final warnings = []; client.onWarning = warnings.add; - final master = client.createIdentity(); + await client.createIdentity(); final me = client.identity!; final user = "e2e${hex(randomBytes(4))}"; final address = parseAddress("$user@$host"); - final learned = await client.connect(address, requirePin: false); - // §8: the first, unpinned session must be announced as unverified. - expect(warnings, isNotEmpty); - expect(warnings.single, contains("not pinned")); - store.pinServer(host, learned.serverStatic); - learned.session.wire.close(); + store.pinServer(host, serverKey); await client.registerAccount(address.short); - expect(store.account()?.user, user); + expect(client.accountAddress()!.short, address.short); await client.send(address.short, "hello e2e", "sealed and signed"); await client.send(address.short, "second", "another sealed envelope"); @@ -75,33 +77,32 @@ void main() { expect(store.listMessages("inbox"), isEmpty); final requests = store.listMessages("requests"); expect(requests.length, 2); - // receivedAt has second granularity, so the order of the two is not - // guaranteed; assert on the pair, then open the one we care about. final subjects = requests.map((m) => client.describe(m).subject).toSet(); expect(subjects, {"hello e2e", "second"}); - final hello = requests.firstWhere( - (m) => client.describe(m).subject == "hello e2e"); + final hello = requests + .firstWhere((m) => client.describe(m).subject == "hello e2e"); final opened = client.describe(hello); expect(opened.error, isNull); - expect(opened.body, "sealed and signed\n"); - expect(hex(opened.sender!), hex(me.publicKey)); + // fumi's describe splits the trailing newline into the frontmatter + // parse, so the body arrives trimmed. + expect(opened.body, "sealed and signed"); + expect(opened.sender, me.publicKey); // The server must be drained: everything that verified was acknowledged. final again = await client.fetch(); expect(again.stored, 0); - // Accept ourselves as a correspondent (§5.8): the change is pushed to the - // server right away. This next message carries our own Accept field, but - // no MAC yet — we cannot know our own token before receiving and parsing - // a message that carries it — so it still lands in requests. + // Accept ourselves as a correspondent (§5.8): the change is pushed to + // the server right away. This next message carries our own Accept field, + // but no MAC yet, so it still lands in requests. await client.acceptContact(address.short); await client.send(address.short, "third", "still unsolicited"); expect((await client.fetch()).stored, 1); expect(store.listMessages("requests").length, 3); expect(store.listMessages("inbox"), isEmpty); - // Having now learned our own token from that message's Accept field, the - // next one carries a matching MAC and reaches the main tier. + // Having now learned our own token, the next one carries a matching MAC + // and reaches the main tier. await client.send(address.short, "fourth", "now accepted"); expect((await client.fetch()).stored, 1); final mainTier = store.listMessages("inbox"); @@ -123,18 +124,17 @@ void main() { // Restore on a second device: same master, fresh store, no pin. Unpinned // recall is refused; re-registering a taken name is refused; recall with // the operator-supplied key then binds the account without REGISTER. - final restored = await SmolStore.open( - stateBox: "e2e-restore-state", mailBox: "e2e-restore-mail"); - final secondDevice = SmolClient(restored); - restored.setMaster(master); - expect( + final secondStore = await freshStore("second", dir.path); + final secondDevice = SmolClient(secondStore); + secondStore.restoreMaster(store.master()!, 0); + await expectLater( secondDevice.recallAccount(address.short), throwsA(isA())); - restored.pinServer(host, learned.serverStatic); + secondStore.pinServer(host, serverKey); await expectLater( secondDevice.registerAccount(address.short), throwsA(isA())); final bound = await secondDevice.recallAccount(address.short); expect(bound.short, address.short); - expect(restored.account()!.user, user); + expect(secondDevice.accountAddress()!.user, user); // Re-resolving our own address finds the same key and says so quietly. final outcome = await client.refreshContact(address.short); @@ -146,24 +146,21 @@ void main() { test("leave mail on server keeps mail until deleted, with dedupe on refetch", () async { if (!await serverUp()) { - markTestSkipped("no smolmaild on $host:$port"); + markTestSkipped("no server on $host:$port"); return; } final dir = await Directory.systemTemp.createTemp("smol-e2e-keep"); Hive.init(dir.path); - final store = - await SmolStore.open(stateBox: "e2e-keep-state", mailBox: "e2e-keep-mail"); + final store = await freshStore("keep", dir.path); final client = SmolClient(store); - client.createIdentity(); + await client.createIdentity(); final user = "e2ekeep${hex(randomBytes(4))}"; final address = parseAddress("$user@$host"); - final learned = await client.connect(address, requirePin: false); - store.pinServer(host, learned.serverStatic); - learned.session.wire.close(); + store.pinServer(host, serverKey); await client.registerAccount(address.short); - store.setLeaveOnServer(true); + await store.setLeaveOnServer(true); await client.send(address.short, "kept", "stays on the server until deleted"); final first = await client.fetch(); @@ -172,19 +169,17 @@ void main() { expect(record.keptOnServer, isTrue); expect(client.describe(record).subject, "kept"); - // Re-fetching from scratch must not duplicate it locally (storeIfNew's + // Re-paging from scratch must not duplicate it locally (the seen-id // dedupe), even though the server still has it (nothing was deleted). - store.setCursor(0, Uint8List(idLen)); - final second = await client.fetch(); + final second = await client.fetch(reset: true); expect(second.stored, 0); expect(store.listMessages("requests").length, 1); - // Deleting removes it from the server too: a further full re-page after - // deletion must come back empty rather than resurrecting it. + // Deleting removes it locally and from the server too: a further full + // re-page after deletion comes back empty rather than resurrecting it. await client.deleteMessage("requests", record); expect(store.listMessages("requests"), isEmpty); - store.setCursor(0, Uint8List(idLen)); - final third = await client.fetch(); + final third = await client.fetch(reset: true); expect(third.stored, 0); expect(store.listMessages("requests"), isEmpty); }, timeout: const Timeout(Duration(minutes: 2))); diff --git a/test/ffi_smoke_test.dart b/test/ffi_smoke_test.dart new file mode 100644 index 0000000..63607c4 --- /dev/null +++ b/test/ffi_smoke_test.dart @@ -0,0 +1,19 @@ +// Spike: the first Dart-to-Rust link. Opens the built cdylib and calls the +// smoke entry point, which round-trips an address through fumi-core's +// in-memory store. Proves the dynamic-library bridge works; the real binding +// replaces this once the surface settles. + +import "dart:ffi"; +import "dart:io"; + +import "package:flutter_test/flutter_test.dart"; + +void main() { + test("native library links and round-trips", () { + final lib = DynamicLibrary.open( + "${Directory.current.path}/native/target/release/libsmol_mail_native.so"); + final smolSmoke = + lib.lookupFunction("smol_smoke"); + expect(smolSmoke(), 0); + }); +} diff --git a/test/interop_fumi_test.dart b/test/interop_fumi_test.dart new file mode 100644 index 0000000..c44f0f6 --- /dev/null +++ b/test/interop_fumi_test.dart @@ -0,0 +1,77 @@ +// Cross-client interop: kirakira's gsmol export imports into fumi, and +// fumi's export imports back into kirakira — the interchange the export +// patch upstream exists for. The kirakira side is driven through the real +// store API now: pins and contacts (with rotation history) land in the +// native store, and the sealed mail round-trip is covered by fumi-core's +// own export tests, so this proves the container compatibility both ways. +// Runs the fumi CLI as a subprocess, so the test skips when no built fumi +// sits in the sibling checkout. + +import "dart:io"; +import "dart:typed_data"; + +import "package:flutter_test/flutter_test.dart"; +import "package:hive_flutter/hive_flutter.dart"; + +import "package:smol_mail/smol/store.dart"; + +const fumiBinary = "../fumi/target/release/fumi"; + +void main() { + final fumiBuilt = File(fumiBinary).existsSync(); + + setUpAll(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + final dir = await Directory.systemTemp.createTemp("smol-interop"); + Hive.init(dir.path); + }); + + test("exports cross-import in both directions", + skip: fumiBuilt ? false : "no built fumi in ../fumi", () async { + final dir = await Directory.systemTemp.createTemp("fumi-interop"); + final master = Uint8List.fromList(List.generate(32, (i) => i + 3)); + // Real, distinct, valid key forms: the system server's and fumi's. + const pinKey = "lm2gqd7e5q67xq3isc5hx6jfj2q7a7xvq4l7trctxipudujovmgq"; + + final mine = await SmolStore.open( + dbPath: "${dir.path}/kirakira.db", + stateBox: "interop-state", + readBox: "interop-read"); + mine.restoreMaster(master, 0); + 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); + + final file = File("${dir.path}/kirakira.json"); + await file.writeAsString(await mine.exportData()); + + // fumi imports it. + final keyFile = File("${dir.path}/identity.key"); + await keyFile.writeAsBytes(master); + Future fumi(List args) => Process.run( + fumiBinary, + ["--key", keyFile.path, "--db", "${dir.path}/fumi.db", ...args]); + final into = await fumi(["import-backup", file.path]); + expect(into.exitCode, 0, reason: into.stderr.toString()); + expect(into.stdout.toString(), contains("1 contacts")); + + // fumi exports its store, and kirakira imports that back. + final fumiFile = File("${dir.path}/fumi.json"); + final out = await fumi(["export", fumiFile.path]); + expect(out.exitCode, 0, reason: out.stderr.toString()); + final restored = await SmolStore.open( + dbPath: "${dir.path}/restored.db", + stateBox: "interop2-state", + readBox: "interop2-read"); + restored.restoreMaster(master, 0); + final summary = await restored.importData(await fumiFile.readAsString()); + expect(summary.contactsAdded, 1); + expect(summary.pinsAdded, 1); + expect(summary.malformed, 0); + // 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"), pinKey); + }); +} diff --git a/test/native_binding_test.dart b/test/native_binding_test.dart new file mode 100644 index 0000000..7d354a6 --- /dev/null +++ b/test/native_binding_test.dart @@ -0,0 +1,127 @@ +// The binding layer's proof: the Dart client drives the C ABI end to end — +// handles, error codes, async isolates, and the backup round-trip. The +// server-backed part runs only when the spike bunshin is up on 19619. + +import "dart:convert"; +import "dart:io"; +import "dart:typed_data"; + +import "package:flutter_test/flutter_test.dart"; + +import "package:smol_mail/native/client.dart"; +import "package:smol_mail/native/ffi.dart"; + +const spikeServerKey = "g5ttsssqjq6j37vso3h7otn33zrk6o6r7c6jjm7guxbbijoa5nqq"; +const spikeServer = "127.0.0.1"; +const spikePort = 19619; + +Future spikeUp() async { + try { + final socket = await Socket.connect(spikeServer, spikePort, + timeout: const Duration(milliseconds: 300)); + socket.destroy(); + return true; + } on SocketException { + return false; + } +} + +void main() async { + test("handles, error codes and async isolates work end to end", () async { + final dir = await Directory.systemTemp.createTemp("native-binding"); + final client = FumiNative("${dir.path}/a.db"); + await client.open(); + client.setMaster(Uint8List.fromList(List.filled(32, 9))); + + // The account public key crosses as base32, driven from a worker isolate. + expect((await client.accountPk()).length, 52); + expect(await client.accountAddress(), isNull); + + // A pinned but dead host fails by code, never by prose: Unreachable (7). + 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"); + fail("register against a dead port must fail"); + } on NativeSmolException catch (err) { + expect(err.code, smolUnreachable); + expect(err.details["host"], "127.0.0.1"); + } + + // Empty folders and contact lookups render, not crash. + expect(await client.mail("inbox"), isEmpty); + expect((await client.contact("ghost@example.org"))["key"], ""); + + await client.close(); + }); + + test("the backup round-trip crosses the ABI in both directions", () async { + final dir = await Directory.systemTemp.createTemp("native-backup"); + final master = Uint8List.fromList(List.generate(32, (i) => i + 3)); + + final mine = FumiNative("${dir.path}/mine.db"); + await mine.open(); + mine.setMaster(master); + await mine.pinServer("example.org", spikeServerKey); + await mine.importContact( + "smol://alice@example.org/ayb6y3mwr3cfkcmcaqbn3cgfi6xsrsoqkalykw5s7oqmwqcpnmsq"); + + final file = await mine.exportBackup(); + + final restored = FumiNative("${dir.path}/restored.db"); + await restored.open(); + restored.setMaster(master); + final summary = jsonDecode(await restored.importBackup(file)) + as Map; + expect(summary["contactsAdded"], 1); + expect(summary["pinsAdded"], 1); + expect(await restored.serverPin("example.org"), spikeServerKey); + + await mine.close(); + await restored.close(); + }); + + test("register, send, fetch and describe against the spike bunshin", + skip: await spikeUp() + ? false + : "no bunshin on 127.0.0.1:19619", () async { + final dir = await Directory.systemTemp.createTemp("native-e2e"); + // Random masters: bunshin refuses a key already bound under another + // username, so identities must be fresh per run. + final aliceMaster = + Uint8List.fromList(List.generate(32, (i) => i * 7 + DateTime.now().microsecondsSinceEpoch % 251)); + final bobMaster = Uint8List.fromList(List.generate(32, (i) => i * 13 + 5)); + final run = DateTime.now().millisecondsSinceEpoch.toRadixString(36); + + final alice = FumiNative("${dir.path}/alice.db"); + await alice.open(); + alice.setMaster(aliceMaster); + 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); + await bob.register("b$run@$spikeServer:$spikePort"); + + final sent = await alice.send("b$run@$spikeServer:$spikePort", + "binding: hello over the ABI", + subject: "binding"); + expect(sent["change"], "new"); + + final summary = await bob.fetch(); + expect(summary["stored"], 1); + // First contact lands in the requests tier (sec 5.8). + final requests = await bob.mail("requests"); + expect(requests, hasLength(1)); + final described = + await bob.describe(requests[0]["id"] as String); + expect(described["text"], "binding: hello over the ABI"); + expect(described["subject"], "binding"); + + await alice.close(); + await bob.close(); + }); +} diff --git a/test/recall_flow_test.dart b/test/recall_flow_test.dart index 147ebbc..3ddfa97 100644 --- a/test/recall_flow_test.dart +++ b/test/recall_flow_test.dart @@ -4,6 +4,8 @@ // router flow itself. import "dart:io"; +import "dart:math"; +import "dart:typed_data"; import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; @@ -12,11 +14,17 @@ import "package:hive_flutter/hive_flutter.dart"; import "package:smol_mail/data/providers/providers.dart"; import "package:smol_mail/presentation/app_widget.dart"; +import "package:smol_mail/smol/address.dart"; import "package:smol_mail/smol/client.dart"; -import "package:smol_mail/smol/crypto.dart"; import "package:smol_mail/smol/errors.dart"; -import "package:smol_mail/smol/proto.dart"; import "package:smol_mail/smol/store.dart"; +import "package:smol_mail/smol/ui.dart"; + +Uint8List randomBytes(int n) => Uint8List.fromList(List.generate(n, (_) => Random.secure().nextInt(256))); + +// A syntactically valid server key for the stubbed pin step: the flow under +// test is the UI routing, not the handshake. +const fakePin = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; /// A [SmolClient] whose network operations complete instantly, so the test /// exercises the flow rather than the network. [restoreAndRecall] still @@ -32,14 +40,14 @@ class StubClient extends SmolClient { throw SmolError("no pinned key for ${addr.host}"); } store.restoreMaster(unhex(masterHex.trim()), 0); - store.setAccount(addr); + store.native.setAccount(addr.short); return addr; } @override Future recallAccount(String addressText) async { final addr = parseAddress(addressText); - store.setAccount(addr); + store.native.setAccount(addr.short); return addr; } } @@ -53,11 +61,11 @@ void main() { final dir = await Directory.systemTemp.createTemp("smol-recall-flow"); Hive.init(dir.path); storeA = await SmolStore.open( - stateBox: "recall-a-state", mailBox: "recall-a-mail"); + dbPath: "${dir.path}/a.db", stateBox: "recall-a-state", readBox: "recall-a-read"); storeB = await SmolStore.open( - stateBox: "recall-b-state", mailBox: "recall-b-mail"); + dbPath: "${dir.path}/b.db", stateBox: "recall-b-state", readBox: "recall-b-read"); storeC = await SmolStore.open( - stateBox: "recall-c-state", mailBox: "recall-c-mail"); + dbPath: "${dir.path}/c.db", stateBox: "recall-c-state", readBox: "recall-c-read"); }); Widget app(SmolStore store) => ProviderScope( @@ -99,13 +107,14 @@ void main() { expect(find.text("Register a new address instead"), findsNothing); fields = find.byType(TextField); expect(fields, findsNWidgets(2)); // address (carried over), server key - await tester.enterText(fields.at(1), b32encode(randomBytes(32))); + await tester.enterText(fields.at(1), fakePin); await tester.tap(find.text("Pin and Recall")); await tester.pumpAndSettle(); expect(find.text("Inbox"), findsOneWidget); expect(store.master(), master); - expect(store.account()!.user, "randogoth"); + expect(parseAddress(SmolClient(store).accountAddress()!.short).user, + "randogoth"); }); testWidgets("restore requires an address before it will submit", @@ -141,7 +150,7 @@ void main() { await tester.tap(find.text("Create Identity")); await tester.pumpAndSettle(); expect(store.master(), isNotNull); - expect(store.account(), isNull); + expect(SmolClient(store).accountAddress(), isNull); // Simulate returning to onboarding later (e.g. a cold restart). Pumping // app(store) directly would just rebuild the existing OnboardingScreen @@ -164,7 +173,7 @@ void main() { // Lands on the recall-framed register step (no pin yet); pin it and finish. expect(find.byType(TextField), findsNWidgets(2)); - await tester.enterText(find.byType(TextField).at(1), b32encode(randomBytes(32))); + await tester.enterText(find.byType(TextField).at(1), fakePin); await tester.tap(find.text("Pin and Recall")); await tester.pumpAndSettle(); diff --git a/test/smol_test.dart b/test/smol_test.dart deleted file mode 100644 index 12ab9e7..0000000 --- a/test/smol_test.dart +++ /dev/null @@ -1,317 +0,0 @@ -// Unit tests: lib/smol must reproduce test/vectors.json byte for byte (the -// vectors are generated from the reference stack — PyNaCl, noiseprotocol — by -// ../gsmol/test/gen_vectors.py), plus protocol-level checks for frontmatter, -// addresses, rotation chains and response framing. -// Run: devbox run test - -import "dart:convert"; -import "dart:io"; -import "dart:typed_data"; - -import "package:flutter_test/flutter_test.dart"; - -import "package:smol_mail/smol/crypto.dart"; -import "package:smol_mail/smol/errors.dart"; -import "package:smol_mail/smol/noise.dart"; -import "package:smol_mail/smol/proto.dart"; - -final vectors = - jsonDecode(File("test/vectors.json").readAsStringSync()) as Map; - -Uint8List vhex(String text) => unhex(text); -String vstring(dynamic value) => value as String; - -void main() { - test("hashes, HMAC/HKDF and AEAD match the reference vectors", () { - for (final v in vectors["sha256"] as List) { - final m = v as Map; - expect(hex(sha256(vhex(vstring(m["in"])))), vstring(m["out"])); - } - for (final v in vectors["sha512"] as List) { - final m = v as Map; - expect(hex(sha512(vhex(vstring(m["in"])))), vstring(m["out"])); - } - for (final v in vectors["hkdf"] as List) { - final m = v as Map; - expect( - hex(hkdfSha256(vhex(vstring(m["ikm"])), vhex(vstring(m["salt"])), - vhex(vstring(m["info"])), m["len"] as int)), - vstring(m["out"])); - } - for (final v in vectors["aead"] as List) { - final m = v as Map; - final key = vhex(vstring(m["key"])); - final nonce = vhex(vstring(m["nonce"])); - final aad = vhex(vstring(m["aad"])); - final sealed = aeadEncrypt( - key, nonce, vhex(vstring(m["plaintext"])), aad); - expect(hex(sealed), vstring(m["sealed"]), reason: "aead-seal ${m["name"]}"); - expect( - hex(aeadDecrypt(key, nonce, vhex(vstring(m["sealed"])), aad)), - vstring(m["plaintext"])); - expect( - () => aeadDecrypt( - key, nonce, Uint8List.fromList(vhex(vstring(m["sealed"])).sublist(0, vhex(vstring(m["sealed"])).length - 1)), aad), - throwsA(isA())); - } - }); - - test("X25519 matches and rejects low-order points", () { - final byName = {}; - for (final v in vectors["x25519"] as List) { - final m = v as Map; - byName[m["name"] as String] = m; - } - expect(hex(x25519Base(vhex(vstring(byName["alice"]!["priv"])))), byName["alice"]!["pub"]); - expect(hex(x25519Base(vhex(vstring(byName["bob"]!["priv"])))), byName["bob"]!["pub"]); - expect( - hex(x25519(vhex(vstring(byName["alice"]!["priv"])), vhex(vstring(byName["bob"]!["pub"])))), - byName["agree"]!["shared"]); - for (final v in vectors["x25519"] as List) { - final m = v as Map; - if ((m["name"] as String).startsWith("low-order")) { - expect( - () => x25519(vhex(vstring(byName["alice"]!["priv"])), vhex(vstring(m["peer"]))), - throwsA(isA())); - } - } - }); - - test("Ed25519 signs and verifies like the reference stack", () { - for (final v in vectors["ed25519"] as List) { - final m = v as Map; - final seed = vhex(vstring(m["seed"])); - expect(hex(ed25519PublicKey(seed)), vstring(m["pub"]), reason: "ed25519-pub ${m["name"]}"); - final sig = ed25519Sign(seed, vhex(vstring(m["message"]))); - if (m["valid"] as bool) { - expect(hex(sig), vstring(m["signature"]), reason: "ed25519-sign ${m["name"]}"); - expect(ed25519Verify(vhex(vstring(m["pub"])), vhex(vstring(m["message"])), sig), isTrue); - expect( - ed25519Verify(vhex(vstring(m["pub"])), vhex(vstring(m["message"])), - vhex(vstring(m["signature"]))), - isTrue, - reason: "ed25519-verify-pynacl ${m["name"]}"); - } else { - expect( - ed25519Verify(vhex(vstring(m["pub"])), vhex(vstring(m["message"])), - vhex(vstring(m["signature"]))), - isFalse, - reason: "ed25519-reject ${m["name"]}"); - } - } - }); - - test("§2 conversions between the identity key and X25519", () { - for (final v in vectors["ed_to_x25519"] as List) { - final m = v as Map; - expect(hex(ed25519SeedToX25519(vhex(vstring(m["seed"])))), vstring(m["x_priv"])); - expect(hex(ed25519ToX25519(ed25519PublicKey(vhex(vstring(m["seed"]))))), vstring(m["x_pub"])); - } - }); - - test("§5 envelope seals, ids and unseals byte for byte", () { - final v = vectors["envelope"] as Map; - final sender = identityFromSeed(vhex(vstring(v["sender_seed"]))); - final recipient = identityFromSeed(vhex(vstring(v["recipient_seed"]))); - Uint8List sealWith(bool pad) => seal(sender, recipient.publicKey, - vhex(vstring(v["body"])), v["time"] as int, - SealOptions(esk: vhex(vstring(v["esk"])), pad: pad)); - - expect(hex(sealWith(false)), vstring(v["envelope"])); - expect(hex(messageId(vhex(vstring(v["envelope"])))), vstring(v["id"])); - final opened = unseal([recipient], vhex(vstring(v["envelope"]))); - expect(hex(opened.sender), hex(sender.publicKey)); - expect(opened.time, v["time"] as int); - expect(hex(opened.body), vstring(v["body"])); - expect( - () => unseal([identityFromSeed(vhex(vstring(v["esk"])))], - vhex(vstring(v["envelope"]))), - throwsA(isA())); - // padding round-trips and is ignored by the receiver (§5.3) - final padded = sealWith(true); - expect((padded.length - envelopeHeader - 16) % padTo, 0); - expect(padded.length > vstring(v["envelope"]).length ~/ 2, isTrue); - expect(hex(unseal([recipient], padded).body), vstring(v["body"])); - }); - - test("Noise NX transcript matches the reference", () { - final v = vectors["noise"] as Map; - final nx = NxInitiator(); - expect(hex(nx.writeMessage1(vhex(vstring(v["initiator_eph_priv"])))), vstring(v["message1"])); - final result = nx.readMessage2(vhex(vstring(v["message2"]))); - expect(hex(result.serverStatic), vstring(v["server_static_pub"])); - expect(hex(result.handshakeHash), vstring(v["handshake_hash"])); - final initiatorFrames = (v["initiator_frames"] as List).cast(); - final responderFrames = (v["responder_frames"] as List).cast(); - for (var i = 0; i < initiatorFrames.length; i++) { - expect(hex(result.send.encrypt(vhex(vstring(initiatorFrames[i]["plaintext"])))), - vstring(initiatorFrames[i]["sealed"]), - reason: "noise-frame-i$i"); - } - for (var i = 0; i < responderFrames.length; i++) { - expect(hex(result.recv.decrypt(vhex(vstring(responderFrames[i]["sealed"])))), - vstring(responderFrames[i]["plaintext"]), - reason: "noise-frame-r$i"); - } - // The responder direction must also produce identical ciphertexts (AEAD is - // deterministic), so the recv cipher can be checked in both directions. - final mirrored = NxInitiator(); - mirrored.writeMessage1(vhex(vstring(v["initiator_eph_priv"]))); - final mirror = mirrored.readMessage2(vhex(vstring(v["message2"]))); - expect(hex(mirror.recv.encrypt(vhex(vstring(responderFrames[0]["plaintext"])))), - vstring(responderFrames[0]["sealed"])); - }); - - test("frontmatter parses and fails closed (§5.5)", () { - const inReplyTo = - "4f2a1c9e8b7d6a5f3e2d1c0b9a8f7e6d5c4b3a291807f6e5d4c3b2a1908f7e6d5"; - final spec = - "---\nSubject: Re: the thing\nIn-Reply-To: $inReplyTo\nX-Mood: cautiously optimistic\n---\nBody text starts here."; - final parsed = parseFrontmatter(spec); - expect(parsed.fields["subject"], "Re: the thing"); - expect(parsed.fields["in-reply-to"], inReplyTo); - expect(parsed.body, "Body text starts here."); - // a malformed line invalidates the whole block, which fails closed toward display - expect(parseFrontmatter("---\nno colon here\n---\nrest").body, - "---\nno colon here\n---\nrest"); - expect(parseFrontmatter("---\nSubject: x\nno end").body, - "---\nSubject: x\nno end"); - expect(parseFrontmatter("---\nA: 1\nA: 2\n---\ntext").fields["a"], "1"); - // keys compare case-insensitively; the first occurrence wins (§5.5) - expect(parseFrontmatter("---\nSubject: x\nsubject: y\n---\ntext").fields["subject"], "x"); - expect(buildFrontmatter(const {}, "---\nactual body"), - "---\n---\n---\nactual body"); - expect(buildFrontmatter({"Subject": "hi"}, "there"), "---\nSubject: hi\n---\nthere"); - expect(buildFrontmatter(const {}, "plain"), "plain"); - expect( - parseFrontmatter("---\n${"X: y\n" * 65}---\nbody").fields["subject"], - isNull); - }); - - test("addresses parse per §3 and round-trip through smol://", () { - final a = parseAddress("Alice@Example.ORG:1961"); - expect(a.user, "alice"); - expect(a.port, 1961); - expect(a.short, "alice@example.org"); // a default port is dropped - expect(parseAddress("bob@host").port, defaultPort); - final key = vhex(vstring((vectors["ed25519"] as List).cast().first["pub"])); - final parsed = parseAddress(parseAddress("bob@h").uri(key)); - expect(parsed.identity, key); - expect(parsed.user, "bob"); - expect(() => parseAddress("-bob@h"), throwsA(isA())); - // §3: never two separators in a row - expect(() => parseAddress("a..b@h"), throwsA(isA())); - expect(parseAddress("a.b_c@h").user, "a.b_c"); - // §3: fingerprints are the first 20 base32 characters in groups of four - final b32 = b32encode(key); - expect( - fingerprint(key), - [ - b32.substring(0, 4), b32.substring(4, 8), b32.substring(8, 12), - b32.substring(12, 16), b32.substring(16, 20) - ].join(" ")); - for (final n in [1, 2, 5, 32, 52, 64]) { - final raw = randomBytes(n); - expect(b32decode(b32encode(raw)), raw, reason: "b32[$n]"); - } - }); - - test("rotation chains validate, break and oversize per §7", () { - const username = "alice"; - final old = identityFromSeed(randomBytes(32)); - final mid = randomBytes(32); - final fresh = randomBytes(32); - final when = nowSeconds(); - final chain = [ - makeCert(username, old, mid, when), - makeCert(username, identityFromSeed(mid), fresh, when), - ]; - expect(walkChain(username, old.publicKey, ed25519PublicKey(fresh), chain), isTrue); - expect(walkChain(username, old.publicKey, old.publicKey, []), isTrue); - expect( - walkChain(username, old.publicKey, ed25519PublicKey(fresh), chain.sublist(1)), - isFalse); - final forged = List.from(chain); - forged[1] = makeCert(username, identityFromSeed(mid), randomBytes(32), when); - expect( - walkChain(username, old.publicKey, ed25519PublicKey(fresh), forged), isFalse); - expect( - walkChain(username, old.publicKey, ed25519PublicKey(fresh), - List.filled(17, chain[0])), - isFalse); - // a chain signed for a different username must not validate (§7) - expect( - walkChain("bob", old.publicKey, ed25519PublicKey(fresh), chain), isFalse); - expect(chain[0].length, certLen); - }); - - test("response framing guards (§6.1)", () async { - Session scripted(Uint8List frame) { - // readNoise wants a u16 length prefix and at least 16 bytes of Noise - // message; the frame is padded up to that floor. - final message = Uint8List.fromList([ - ...frame, - ...List.filled(frame.length < 16 ? 16 - frame.length : 0, 0), - ]); - final session = Session(_ScriptedWire(concat([u16be(message.length), message])), - _PassthroughCipher(), _PassthroughCipher()); - return session; - } - - Future refuses(String name, Uint8List frame, int op) async { - try { - await scripted(frame).call(op); - fail("$name: call resolved instead of throwing"); - } on TestFailure { - rethrow; - } catch (_) { - // expected - } - } - - // The shortest legal response is a type byte and a status byte. - await refuses("frame-too-short", concat([u32be(1), Uint8List.fromList([opFetch])]), opFetch); - // A response reuses the request's type byte; a mismatch means the session - // desynchronised, which must not be read as a status. - await refuses("frame-op-mismatch", - concat([u32be(2), Uint8List.fromList([opResolve, 0])]), opFetch); - // The same frame with the right echo still passes, so the guard is not - // simply rejecting everything. - final okSession = - scripted(concat([u32be(2), Uint8List.fromList([opFetch, 0])])); - expect((await okSession.call(opFetch)).status, 0); - }); -} - -/// Serves a scripted response and ignores sends, so framing can be tested -/// without a server. -class _ScriptedWire implements Wire { - final Uint8List _queue; - int _pos = 0; - - _ScriptedWire(this._queue); - - @override - void send(Uint8List bytes) {} - - @override - void close() {} - - @override - Future readExact(int n) async { - if (_pos + n > _queue.length) { - throw const SmolError("script exhausted"); - } - final out = Uint8List.fromList(_queue.sublist(_pos, _pos + n)); - _pos += n; - return out; - } -} - -class _PassthroughCipher implements SessionCipher { - @override - Uint8List encrypt(Uint8List plaintext) => Uint8List.fromList(plaintext); - - @override - Uint8List decrypt(Uint8List sealed) => Uint8List.fromList(sealed); -} diff --git a/test/store_test.dart b/test/store_test.dart index 7d6abe8..1b783a6 100644 --- a/test/store_test.dart +++ b/test/store_test.dart @@ -1,22 +1,22 @@ -// Store-level behavior: contact key history (§8), import binding, and the -// export/import backup file. +// Store-level behavior against the real native store: master lifecycle, read +// marks, pins and the settings the app owns in Hive. -import "dart:convert"; import "dart:io"; +import "dart:math"; +import "dart:typed_data"; import "package:flutter_test/flutter_test.dart"; import "package:hive_flutter/hive_flutter.dart"; -import "package:smol_mail/smol/client.dart"; -import "package:smol_mail/smol/crypto.dart"; -import "package:smol_mail/smol/errors.dart"; -import "package:smol_mail/smol/proto.dart"; import "package:smol_mail/smol/store.dart"; -// Each store gets its own boxes; Hive is a per-process singleton, so without -// this the "exporting" and "importing" stores would be the same store. -Future freshStore(String tag) => - SmolStore.open(stateBox: "test-$tag-state", mailBox: "test-$tag-mail"); +Uint8List randomBytes(int n) => + Uint8List.fromList(List.generate(n, (_) => Random.secure().nextInt(256))); + +Future freshStore(String tag) => SmolStore.open( + dbPath: "${Directory.systemTemp.createTempSync("smol-store-$tag").path}/store.db", + stateBox: "$tag-state", + readBox: "$tag-read"); void main() { setUpAll(() async { @@ -25,182 +25,69 @@ void main() { Hive.init(dir.path); }); - test("contact history keeps displaced keys, not re-saves", () async { - final store = await freshStore("history"); - final a = randomBytes(32), b = randomBytes(32), c = randomBytes(32); + test("master set, restore and rotations", () async { + final store = await freshStore("master"); + expect(store.master(), isNull); + expect(store.identity(), isNull); - store.saveContact("alice@example.org", a, true); - expect(store.contact("alice@example.org")!.history, isEmpty); - - store.saveContact("alice@example.org", b, false); - final rotated = store.contact("alice@example.org")!; - expect(rotated.key, b); - expect(rotated.history.length, 1); - expect(rotated.history.first.key, a); - expect(rotated.history.first.until, greaterThan(0)); - - // Re-saving the same key is not a rotation and must not add an entry. - store.saveContact("alice@example.org", b, true); - expect(store.contact("alice@example.org")!.history.length, 1); - - // A second displacement appends, oldest first. - store.saveContact("alice@example.org", c, false); - final history = store.contact("alice@example.org")!.history; - expect(history.length, 2); - expect(history[0].key, a); - expect(history[1].key, b); - expect(store.allContacts().single.$2.history.length, 2); - }); - - test("importContact binds a smol:// address to the key it carries", () async { - final store = await freshStore("import-contact"); - final client = SmolClient(store); - final identity = identityFromSeed(randomBytes(32)); - client.importContact( - "smol://bob@example.org/${b32encode(identity.publicKey)}"); - final saved = store.contact("bob@example.org")!; - expect(saved.verified, isTrue); - expect(saved.key, identity.publicKey); - }); - - test("export never contains the master secret and round-trips through import", - () async { - final a = await freshStore("export"); - final b = await freshStore("round-trip"); - a.setMaster(randomBytes(32)); - a.pinServer("example.org", randomBytes(32)); - a.saveContact("alice@example.org", randomBytes(32), true); - a.saveContact("alice@example.org", randomBytes(32), false); // history grows - await a.storeMessage( - "inbox", MailRecord("aa", randomBytes(64), receivedAt: 5)); - await a.storeMessage("sent", - MailRecord("bb", randomBytes(64), recipient: "bob@example.org", sentAt: 6)); - - final data = a.exportData(); - expect(data["gsmolExport"], 2); // sealed to the identity's master, like gsmol - expect(jsonEncode(data).contains(hex(a.master()!)), isFalse); - - // v2 is sealed to the exporting identity's master — a restore-on-new-device - // scenario, not a transfer to someone else's identity (see the test below). - b.setMaster(a.master()!); - final summary = await b.importData(data); - expect(summary.mailAdded, 2); - expect(summary.pinsAdded, 1); - expect(summary.contactsAdded, 1); - expect(b.serverPin("example.org"), a.serverPin("example.org")); - expect(b.contact("alice@example.org")!.history.length, 1); - expect(b.getMessage("inbox", "aa"), isNotNull); - expect(b.getMessage("sent", "bb"), isNotNull); - }); - - test("v2 import refuses a different identity's export", () async { - final a = await freshStore("export-wrong-identity"); - final c = await freshStore("round-trip-wrong-identity"); - a.setMaster(randomBytes(32)); - a.pinServer("example.org", randomBytes(32)); - final data = a.exportData(); - - c.setMaster(randomBytes(32)); // a different master than a's - expect(() => c.importData(data), throwsA(isA())); - }); - - test("accept tokens: accept/block gate the sync set, tiers split the inbox view", - () async { - final store = await freshStore("accept-tokens"); final master = randomBytes(32); store.setMaster(master); - final alice = randomBytes(32); - store.saveContact("alice@example.org", alice, true); + expect(store.master(), master); + // A fresh identity's public key is the native-derived one. + expect(store.identity()!.publicKey.length, 52); + expect(store.rotations(), 0); - // No one accepted yet: an empty set is already complete, so it may sync. - var (sync, tokens) = store.tokenSet(master); - expect(sync, 1); - expect(tokens, isEmpty); - - store.accept("alice@example.org", alice); - (sync, tokens) = store.tokenSet(master); - expect(sync, 1); - expect(tokens, [tokenFor(master, alice)]); - expect(store.accepted("alice@example.org")!.active, isTrue); - - store.block("alice@example.org"); - expect(store.accepted("alice@example.org")!.active, isFalse); - expect(store.tokenSet(master).$2, isEmpty); - // Re-accepting keeps the identity frozen at the original acceptance, - // so the token a correspondent already holds keeps working. - store.accept("alice@example.org", randomBytes(32)); - expect(store.accepted("alice@example.org")!.identity, alice); - - expect(() => store.block("bob@example.org"), throwsA(isA())); - - // A restored master must not silently replace the server's set. - store.setSyncOk(false); - (sync, tokens) = store.tokenSet(master); - expect(sync, 0); - expect(tokens, isEmpty); - - await store.storeIfNew( - "inbox", MailRecord("aa", randomBytes(64), receivedAt: 1, tier: tierMain)); - await store.storeIfNew("inbox", - MailRecord("bb", randomBytes(64), receivedAt: 2, tier: tierRequests)); - expect(store.listMessages("inbox").map((r) => r.id), ["aa"]); - expect(store.listMessages("requests").map((r) => r.id), ["bb"]); - expect(store.getMessage("requests", "bb"), isNotNull); + final other = randomBytes(32); + store.restoreMaster(other, 3); + expect(store.master(), other); + // The account handle was rebuilt at the restored rotation index. + expect(store.identity()!.publicKey.length, 52); }); - test("v1 legacy export still imports without an identity", () async { - final store = await freshStore("import-legacy-v1"); - final summary = await store.importData({ - "gsmolExport": 1, - "servers": {"example.org": b32encode(randomBytes(32))}, - }); - expect(summary.pinsAdded, 1); + test("leave-on-server is a setting, not protocol state", () async { + final store = await freshStore("leave"); + expect(store.leaveOnServer(), isFalse); + await store.setLeaveOnServer(true); + expect(store.leaveOnServer(), isTrue); + await store.setLeaveOnServer(false); + expect(store.leaveOnServer(), isFalse); }); - test("import never overwrites a differing trust binding", () async { - final store = await freshStore("conflict"); - final mine = randomBytes(32), other = randomBytes(32); - store.pinServer("example.org", mine); - store.saveContact("alice@example.org", mine, true); - final summary = await store.importData({ - "gsmolExport": 1, - "servers": {"example.org": b32encode(other)}, - "contacts": { - "alice@example.org": {"key": b32encode(other), "verified": true}, - "bob@example.org": {"key": b32encode(randomBytes(32)), "verified": false}, - }, - }); - expect(summary.pinsConflicted, 1); - expect(summary.pinsAdded, 0); - expect(summary.contactsConflicted, 1); - expect(summary.contactsAdded, 1); - expect(store.serverPin("example.org"), mine); - expect(store.contact("alice@example.org")!.key, mine); - expect(store.contact("bob@example.org"), isNotNull); + test("read marks drive the unread counts", () async { + final store = await freshStore("read"); + expect(store.unreadCount(), 0); + // Nothing is stored yet, so marking an id is harmless bookkeeping. + store.markRead("ab" * 32); + expect(store.isRead("ab" * 32), isTrue); }); - test("import skips malformed entries and rejects wrong files", () async { - final store = await freshStore("malformed"); - final summary = await store.importData({ - "gsmolExport": 1, - "servers": {"bad": "notbase32!", "short": b32encode(randomBytes(8))}, - "contacts": { - "x": {"key": "nope"}, - "y": "not a record", - }, - "inbox": [ - {"id": "ok", "envelope": base64Encode(randomBytes(64)), "receivedAt": 1}, - {"id": "bad", "envelope": "!!!not base64!!!"}, - "not a record", - ], - "sent": "not a list", - }); - expect(summary.malformed, 7); // 2 pins, 2 contacts, 2 mail, 1 sent - expect(summary.pinsAdded, 0); - expect(summary.mailAdded, 1); - expect(store.getMessage("inbox", "ok"), isNotNull); - expect(store.getMessage("inbox", "bad"), isNull); + test("pins save, list and unpin", () async { + final store = await freshStore("pins"); + 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); + expect(store.serverPin("example.org"), key); + expect(store.allPins().length, 1); + await store.unpinServer("example.org"); + expect(store.serverPin("example.org"), isNull); + }); - expect(() => store.importData({"nope": 1}), throwsA(isA())); + test("wipe clears every secret and mark, overwriting the master", () async { + final store = await freshStore("wipe"); + final master = randomBytes(32); + store.setMaster(master); + store.pinServer("example.org", + "lm2gqd7e5q67xq3isc5hx6jfj2q7a7xvq4l7trctxipudujovmgq"); + store.markRead("cd" * 32); + + await store.wipe(); + // The bytes the store owned are overwritten, not just dereferenced — + // the caller's reference sees the zeros too. + expect(master.every((b) => b == 0), isTrue); + expect(store.master(), isNull); + expect(store.identity(), isNull); + expect(store.serverPin("example.org"), isNull); + expect(store.isRead("cd" * 32), isFalse); }); } diff --git a/test/vectors.json b/test/vectors.json deleted file mode 100644 index 7acb656..0000000 --- a/test/vectors.json +++ /dev/null @@ -1,226 +0,0 @@ -{ - "sha256": [ - { - "in": "", - "out": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" - }, - { - "in": "616263", - "out": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" - }, - { - "in": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", - "out": "fdeab9acf3710362bd2658cdc9a29e8f9c757fcf9811603a8c447cd1d9151108" - }, - { - "in": "736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c", - "out": "58a0adce483c517ae1b37025dbe3b534880972be4d9d10b78959a13fb8b13462" - } - ], - "sha512": [ - { - "in": "", - "out": "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e" - }, - { - "in": "616263", - "out": "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f" - }, - { - "in": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", - "out": "ee4320ebaf3fdb4f2c832b137200c08e235e0fa7bbd0eb1740c7063ba8a0d151da77e003398e1714a955d475b05e3e950b639503b452ec185de4229bc4873949" - } - ], - "hkdf": [ - { - "name": "rfc5869-1", - "ikm": "0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b", - "salt": "000102030405060708090a0b0c", - "info": "f0f1f2f3f4f5f6f7f8f9", - "len": 42, - "out": "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865" - }, - { - "name": "seal-shaped", - "ikm": "61677265656d656e7420736861726564", - "salt": "65706b726563697069656e74", - "info": "736d6f6c6d61696c2f31207365616c", - "len": 32, - "out": "b9f729e45d7bab89598edbce35f3f5bb91d6f403a5ff85943a838defbfcd7a0c" - } - ], - "aead": [ - { - "name": "empty", - "key": "831386140c054287c460b9f6092d43a5bb6eb2d2c549b00fc3fce0e7cca1a19d", - "nonce": "3edd69829394f0807df7b01d", - "aad": "", - "plaintext": "", - "sealed": "941b286ea0ee86bb66236fc3c16b2e48" - }, - { - "name": "short", - "key": "831386140c054287c460b9f6092d43a5bb6eb2d2c549b00fc3fce0e7cca1a19d", - "nonce": "3edd69829394f0807df7b01d", - "aad": "50515253c0c1c2c3c4c5c6c7", - "plaintext": "68656c6c6f", - "sealed": "7dbede6dd11ffa046f102dedea535912be561e3c29" - }, - { - "name": "multiline", - "key": "831386140c054287c460b9f6092d43a5bb6eb2d2c549b00fc3fce0e7cca1a19d", - "nonce": "3edd69829394f0807df7b01d", - "aad": "50515253c0c1c2c3c4c5c6c7", - "plaintext": "4c616469657320616e642047656e746c656d656e206f662074686520636c617373206f66202739393a206966204920636f756c64206f6666657220796f75206f6e6c79206f6e652074697020666f7220746865206675747572652c2073756e73637265656e20776f756c642062652069742e", - "sealed": "59bad668dbf00561dd86add05afe35b269f9997d57e46cee0e42fd69693c3df16b681f3905bbea4135cb379f4a0c730b5dbb3ba06d1231ce396660a16f8cadb8b422d745b932df3c1eed2df9636750551a0333b3d06877582f172f3ec2d437061143699bfc7258aa98e319f23a1f31f88fd15a24a97b46fd2e05c266d31ee96f5e24" - } - ], - "x25519": [ - { - "name": "alice", - "priv": "c63095403fea13cb2c43380599e669ebfb41ab184b04df28a143472e4283aa33", - "pub": "712e68cefc13d0e1778226b7f7aaeb59042225e7a4def3816344da256e031664" - }, - { - "name": "bob", - "priv": "33485a5b40b70730b3c3e468a8262543e5a4d9dc98de06399de66a4d579e02a7", - "pub": "b8540c30e8fb348aed0abc8189cf8025ce09a9c5d74e0681c4e50f8d281da252" - }, - { - "name": "agree", - "shared": "e6a3b1d2ae10c29b51519a71255af4bd20deee697c6ced1a44eadff428439e13" - }, - { - "name": "low-order-0", - "peer": "0000000000000000000000000000000000000000000000000000000000000000", - "peer_rejected": true, - "raised": true - }, - { - "name": "low-order-1", - "peer": "0100000000000000000000000000000000000000000000000000000000000000", - "peer_rejected": true, - "raised": true - } - ], - "ed25519": [ - { - "name": "vector-seed-a", - "seed": "81712c4bef43282ffd12909479bece9da17d404bdbc629bec3a6fdf246f847fb", - "pub": "7ce749eb2966b9b747543236ef5f61b46328a82f92677b82470439db065154e0", - "message": "", - "signature": "b20543ac2e0ba66c1b437de2afda7ca8ca6f9feb2af3e3e7ac3183e388d1786c9c027bfe549639140d0e45e7e850999b3fb992c7c003946c1c5aaeb09892cc0c", - "valid": true - }, - { - "name": "vector-seed-a", - "seed": "81712c4bef43282ffd12909479bece9da17d404bdbc629bec3a6fdf246f847fb", - "pub": "7ce749eb2966b9b747543236ef5f61b46328a82f92677b82470439db065154e0", - "message": "736d6f6c6d61696c2f3120617574680000000000000000000000000000000000000000000000000000000000000000", - "signature": "f7e74a0540e05843b52efb7dee4f3622ab8a88333142f6dd1d5386612f7f0f1410bd0b1f1ff09dea9419922b756920a4c1fb31a95eac3cc55a410d0d6f1dab03", - "valid": true - }, - { - "name": "vector-seed-a", - "seed": "81712c4bef43282ffd12909479bece9da17d404bdbc629bec3a6fdf246f847fb", - "pub": "7ce749eb2966b9b747543236ef5f61b46328a82f92677b82470439db065154e0", - "message": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", - "signature": "e55b07b43bed9938bdf401d264226d22ea87f85867dc2f3a22edf129088e0a100dda4000c9c2075b946c12a645115911a041493bd3a6c6d6e3892233c7f3e209", - "valid": true - }, - { - "name": "vector-seed-a", - "seed": "81712c4bef43282ffd12909479bece9da17d404bdbc629bec3a6fdf246f847fb", - "pub": "7ce749eb2966b9b747543236ef5f61b46328a82f92677b82470439db065154e0", - "message": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", - "signature": "e45b07b43bed9938bdf401d264226d22ea87f85867dc2f3a22edf129088e0a100dda4000c9c2075b946c12a645115911a041493bd3a6c6d6e3892233c7f3e209", - "valid": false - }, - { - "name": "vector-seed-b", - "seed": "053a9de5a92ac01208edcd9f9d585ffa1462b60e895567d13a262ebbd5138b5d", - "pub": "7068fc61a59adcb58e856393df939349389af09095fffdfb7374d07be183201e", - "message": "", - "signature": "2dfb4b1e65dfd4bf991ef50be88acddf2d59fe158daf2879bec5641ab80b1e0c542b9ea2bd9a6bc76f2020b76b14dc80ae9f68c62ea58dbd8f5b1990ff069e08", - "valid": true - }, - { - "name": "vector-seed-b", - "seed": "053a9de5a92ac01208edcd9f9d585ffa1462b60e895567d13a262ebbd5138b5d", - "pub": "7068fc61a59adcb58e856393df939349389af09095fffdfb7374d07be183201e", - "message": "736d6f6c6d61696c2f3120617574680000000000000000000000000000000000000000000000000000000000000000", - "signature": "f6d398cd25fec0ba882af13b85c6addcc2f546181fba84f8925e6e196b759897337a2291f4b73c40a062b0a2283ef096ded790154af58e9d4bd39c32b3db2f05", - "valid": true - }, - { - "name": "vector-seed-b", - "seed": "053a9de5a92ac01208edcd9f9d585ffa1462b60e895567d13a262ebbd5138b5d", - "pub": "7068fc61a59adcb58e856393df939349389af09095fffdfb7374d07be183201e", - "message": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", - "signature": "24f8405160d7f8eca42d9bc5e703bc81e58082b34e5d0f28404e5a3518b500b46d48a9bd64c69f5dde38c978ae06c9ce5dfeabdfacd410753176ea11f171140e", - "valid": true - }, - { - "name": "vector-seed-b", - "seed": "053a9de5a92ac01208edcd9f9d585ffa1462b60e895567d13a262ebbd5138b5d", - "pub": "7068fc61a59adcb58e856393df939349389af09095fffdfb7374d07be183201e", - "message": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", - "signature": "25f8405160d7f8eca42d9bc5e703bc81e58082b34e5d0f28404e5a3518b500b46d48a9bd64c69f5dde38c978ae06c9ce5dfeabdfacd410753176ea11f171140e", - "valid": false - } - ], - "ed_to_x25519": [ - { - "name": "vector-seed-a", - "seed": "81712c4bef43282ffd12909479bece9da17d404bdbc629bec3a6fdf246f847fb", - "x_priv": "30dc8ba3a49892d4b8a626cd371fd43bff4e5651c2a45f1be16e89d84fa89e68", - "x_pub": "77bc3dae84c9b4085862725a21e0a366c09563d6da8f29c408ac2b6928937910" - }, - { - "name": "vector-seed-b", - "seed": "053a9de5a92ac01208edcd9f9d585ffa1462b60e895567d13a262ebbd5138b5d", - "x_priv": "b03deb6f9f4ab25d15471a355ff4ee23ea98f7d24695c500ed80b6af4b7b9963", - "x_pub": "253d4b5b14df341a5dde486ddd588e292444118ed8eed1fe0738823b82e4243d" - } - ], - "envelope": { - "sender_seed": "89c16df9e4352e706abe701928c230d8bd169cd31633bf4589c2d410cb3ae8cc", - "recipient_seed": "6f845ccc435252d39dd08e964d219258e92c3d6c54af0376fa45d5e55eec0aaf", - "esk": "c31fee505964c44b711cf354b431f9716c644a3dbf8c1d29c5e3cedf884d0a48", - "body": "2d2d2d0a5375626a6563743a20766563746f720a2d2d2d0a68656c6c6f20626f620a", - "time": 1730000000, - "envelope": "534d4f4c01e048814b56d9b82e54fd367d3c980661313cc6a3d81a80315561fc0ac87f81184e49921528d72321669ae1b275229800d8786c1ecdd7d9331bcb2cc64dc27c0399b106b5061e9105d8adf82f6b7464930fa31cb08ba85dba4764ce14cf3ddc32599f43fea73ced76ffa3a3b768e5a127034f5e82d667687369c19f060e8eafb09da0e212683290f4e1a73ce072b94f053c9088652109d3639f7b9aa0d48619626ecefe33855aade6ab8bf9de14ebb7cf07eec0ef9c193ae4537c370183e0c8f7cd7230471b9d8e7389ac654e1b09dc9b0160c875270fdad218f0c9da735bab", - "id": "b05d15a2ab5293164eda564de02a69019aa97895ff988f3d9fa2be5126516553", - "unpadded_plaintext_len": 143 - }, - "noise": { - "initiator_eph_priv": "af5f15993914cfd4e308856810d483ab25a383bfb2d708a0e15f80275f1fe6c2", - "responder_eph_priv": "1c21927bb3e579efb69c937a2bf2e299ca1da9c83a62fb7f8ea1a375eb6f1c80", - "server_static_priv": "c7b206a746c9b167da412a6b1a235869e316e9f08dbbc8478430b2998130f79e", - "server_static_pub": "fa111eca8e853320f8e076674143fd4d1cd181bddeda7d9950a65922b9eafc32", - "message1": "b8b73e6f132dab5659270e6496d2c575ae7daf45a0961ae8e19405a12ed1cc2e", - "message2": "0b03ec78fd19cf7b8480881f33c6b4ca1094fac03c87860095c87c6737349c28256e498747bcf2018420d145c378e6605e63b217f92925ae5771dbe387b94cf9c995f7a3a8314fe2e671ca8fa1463e0642c1d7974f326737cadf630b8aac8ed9", - "handshake_hash": "a039471479680a596a8a61b8827afb01853274b03de2870095edb9b7dd4e2c72", - "assert_hash_equal": true, - "initiator_frames": [ - { - "plaintext": "70696e67", - "sealed": "f1885096d9b9383b0bc38b08dff77c005d69f0f0" - }, - { - "plaintext": "7365636f6e64206672616d6520746f20746573742074686520636f756e746572", - "sealed": "4a6481a2f7d0c909d9b321bc097e09a1929aeaf8647751f64d65b5e1f92abe960796dbf1c619bef48845aea257f2c73c" - } - ], - "responder_frames": [ - { - "plaintext": "706f6e67", - "sealed": "a7dda7fe3ef32435b3e72fda49714e9977318f0b" - }, - { - "plaintext": "787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878", - "sealed": "9aa3af13c00a8a0cd81167b48a5443541e0c862297638e4b7e5c71196657bc565aad46c2d147b23f752ac48c29b69699209e252d3e840c13fc466dd0445515dec2d289902c7177a237f9256afe9508a45b64ea12cdb597a8c38b6ce0c43891ec772c21ca360980094268686642eae8e01ccf89650a427297dbab052aabfeb08adbb2087ae303dd168ceb3beffa63c8d3ccd1c5b2687c2a9c0d43eaab237fde648bf8b0f347b4952ff6da2212360b4a2a5b1316e9e776d82961c498e51f35a58c999f080ac1c12d7ac08f6ddd7addb70c37ea6bef42ed2c498362f4fd75a222068035a7f372c4f57e613427193ffd8ed40a2d0765ba62cbfd6cb5103165dc15be7ad2db723a4edc73cefe9f7fe5ff78a8ea06ecbc7c5135e5d810a1bd4e47f0cd4a1b6e8dd88b85236dd4b41296e00b7054139ee4fd4faa5876e01d62" - } - ] - } -} diff --git a/test/widget_test.dart b/test/widget_test.dart index 640041c..e3689ba 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -17,7 +17,7 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); final dir = await Directory.systemTemp.createTemp("smol-widget-test"); Hive.init(dir.path); - store = await SmolStore.open(); + store = await SmolStore.open(dbPath: "${dir.path}/widget.db"); }); testWidgets("onboarding invites to create or restore an identity", From 6f6fa20b583000d8076091f3b860b0a084a83a76 Mon Sep 17 00:00:00 2001 From: randogoth Date: Mon, 28 Sep 2026 23:49:39 +0300 Subject: [PATCH 11/22] feat: patrol on-device integration smoke test --- .gitignore | 11 ++ android/.gitignore | 5 +- android/app/build.gradle.kts | 26 +++ .../integration/MainActivityTest.java | 38 ++++ android/gradlew | 171 ++++++++++++++++++ devbox.json | 13 +- devbox.lock | 8 + integration_test/smoke_test.dart | 97 ++++++++++ pubspec.lock | 89 ++++++++- pubspec.yaml | 14 ++ 10 files changed, 466 insertions(+), 6 deletions(-) create mode 100644 android/app/src/androidTest/java/com/app/smol_mail/integration/MainActivityTest.java create mode 100755 android/gradlew create mode 100644 integration_test/smoke_test.dart diff --git a/.gitignore b/.gitignore index 24476c5..a305876 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,14 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release +/android/build + +# Rust wrapper crate (native/) +native/target/ + +# Rust cdylibs built into the APK by devbox run build-native-android +android/app/src/main/jniLibs/ + +# Patrol generates the bundle from the integration tests at run time +**/test_bundle.dart +.patrol.env diff --git a/android/.gitignore b/android/.gitignore index 6f56801..1e367f8 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -1,8 +1,9 @@ gradle-wrapper.jar /.gradle /captures/ -/gradlew -/gradlew.bat +# gradlew stays tracked: it carries the nix project-cache redirect that every +# non-flutter gradle invocation (patrol_cli, manual builds) needs, so the +# wrapper can't be re-injected from the template. /local.properties GeneratedPluginRegistrant.java diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index db7cfc9..22ada6c 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -25,6 +25,25 @@ android { targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName + // The Android Test Orchestrator (with clearPackageData) wipes the + // integration flavor's app data between test runs, and the flavor + // installs it beside — never over — the real app, so every patrol run + // starts at onboarding with a fresh identity. + testInstrumentationRunner = "pl.leancode.patrol.PatrolJUnitRunner" + testInstrumentationRunnerArguments["clearPackageData"] = "true" + } + + testOptions { + execution = "ANDROIDX_TEST_ORCHESTRATOR" + } + + flavorDimensions += "test" + productFlavors { + create("integration") { + dimension = "test" + applicationIdSuffix = ".integration" + versionNameSuffix = "-integration" + } } buildTypes { @@ -38,3 +57,10 @@ android { flutter { source = "../.." } + +dependencies { + // The orchestrator referenced by testOptions.execution above. The patrol + // native library itself comes from the patrol pub package's android + // module (a plugin subproject), not from a Maven artifact. + androidTestUtil("androidx.test:orchestrator:1.5.1") +} diff --git a/android/app/src/androidTest/java/com/app/smol_mail/integration/MainActivityTest.java b/android/app/src/androidTest/java/com/app/smol_mail/integration/MainActivityTest.java new file mode 100644 index 0000000..cf41662 --- /dev/null +++ b/android/app/src/androidTest/java/com/app/smol_mail/integration/MainActivityTest.java @@ -0,0 +1,38 @@ +package com.app.smol_mail.integration; + +import androidx.test.platform.app.InstrumentationRegistry; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; +import pl.leancode.patrol.PatrolJUnitRunner; + +// The Patrol host class: each Dart test in the integration_test directory is +// executed through this JUnit suite. Its package is the flavored applicationId +// (com.app.smol_mail + .integration suffix); the namespace differs, so +// MainActivity needs the explicit import below. +@RunWith(Parameterized.class) +public class MainActivityTest { + + @Parameters(name = "{0}") + public static Object[] testCases() { + PatrolJUnitRunner instrumentation = + (PatrolJUnitRunner) InstrumentationRegistry.getInstrumentation(); + instrumentation.setUp(com.example.smol_mail.MainActivity.class); + instrumentation.waitForPatrolAppService(); + return instrumentation.listDartTests(); + } + + public MainActivityTest(String dartTestName) { + this.dartTestName = dartTestName; + } + + private final String dartTestName; + + @Test + public void runDartTest() { + PatrolJUnitRunner instrumentation = + (PatrolJUnitRunner) InstrumentationRegistry.getInstrumentation(); + instrumentation.runDartTest(dartTestName); + } +} diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..a32d0e0 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,171 @@ +#!/nix/store/gik3rh1vz2jlgnifb9dh6vc6sxwwz9jj-bash-5.3p9/bin/bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +# Nix's flutter keeps the flutter_tools/gradle composite build on a read-only +# store path, so every gradle invocation needs its project and Kotlin caches +# redirected off it — the same two arguments the flutter tool itself passes. +# Without them, any gradlew run other than through the flutter tool (patrol_cli, +# manual builds) fails with a silent exit 1. +_nix_cache="${XDG_CACHE_HOME:-$HOME/.cache}/flutter/nix-flutter-tools-gradle/gradle-project-cache" +case " $* " in + *" --project-cache-dir"*) ;; + *) set -- "$@" "--project-cache-dir=$_nix_cache" "-Pkotlin.project.persistent.dir=$_nix_cache/kotlin" ;; +esac + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/devbox.json b/devbox.json index 2c74757..d8a38b7 100644 --- a/devbox.json +++ b/devbox.json @@ -10,7 +10,9 @@ "nixpkgs#ninja", "nixpkgs#pkg-config", "nixpkgs#gtk3", - "nixpkgs#libsysprof-capture" + "nixpkgs#libsysprof-capture", + "nixpkgs#rustup", + "nixpkgs#cargo-ndk" ], "shell": { "init_hook": [ @@ -22,11 +24,16 @@ "test": "flutter test", "run-linux": "flutter run -d linux", "build-linux": "flutter build linux", - "link-android-tools": "if [ -n \"$ANDROID_HOME\" ] && [ ! -d \"$ANDROID_HOME/cmdline-tools/latest\" ] && [ -d \"$ANDROID_HOME/cmdline-tools/8.0\" ]; then ln -s \"$ANDROID_HOME/cmdline-tools/8.0\" \"$ANDROID_HOME/cmdline-tools/latest\"; fi" + "link-android-tools": "if [ -n \"$ANDROID_HOME\" ] && [ ! -d \"$ANDROID_HOME/cmdline-tools/latest\" ] && [ -d \"$ANDROID_HOME/cmdline-tools/8.0\" ]; then ln -s \"$ANDROID_HOME/cmdline-tools/8.0\" \"$ANDROID_HOME/cmdline-tools/latest\"; fi", + "build-native": "cd native && cargo build --release", + "build-native-android": "rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android && cd native && cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 -o ../android/app/src/main/jniLibs build --release", + "test-native": "cd native && cargo test --release && cargo test --release -- --ignored", + "activate-patrol": "dart pub global activate patrol_cli", + "test-integration": "export PATH=\"$HOME/.pub-cache/bin:$PATH\" && patrol test --dart-define=SMOL_TEST_HOST=$(hostname -I | tr ' ' '\\n' | grep -E '^(192\\.168|10\\.|172\\.)' | head -1)" } }, "env": { "ANDROID_SDK_ROOT": "$DEVBOX_PACKAGES_DIR/share/android-sdk", "ANDROID_HOME": "$DEVBOX_PACKAGES_DIR/share/android-sdk" } -} \ No newline at end of file +} diff --git a/devbox.lock b/devbox.lock index 6ffe55e..5db83f3 100644 --- a/devbox.lock +++ b/devbox.lock @@ -57,6 +57,10 @@ "last_modified": "1970-01-01T00:00:00Z", "resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#android-tools" }, + "nixpkgs#cargo-ndk": { + "last_modified": "1970-01-01T00:00:00Z", + "resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#cargo-ndk" + }, "nixpkgs#clang": { "last_modified": "1970-01-01T00:00:00Z", "resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#clang" @@ -84,6 +88,10 @@ "nixpkgs#pkg-config": { "last_modified": "1970-01-01T00:00:00Z", "resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#pkg-config" + }, + "nixpkgs#rustup": { + "last_modified": "1970-01-01T00:00:00Z", + "resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#rustup" } } } diff --git a/integration_test/smoke_test.dart b/integration_test/smoke_test.dart new file mode 100644 index 0000000..7a6c75a --- /dev/null +++ b/integration_test/smoke_test.dart @@ -0,0 +1,97 @@ +// Device integration tests over the real UI, driven by Patrol against the +// system bunshin on the test host. The integration flavor installs beside +// the real app and the instrumentation runner clears its data, so every run +// starts at onboarding with a fresh identity — the app's own flows are the +// subject under test, including the network path. +// +// Run: devbox run test-integration +// (the script resolves the host's LAN IP into SMOL_TEST_HOST, because the +// device reaches the server over Wi-Fi, not loopback.) + +import "package:flutter/material.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:hive_flutter/hive_flutter.dart"; +import "package:path_provider/path_provider.dart"; + +import "package:patrol/patrol.dart"; + +import "package:smol_mail/data/providers/providers.dart"; +import "package:smol_mail/presentation/app_widget.dart"; +import "package:smol_mail/smol/config.dart"; +import "package:smol_mail/smol/store.dart"; + +const testHost = String.fromEnvironment("SMOL_TEST_HOST", defaultValue: "10.0.2.2"); + +// The system bunshin's static key: a documented, operator-supplied value on +// this machine — the trusted channel SPEC.md §4 asks a pin to come from. +const serverKey = "lm2gqd7e5q67xq3isc5hx6jfj2q7a7xvq4l7trctxipudujovmgq"; + +Future bootApp() async { + // main()'s boot, minus runApp: the test pumps the same tree. + final dataDir = await getApplicationSupportDirectory(); + Hive.init(dataDir.path); + final store = await SmolStore.open(dbPath: "${dataDir.path}/smol-mail.db"); + applyPresetServer(store); + return ProviderScope( + overrides: [storeProvider.overrideWithValue(store)], + child: const AppWidget(), + ); +} + +void main() { + patrolTest("smoke: onboarding, register, self-send, fetch, read in requests", + ($) async { + await $.pumpWidgetAndSettle(await bootApp()); + + // Onboarding: a fresh identity, backed up, registered under a name no + // server has bound yet — registration must succeed against the real + // server over Wi-Fi, through the Dart-resolved dial path. + await $("Create Identity").tap(); + await $.pumpAndSettle(); + await $("I have backed it up").waitUntilVisible(); + await $("I have backed it up").tap(); + await $.pumpAndSettle(); + + final user = "it${DateTime.now().millisecondsSinceEpoch.toRadixString(36)}"; + // Enter text by TextField, not by its labelText: the decoration label is + // a RichText that is not hit-testable, so a text finder times out. + await $.enterText($(TextField).at(0), "$user@$testHost:1961"); + await $.enterText($(TextField).at(1), serverKey); + await $("Pin and Register").tap(); + + // The register round trip is real network: the inbox tabs only exist + // behind HomeGuard, so reaching them proves the account bound. + await $("Inbox").waitUntilVisible(timeout: const Duration(seconds: 30)); + await $("Requests").waitUntilVisible(); + + // Compose the first self-addressed mail. + await $(Icons.edit).tap(); + await $.pumpAndSettle(); + await $.enterText($(TextField).at(0), "$user@$testHost:1961"); + await $.enterText($(TextField).at(1), "smoke subject"); + await $.enterText($(TextField).at(2), "smoke body from patrol"); + await $("Send").tap(); + // Compose pops back to the inbox once the send round trip completes. + await $("Inbox").waitUntilVisible(timeout: const Duration(seconds: 30)); + await $.pumpAndSettle(); + + // Fetch over the air, then read what arrived. + await $(Icons.cloud_download).tap(); + await $.pumpAndSettle(duration: const Duration(seconds: 5)); + await $("Requests (1)").tap(); + await $.pumpAndSettle(); + + // First contact is unsolicited, so it landed in requests (sec 5.8) — + // and the §5.6 sent copy must read back as plain text, not . + await $("smoke subject").tap(); + await $.pumpAndSettle(); + await $("smoke body from patrol").waitUntilVisible(); + await $.native.pressBack(); // ignore: deprecated_member_use + await $.pumpAndSettle(); + await $("Sent").tap(); + await $.pumpAndSettle(); + await $("smoke subject").tap(); + await $.pumpAndSettle(); + await $("smoke body from patrol").waitUntilVisible(); + }); +} diff --git a/pubspec.lock b/pubspec.lock index a96a269..b7c28cf 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -249,6 +249,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.8.0" + dispose_scope: + dependency: transitive + description: + name: dispose_scope + sha256: "48ec38ca2631c53c4f8fa96b294c801e55c335db5e3fb9f82cede150cfe5a2af" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2" + url: "https://pub.dev" + source: hosted + version: "2.1.0" fake_async: dependency: transitive description: @@ -258,7 +274,7 @@ packages: source: hosted version: "1.3.3" ffi: - dependency: transitive + dependency: "direct main" description: name: ffi sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" @@ -334,6 +350,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_launcher_icons: dependency: "direct dev" description: @@ -384,6 +405,11 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" glob: dependency: transitive description: @@ -440,6 +466,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.15.7" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" http_multi_server: dependency: transitive description: @@ -464,6 +498,11 @@ packages: url: "https://pub.dev" source: hosted version: "4.10.1" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" intl: dependency: "direct main" description: @@ -672,6 +711,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + patrol: + dependency: "direct dev" + description: + name: patrol + sha256: "662c50d517b2f159fedbc359a7fb8d0d30cbefea6d5c2a08392a0593079d64d1" + url: "https://pub.dev" + source: hosted + version: "4.10.0" + patrol_finders: + dependency: transitive + description: + name: patrol_finders + sha256: "8608cdacaab90a3b00ecd7b09df11f13a62b01dea13c211b9f13fd86854e103a" + url: "https://pub.dev" + source: hosted + version: "3.6.0" + patrol_log: + dependency: transitive + description: + name: patrol_log + sha256: "3d91c93e8d0ed19cb12d4b27aa24eca7294e0a48d9d8445505c9775feb5fde2d" + url: "https://pub.dev" + source: hosted + version: "0.10.1" petitparser: dependency: transitive description: @@ -712,6 +775,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.5.2" + process: + dependency: transitive + description: + name: process + sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891" + url: "https://pub.dev" + source: hosted + version: "5.0.6" pub_semver: dependency: transitive description: @@ -869,6 +940,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" term_glyph: dependency: transitive description: @@ -1005,6 +1084,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "28b82ec894fed45dd71c23ba62d1af973ed97dd59a4f5790a4d38b0b13e5657e" + url: "https://pub.dev" + source: hosted + version: "3.2.0" webkit_inspection_protocol: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 52af655..9738da7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,6 +12,7 @@ dependencies: flutter: sdk: flutter crypto: ^3.0.3 + ffi: ^2.1.0 hive: ^2.2.3 hive_flutter: ^1.1.0 auto_route: ">=11.1.0 <11.2.0" @@ -24,12 +25,25 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + integration_test: + sdk: flutter + patrol: ^4.10.0 build_runner: ^2.4.6 flutter_lints: ^6.0.0 auto_route_generator: ^10.5.0 flutter_native_splash: ^2.3.4 flutter_launcher_icons: ^0.14.4 +# patrol test reads this block; android.package_name is the flavored +# applicationId (the integration flavor adds the .integration suffix), and +# patrol_cli refuses to run without it. +patrol: + app_name: kirakira + flavor: integration + test_directory: integration_test + android: + package_name: com.app.smol_mail.integration + flutter_launcher_icons: android: "launcher_icon" ios: false From aa8c7ec71a80b71fd0c53afce6cf5339e64af012 Mon Sep 17 00:00:00 2001 From: randogoth Date: Tue, 29 Sep 2026 20:35:15 +0300 Subject: [PATCH 12/22] feat: add AI-DECLARATION.md and MIT license badge Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- AI-DECLARATION.md | 17 +++++++++++++++++ README.md | 3 +++ 2 files changed, 20 insertions(+) create mode 100644 AI-DECLARATION.md diff --git a/AI-DECLARATION.md b/AI-DECLARATION.md new file mode 100644 index 0000000..e028c4a --- /dev/null +++ b/AI-DECLARATION.md @@ -0,0 +1,17 @@ +--- +version: "0.1.2" +level: copilot +processes: + design: assist + implementation: pair + testing: pair + documentation: copilot + review: copilot + deployment: assist +--- + +This format is based on [AI-DECLARATION.md](https://ai-declaration.md/en/0.1.2). + +## Notes + +- diff --git a/README.md b/README.md index 92549c2..5d80e14 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # kirakira +[![AI-DECLARATION: copilot](https://img.shields.io/badge/䷼%20AI--DECLARATION-copilot-fee2e2?labelColor=fee2e2)](https://ai-declaration.md) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + A mobile client for [Smol Mail](https://smol.place), a minimalist end-to-end encrypted mail protocol: one Ed25519 identity, five operations, sealed and signed messages over a Noise_NX transport. Sibling of the reference CLI client (`https://smol.place`) and the browser client (`https://code.randogoth.com/randogoth/gsmol`). kirakira began as [FlashMail](https://github.com/sarthakkimtani/flash-mail), a Flutter UI template for a disposable-email app built on mail.tm. Its networking layer was replaced end to end with a from-scratch Smol Mail implementation (`lib/smol/`: crypto, Noise handshake, framing, client flows), and the UI was redesigned around a Material 3 theme — dark navy and mint green by default, with a matching light theme — built from a single `ColorScheme` and `AppColors` extension rather than hardcoded colors per screen. From d7179694a6b151b47d5772e8280600ec7e6645e4 Mon Sep 17 00:00:00 2001 From: randogoth Date: Tue, 29 Sep 2026 20:35:16 +0300 Subject: [PATCH 13/22] fix: serialize contact history entries as objects across the FFI --- native/src/ffi.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/native/src/ffi.rs b/native/src/ffi.rs index 04cb17e..f16f3c8 100644 --- a/native/src/ffi.rs +++ b/native/src/ffi.rs @@ -887,12 +887,21 @@ pub extern "C" fn smol_contact(store: *mut Store, address: *const c_char) -> *mu let address = text(address)?; #[derive(Serialize)] #[serde(rename_all = "camelCase")] + // History entries serialize as objects, matching smol_contacts and + // the facade's parser — a bare tuple would cross the FFI as a JSON + // array and break the first contact that ever gains rotation history. + struct History { + key: String, + until: i64, + } + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] struct Out { key: String, verified: bool, active: Option, accepted_key: Option, - history: Vec<(String, i64)>, + history: Vec, } let Some((key, verified)) = store.contact(address)? else { return Ok(ser(&Out { @@ -913,7 +922,7 @@ pub extern "C" fn smol_contact(store: *mut Store, address: *const c_char) -> *mu history: store .history(address)? .into_iter() - .map(|(key, until)| (b32(&key), until)) + .map(|(key, until)| History { key: b32(&key), until }) .collect(), })?) }) From f7452832eabb495550a0d35c0d013c61bb5db83e Mon Sep 17 00:00:00 2001 From: randogoth Date: Tue, 29 Sep 2026 20:35:16 +0300 Subject: [PATCH 14/22] fix: copy the master at each layer so zeroization stays local --- lib/native/client.dart | 7 ++++++- lib/smol/store.dart | 7 +++++-- test/store_test.dart | 10 ++++++++-- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/lib/native/client.dart b/lib/native/client.dart index f445693..6685b74 100644 --- a/lib/native/client.dart +++ b/lib/native/client.dart @@ -67,7 +67,12 @@ class FumiNative { /// operation, so it never needs an isolate — and widget tests can settle /// it inside their fake-async zones. void setMaster(Uint8List master, {int? rotations}) { - _master = master; + // Own copy: the caller keeps its buffer (the onboarding screen zeroes + // its reference on dispose, and wipe zeroes Hive's), and the account + // rebuilds after register/restore/rotate must derive from untouched + // bytes — a shared buffer zeroized elsewhere would rebuild an account + // that matches none of our keys. + _master = Uint8List.fromList(master); _rebuildAccount(rotations: rotations); } diff --git a/lib/smol/store.dart b/lib/smol/store.dart index 9ace821..ef4a414 100644 --- a/lib/smol/store.dart +++ b/lib/smol/store.dart @@ -145,14 +145,17 @@ class SmolStore { void setMaster(Uint8List fresh) { // Hive's in-memory state updates synchronously and persists in the // background, so the store is consistent without awaiting the write. - unawaited(_meta.put("master", fresh)); + // The put takes its own copy: the caller's buffer is the caller's to + // zeroize (the onboarding screen does, on dispose), and a shared object + // would scrub the store's view with it. + unawaited(_meta.put("master", Uint8List.fromList(fresh))); _native.setMaster(fresh, rotations: 0); } /// The master restored from a backup, already at the rotation index the /// server bound. void restoreMaster(Uint8List master, int index) { - unawaited(_meta.put("master", master)); + unawaited(_meta.put("master", Uint8List.fromList(master))); _native.setMaster(master, rotations: index); } diff --git a/test/store_test.dart b/test/store_test.dart index 1b783a6..5e296af 100644 --- a/test/store_test.dart +++ b/test/store_test.dart @@ -77,14 +77,20 @@ void main() { final store = await freshStore("wipe"); final master = randomBytes(32); store.setMaster(master); + // The store's own copy, read back before the wipe. + final stored = store.master()!; store.pinServer("example.org", "lm2gqd7e5q67xq3isc5hx6jfj2q7a7xvq4l7trctxipudujovmgq"); store.markRead("cd" * 32); await store.wipe(); // The bytes the store owned are overwritten, not just dereferenced — - // the caller's reference sees the zeros too. - expect(master.every((b) => b == 0), isTrue); + // the reference read back through the store sees the zeros. The + // caller's own buffer is the caller's to zeroize: the store holds a + // copy precisely so nobody else's zeroization can reach it, and vice + // versa a wipe never reaches a buffer the store handed out. + expect(stored.every((b) => b == 0), isTrue); + expect(master.every((b) => b == 0), isFalse); expect(store.master(), isNull); expect(store.identity(), isNull); expect(store.serverPin("example.org"), isNull); From fa0809f021fe9351dc08f1cbe845740baa630925 Mon Sep 17 00:00:00 2001 From: randogoth Date: Tue, 29 Sep 2026 20:35:16 +0300 Subject: [PATCH 15/22] test: patrol flows for fetch cancel, rotation and unchanged-key refresh --- integration_test/smoke_test.dart | 206 ++++++++++++++++++++++++++----- native/src/lib.rs | 8 +- pubspec.yaml | 3 + test/native_binding_test.dart | 57 ++++++++- 4 files changed, 240 insertions(+), 34 deletions(-) diff --git a/integration_test/smoke_test.dart b/integration_test/smoke_test.dart index 7a6c75a..4989479 100644 --- a/integration_test/smoke_test.dart +++ b/integration_test/smoke_test.dart @@ -10,6 +10,7 @@ import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:flutter_test/flutter_test.dart"; import "package:hive_flutter/hive_flutter.dart"; import "package:path_provider/path_provider.dart"; @@ -26,43 +27,68 @@ const testHost = String.fromEnvironment("SMOL_TEST_HOST", defaultValue: "10.0.2. // this machine — the trusted channel SPEC.md §4 asks a pin to come from. const serverKey = "lm2gqd7e5q67xq3isc5hx6jfj2q7a7xvq4l7trctxipudujovmgq"; -Future bootApp() async { - // main()'s boot, minus runApp: the test pumps the same tree. +// main()'s boot, minus runApp: the test pumps the same tree. The container is +// the one the UI reads from, so the tests drive the same SmolClient the +// screens do — the fetch cancellation test needs exactly that. +Future boot(PatrolIntegrationTester $) async { final dataDir = await getApplicationSupportDirectory(); Hive.init(dataDir.path); final store = await SmolStore.open(dbPath: "${dataDir.path}/smol-mail.db"); applyPresetServer(store); - return ProviderScope( - overrides: [storeProvider.overrideWithValue(store)], + final container = + ProviderContainer(overrides: [storeProvider.overrideWithValue(store)]); + await $.pumpWidgetAndSettle(UncontrolledProviderScope( + container: container, child: const AppWidget(), - ); + )); + return container; +} + +// Onboarding into a registered account: a fresh identity, backed up, then +// registered under a name no server has bound yet. Returns the username. +Future onboard(PatrolIntegrationTester $, ProviderContainer container) async { + await $("Create Identity").tap(); + await $.pumpAndSettle(); + await $("I have backed it up").waitUntilVisible(); + await $("I have backed it up").tap(); + await $.pumpAndSettle(); + + final user = "it${DateTime.now().millisecondsSinceEpoch.toRadixString(36)}"; + // Enter text by TextField, not by its labelText: the decoration label is + // a RichText that is not hit-testable, so a text finder times out. + await $.enterText($(TextField).at(0), "$user@$testHost:1961"); + await $.enterText($(TextField).at(1), serverKey); + await $("Pin and Register").tap(); + + // The register round trip is real network: the inbox tabs only exist + // behind HomeGuard, so reaching them proves the account bound. + await $("Inbox").waitUntilVisible(timeout: const Duration(seconds: 30)); + return user; +} + +// Imports the account's own smol:// address as a contact — the §8 verified +// path — so later steps have an entry to re-resolve. Leaves the app on the +// Contacts screen. +Future importSelfContact(PatrolIntegrationTester $, ProviderContainer container) async { + final client = container.read(clientProvider); + final uri = client.accountAddress()!.uri(client.identity!.publicKey); + await $("Contacts").tap(); + await $.pumpAndSettle(); + await $(Icons.person_add).tap(); + await $.pumpAndSettle(); + await $.enterText($(TextField).at(0), uri); + await $("Import").tap(); + await $("contact imported (verified key)").waitUntilVisible(); + // The snackbar floats over the bottom navigation bar; let it expire + // before the next navigation tap. + await $.pump(const Duration(seconds: 5)); } void main() { patrolTest("smoke: onboarding, register, self-send, fetch, read in requests", ($) async { - await $.pumpWidgetAndSettle(await bootApp()); - - // Onboarding: a fresh identity, backed up, registered under a name no - // server has bound yet — registration must succeed against the real - // server over Wi-Fi, through the Dart-resolved dial path. - await $("Create Identity").tap(); - await $.pumpAndSettle(); - await $("I have backed it up").waitUntilVisible(); - await $("I have backed it up").tap(); - await $.pumpAndSettle(); - - final user = "it${DateTime.now().millisecondsSinceEpoch.toRadixString(36)}"; - // Enter text by TextField, not by its labelText: the decoration label is - // a RichText that is not hit-testable, so a text finder times out. - await $.enterText($(TextField).at(0), "$user@$testHost:1961"); - await $.enterText($(TextField).at(1), serverKey); - await $("Pin and Register").tap(); - - // The register round trip is real network: the inbox tabs only exist - // behind HomeGuard, so reaching them proves the account bound. - await $("Inbox").waitUntilVisible(timeout: const Duration(seconds: 30)); - await $("Requests").waitUntilVisible(); + final container = await boot($); + final user = await onboard($, container); // Compose the first self-addressed mail. await $(Icons.edit).tap(); @@ -82,7 +108,7 @@ void main() { await $.pumpAndSettle(); // First contact is unsolicited, so it landed in requests (sec 5.8) — - // and the §5.6 sent copy must read back as plain text, not . + // and the sec 5.6 sent copy must read back as plain text, not . await $("smoke subject").tap(); await $.pumpAndSettle(); await $("smoke body from patrol").waitUntilVisible(); @@ -94,4 +120,128 @@ void main() { await $.pumpAndSettle(); await $("smoke body from patrol").waitUntilVisible(); }); + + // Checklist item 11: a fetch interrupted mid-run keeps everything that + // already arrived, and fetching again completes the set without re-storing + // what the first pass took. There is no cancel control in the UI yet, so + // the test raises the facade's cancellation flag — the same call a cancel + // button would make; when one exists, drive it from the UI instead. + patrolTest( + "smoke: cancelled fetch keeps what arrived and resume completes it", + ($) async { + final container = await boot($); + final user = await onboard($, container); + final client = container.read(clientProvider); + final address = "$user@$testHost:1961"; + + // 20 letters with 40 KiB bodies: the fetch has real pages to chew + // through, so a cancel 600 ms in lands mid-run, not after it. + for (var i = 0; i < 20; i++) { + await client.send(address, "cancel $i", "cancel body $i\n${"x" * 40960}"); + } + + final fetch = client.fetch(); + await Future.delayed(const Duration(milliseconds: 600)); + client.cancelFetch(); + final summary = await fetch; + container.read(revisionProvider.notifier).bump(); + await $.pumpAndSettle(); + + // If the cancel landed mid-run, part of the page arrived and the + // warning banner says so; if the fetch had already finished, nothing + // was lost either way and the resume below is a no-op. + if (summary.stored < 20) { + await $("fetch cancelled; partial results kept").waitUntilVisible(); + } + + // The UI's fetch resumes and completes the set. The requests badge + // counts every unread message, so a re-stored duplicate would push + // the count past 20. + await $(Icons.cloud_download).tap(); + await $.pumpAndSettle(duration: const Duration(seconds: 5)); + await $("Requests (20)") + .waitUntilVisible(timeout: const Duration(seconds: 30)); + }); + + // Checklist item 13: after rotating, a correspondent re-resolving the + // address sees the signed-chain rotation banner — a warning, not the + // terminal mismatch — and mail sealed to the superseded key still reads. + patrolTest( + "smoke: rotation re-resolves through the chain; old mail stays readable", + ($) async { + final container = await boot($); + final user = await onboard($, container); + final client = container.read(clientProvider); + final address = "$user@$testHost:1961"; + + // A letter sealed to the pre-rotation key, waiting in requests. + await client.send(address, "rotation subject", + "rotation body before the key change"); + await client.fetch(); + container.read(revisionProvider.notifier).bump(); + await $.pumpAndSettle(); + + // The correspondent entry, bound to the current key, must exist before + // the rotation so re-resolving has a known key to move away from. + await importSelfContact($, container); + + // Rotate: the dialog pushes the next index's key with a certificate. + await $("Settings").tap(); + await $.pumpAndSettle(); + // The rotate row sits below the settings list's fold, and a SliverList + // builds lazily — off-screen rows do not exist as widgets until + // scrolled to, so bring it into the tree before tapping. + await $.scrollUntilVisible(finder: $("Rotate identity key")); + await $("Rotate identity key").tap(); + await $.pumpAndSettle(); + await $("Rotate").tap(); + await $(RegExp("rotated; new key")).waitUntilVisible(); + await $.pump(const Duration(seconds: 5)); + + // Re-resolve: the chain validates, so the outcome is the rotation + // banner, and the superseded key moves to the §8 history section. + await $("Contacts").tap(); + await $.pumpAndSettle(); + await $(client.accountAddress()!.short).tap(); + await $.pumpAndSettle(); + await $("Re-resolve").tap(); + await $(RegExp("rotated its key; a signed chain confirms it")) + .waitUntilVisible(timeout: const Duration(seconds: 30)); + await $("previous keys (1)").waitUntilVisible(); + await $("Dismiss").tap(); + + // Mail sealed to the old key: the master still derives it, so the + // letter reads exactly as before the rotation. The AppBar's back arrow + // pops the detail route; the native back press is avoided here because + // it killed the patrol connection at this point in an earlier run. + await $(Icons.arrow_back).tap(); + await $.pumpAndSettle(); + await $("Mail").tap(); + await $.pumpAndSettle(); + await $("Requests (1)").tap(); + await $.pumpAndSettle(); + await $("rotation subject").tap(); + await $.pumpAndSettle(); + await $("rotation body before the key change").waitUntilVisible(); + }); + + // Checklist item 14: re-resolving a contact whose key did not change is a + // quiet confirmation — no banner, no history section. + patrolTest("smoke: re-resolving an unchanged contact is quiet", ($) async { + final container = await boot($); + await onboard($, container); + final client = container.read(clientProvider); + final short = client.accountAddress()!.short; + + await importSelfContact($, container); + + await $(short).tap(); + await $.pumpAndSettle(); + await $("Re-resolve").tap(); + await $("$short: key unchanged") + .waitUntilVisible(timeout: const Duration(seconds: 30)); + // A banner would have carried the rotation warning instead; history + // records a rotation, and there was none. + expect($("previous keys (1)"), findsNothing); + }); } diff --git a/native/src/lib.rs b/native/src/lib.rs index faec893..b04be2b 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -64,7 +64,7 @@ mod tests { use rand_core::{OsRng, RngCore}; let server: [u8; KEY_LEN] = unb32( - "g5ttsssqjq6j37vso3h7otn33zrk6o6r7c6jjm7guxbbijoa5nqq", + "wukbhdiwboarbg4pujxstbqi3caveunrruaopbwxr226ga3xv5ga", ) .unwrap() .try_into() @@ -146,7 +146,7 @@ mod tests { use std::sync::Arc; let server: [u8; KEY_LEN] = unb32( - "g5ttsssqjq6j37vso3h7otn33zrk6o6r7c6jjm7guxbbijoa5nqq", + "wukbhdiwboarbg4pujxstbqi3caveunrruaopbwxr226ga3xv5ga", ) .unwrap() .try_into() @@ -254,7 +254,7 @@ mod tests { use rand_core::{OsRng, RngCore}; let server: [u8; KEY_LEN] = unb32( - "g5ttsssqjq6j37vso3h7otn33zrk6o6r7c6jjm7guxbbijoa5nqq", + "wukbhdiwboarbg4pujxstbqi3caveunrruaopbwxr226ga3xv5ga", ) .unwrap() .try_into() @@ -328,7 +328,7 @@ mod tests { use rand_core::{OsRng, RngCore}; let server: [u8; KEY_LEN] = unb32( - "g5ttsssqjq6j37vso3h7otn33zrk6o6r7c6jjm7guxbbijoa5nqq", + "wukbhdiwboarbg4pujxstbqi3caveunrruaopbwxr226ga3xv5ga", ) .unwrap() .try_into() diff --git a/pubspec.yaml b/pubspec.yaml index 9738da7..1c448de 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -41,6 +41,9 @@ patrol: app_name: kirakira flavor: integration test_directory: integration_test + # A failing device test leaves the phone in an unknown state; the screenshot + # is the only way to see it from the host. + screenshot_on_failure: true android: package_name: com.app.smol_mail.integration diff --git a/test/native_binding_test.dart b/test/native_binding_test.dart index 7d354a6..3a36ebc 100644 --- a/test/native_binding_test.dart +++ b/test/native_binding_test.dart @@ -4,6 +4,7 @@ import "dart:convert"; import "dart:io"; +import "dart:math"; import "dart:typed_data"; import "package:flutter_test/flutter_test.dart"; @@ -11,7 +12,7 @@ import "package:flutter_test/flutter_test.dart"; import "package:smol_mail/native/client.dart"; import "package:smol_mail/native/ffi.dart"; -const spikeServerKey = "g5ttsssqjq6j37vso3h7otn33zrk6o6r7c6jjm7guxbbijoa5nqq"; +const spikeServerKey = "wukbhdiwboarbg4pujxstbqi3caveunrruaopbwxr226ga3xv5ga"; const spikeServer = "127.0.0.1"; const spikePort = 19619; @@ -90,7 +91,8 @@ void main() async { // username, so identities must be fresh per run. final aliceMaster = Uint8List.fromList(List.generate(32, (i) => i * 7 + DateTime.now().microsecondsSinceEpoch % 251)); - final bobMaster = Uint8List.fromList(List.generate(32, (i) => i * 13 + 5)); + final bobMaster = + Uint8List.fromList(List.generate(32, (_) => Random.secure().nextInt(256))); final run = DateTime.now().millisecondsSinceEpoch.toRadixString(36); final alice = FumiNative("${dir.path}/alice.db"); @@ -124,4 +126,55 @@ void main() async { await alice.close(); await bob.close(); }); + + // sec 7's readability invariant: mail sealed to the pre-rotation key must + // still open after a rotation, because the account derives every superseded + // key from the master. + test("old mail stays readable across a rotation", + skip: await spikeUp() + ? false + : "no bunshin on 127.0.0.1:19619", () async { + final dir = await Directory.systemTemp.createTemp("native-rotate"); + // Random masters: bunshin refuses a key already bound under another + // username, so identities must be fresh per run. + final rng = Random.secure(); + final aliceMaster = + Uint8List.fromList(List.generate(32, (_) => rng.nextInt(256))); + final bobMaster = + Uint8List.fromList(List.generate(32, (_) => rng.nextInt(256))); + final run = DateTime.now().millisecondsSinceEpoch.toRadixString(36); + + final alice = FumiNative("${dir.path}/alice.db"); + await alice.open(); + alice.setMaster(aliceMaster); + await alice.pinServer(spikeServer, spikeServerKey); + await alice.register("a$run@$spikeServer:$spikePort"); + + final bob = FumiNative("${dir.path}/bob.db"); + await bob.open(); + bob.setMaster(bobMaster); + // 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); + await bob.register("b$run@$spikeServer:$spikePort"); + + await alice.send("b$run@$spikeServer:$spikePort", + "before the rotation", + subject: "pre-rotation"); + final summary = await bob.fetch(); + expect(summary["stored"], 1); + final requests = await bob.mail("requests"); + final preRotationId = requests[0]["id"] as String; + final before = await bob.describe(preRotationId); + expect(before["subject"], "pre-rotation"); + + await bob.rotate(); + final after = await bob.describe(preRotationId); + expect(after["subject"], "pre-rotation", + reason: "mail sealed to the superseded key must stay readable"); + + await alice.close(); + await bob.close(); + }); } From b2880e6546e4838dd2aa46fbb7d8222696c344ce Mon Sep 17 00:00:00 2001 From: randogoth Date: Tue, 29 Sep 2026 21:02:36 +0300 Subject: [PATCH 16/22] docs: add license, Flutter, Mistral and fork badges to the README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5d80e14..6965881 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # kirakira [![AI-DECLARATION: copilot](https://img.shields.io/badge/䷼%20AI--DECLARATION-copilot-fee2e2?labelColor=fee2e2)](https://ai-declaration.md) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) ![Powered by Mistral](https://img.shields.io/badge/Powered_by-Mistral_AI-FA520F?logo=mistral-ai&logoColor=white) ![Flutter](https://img.shields.io/badge/Flutter-02569B?logo=flutter&logoColor=white) [![Forked from flash-mail](https://img.shields.io/badge/Forked_from-sarthakkimtani%2Fflash--mail-59636e?logo=github&logoColor=white)](https://github.com/sarthakkimtani/flash-mail) A mobile client for [Smol Mail](https://smol.place), a minimalist end-to-end encrypted mail protocol: one Ed25519 identity, five operations, sealed and signed messages over a Noise_NX transport. Sibling of the reference CLI client (`https://smol.place`) and the browser client (`https://code.randogoth.com/randogoth/gsmol`). From ef13b31dee0b1ae1a875854399065292ebb27840 Mon Sep 17 00:00:00 2001 From: randogoth Date: Tue, 29 Sep 2026 21:02:37 +0300 Subject: [PATCH 17/22] fix: name a prod flavor so the real app stays buildable --- android/app/build.gradle.kts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 22ada6c..9f8f7b0 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -39,6 +39,13 @@ android { flavorDimensions += "test" productFlavors { + // The real app: the identity the alpha testers hold, no suffix. + // With a flavor dimension every build must name its flavor, so + // shipping builds are `flutter build apk --flavor prod`. + create("prod") { + dimension = "test" + } + // The patrol test app, installed beside — never over — the real one. create("integration") { dimension = "test" applicationIdSuffix = ".integration" From 503c4489831170fb36ba5c76dd8ee7ac2ccc66aa Mon Sep 17 00:00:00 2001 From: randogoth Date: Tue, 29 Sep 2026 21:02:37 +0300 Subject: [PATCH 18/22] feat: adopt the sparkle as the app icon and splash --- .../src/main/res/drawable-hdpi/branding.png | Bin 0 -> 5158 bytes .../drawable-hdpi/ic_launcher_foreground.png | Bin 3976 -> 6862 bytes .../app/src/main/res/drawable-hdpi/splash.png | Bin 19535 -> 5158 bytes .../src/main/res/drawable-mdpi/branding.png | Bin 0 -> 3466 bytes .../drawable-mdpi/ic_launcher_foreground.png | Bin 2568 -> 4609 bytes .../app/src/main/res/drawable-mdpi/splash.png | Bin 10610 -> 3466 bytes .../src/main/res/drawable-v21/background.png | Bin 69 -> 69 bytes .../res/drawable-v21/launch_background.xml | 3 +++ .../src/main/res/drawable-xhdpi/branding.png | Bin 0 -> 6693 bytes .../drawable-xhdpi/ic_launcher_foreground.png | Bin 5466 -> 8936 bytes .../src/main/res/drawable-xhdpi/splash.png | Bin 22210 -> 6693 bytes .../src/main/res/drawable-xxhdpi/branding.png | Bin 0 -> 9880 bytes .../ic_launcher_foreground.png | Bin 8546 -> 18867 bytes .../src/main/res/drawable-xxhdpi/splash.png | Bin 42345 -> 9880 bytes .../main/res/drawable-xxxhdpi/branding.png | Bin 0 -> 12774 bytes .../ic_launcher_foreground.png | Bin 12479 -> 28477 bytes .../src/main/res/drawable-xxxhdpi/splash.png | Bin 50111 -> 12774 bytes .../app/src/main/res/drawable/background.png | Bin 69 -> 69 bytes .../main/res/drawable/launch_background.xml | 3 +++ .../res/mipmap-anydpi-v26/launcher_icon.xml | 6 +++++- .../main/res/mipmap-hdpi/launcher_icon.png | Bin 1620 -> 3143 bytes .../main/res/mipmap-mdpi/launcher_icon.png | Bin 1007 -> 2134 bytes .../main/res/mipmap-xhdpi/launcher_icon.png | Bin 2182 -> 4200 bytes .../main/res/mipmap-xxhdpi/launcher_icon.png | Bin 3576 -> 6116 bytes .../main/res/mipmap-xxxhdpi/launcher_icon.png | Bin 4785 -> 8080 bytes .../src/main/res/values-night-v31/styles.xml | 1 - .../app/src/main/res/values-v31/styles.xml | 1 - assets/images/sparkle.png | Bin 0 -> 11259 bytes pubspec.yaml | 10 ++++------ 29 files changed, 15 insertions(+), 9 deletions(-) create mode 100644 android/app/src/main/res/drawable-hdpi/branding.png create mode 100644 android/app/src/main/res/drawable-mdpi/branding.png create mode 100644 android/app/src/main/res/drawable-xhdpi/branding.png create mode 100644 android/app/src/main/res/drawable-xxhdpi/branding.png create mode 100644 android/app/src/main/res/drawable-xxxhdpi/branding.png create mode 100644 assets/images/sparkle.png diff --git a/android/app/src/main/res/drawable-hdpi/branding.png b/android/app/src/main/res/drawable-hdpi/branding.png new file mode 100644 index 0000000000000000000000000000000000000000..5cf6791bc339850367769ac7500bdc37253bb15b GIT binary patch literal 5158 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 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png index 2a2e0b77fe55f910c4f321e1f2df11feb6b03bac..600babb942d3ae2cd8e6d73ef073ccc03aaf6089 100644 GIT binary patch literal 6862 zcmeAS@N?(olHy`uVBq!ia0y~yU|0mg9Bd2>42M36Ni#4oNS3%plmzFem6RtIr81P4 zm+NKbWfvzW7NqLs7p2dBXCuYHAXVV$;uumf=j~kX3hAp;kDs3?tG-Podeh^X6NS~M z>9il2_U48I)0Qm>t(#ikF?ru)4cfNUVV8HNG}rA*>;X|3Qe~M@T)h34rs;}sE#+>U z=A;wIF(rM8N|4-OC{}1Ho28K<=nqpILVB~dg9xeJ13d$f6px+amluRX8p6m z=Qf`>6&EbFJ(#hCMLv7(y9}8PXHBd^zwd zz`tVa?AR|IU5+ajNEb?RXfl?&?0)}7RxJPL1wsE>mFZ{yrSm?{Y|A^MsieZr71#IX zPwG91$hD<8$5MW-o4xt{b*pt^FWlxZ)dcB$X=R@MNgzB^*804`w*|*z+9k;Np!eH@e1SsATv?U} z=ckILb!_94da_4w`y0+0HUZl$r6+bo{tn(^dO_)V?~Pi8I`&*%cLk0V<6OZlCo+}U zk_CTy+;E9LE?5;~=B^JC%vJvM)_lRLm4Abko^Q+PQ%&#PvDf;L;z91r*A{KgTUals zr1#!1*NI~+IvV^ez$u!+x9D1jlUAK&F7NR`)g?!4j%7$L)oo$3%se7%`<9#WLCGzv zzQYKGwRn=a1tpr!8vFdn8`nF6U*6$ap=Y=iWia zodxmck8<4dM4MRl&8rM=uWwhF^Yupb`WyD;$94+dirS*GyypbJ*Vcvr-{|9=4h?(u zR3^tWH*DRqHN(?SQc%FoE!rtc;7|J9j?FUy);t#8V!DLY>FK$M_N{%ZyDm>#Bx;#_ zit=#y@_2h&aYk$b2AsOKFDdIf;$hiZKlcRNDwOM(HA&d*j#|mxX^EQgFB=n{sun7@JWS7hQ2pQI)0&Q!BhSUBz1t`%F~7ia z+L4UYAx)9_N3MTov14Rew`^^4+Y@C6S($&z{SpoPmO&C% zk?FIKKCw0a(KDI1Tdw*{kIZyYlsuoEDQ*4`6nKnGSK5o|DM*4*LZ9H(1W8k-lGi`HKdm;1q(yvgp2 z)Y|h#N3S`kiKm^_Qr@S`DmGz}W%j1$KRa(JD9@KYc3z<7x81RjT^Emr=BD_DP29HW z@k~><9zNAihaNT_7uYak+q5$*+N_%YoZ~J2`5sHzdH(Fp>&9x?$70;iNh!a(b-zrU zDa}Whi}A+0XRFpRHaz_Ijft7N6g<{l_gPMk(WHwMNmPkMRf1b{x+B zbpNGZ+z*4p23L-x>=mqC`|owjlnVY_+2e*mrh6V7O|CyMYwg^glSQ)?I_(#$XFG{; zPWlnhtx|W;HmuY-+hFSjgOqDhfpWgvX6H}kt#D)$JDp#Cb2^XQe`oR66(`hl|C%w} zdU0Zw42y-f$m<(2N&*L8+<0cS?N_Gr{|VdIt@E3FxhzcT;&myD`Tj@exA5q4%UZKU ztNpUf-d1x!!u!2=&`0a7;ZxN(%A9zzoi?73V(55(OR#p)zAeAj1&iGZ&7D0x)Xhg` z!kT;+r*c(3zLWcx*xag8TfFenzdtGKbysbp*HyzjKMZF%+Ke>RRn^(*#ig-YD-|I*4FFKN`t zzg8n_$NAX6yOwv48eQx7G`3{(2ai_GJ9%Dx_VlUY{+Y`X+@u0M_(Xn0G4D5(_@uXV zZ7P?{n$0)&+g#;eTV!^3v#nTVU3tp;(j^7wZZf1O%IKL-+3x$~jAU5eqm_Lrt|!@B zx}9r|A1$)~aNyrL`<;=$mDim7q<$>NHgl8fG0BP3AGzM!c0ito&1U|#X_^HMhEeO* zJ(szx^*86in`&u`-}Qc9r1U$Boi~0fZBn^skv#R5Av>cU*T;$7PKGa%y1cJlpK2}l zsB5kAiyUpL!Gcs;o?9O{&;YPuV4T+9dW%Xt+_U~mVvU{izT|9Y0&2iR_ z=PL!BLj}Y*PagSuDB(esc=%W4YbljohSTc`{@gY?*74qh(LK82K_$zYP-`*84|ds` z{>?LtR$7?x`)YDSCtLK@wJZU4qGC#neF_`jKd}~@Y#717!QD4U?&<3tDhw~TNP6F! z7a#L&OZV3I;nDf6H+^Ph&sz7bmHA)J&dlE&9Hol;9-AMOxU*r^vX4o}QvL==>Xa&T z1?_q8^usmN%7h)f45veRN()ZR*pc75^I^T#^QmmQVyAyB);^ZOD!AJ9_icSkWzcaXLSJCr=*qxuX z4`w`BsUW{olrb!IsiNTP_)P9Q)s@bh=WJfNSMcdVvx6DG*EUW0p~tl9Ylx%SUGI0F zUd@i>7JDC}$rgIY?6=tVv;S5m=md%Pl-=p`&|ADVbCm#7ID7I$_q%icnx5@B!T;^~ zS3Xs{y~H7P$O4by;Y&}I_IPcXEpn|f=iC1hZ?pk96jB1@kDsbl#0cQ@*(S2 zDKqRje)Gne8AaDygdaYW$z>?;){wd>@I+a$a)Cguudt#AbNcdB7sIKC4mz*QQYi46 zuCbxR<()h4w8~}w9yu+xnBj58w@0=_g>Ts`A@(3%h7BR-5`+J1pHNd-dph?Ucf{na zzTFi!Jqj0IbYO5Z{&r1j=GpCGHLe!EW3y1OQuExu)b;gmXE5%cak77kzyt5EqJ4L_l%{Cy5z)VU zbo)PjrnF6*Pd`WfQFi;XcmLm>8wLM2XPhYc=y1mQL-j2sBT+la?Z0j@=_@D5=IW}m zm)D)>*mv!~&OZfzSXnd{scYpHY!LYO==R5N_0y*tM{`aPwAtfm&n|F$wt=GDtCEf0 zMQIu50-q=^EWguIpp~op^1uwQe}BxC+rE|SJq>0)&zN8~`1${N@SVCgd@ER zGk#m^%}x%pdu1*5)8j=pZw2GRdE2IyB=$^;4xb_2TB!W4YukFp7Pec9LXz?fu6=y{ zDd~0Onu}E(+a$Jf9J}S!yz!jx3|BQ5n=cK!Qx?WZu>Z8KE`RoM<&WN*GY(8N<5=Fh zXyVs3mw%Ps3c8X|eEVDfSJmJ1og0j9MO{(}%hUV0Zu71;g3nGY^ZD2JcjI~H1(UZK zZJn5HC&&4pFl_LrusMRR_v_J8U9?R5VaFHKK{mI<-Rm6iM0(mv{`7&r)Q z2p7;wP7dM~NMJDJ=lJrp_sRR!u@O6`nnwRSGh>asmiP(w1DfIUQng(trRD~_+G8j2 z_Sf9*wN*1rCAMG4IcgUCBjwJH#mj$7x=5+1#6DGf?Y!pZCu#H7FMgdkedmf)(G&Sf znd#ml42t2cNfTE)wY*6bn#LY$c6_l{iS~s_d@maIZ*!G%WYPW38EjGV;-J;>N%uFT zJuW+TLStHvj`T)t_OlfdN0vHiZ9FK>cjuVI+SeAzC!I@0`&e=#UtSQC`ys{jGradu zuYz3Fg@+06A`^@ZBxX)1xM&ffX>@6x3(|IM3UCS6OJ z8+4mT_lDT(8QZ;I{4r%Z6KpEbI6cPf_jm2*k@sz)KCV#v>8&+Q=9Sk{voFgY?W_y2 zVY4|b&bKGl`u@L}ZLQw~a!uVQ&Y$(BZ)^3_iRoHxl1aVSww^WZ z9v4zK|IfBvUh8&whvO45fv>9E`k;2h%(PqAH3UBW`ZH;heL?Hd=u>B>38ZVu?mu?x z;^k*NU(5cmaK^3JQT0_*FzAu|-o*WX_RM;<;b_8|zd0*}@alT*nN_*AeucBAy zf85Hp?nyB7eMa7>E{!uXjzaYxkC@$m6?LvIm^uCavaJl3;tC?&cgmM|&$!fc@-p9@ zqulS4e7;0Ys(&%%HYL4VLKWo=z5B6Vw_ceR4M>U4EwJdS3UMH+DOP5+=I-T+XLA+xc8e zrTc_O@!Ut1u78+RG55{^hmQ`g0w!1(l&t;l@Z{CIg+JtY1t&t=(U;$KW_{_ zH<@pev`E}H=3^N-c=H;2WwiD}B*gi0% zlpQW(Iq}-JbNi2|`_^|K=ls%14og3g==jHwR zRWy0_X8Co;pWYT{YBc`-?sY_A0bilwgG)Vkp1XXhxXqNm%i_0b-noC_OQ+wzeED0X zL+a)=k&{z%H*eN_tR|JxBG`ZYtktgn|4aUEnO$sC`z3v0MzyM=O|gIeZAs9a~?L zlHX>ux~F1_*3W%y&9-_Q{yTUad;fjg{$lkBmhnss&0-&aPT7%IT#Ggl~3!Yw_uu z-~1^Re?Gp|y}m9l;%myT`NBe+&g)g`+)w^e+{_#A^Emg)%&%@XUY|8j$3I+JG1>gh ze@3RdxaRkHCIW|r?}hC@8hrVSgOxl7$K3xs4X3Aw1hR&%>f0XLHOEj%=G__jy+u2p zN9C{nAE~H%%G{zg>$phJBin_4=jyIw?e@Q=k)?3;H}BHbniCz4sr``i_LKam@oBMc zzQqUsCXwmuA6|Kv6DaZ~_xH~1gO7yOCb1+>EN1vnlIlD;?pV%Ot=-G4o-7eetWii# z?p}Yko^yUrK9j@NYRh@sm9ND`pSLXY{ni$sWAubbkga@rizMLxx) zmrvg_`~SRU$&V$siYK#b{>$9C@|eqd)?`6T#h;hC+H1}qy;Z;N!>m;kFS||o7-IhS z-tAZacTErq)t&mnw=4dQ|F`@Z87qGH?R%TX&^$@JedW_Pi&o#@(POZlH^*E_W?$N# z?=!#Gn94tCzVtGzo_%AP^!>`o$78<5AOBZTUA|y?N9O}V|TU~$ZxbLx+a%fFT7xP;_oSTJ&2Ik(+@ z$(5?8h0-56K4o-hzY1T=@1~sJmh^UiaHdK0JcZ8h)zL?-pTrq798heU^z-MEO)b%< zBX#60|E62*66`;myLtb#`SxY+w*8%(fAVOsR_jdu6{l8oZa=r}`o3j{cCA`0UC18eYv=8rrRe6Brw_G+1TO>U!o(=OZeG^guNPuuEW6f+bqQC`NFy{$}tvxVPX z(foQo!71KRp&U#p+_}8QuI5Rzbk?tu+IUv~=Nx^R59v{vt7jVTag09fbFtTg^~twm z+}yh1W&GXi`}}vD5?Hn1Rp~9Mw644(nnm9(WU3GalxT zObRyk$yZH|)^|JNBVqh4eAyC}*K#jq`}2-uDl8AbTKvt_ao=OzwOY*Ti}#$E^!0lC z0_nnEJ97HUw|_0On`Q2{ZrRFa)9B6rehHK)pX&+U>AF4t^~*g$c})JEhuGJg{G_+^ zM%Ni`i|pV z*K6ksy%xnWYm(1@JJ)x$jj6)tEz_FEk()MXWpB>yb2neav?lK2pC`h%_hd7Ch~Yh~ z7E!#i&gIHagQsezpT%h|oGHk8V0O<%qgA0cTQBym5Lk2aSJwI^cWpe6g&1jX%k5hv z9jNH05HB~&V%b$+?(A8zzuVnG>U!@eW%{q#%FVF##2Lx7o@?7pIu=Xr5}exNAim(2 zW>U+9{hQKWJ8pfP(V6F>>e!Ph*rvN}TZW*!cZsqVmt@GU{SRFXT(8-$l5+o;TZWHos!KD0jDmOX>W%5Ob!9cji34f2G%v{ccB>Q;4m;c+Z2wdb8(- z8?)(_C~I*lw!Z2Kk&EB+|FQS%Yb&={l!62oc`lvG868r^o4v`;Vot5u0_i|Ujt1A> z$BM~Y*5t3tob&0!f*dFFMNJBfZXfSTGrixHb9KAco~&aFr2_*w7Jso@)V+Cqs;{bY zm)@+#7j7XOab8cDlUg^ep0xe0*;DpO=}Nl%RZ)@$ALlP}yt_*~Op zoD{7xO%<&Vs5sl>YQCsQV{Yl=^t0C#Td$vqC{fmO(YTcu{>d)&O2Q1$ucuxvk`B}? zIJ54FY%%lZAF@ZqPBWD#Yq1{8axKr}c;W6ccjZN^3tHEWZ;Qql1tn_UTm7I)vOmv- zwPO9^YF3Gac}d(toA#~Q;PZ6KrZZ0Fib81*uSAY~7Y;b-H_vzShQoZ40FX8K)Iq?SJ35 z^)&OrwZ@U{hLLrLM5EJdmz{Na;TF>HH0Ek!z?Rn+ZDb;?@0xNnnu%xMU$JJ>8%Og+ zPCK55|EmbEcgedEY0=v+yl%x8?agbe4~JanT_M0b^;_*<_Q$U)JpcR)vA0eUs=xCl z=iJHc!;4?Ig$PVv`zmi=>Fwo=56+a>H(PxB!YVp1f7NQ8&lh`FDD-UnlCXL6N1=R+ z4?;{PbKd-2tn$9`^QzhVPKixDGplX(Wmoe>hc>8b?>}|($H#Ex=42M36Ni#4o*p#?NlsM<-=BDPAFjN#- zrC7N*x|LQYB^npyWm_4U>10`%8(A8d8CY0Ym>CNx`}zU=gW<; z*Ol|8>2JGqrNDC0is%U^^_DqCy%6Zq4>71&@aW+7A54Pm{B3!O>aHJ5n<07c zM(h9Y*6Dxj_5L5PsC#a=zxuckhZBR&{}G-FJ3ITIVe8VRswFQHZpF^@Ve9Mb>+gSBWVvI<4u;c( zwKHa{HxMk}`XD^ZctS^&0ps0By^4mn-wr6cZEk@vsSJl|MU!@%6Q}tAB3p3j3Lq%ODik-rUU0 z!os4Hvb#27FK<#(l7f;F)4KTm=Pu{&(mY`OE_vSK%(GY73=jH9o%j8!r?Ybg`_jbf zhpX4`dvsiDs#f9gKG~JJKc?OiQCY;7o}NB)`SRxyckb^fR6aA$_O`;_#~6rgfuB$_BSC=G#37 zYSr2TtCk1Kot?H}#@0#P=1UKLd(xbco!z}Wucf7BjYaLRlFjdK$0-O(?%TJI`DUAW zyl>}=FFyCv?#llb*VntlvU-0EpL09k(csWEaX%hDJly`??Des?96}Q>E_Uxv+@Y(Z z)1n=|PGZT)wYmor1J0-2wXgqeC~-BN!)LzTT>130w153qdHJVJnPT#K-&;;0!HT~V z(u32_&wF~f^4E>8MU2|Z`JOpTy>bi=V`Safu9$e8@t#-O6GKk%fQdOBPh5|DObrhY z*L!qvv3u{Ojmo=M3MpB}zi&GE_8c$(diCFw|D>0)?AW>U;Y#UlG2L07wQb8L?&(NA-d8&__ldY(OoMefkNeFv zu~tpF{pWmly`L(2`@!1h?uW0e4E`9J3;j99_8B_<|#M6 zzdyG)SL*cL!_ytAjl{#|-aEMd{HasFSfft{_3q2#m?RdsFyO(3d)zznSJk~RSm3_+ z{mRM!#&D;{ids4{yUO49E#I=n!Y|?cr*B`suKixMHL7-Us4$ma{JuLc(zmCo=qgPX z@GUeKf01B+VC%*2XMU!c|ERL%o~-V_&Qf)@VX_;~Z<8-#q8m4E3{E>Y=jY@pMFF1O zFP=aD&U5>Qh=@pnNKfFd+W*HoBdSd0yC(S^-u30NX>FVDK{W$|55*!ISDI&ZdxW{f z9^uv3K0n7YS>#2+%M9+acd^nn_PJZd*0riTq*k)-`T1-%+vjL0rfaIxb)(rPtNA`! z-(DQ2;XZ|7M)J>>%P!8fsl23dH*tb=#EmG^J;t-QX11;Qwcz6WMyC1mg?Z~Ge+ljR z{ciW4n53jf+qb$`+_Ih|I??jQ5i=VNf%b1%hm|);Ph6SUmzJ1*`Nj7Wy?$-;o|bJW zdmB~9e=WjNR!&Y%ap@g_iEBFEy?bXd>yUEZhKzN-$HnGICG<^N6J3xVETzH!_ta{x zo<%ce%s9}~!*lX%?zA->|Ni~6QQEy?1;?h%n|Uv#Oe=~r*{&9$694ymS;Sr=nKs_X z2koPl21%ZA*`siDg^Xv{iu)65-`%l%+#$VPFX8UZg`xKq^NYD`?w@5|<~#dXzp=4# zvcO8WryfB^{{8(OZ7le|<+mDh;@;m|Uwy9kwq#GWpWAdI{&L0MfTcl8UEJKxd3ktb zTz%fvqvBfg@u;}%&9?JxMyiXFo-~A2m%ZG2yWnlN?gs5d_GhOmw5)Y>-pu{_`nvb( znK9g+T_>W>SgcWBy<$Z}@Nz#%wNnw!M^}fhwh0gZC!nWygXRCM?Hke>sEg0y zA1<6-E974!XgxYM@oAe8_s)wyp9O7rEpnpo`N3nt>IY7p;_`pm+TO0O#wo98DVWV9 zTX26$ho6P%0>y>#y%LAC&+liyet61<)6**U{ZL;#$#lZ<3sV~JPn^^7_*n1Zf6lXC zy~?sEc`+gI*V$fnhpk@=?wTjgt6DpgPgcr6tar6_Oyn-nFg@PZ>v7e*{+TlxHaj~z z2Y>qfdG;6CTgsD!1l{#*Y;67%$af#PC42M4;rm@T_a802cl_8^;TvDeGA|exE0rI9 zvApfjn#j!`0%p&gxwGu!qoaXA-R5ifJv%#;#nx;+y`E zuKMNlg%OopVQDs5Ggn`ICA_9^lYwdIrqc?uT!WN0-4QUadBgYn!?iV$!S78cPMlcq z@{($Eao5DZulKH7w1H!jU4Qcn(|G6i_iEH0*s3Ki2wFMi&8iKhikgCC^lBM9pU5C~j>zD7Jd-qQ`YmZ9`)2=-q zdv0dSr-(az`&41EL0$6Kx2M9NPe=Ow_%=;9Iz;!wt+!I1T^W2b|2}{J{+v7dam%V( z6L0TGSRT7xZ<+qJtcSO}RCRto@~(JiDVNyBC^~;rRU+G0@oW5@E83kFPT-ffo724` z{k+_P1st_=;y)Ti>pZ?x95*di(ZXVe{ARa6o724emnrQ`oX}Cl!pdsPyX&F-(xA*K z_l}-F{iM{Q*QDH6D96)x^-Q-aS!q{JPR=doe3=ymOJCIeJ2U_7sls0lMMaw&t@@T( zRM@4K%w6_IF=obp+nRj}EbEU4i~1beZ!|GeW~bnU4y#?IuhR~`YOk?N4+;{Re|nMs zb#pff*QX)jnGYT<@-=w*bff9Q*jYDj+~_}a=ulta-VKElI;@zOm^N7UWM>}cxBrtc z<=$17Y|HAid>?9FumlDM?(sNx;>3@6o;xP0`uM8xB7d}GX5L8EI&^@ z_(jvEMDA4C{PwfQ`Ba3*GV$@d zneAr%Vf2|}A;@pICEGN6flAQCJ0%`24;E|g$XdgCF8|){^7W3hN~ibL{9z zwP4>rhOj?7n&t&6~IW@xOhwzk8O<{O|p-RkwYK>k$R^Ye{Z`iMO}q9(#HITl*t!^M_$q z>^yw!4m@`;mOFCv=-wU8ScLWb1j1>TerX)Ze+?zq~0eHT7Z0 z1&>)=w?27}$ zL|1Ch%}0-m+4<#6yxyl?=2Y6>xh8VC+tH#!E7wG^K`e((1zl_N_Z+l6RN-M(`5vab`?bgWqs`dF;~Jj?Ovg|E^b92)jDoXCw? zuBN7zreAt>i{2#Bi6=#0KYwoSx-ejmN!l5S1O0MGEiEnYx3#scOZa-+Y0`$T6W4V7 z{{CJ+_2_5q;^*gN&8xn=NT{x^elvgPsiFh#A3u6zrmm(Y7G!dEvhyaTM-$|-8MAMw zUp#zR`Qp{9q34PYX6bBPsrTc_#5Eni680mdKI;Vst0AtWzdjJ3c diff --git a/android/app/src/main/res/drawable-hdpi/splash.png b/android/app/src/main/res/drawable-hdpi/splash.png index 783f21f942a0696467101b692b11202d1e49d7ef..5cf6791bc339850367769ac7500bdc37253bb15b 100644 GIT binary patch literal 5158 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 literal 19535 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 Ofx*+&&t;ucLK6U3tSaLG diff --git a/android/app/src/main/res/drawable-mdpi/branding.png b/android/app/src/main/res/drawable-mdpi/branding.png new file mode 100644 index 0000000000000000000000000000000000000000..2229786efcf11b90872723e11f4b2893cc34562a GIT binary patch literal 3466 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 N!PC{xWt~$(699VDncM&X literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png index 502c34804bef939821c148dc89fe695d4da054de..8ca718ad02eee9999b9f2543d0eaaae269ea0a9e 100644 GIT binary patch literal 4609 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 aQ9a{agUZjzhd4lEIt-q!elF{r5}E)TkoLg< literal 2568 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 FO#pFM)9L^K diff --git a/android/app/src/main/res/drawable-mdpi/splash.png b/android/app/src/main/res/drawable-mdpi/splash.png index e1c91a4c363e3e06c1fa386329dff8cc99b8df8c..2229786efcf11b90872723e11f4b2893cc34562a 100644 GIT binary patch literal 3466 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 N!PC{xWt~$(699VDncM&X literal 10610 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 + + + diff --git a/android/app/src/main/res/drawable-xhdpi/branding.png b/android/app/src/main/res/drawable-xhdpi/branding.png new file mode 100644 index 0000000000000000000000000000000000000000..10503d27be9ecc9624a2116837bddf7e384a5ca7 GIT binary patch literal 6693 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 diff --git a/android/app/src/main/res/drawable-xhdpi/splash.png b/android/app/src/main/res/drawable-xhdpi/splash.png index 69ae71bce99e6a6f5ea669e586daa32dc2c25126..10503d27be9ecc9624a2116837bddf7e384a5ca7 100644 GIT binary patch literal 6693 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 I&MBb@02|`xNdN!< diff --git a/android/app/src/main/res/drawable-xxhdpi/branding.png b/android/app/src/main/res/drawable-xxhdpi/branding.png new file mode 100644 index 0000000000000000000000000000000000000000..d2c9a45c17d4734e49c5ef5f16a840046d694892 GIT binary patch literal 9880 zcmeAS@N?(olHy`uVBq!ia0y~yV0g&D!0?lUje&vTqFGQ60|SF(iEBhjaDG}zd16s2 zLwR|*US?i)adKios$PCk`s{Z$QVa}gR-P`7Ar*7p-sP?de)`w`!~5?lU))yfUQk;Z zGigfqr6s;AFI(=MBKG0Z*kbU_2kcgWnP61t#KabiqD<=T(|d?x8m8)E8i}+w0v%P zezCgJgB|-qe%Q%1C;qimI?(Dc!I>dlae^enWJZN~9a5|7~tl2Q=Mx9-rIq!m7dXT%gmK;mhOxzoNaz|7?DKUqD^xfD6NpkMXm&>)nmp{dZMU zQwE4Ocb+UX(5u&9G$aYup< z(=!GKIfjV+U-ybJEO?%pHzC!U)y07E!PTl^;*MrTw-!acE*vGWL|D(jKT|5kN zDY7oSQ@)*j{Hy(`-1YN<9|9RJY~(98QfZi8%C_}^YKY*=z42$iJy^{Vam>owzA1HQ zYj$LN0C$|z)DK&8ygqK(YPXs>po&pZ+4Aed#mj4V{QD@h>cFK{qR%^4{Pt)!X)B%f zPvGIDMIVFbCCqj9t>6@0w{2-;k7?!d?uEPZGEU3qOtsZYJ))-(}A07Hh*bnIJJCP@9wK-xGhxn zv~WG)G*NfpxSOS+z@&Tc#g8}3j5MCG$|%J-v3}Z?QyJ24FtfDpUy90dyC9RgQ%(&^ z*8)R&rw1fzW!>3-QTfgpEBAVi2j|_nPR!Mdb7C#Lwq_3#N8NEN_xdH@8&dlY#cw>f zP;^fV*TcLOyF@zT`d{SoUUc|!PUW!W=buM^oOq}j(s1dQ zlXORA&yV-NL`+z8`?ETO#Az${{~IplZOKcR>y%x=Dalo~O?{{I>Ur!>{ZG7eT=?#P z;#|k>3QozQ+=y!7S6^3YC@{Ryw=VZHeJv1m#QTK6tFPgk&lzxZo|c||{JgCFcZ1go zQHQ-xIGmqzVcN2E28H(0X^sMA<;T1$f7Uh$9`n}Nyla=$n%7+W*f}moq)X~F7U<|F zKKUqgs>e;ux4GdL*Nx^Qf7d8y2l571ppIaEZ>d(S}+3RcO1$i`Vj(9GirSc=Z@8YJ`Qogm1RHrzmGl_0GR}eMf z&l=?nN&UnrmCgS=84S8h`z|L+%-c4vluh@sYL8KTcfBD4Px{=)B}T!&kRw>6R^S zE#*1;NVTUae1B1|j99x?#E#4am%xxeZ6;aAyd_u*4}aie5{P)-AtAW`xK-1%k3w!Y zCYriD47+Bj#FaQ{bK21>m+nN)-`Kavbx(`nG^ICF<4l7hpLbXYy<=jCJEZYIwZ}vx zKj4?wH|G-1%CD!gPkl<$OO$oVuHaN&Q5NlN!*DKnuCkZFgn1v?w(D$_J|>qW>*ig- zsqDF4dim4;%Q+^rBvmZ>^1H26O!wj&ejaX*jO4kBx1zq4_-vh7!KvIi-Jt1J`L&x2 zlOm3HteV??Yx#zA3rzR42&%LR9nbna=S<FRF=5&ccE;pqYZL`Ny2Jm@Ead}fWe|F>PQp^i zmMQVu{nz%E?>C*?ygy0SmAit|fAadJnIhk0x{gh0IUH-1{`H*h^C!Jhw~t!&-1;aq z&#YvF-+_*=pC`QDWD^l^(5eF@xa^n1P!+%$SOb17r_xs=6EH#48C4x2mw z&5M+|p1l>E{)adHI`zch)w-0q&5|u9f)B3mb2wjT*gosTUslcyf((BgY~>w43gqm0 z)pUsA^$M?J-W5{2b{~mXaxi)=!!=Q1twDRq8LeWD1>P+iE^jX4W!yW%`pgCuYinz> zb&q+N^dAb8l$1PZpS}8>V?%XhdLW}LgRg7D%B>3zd|#ORBlPCWv^t~b5_?*lH++5Y z?xo8If!8vtoFig(*9Kp@e`$J5mHi$4O?`_+_Ov)Z;LY2ov}_%>o5;U}ptD!l6~yaK zTXhC~5aPQO|9|Hv_FpW!+DiFMl@HzDA+>s&NbmelOs{pk4tQH|JT+Z6^A}5v_?9gj z^rdgyy7{rBHQhD2Gdb+cdB>-!G7EOe`hIly!KI&YQ( zlQ(Yp8(uEomvH&fNy*6lr%uX<2OH;CaIz~*sd!+<;FCPp`EdzL!Q}N@KgzuK`~OF~ zm}}ccJ{9q}Lvg@%c7~ z&*c%%A3WeusyO&EGLnnQHfC1vi!@n{Nfn&_i>jVa+wqn0zx+h`b}d$>3pxUc(=I>$ zcPDSl9u^Myn>^Fru4$gD7k6m#jSXu*ioX2I`C3MB1N($`Y)y9=EZlEB;$h%f<2-j6 z_g=>Tt(RQ+^1VOvF72DCzlpEJs)F84YJUG$Qi>MTF&sOc}=kk+uHLbFZyQp78U+G z%HH(**6BO5SA{b*w8n1h=u}>!u2!||?n|$ARVOMe{;_VoyZMB7MbFZ!?{+F>WoX(T zInTY|LA3pZwkccAHdJs5gfbs6Fng$!my?}-{889~+1Xi9;?tg<`_oanpz)FFo6x|w zyXFYQTNeqhGUKr3|Kb0weCbZ(*K*k7}c4&#|?%{yk-OW!my|+kor0BG?+#eg>Uneb3h`|MR|I|8Bv+ znCZDEelaoZGvYXWJv}9517G=^8F~wgW1nhV=)6?DOmfX;7uLq_A@#avFMio-wzt{g zzizySWa4?1?iwY<`{Ez&Yy1tAUHJLT-q&Jhz*Sv^`_UZEyS<^G9n|f(8KUM2zw-C@ zxqa(?^P6LZr)wJ1a;I(JZwa}zQUBj>zy9BsFLGvfFPl5}JL78~!^lI)_x@*of9)QT z<>UMI`;@eT#bF z!@|2x9sXHw|K9IpX_(4fIj7{?mZm%X!iP3r^k3?6-~EE<-l*>{Q^V8GCwy8Z5v*Eb z8e;3wuyMnI3&-|+xyY=w@W~sK*W6#<&OYaQWWJmEw4|f_ule>r{j$Seq)hh5h6jJR z=e9lB!nc#Hf2z=;%a8y4XDDEQtx%-UbX+~n?~UAx$Fga~Clbx>KlrjE-TBQrIRSOq zMP161^-9gd^jR45bkA!3X_sNn-q%uES{iX%w1QD!eezsawM80y)zuvH-95gjDgIO9 znt3Hnr{hCj>+!#p+b$OM-p#lva`vD}?4h>1yt`hlo9q8FX?eq?(+N*Qe_oDi`?S(n z-KX-%-H1{-hB%3z39pPSx0y>8gYUF}UE&4v^)fA;&Tl)nvGv84{0iOEx;0Do+$!Z$`sCWzFYe!9-sHFE z-pj)OhNeG1Fi+g^V*O5`Ur}aDuWUEjufrjc&3Z?B(U0QYCvQn~s%y`gP+uGU{N44} zuV2eP>QH(4;R=^SCPT%SOMNB0K~djTU87naoXB-GbpBPpVZ&nMe`U*$$IiC1$=#`F z`L(w3#O%45vL%|Pp@)jrpYBTh<05e3lvVecT^>gcX)iw$CB9PKL~VIn&&ieZZ}f(} zym#1s?yHUKuZQ)CxdiSymezmeht@<6hUeSg=1;eiQ)pnmw{6+{X;02cU3qtOmgYk9 z2mZ$m*0DACT#J0-u0PFJvnPU)-8*XD1?h~a7u+WM&ucXcDL4pC{1CM>{ppo}=@tL4 zJ{Nr}b>eyEqLzCOlW$6Y*x%N$G2}oV!zvzGp?iBZtowgzr6{*A*LVMP-@Ai9g2X3#`XEesc)^Xs;}7B;@AILiXo<@;p3Xka~?1-tYK|QQ7HIw zXSv4%*Coq2T5VY-bSd>Zv9L2nE>Zo*ppeOMUVHEJA55%2N;LGu$M?Ef{5PIk%+R^^xAnWcyY~xYa}z#4O|5KQ?f1@)BL%SO6ms)LjIcuVUpXO(W??R2*M_cr7@<-Yk%szYk?%n_k#s7`h&V4g~<96Ef zxw4nGPx|uN*O?o>Je;IjI;F>|_{dr7?{DgynOE;}^@_<|kf|wfX8TN?`&Lzfv7EKK zXJ_79P`{RsW$B*(zfDwH>J04D&;6ThdtkzxTbUkyf4%NSyg9SYB20Jn|BRv)**B^g z3K(D8cr`Q#?{WMuzR%(E9^vgx!Lgj#&A$E%iq17$yU8;xX!psYOH9XBJvu4=Hf~Se z-JX=h^T#_Dw0@0Nb`%hteuZsz*i(r$Yu*0`h^q3GZBt)+W6L|Y#t%^}XHw^~Glbpl znZ_>s$Z4vuTZG8|2?E;Y&z_gFe=q&Bp|t*Y|6a!bB`iC(d_PooW|Cg=74}*3=L{qE z@67cPRV^_M(-(BuF{iXIZvE4qIU5viTc{ne>X4EM3i>V;6g2%t?=;nY4#lg?Z+wwf z+QG?QrWP@|Bf-)2?Wv=xmd4fhS$oU2_HX7}6lS(~ z=ixMy|DU#&u9m<0a@SJs-<4~O4VzTHt;x6Mof38ERxauXUrL zn<10uwa(sqUYzoKR%O)8OIm++WuTi^RCH!s;7jlL`gxNIK3quVu>D?h?2<~sN!xqV zmZ^(|I;BtD@?U1k_n1#%ZCbU5*FEYE-O#l;D%Sj^Q>NGTucxoA{SoV-FT!~6s_kq4 zqg*XA&s2HdWuO1C< z_3n(V(hj%$FK#N!{OYy5V@ufT$%mq?Z-1UK<%0MJ|1WH_;$2!+>KwBdW%@A9lFM|P zXIItP)3YNVf7!9LT5A7!EAJ46nMy%U`Q7y*TvJ?>UI@1E%|5y3_lx_UtLI+qSi<9U zVtxKin^>1XD_z^thH{=0x$h&>=TzEiyztdj*JWn^xZ>t}jy|~{&iIvUd^)^^*Ru&~3{7{Z*s_P3CVM$%m6 zu1jp8PWcONzTh_bt7j+b)u`}OWa%U?OG6>fr%f#9r&x8d?a49~uf4RbRN;{Gnqrrk z7xFoyzi&Oh{oVT=S8iW2VHeO3KX&vphjNCM`r$v?*9><&-YU@7ynAN7Ommx=E!WNN(^?J)?KCi!Y)%oT7J-3^?H(lPGoYtLb`6Z@{AwtZX z_086Av-Klp9hUU6`N7EWZ1dSAH7o_qn~&X{IyrA&w#Mms#m9?HL;mu({O~@%X~M?l zh6nzdYF}!D5A~Z@)-7Xyz$zob$dR|2_g8y*dQ@_0`R$Guwk)-7uWwJE-@zcKds=&s zbYJbu>&%(Od2uF+dLOp_kl!{_NAPBg>a{7?cbs=_Fi%?Bz_2AT;_$mxTW=?M-G`S} ziB4}+m~-4>rqn|78(%ukru^i5t&sINZ^gV`mi)}0W=ri_I2L^33I5U7E&G3`d+7c( zb&+#4dTR8()k(W@#f1ev{uS$)H!(;|DQwTY-7ooN;+iCaS--TjTuA*~{OpJSAMV;6 z+Hb2jmRKrDb$qZ}etfC9WyyCj2gd!^D!4A`|2?v9Vm?z-XX!LW1(qDOhPNIf3U77_ zpSWY~zia*GU5N}a%r5Vr&(NtqX%_YCj)a@Pf=)->w}^)Qc`G^7fAjeE3%|O4gEyIf zqu&yfgSBtl0MLJ| z2`n6(w&BwmtqgVEY?y2MZi=;$V}7-M&#mc#*&O;`*=8+e&Ex;g)cfCmf8llcuAje5 z?iZMb-EHpB(om>Xd-dX(_S?;Sm@BuOH42#U(;g?Y~am@jS|W(0L)Ja(+SB z?zm0TXZycycb|K0YPG$$^O2uxioYyo6p%Z2(qy03^B#?rE$vBfc&5%d#`viD-h*e+ zdk#M9&v9FQ+Q8=Rdr8fehM^~xN5|<&NXqTzV7MHS{_?P%LebJ0E&HR&Qumxs`nN$& zU{+(ok~_@DXYer0dC&dhdG*2n_Jhc`=a4OISxB!yI?mnjtRn-IPZO$HMM-|({nLrto*lBZa0f*tlE2tOGD_;>u)}% zeZTcA(z}-U9J+SqN=Al(a(0o-{J}L-KSN>{Eu_R_50_n zjvMZJ?DXNyk&D;b-@0v1dZ&0be%|-^QosJ_T@m%{uMIY~P3YRId)EHTwjcRAr6RhC zbJZuseGFN4toa37z^Shee|Qd69QHq_d*guB$<8W0gErTP`c{sAb}IGX-m1^X5dV$s zPxM>2?%Aa?JUZE{q#UBIL<;^3d^O{z^HROko56=xFB37bS{-|up-}F%gCSQf2=G$#fn*3ayp+xny%%wObrLOg7w>{Gj%VKkmqGrEiQmmm2LUg@2(M_sk-RXraelk zf0SNaH&@*o_^||81pB#p}_pu zsas0wjtr0YHlF3b)f>5e<2fS{QB&^qNU%Kn+>U`|emmdeS2mj~og)sMFET!(TWkB_)aU2#{(g_X{Cm&AW#4Ob&t5vV zpg5`c@d;&t2WiWXr*4p3>wmL%+SN6md8U|Oc%mS5FR?VQkzpIxZoWUq<`sY2^rmEU z*0c>a>st11X8qU3vN}8Z?|S~kpCA1gs^+>p{Mn|tde!^(-$xJDY`UB9_fp=5e;GG# zc_n8=++b~(%x<|ajDsQK4fl_3+s0?CpJk2X6J=M}&vPg~^vmIM`z5olJxVP_J=5fG z$xO>~d(0m>zx&k!^K(2Po$;$v|ANlhEhl6?31>FQy|~ylOVm_#=Vp<=hnF&MY)E9}Z({hI z_8V1L!U{^);hx7AzLpD~MCchWFw z+a?JcA+{}hV%{gd+INrt=9hIT+wSCT(V2elL-(S0c`jOg2Vc*;q?OVd%~$H9y}s;z z+_dl?b0+!ioPK@H9uC3h1`<{6XD!|p?Y_9^mhs62%S`2}`Hy=|tGctki=lsI?)Bv! zFD#3DKKSfBUfX%s`q){`^wIz10^=HP1Y%|+=Z~9()y(P#YpqTS_P||GWy_VDM z?yXp}=E{4E<|0RdZdQSH3JSXWyk2o8Of-M6ezUZ0{0ZBNzuVTf7WuDrSYVywc3Q2{ zy;sk7>W2tcR#wH#&DZbsx0YJZIwvByiLtQg@w>K*?Yq8QGCgt9!9eQ6iD&v+s%Ncr z*&Ajr5soP0jkehX5M9HxU~AHc-iBR z`d!|(?>--9_}q}dCRbU%c$;kC@t;ip_+K&kc6-aXUYu1{A@hEl?sw)> z9)9#;T1H>tA{pmt!RJ0cIpST>wCRqd9K!@P|NGB!Gp(crmS1C?Rm4$X#xUosReXI7 z<9{WEUAOW!)k*a}=PxOH(SCGcNXL^5n|k>}N?qG;^osTSJU-bs{e#FGwbdo!-`HM$ z*eaxD7@+*GV?{b^)5|rR=N^iP->`F&-i!TPj|6_1?qOefd*9tlR$DhK|J|eR-o5V3 z+^rt_K0Z0_ZQ)>h`u6j0ISHSpZ9Z1ZFYsqx>9SzO1L`%p|3u9uKYi_Cm;ZI$+RIO^ z6ulVkGIe(S`!B<}r^Pv7R`iek`^5fBms-Dm)^^x)zeB2@(1ojPv*UQUA06Gdp?o&SqX)UUzQJU*;cEf>+(Y_@~%mj>ycCzqfO5{n;6lyzz*4#jH1K*#*wU zADRjt9ZctV5OuBcSHk7)s!TWEn=frnZZgdlQsk`He7@zQWWa{leHU*$RPAYa#H}|W z?`716MH^qu&z&pVuU)`Z(PY!Skd~se2c@>=x$R*P`-U>Z;+!SO)z>ma|g! zsS`J!+RDZfvu?}miP7K7ZMNi`ou#^WzX4n7^?zp8N>g0aZ=O`lta$g{u!2*0O^H`v zZspeB)g1wYF)JyN(z1L*>hlvMk=9xdxpI9#^zxKR`-|wJriINXgdnPUVSgAej z>W*S(mP==*>RhX=;&k}XekOixUU6g>3#a}iffQU7T!{lqC#Z+6d$ znZ0UV_TB3rcLn7c@x8nMSJpZr?CW=n<>{#_P4>sm3%UPey6xSnHDLG5qnyUG*8a{ zx$g4gX?$)n*SMT-vJ(S5yw>wve0 z@#@MybEUVLdTn=lYS3qNR{YC)=f-n(h07mYzqZ!w<(Go3J(0Kg%Q>dska;%4Lb^&R z&S~b;Ua8xrK5yndySpzkO72nFvG;<)dv)u=W3&HqPrkf)KU>oT^B40MUcMO0pmBR) zM#q=>z1tG1e8dDQIB#x9O?kUDHRbNhFFSk~B2Jm{_K6?%tpC8;rxV|G{7!{vKgveV`iB(KBVsw%*Fs={}!) zs@oa#(&oCeu}jIUzGpL6cJ9`u=>d-K+J7INE@ZFoZBwx$@RjOoo4{w&_r+aPoYF8o zAaUL7l81KT$yamN&Xx7ceedD3>Q20!R0XH#ii|%~%;#KPXV~x~{aWOrqiI_{34ME{ z8nWWjo<;6+<+d|3tV^1!>NP8pJ%B6DiPc*D>aGR*_GYrk-Bk2SK5^QrG3bMk){MhB zd@qHL+^%VS);*=bxPYbfjZt1^$jx)bfrq_U6qGIAx>9e$PeG<#?WNN=el1FGmR-lL zzmTiO{A$ttS$lh}9YvOC#u>dAl4s?3x<*-pQ-2}XjI(JMd7d4gBX|8f!-t}4m425K z|27`kznO2L%AOXjB;%;*znJPmrwcItGo7;Z$JU5{8Ozcz>+7xU=+s%fX_r&PSei ze`}o{a8z%$O}cMu`cvVonU~r+_O#SKPz|xJ{a!3^Nk8Nu?}51|*$tnGm%ifGUwCTj zjb}NfVY|ba7v0(Yl8;L;)Z_B|i01)HaZbD(vSMK`ggq{*y2x5ZrUz)nIq??EIQzQ) zo$A5>#l3Mqx=J~gK2p8H!1Qsx`?AzidnTWD5Zn`Mvva}ZQXY^*gG}d%v@sp@P#?MdaH1%bZ?20~@2f zK5<>!=(mtJxwGzrP*I>m`|V>WKFWMWurpY0bi>w|K5amQ7YYpwsO2Q7DT;vB%MEeT&)TuX4LDZkl=a#j9i9 zD-<=7*fwN7EOU^pm$z`)(=tn`FL}eY;Kg?nW@a1r*s6R#5OvUdg(^p65x=L>v>QIt z+;$$nDir7B>$v$0bDr*Oqsiy_ZGHE&NIhJ$b_;{UsyWF*588|RmWS?Xk#cmYEZQ%< zMf&XgQ#MP~J_=>EFc|b5th~tfv;B%(oKx?FIL=hLqWWVWLiYVFW%(eK)qY1cCeOpu zTH*7ISx3BA$Uc4|%lA{yTkw$g3dViYo(VEcPV0lyzX?;a@&C-z6xmnwq&}#zF5Dj4cxs!`WGx z;tp{&7o2!a;RZir2d_)G6QyK%7v+pwLa_(5N zIJ@}#Cb^zR5{BK=1DIvpK40JG5aJ*8>(m=p--|K|(>@AiIn4NSsmWXRV$=)G+KN}f z7c1i4ae(vIF_!D>Qgz%M{%#Ln&i3VeS+jbx#v|1$9fzx1o(l&ZoiBXg2Zvp52gkaT za>i;R2cJLjJK()S@Vc0Y%qP}A|DW=j{1s+U-e`UK-P~hm?f0GCkr2ub;0-Y}yee&5G6U2jT^_p51uQtl?{wOh*;Z(VZe zan%e4alQ|i&2IHx>&~soTk)oX(=_Ls%5hukZ*MBM@5?O?wD7qss36WDR247V%pzc- uvOFwJW%<6#s%mStN*cRfo0)v^KVwf$B(LLX0}}=Y1_n=8KbLh*2~7Zi+1WV& literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png index 1a9b6e6b84a0e9bbc6ddaada138ead16acf067aa..749680b1bbce4b518a63101c2828b63fc679030e 100644 GIT binary patch literal 18867 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 AY5)KL diff --git a/android/app/src/main/res/drawable-xxhdpi/splash.png b/android/app/src/main/res/drawable-xxhdpi/splash.png index 2c74b8b6e6391e9f0f17a8596df5fbf18fa285a2..d2c9a45c17d4734e49c5ef5f16a840046d694892 100644 GIT binary patch literal 9880 zcmeAS@N?(olHy`uVBq!ia0y~yV0g&D!0?lUje&vTqFGQ60|SF(iEBhjaDG}zd16s2 zLwR|*US?i)adKios$PCk`s{Z$QVa}gR-P`7Ar*7p-sP?de)`w`!~5?lU))yfUQk;Z zGigfqr6s;AFI(=MBKG0Z*kbU_2kcgWnP61t#KabiqD<=T(|d?x8m8)E8i}+w0v%P zezCgJgB|-qe%Q%1C;qimI?(Dc!I>dlae^enWJZN~9a5|7~tl2Q=Mx9-rIq!m7dXT%gmK;mhOxzoNaz|7?DKUqD^xfD6NpkMXm&>)nmp{dZMU zQwE4Ocb+UX(5u&9G$aYup< z(=!GKIfjV+U-ybJEO?%pHzC!U)y07E!PTl^;*MrTw-!acE*vGWL|D(jKT|5kN zDY7oSQ@)*j{Hy(`-1YN<9|9RJY~(98QfZi8%C_}^YKY*=z42$iJy^{Vam>owzA1HQ zYj$LN0C$|z)DK&8ygqK(YPXs>po&pZ+4Aed#mj4V{QD@h>cFK{qR%^4{Pt)!X)B%f zPvGIDMIVFbCCqj9t>6@0w{2-;k7?!d?uEPZGEU3qOtsZYJ))-(}A07Hh*bnIJJCP@9wK-xGhxn zv~WG)G*NfpxSOS+z@&Tc#g8}3j5MCG$|%J-v3}Z?QyJ24FtfDpUy90dyC9RgQ%(&^ z*8)R&rw1fzW!>3-QTfgpEBAVi2j|_nPR!Mdb7C#Lwq_3#N8NEN_xdH@8&dlY#cw>f zP;^fV*TcLOyF@zT`d{SoUUc|!PUW!W=buM^oOq}j(s1dQ zlXORA&yV-NL`+z8`?ETO#Az${{~IplZOKcR>y%x=Dalo~O?{{I>Ur!>{ZG7eT=?#P z;#|k>3QozQ+=y!7S6^3YC@{Ryw=VZHeJv1m#QTK6tFPgk&lzxZo|c||{JgCFcZ1go zQHQ-xIGmqzVcN2E28H(0X^sMA<;T1$f7Uh$9`n}Nyla=$n%7+W*f}moq)X~F7U<|F zKKUqgs>e;ux4GdL*Nx^Qf7d8y2l571ppIaEZ>d(S}+3RcO1$i`Vj(9GirSc=Z@8YJ`Qogm1RHrzmGl_0GR}eMf z&l=?nN&UnrmCgS=84S8h`z|L+%-c4vluh@sYL8KTcfBD4Px{=)B}T!&kRw>6R^S zE#*1;NVTUae1B1|j99x?#E#4am%xxeZ6;aAyd_u*4}aie5{P)-AtAW`xK-1%k3w!Y zCYriD47+Bj#FaQ{bK21>m+nN)-`Kavbx(`nG^ICF<4l7hpLbXYy<=jCJEZYIwZ}vx zKj4?wH|G-1%CD!gPkl<$OO$oVuHaN&Q5NlN!*DKnuCkZFgn1v?w(D$_J|>qW>*ig- zsqDF4dim4;%Q+^rBvmZ>^1H26O!wj&ejaX*jO4kBx1zq4_-vh7!KvIi-Jt1J`L&x2 zlOm3HteV??Yx#zA3rzR42&%LR9nbna=S<FRF=5&ccE;pqYZL`Ny2Jm@Ead}fWe|F>PQp^i zmMQVu{nz%E?>C*?ygy0SmAit|fAadJnIhk0x{gh0IUH-1{`H*h^C!Jhw~t!&-1;aq z&#YvF-+_*=pC`QDWD^l^(5eF@xa^n1P!+%$SOb17r_xs=6EH#48C4x2mw z&5M+|p1l>E{)adHI`zch)w-0q&5|u9f)B3mb2wjT*gosTUslcyf((BgY~>w43gqm0 z)pUsA^$M?J-W5{2b{~mXaxi)=!!=Q1twDRq8LeWD1>P+iE^jX4W!yW%`pgCuYinz> zb&q+N^dAb8l$1PZpS}8>V?%XhdLW}LgRg7D%B>3zd|#ORBlPCWv^t~b5_?*lH++5Y z?xo8If!8vtoFig(*9Kp@e`$J5mHi$4O?`_+_Ov)Z;LY2ov}_%>o5;U}ptD!l6~yaK zTXhC~5aPQO|9|Hv_FpW!+DiFMl@HzDA+>s&NbmelOs{pk4tQH|JT+Z6^A}5v_?9gj z^rdgyy7{rBHQhD2Gdb+cdB>-!G7EOe`hIly!KI&YQ( zlQ(Yp8(uEomvH&fNy*6lr%uX<2OH;CaIz~*sd!+<;FCPp`EdzL!Q}N@KgzuK`~OF~ zm}}ccJ{9q}Lvg@%c7~ z&*c%%A3WeusyO&EGLnnQHfC1vi!@n{Nfn&_i>jVa+wqn0zx+h`b}d$>3pxUc(=I>$ zcPDSl9u^Myn>^Fru4$gD7k6m#jSXu*ioX2I`C3MB1N($`Y)y9=EZlEB;$h%f<2-j6 z_g=>Tt(RQ+^1VOvF72DCzlpEJs)F84YJUG$Qi>MTF&sOc}=kk+uHLbFZyQp78U+G z%HH(**6BO5SA{b*w8n1h=u}>!u2!||?n|$ARVOMe{;_VoyZMB7MbFZ!?{+F>WoX(T zInTY|LA3pZwkccAHdJs5gfbs6Fng$!my?}-{889~+1Xi9;?tg<`_oanpz)FFo6x|w zyXFYQTNeqhGUKr3|Kb0weCbZ(*K*k7}c4&#|?%{yk-OW!my|+kor0BG?+#eg>Uneb3h`|MR|I|8Bv+ znCZDEelaoZGvYXWJv}9517G=^8F~wgW1nhV=)6?DOmfX;7uLq_A@#avFMio-wzt{g zzizySWa4?1?iwY<`{Ez&Yy1tAUHJLT-q&Jhz*Sv^`_UZEyS<^G9n|f(8KUM2zw-C@ zxqa(?^P6LZr)wJ1a;I(JZwa}zQUBj>zy9BsFLGvfFPl5}JL78~!^lI)_x@*of9)QT z<>UMI`;@eT#bF z!@|2x9sXHw|K9IpX_(4fIj7{?mZm%X!iP3r^k3?6-~EE<-l*>{Q^V8GCwy8Z5v*Eb z8e;3wuyMnI3&-|+xyY=w@W~sK*W6#<&OYaQWWJmEw4|f_ule>r{j$Seq)hh5h6jJR z=e9lB!nc#Hf2z=;%a8y4XDDEQtx%-UbX+~n?~UAx$Fga~Clbx>KlrjE-TBQrIRSOq zMP161^-9gd^jR45bkA!3X_sNn-q%uES{iX%w1QD!eezsawM80y)zuvH-95gjDgIO9 znt3Hnr{hCj>+!#p+b$OM-p#lva`vD}?4h>1yt`hlo9q8FX?eq?(+N*Qe_oDi`?S(n z-KX-%-H1{-hB%3z39pPSx0y>8gYUF}UE&4v^)fA;&Tl)nvGv84{0iOEx;0Do+$!Z$`sCWzFYe!9-sHFE z-pj)OhNeG1Fi+g^V*O5`Ur}aDuWUEjufrjc&3Z?B(U0QYCvQn~s%y`gP+uGU{N44} zuV2eP>QH(4;R=^SCPT%SOMNB0K~djTU87naoXB-GbpBPpVZ&nMe`U*$$IiC1$=#`F z`L(w3#O%45vL%|Pp@)jrpYBTh<05e3lvVecT^>gcX)iw$CB9PKL~VIn&&ieZZ}f(} zym#1s?yHUKuZQ)CxdiSymezmeht@<6hUeSg=1;eiQ)pnmw{6+{X;02cU3qtOmgYk9 z2mZ$m*0DACT#J0-u0PFJvnPU)-8*XD1?h~a7u+WM&ucXcDL4pC{1CM>{ppo}=@tL4 zJ{Nr}b>eyEqLzCOlW$6Y*x%N$G2}oV!zvzGp?iBZtowgzr6{*A*LVMP-@Ai9g2X3#`XEesc)^Xs;}7B;@AILiXo<@;p3Xka~?1-tYK|QQ7HIw zXSv4%*Coq2T5VY-bSd>Zv9L2nE>Zo*ppeOMUVHEJA55%2N;LGu$M?Ef{5PIk%+R^^xAnWcyY~xYa}z#4O|5KQ?f1@)BL%SO6ms)LjIcuVUpXO(W??R2*M_cr7@<-Yk%szYk?%n_k#s7`h&V4g~<96Ef zxw4nGPx|uN*O?o>Je;IjI;F>|_{dr7?{DgynOE;}^@_<|kf|wfX8TN?`&Lzfv7EKK zXJ_79P`{RsW$B*(zfDwH>J04D&;6ThdtkzxTbUkyf4%NSyg9SYB20Jn|BRv)**B^g z3K(D8cr`Q#?{WMuzR%(E9^vgx!Lgj#&A$E%iq17$yU8;xX!psYOH9XBJvu4=Hf~Se z-JX=h^T#_Dw0@0Nb`%hteuZsz*i(r$Yu*0`h^q3GZBt)+W6L|Y#t%^}XHw^~Glbpl znZ_>s$Z4vuTZG8|2?E;Y&z_gFe=q&Bp|t*Y|6a!bB`iC(d_PooW|Cg=74}*3=L{qE z@67cPRV^_M(-(BuF{iXIZvE4qIU5viTc{ne>X4EM3i>V;6g2%t?=;nY4#lg?Z+wwf z+QG?QrWP@|Bf-)2?Wv=xmd4fhS$oU2_HX7}6lS(~ z=ixMy|DU#&u9m<0a@SJs-<4~O4VzTHt;x6Mof38ERxauXUrL zn<10uwa(sqUYzoKR%O)8OIm++WuTi^RCH!s;7jlL`gxNIK3quVu>D?h?2<~sN!xqV zmZ^(|I;BtD@?U1k_n1#%ZCbU5*FEYE-O#l;D%Sj^Q>NGTucxoA{SoV-FT!~6s_kq4 zqg*XA&s2HdWuO1C< z_3n(V(hj%$FK#N!{OYy5V@ufT$%mq?Z-1UK<%0MJ|1WH_;$2!+>KwBdW%@A9lFM|P zXIItP)3YNVf7!9LT5A7!EAJ46nMy%U`Q7y*TvJ?>UI@1E%|5y3_lx_UtLI+qSi<9U zVtxKin^>1XD_z^thH{=0x$h&>=TzEiyztdj*JWn^xZ>t}jy|~{&iIvUd^)^^*Ru&~3{7{Z*s_P3CVM$%m6 zu1jp8PWcONzTh_bt7j+b)u`}OWa%U?OG6>fr%f#9r&x8d?a49~uf4RbRN;{Gnqrrk z7xFoyzi&Oh{oVT=S8iW2VHeO3KX&vphjNCM`r$v?*9><&-YU@7ynAN7Ommx=E!WNN(^?J)?KCi!Y)%oT7J-3^?H(lPGoYtLb`6Z@{AwtZX z_086Av-Klp9hUU6`N7EWZ1dSAH7o_qn~&X{IyrA&w#Mms#m9?HL;mu({O~@%X~M?l zh6nzdYF}!D5A~Z@)-7Xyz$zob$dR|2_g8y*dQ@_0`R$Guwk)-7uWwJE-@zcKds=&s zbYJbu>&%(Od2uF+dLOp_kl!{_NAPBg>a{7?cbs=_Fi%?Bz_2AT;_$mxTW=?M-G`S} ziB4}+m~-4>rqn|78(%ukru^i5t&sINZ^gV`mi)}0W=ri_I2L^33I5U7E&G3`d+7c( zb&+#4dTR8()k(W@#f1ev{uS$)H!(;|DQwTY-7ooN;+iCaS--TjTuA*~{OpJSAMV;6 z+Hb2jmRKrDb$qZ}etfC9WyyCj2gd!^D!4A`|2?v9Vm?z-XX!LW1(qDOhPNIf3U77_ zpSWY~zia*GU5N}a%r5Vr&(NtqX%_YCj)a@Pf=)->w}^)Qc`G^7fAjeE3%|O4gEyIf zqu&yfgSBtl0MLJ| z2`n6(w&BwmtqgVEY?y2MZi=;$V}7-M&#mc#*&O;`*=8+e&Ex;g)cfCmf8llcuAje5 z?iZMb-EHpB(om>Xd-dX(_S?;Sm@BuOH42#U(;g?Y~am@jS|W(0L)Ja(+SB z?zm0TXZycycb|K0YPG$$^O2uxioYyo6p%Z2(qy03^B#?rE$vBfc&5%d#`viD-h*e+ zdk#M9&v9FQ+Q8=Rdr8fehM^~xN5|<&NXqTzV7MHS{_?P%LebJ0E&HR&Qumxs`nN$& zU{+(ok~_@DXYer0dC&dhdG*2n_Jhc`=a4OISxB!yI?mnjtRn-IPZO$HMM-|({nLrto*lBZa0f*tlE2tOGD_;>u)}% zeZTcA(z}-U9J+SqN=Al(a(0o-{J}L-KSN>{Eu_R_50_n zjvMZJ?DXNyk&D;b-@0v1dZ&0be%|-^QosJ_T@m%{uMIY~P3YRId)EHTwjcRAr6RhC zbJZuseGFN4toa37z^Shee|Qd69QHq_d*guB$<8W0gErTP`c{sAb}IGX-m1^X5dV$s zPxM>2?%Aa?JUZE{q#UBIL<;^3d^O{z^HROko56=xFB37bS{-|up-}F%gCSQf2=G$#fn*3ayp+xny%%wObrLOg7w>{Gj%VKkmqGrEiQmmm2LUg@2(M_sk-RXraelk zf0SNaH&@*o_^||81pB#p}_pu zsas0wjtr0YHlF3b)f>5e<2fS{QB&^qNU%Kn+>U`|emmdeS2mj~og)sMFET!(TWkB_)aU2#{(g_X{Cm&AW#4Ob&t5vV zpg5`c@d;&t2WiWXr*4p3>wmL%+SN6md8U|Oc%mS5FR?VQkzpIxZoWUq<`sY2^rmEU z*0c>a>st11X8qU3vN}8Z?|S~kpCA1gs^+>p{Mn|tde!^(-$xJDY`UB9_fp=5e;GG# zc_n8=++b~(%x<|ajDsQK4fl_3+s0?CpJk2X6J=M}&vPg~^vmIM`z5olJxVP_J=5fG z$xO>~d(0m>zx&k!^K(2Po$;$v|ANlhEhl6?31>FQy|~ylOVm_#=Vp<=hnF&MY)E9}Z({hI z_8V1L!U{^);hx7AzLpD~MCchWFw z+a?JcA+{}hV%{gd+INrt=9hIT+wSCT(V2elL-(S0c`jOg2Vc*;q?OVd%~$H9y}s;z z+_dl?b0+!ioPK@H9uC3h1`<{6XD!|p?Y_9^mhs62%S`2}`Hy=|tGctki=lsI?)Bv! zFD#3DKKSfBUfX%s`q){`^wIz10^=HP1Y%|+=Z~9()y(P#YpqTS_P||GWy_VDM z?yXp}=E{4E<|0RdZdQSH3JSXWyk2o8Of-M6ezUZ0{0ZBNzuVTf7WuDrSYVywc3Q2{ zy;sk7>W2tcR#wH#&DZbsx0YJZIwvByiLtQg@w>K*?Yq8QGCgt9!9eQ6iD&v+s%Ncr z*&Ajr5soP0jkehX5M9HxU~AHc-iBR z`d!|(?>--9_}q}dCRbU%c$;kC@t;ip_+K&kc6-aXUYu1{A@hEl?sw)> z9)9#;T1H>tA{pmt!RJ0cIpST>wCRqd9K!@P|NGB!Gp(crmS1C?Rm4$X#xUosReXI7 z<9{WEUAOW!)k*a}=PxOH(SCGcNXL^5n|k>}N?qG;^osTSJU-bs{e#FGwbdo!-`HM$ z*eaxD7@+*GV?{b^)5|rR=N^iP->`F&-i!TPj|6_1?qOefd*9tlR$DhK|J|eR-o5V3 z+^rt_K0Z0_ZQ)>h`u6j0ISHSpZ9Z1ZFYsqx>9SzO1L`%p|3u9uKYi_Cm;ZI$+RIO^ z6ulVkGIe(S`!B<}r^Pv7R`iek`^5fBms-Dm)^^x)zeB2@(1ojPv*UQUA06Gdp?o&SqX)UUzQJU*;cEf>+(Y_@~%mj>ycCzqfO5{n;6lyzz*4#jH1K*#*wU zADRjt9ZctV5OuBcSHk7)s!TWEn=frnZZgdlQsk`He7@zQWWa{leHU*$RPAYa#H}|W z?`716MH^qu&z&pVuU)`Z(PY!Skd~se2c@>=x$R*P`-U>Z;+!SO)z>ma|g! zsS`J!+RDZfvu?}miP7K7ZMNi`ou#^WzX4n7^?zp8N>g0aZ=O`lta$g{u!2*0O^H`v zZspeB)g1wYF)JyN(z1L*>hlvMk=9xdxpI9#^zxKR`-|wJriINXgdnPUVSgAej z>W*S(mP==*>RhX=;&k}XekOixUU6g>3#a}iffQU7T!{lqC#Z+6d$ znZ0UV_TB3rcLn7c@x8nMSJpZr?CW=n<>{#_P4>sm3%UPey6xSnHDLG5qnyUG*8a{ zx$g4gX?$)n*SMT-vJ(S5yw>wve0 z@#@MybEUVLdTn=lYS3qNR{YC)=f-n(h07mYzqZ!w<(Go3J(0Kg%Q>dska;%4Lb^&R z&S~b;Ua8xrK5yndySpzkO72nFvG;<)dv)u=W3&HqPrkf)KU>oT^B40MUcMO0pmBR) zM#q=>z1tG1e8dDQIB#x9O?kUDHRbNhFFSk~B2Jm{_K6?%tpC8;rxV|G{7!{vKgveV`iB(KBVsw%*Fs={}!) zs@oa#(&oCeu}jIUzGpL6cJ9`u=>d-K+J7INE@ZFoZBwx$@RjOoo4{w&_r+aPoYF8o zAaUL7l81KT$yamN&Xx7ceedD3>Q20!R0XH#ii|%~%;#KPXV~x~{aWOrqiI_{34ME{ z8nWWjo<;6+<+d|3tV^1!>NP8pJ%B6DiPc*D>aGR*_GYrk-Bk2SK5^QrG3bMk){MhB zd@qHL+^%VS);*=bxPYbfjZt1^$jx)bfrq_U6qGIAx>9e$PeG<#?WNN=el1FGmR-lL zzmTiO{A$ttS$lh}9YvOC#u>dAl4s?3x<*-pQ-2}XjI(JMd7d4gBX|8f!-t}4m425K z|27`kznO2L%AOXjB;%;*znJPmrwcItGo7;Z$JU5{8Ozcz>+7xU=+s%fX_r&PSei ze`}o{a8z%$O}cMu`cvVonU~r+_O#SKPz|xJ{a!3^Nk8Nu?}51|*$tnGm%ifGUwCTj zjb}NfVY|ba7v0(Yl8;L;)Z_B|i01)HaZbD(vSMK`ggq{*y2x5ZrUz)nIq??EIQzQ) zo$A5>#l3Mqx=J~gK2p8H!1Qsx`?AzidnTWD5Zn`Mvva}ZQXY^*gG}d%v@sp@P#?MdaH1%bZ?20~@2f zK5<>!=(mtJxwGzrP*I>m`|V>WKFWMWurpY0bi>w|K5amQ7YYpwsO2Q7DT;vB%MEeT&)TuX4LDZkl=a#j9i9 zD-<=7*fwN7EOU^pm$z`)(=tn`FL}eY;Kg?nW@a1r*s6R#5OvUdg(^p65x=L>v>QIt z+;$$nDir7B>$v$0bDr*Oqsiy_ZGHE&NIhJ$b_;{UsyWF*588|RmWS?Xk#cmYEZQ%< zMf&XgQ#MP~J_=>EFc|b5th~tfv;B%(oKx?FIL=hLqWWVWLiYVFW%(eK)qY1cCeOpu zTH*7ISx3BA$Uc4|%lA{yTkw$g3dViYo(VEcPV0lyzX?;a@&C-z6xmnwq&}#zF5Dj4cxs!`WGx z;tp{&7o2!a;RZir2d_)G6QyK%7v+pwLa_(5N zIJ@}#Cb^zR5{BK=1DIvpK40JG5aJ*8>(m=p--|K|(>@AiIn4NSsmWXRV$=)G+KN}f z7c1i4ae(vIF_!D>Qgz%M{%#Ln&i3VeS+jbx#v|1$9fzx1o(l&ZoiBXg2Zvp52gkaT za>i;R2cJLjJK()S@Vc0Y%qP}A|DW=j{1s+U-e`UK-P~hm?f0GCkr2ub;0-Y}yee&5G6U2jT^_p51uQtl?{wOh*;Z(VZe zan%e4alQ|i&2IHx>&~soTk)oX(=_Ls%5hukZ*MBM@5?O?wD7qss36WDR247V%pzc- uvOFwJW%<6#s%mStN*cRfo0)v^KVwf$B(LLX0}}=Y1_n=8KbLh*2~7Zi+1WV& literal 42345 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 W*%UAFt(*)D3=E#GelF{r5}E+%G*hJj diff --git a/android/app/src/main/res/drawable-xxxhdpi/branding.png b/android/app/src/main/res/drawable-xxxhdpi/branding.png new file mode 100644 index 0000000000000000000000000000000000000000..e78b0540ce35f227f0f868911ce186c89aa54019 GIT binary patch literal 12774 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%0DX3HV`NM~TNQuiH6}8U_ES7yb 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 ma1koUfO2+0*HNco-NM7(8A5T-G@yGywpA{u_}1 diff --git a/android/app/src/main/res/drawable-xxxhdpi/splash.png b/android/app/src/main/res/drawable-xxxhdpi/splash.png index a14ee37dc0962e161e29581c664f8d0cbb2fd973..e78b0540ce35f227f0f868911ce186c89aa54019 100644 GIT binary patch literal 12774 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

+H3=uYJ#5`*-urHDAC>-&v5m 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

0a8^|z9*J*lUsy}Zl75TU}v*I;?Ie($`k^^5PX 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 diff --git a/android/app/src/main/res/drawable/background.png b/android/app/src/main/res/drawable/background.png index bcfb0677d3ff468adc2f5b17010ae6d273108254..4768b847912a74338256be4bc92fa656e80b6437 100644 GIT binary patch delta 32 ncmZ>DouDN>Cpl4M3M&Ic7=wucBhwEC1_lOCS3j3^P6DouDPnn4Bmwg_VIJjKM^yY2RuF1_lOCS3j3^P6 + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/launcher_icon.xml b/android/app/src/main/res/mipmap-anydpi-v26/launcher_icon.xml index 5f349f7..c79c58a 100644 --- a/android/app/src/main/res/mipmap-anydpi-v26/launcher_icon.xml +++ b/android/app/src/main/res/mipmap-anydpi-v26/launcher_icon.xml @@ -1,5 +1,9 @@ - + + + diff --git a/android/app/src/main/res/mipmap-hdpi/launcher_icon.png b/android/app/src/main/res/mipmap-hdpi/launcher_icon.png index 534cec8c93602ee1d6d122d26638c3f4505bb903..7419cd0634555b8c1c268357adb274a34365be6c 100644 GIT binary patch literal 3143 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 delta 1603 zcmX>uafN4sO1({qYeb22er|4RUI{}*kyVP7i=$g0#(s zg&0=R5IJ#vl@qc{U4l1c%rbM$SkT158}#qM1cxmb7(}<$M;Qiqeem+=@r)Lo?WN$- z#puMz;jXa6GK=*R!zHDVJwb8P4-}T)zS(=>@zX0NXMP?3Gs*Ya?YX@1bvI}JOye^Q zi0hc~o+Ykyg#u5A0Gk#EvuH~r*C7Yi#05SSVnkLBdkqLVX?(=P=D2N&n&=EkxzF?Jk{vFZsmKc>jC7+(S3QSUd`}*}|Q*-m*ZSC!cFWZ(SY(LD};vv+1G=*)YOH&k6LO`^R zQ1kB#On3fnyX0H1^iWQqS=>=;s@KmqH;wPVc#)wO7Jq_0`sXy>->;=hK17y?1qTLB zv@|dd(NPhB}*1Ae5fw;bCZCmh>*~x{G6OKg(W3Zx@G^~x&9?_ztBOe!+sIB*^f?N zCtX}z+`l-fulvaCi)jCPUGMVQFNZB8G_-cn#f&J=r#q#Rnxdkkt-HFrA72c3 ze9kmPYpVRDvRPA^GxTo#TK2x^{?7Zy9z52~Dkvye;pUpO^5ThTMPI)OaqH8$xVg2b zKf1j=zkcE6mpirXK#$Cv9hwd@svB|;lDe7 zPqN8QahaRe{`l#oOP9p#ilTY-o9?+@xpIZWM@`s5tGwv=+O~zuC*+88+^hb+O?93{ zVdMJ8y;naJCA4MDTI|i9wy8kXpgxUz$97+bThD*~+OcEDkB=V&D*UWrd^x9tXo`PZvE=j(yDVWcPr>=sHr{Md+eCo?ABJ+4fmDV zo&O8}cs1AL#mkpFyRw$3UHbVzp-%1#Bhyvqn4IT|&69)bzpbt+cyVFj=bCHQ(vBe) z>fhbnJy(>=HKn%JR$TtBB*)9;m*&Y?m+6#MRQ$NQnfVFp$%_{zsi~+qgoKFv{(U2P z%Xu#s=e#*7a{A1Hj=H+Ke_2^s+mn-%CvT0)&Gzy-Ro>j(%&k&f5!u1zG&@v7ODii) ve^UMT{|A2W-@oS-Mu})cR7u%Z&#;c;-oiD%w}~?_Ffe$!`njxgN@xNA5v}gf diff --git a/android/app/src/main/res/mipmap-mdpi/launcher_icon.png b/android/app/src/main/res/mipmap-mdpi/launcher_icon.png index 5e5bc83e24767bea5f7f2df3ec04f841c57654f9..d8aef7345f6e4b8f70dd77c0cce13e9e06dba9a1 100644 GIT binary patch delta 2121 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 J);T3K0RXah65s#; literal 2182 zcmeAS@N?(olHy`uVBq!ia0y~yU`POA4mJh`hDS5XEf^RWY)V`sN}Tg^b5rw57%Gaa zQmkAY-AXHy5{--UvaO8Fbh50>jVuk!3@j`x%#4jp3@t1zjm&gPtc;8e4U9|-%s+h$ zVq#$6VD)ry45?szJB!;VI9#IPe~<)!q~p>@2iT?Vu&~T&ikfMlmX@LRNZG183HR zHVPybJ2mw6@mW|~``_A8__$1Oy7kAcmzBOuSSeG!usx(uL4f0kudnZ;M~@!;*s)_r zy_?d#gARKb%FD|?H#9UXSn56f*OjYRUrwDijj8)6li6(ETesF9N`~Gkvaomp{mJk!jAHInNg7 z|77v^_uosXI)85kK|+O%!kJaK91 z)iRH7_{v_m^Zfei1NT2_Z`iV>W@p-2sroGylc)cwRtP(AsFhpyqVV<&8v-nkZQTB% zeBKgYmN{$KHyCR=wLM`?PfN3t*`{F1u#qX?SLB78H&1G5YQB`aki>XA&1?VXtaHyC zE-jqTaP^<-!i5XfZ{NDrW*PKVA&enwPeu8=JB{M{aVPq}s?TmX7jd}!T;|Td5_{CF z4$ZMFwhFxbjd%X}={+uMUO6mbU}k3CuzmaUxz^==md7@3u6Qo9?A>&=J53X~B`3_E zUvHzSxw2-);VT?2Tn^pp$8X%Yv1-8r2g_^m|JL`OZoXf}YJ4?V_*KRFh4B+!zh3V+ z^}_z6M~^-|d-kkUf8`1RO$OZqd@o)$$1&XtWZY_7#kMZ?*@I`#j>$;Pw=7ostE)e? zwY9a9m6i4H%99mW8$B3(n8V`Z|F2>+cHzCTWxIL8d87Yrdybz?*!=0U`Mh+k#VZdq zZrNfYrj>6m)i8ENtH^Y>q=xRB*A zr(qu38AClikpH$yeENM=UHp*acVC0#V?Am0_5Vv(7zt%6`1Cr?VQ^(Gdwh)d=?w>| z4dxe5)pK#b;z|6x@a(1+FJ9z&XvG@`X7N2@G2oryp;EP$akkBi?-RF_H$C~T)Zy`m zJK(AC&ieoVKC8s>EBP^8ZU_wt;aSC4Exc9T=;5P@@e39&UVP^I_44rK z2ACv#{P^+I#)V7fw|x=1@p_L~+WC2Z4;??A9$Q&ixts0q6%7~87nujvgz6tKoqne| z=zef*ob}P;yvIJ;y)?e}?So{^sr1J9E%SA>v|dGDnG(vEeId(f3d5BDvKJ@!Wiek1 z{2lY}1#6V}v50+j`Em|gubR%C$S(L5qqQKv*2lxcgJ-XVImeEecM;3Jxr=shVh;$2 zjpdEro;UUX*_o!SB|F_}pV@bwX!_jVXFOd-EAZsb#p~APxjYJEU&OLx*)p}orV1QO z6}gW}yyCvG!sKpCo#?3u-3wjr@y!p%8x7JHhm|@wG~CZP@q3kk?t!ys&vLD0H=aCs z^3-HCt(VIU|C{B^-q~`~{>s6a=R1p@dU5wzTy4x?l#`RwZgEo7wzrS3Ta%r|(-wAj zx~kQyR)f6@IKLfZH>#|cKlNMHB3V}J2v@?cM@PHG*Pl6Yf??u{-%QUc#HAA!$~kCF zb2xo6#-r1(>G${d@jP5yT@t>P@uEqrA1Z8$kKCCuMday>lj3)uru#lg?cm~Jjg7tA zuJQi2UDcP48@2X-9oH~Ssk6Ct`}WP7H*bDQ&h4|4I6G7Ce#ukYlbkWJvDVGa&HK-t zIwh3E7pmJKa^Q}r^Zfnkd3kzUwv?=2{a7sW^!MsnPp=(rJQD14&t5=IF7C$pXhHso zOl#Jx>Cx5EiQ$vCTO%X=K8N|d{}YFxC#(*&j#VBWC(bW6eZSgi2}6GVd(n)CS3{rF zZ)?=sGTpXqMy>n73l}zUnWjtstogU%?C(_q>J6Uu@7}$OtE{YCu%mO)(Q8e$^D7O_ zv*Q+3)tCDDo%>vOFYSuM90p@!<852FeqD6tjL(HjmzqLXhcQOw96Wcp?N7-teK+i ZPq=(aRB73neGCi?44$rjF6*2UngAPH@S6Yt diff --git a/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png index 70eee0b25dd15bccebc0186a461e2080f8cf20c3..51b6a6b7ff98b38deed2cb6b3561cb7729a57920 100644 GIT binary patch literal 6116 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 Bn>qjh diff --git a/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png index 8a4d17c0d8bbc717c9899733172e43a3663ffe19..b012600bf79ae7a9d8c1c70b337caad93528aa3d 100644 GIT binary patch literal 8080 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 literal 4785 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 diff --git a/android/app/src/main/res/values-night-v31/styles.xml b/android/app/src/main/res/values-night-v31/styles.xml index 7cc5bc8..a3653cb 100644 --- a/android/app/src/main/res/values-night-v31/styles.xml +++ b/android/app/src/main/res/values-night-v31/styles.xml @@ -2,7 +2,6 @@