From e94e4158ccf8ae6dfcad39ac812f482c97dc10d5 Mon Sep 17 00:00:00 2001 From: randogoth Date: Sat, 26 Sep 2026 22:45:38 +0300 Subject: [PATCH] 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); }); }