diff --git a/.bumpversion.cfg b/.bumpversion.cfg new file mode 100644 index 0000000..84b9f17 --- /dev/null +++ b/.bumpversion.cfg @@ -0,0 +1,20 @@ +[bumpversion] +current_version = 0.4.8+0 +commit = False +tag = False +parse = (?P\d+)\.(?P\d+)\.(?P\d+)\+(?P\d+) +serialize = + {major}.{minor}.{patch}+{buildcode} + +[bumpversion:file:pubspec.yaml] +search = version: {current_version} +replace = version: {new_version} + +[bumpversion:part:major] +first_value = 0 + +[bumpversion:part:minor] +first_value = 0 + +[bumpversion:part:patch] +first_value = 0 diff --git a/.gitignore b/.gitignore index dc159e4..79e7a9c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,9 +5,11 @@ *.swp .DS_Store .atom/ +.build/ .buildlog/ .history .svn/ +.swiftpm/ migrate_working_dir/ # IntelliJ related @@ -51,8 +53,13 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release +/android/key.properties # WASM /web/wasm/ -android/key.properties \ No newline at end of file +# Flatpak +flatpak/build-dir/ +flatpak/repo/ +flatpak/.flatpak-builder/ +*.flatpak diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 36ac2e6..a29048e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -3,37 +3,122 @@ stages: - build -# - test -.macos_saas_runners: - tags: - - saas-macos-medium-m1 - image: macos-12-xcode-14 - before_script: - - echo "started by ${GITLAB_USER_NAME}" +#.macos_saas_runners: +# tags: +# - saas-macos-medium-m1 +# image: macos-12-xcode-14 +# before_script: +# - echo "started by ${GITLAB_USER_NAME}" -build: - extends: - - .macos_saas_runners +#build_macos: +# extends: +# - .macos_saas_runners +# stage: build +# script: +# - echo "place holder for build" +# - sudo softwareupdate --install-rosetta --agree-to-license +# - git clone https://gitlab.com/veilid/veilid.git ../veilid + #- curl –proto ‘=https’ –tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + #- source "$HOME/.cargo/env" + #- brew install capnp cmake wabt llvm protobuf openjdk@17 jq cocoapods + #- cargo install wasm-bindgen-cli wasm-pack cargo-edit +# - wget https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_arm64_3.13.5-stable.zip +# - unzip flutter_macos_arm64_3.13.5-stable.zip && export PATH="$PATH:`pwd`/flutter/bin" +# - flutter upgrade +# - yes | flutter doctor --android-licenses +# - flutter config --enable-macos-desktop --enable-ios +# - flutter config --no-analytics +# - dart --disable-analytics +# - flutter doctor -v + #- flutter build ipa + #- flutter build appbundle +# only: +# - schedules + +build_linux_amd64_bundle: stage: build + tags: + - saas-linux-medium-amd64 + image: ghcr.io/cirruslabs/flutter:3.29.2 script: - - echo "place holder for build" - - sudo softwareupdate --install-rosetta --agree-to-license + - apt-get update + - apt-get install -y --no-install-recommends cmake ninja-build clang build-essential pkg-config libgtk-3-dev liblzma-dev lcov rustup + - rustup toolchain install 1.81 --profile minimal --no-self-update + - flutter config --enable-linux-desktop - git clone https://gitlab.com/veilid/veilid.git ../veilid - - curl –proto ‘=https’ –tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y - - source "$HOME/.cargo/env" - - brew install capnp cmake wabt llvm protobuf openjdk@17 jq - - cargo install wasm-bindgen-cli wasm-pack cargo-edit - - sudo gem install cocoapods - - wget https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_arm64_3.13.5-stable.zip - - unzip flutter_macos_arm64_3.13.5-stable.zip && export PATH="$PATH:`pwd`/flutter/bin" - #- yes | flutter doctor --android-licenses - - flutter config --enable-macos-desktop --enable-ios - - flutter config --no-analytics - - dart --disable-analytics - - flutter doctor -v - - flutter build ipa - when: manual + - flutter build linux + artifacts: + paths: + - build/linux/x64/release/bundle/ + rules: + - if: '$CI_COMMIT_TAG =~ /v\d.+/' + +build_linux_amd64_flatpak: + tags: + - saas-linux-small-amd64 + image: ubuntu:24.04 + stage: build + dependencies: [build_linux_amd64_bundle] + needs: + - job: build_linux_amd64_bundle + artifacts: true + script: + - apt-get update + - apt-get install -y --no-install-recommends flatpak flatpak-builder gnupg2 elfutils ca-certificates + - flatpak remote-add --no-gpg-verify --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo + - flatpak install -y --noninteractive org.gnome.Sdk/x86_64/46 org.gnome.Platform/x86_64/46 app/org.flathub.flatpak-external-data-checker/x86_64/stable org.freedesktop.appstream-glib + - pushd flatpak/ + - flatpak-builder --force-clean build-dir com.veilid.veilidchat.yml --repo=repo + - flatpak build-bundle repo com.veilid.veilidchat.flatpak com.veilid.veilidchat + - popd + artifacts: + paths: + - flatpak/com.veilid.veilidchat.flatpak + rules: + - if: '$CI_COMMIT_TAG =~ /v\d.+/' + +build_linux_arm64_bundle: + stage: build + tags: + - saas-linux-small-arm64 + image: ghcr.io/cirruslabs/flutter:3.29.2 + script: + - apt-get update + - apt-get install -y --no-install-recommends cmake ninja-build clang build-essential pkg-config libgtk-3-dev liblzma-dev lcov rustup + - rustup toolchain install 1.81 --profile minimal --no-self-update + - flutter config --enable-linux-desktop + - git clone https://gitlab.com/veilid/veilid.git ../veilid + - flutter build linux + artifacts: + paths: + - build/linux/arm64/release/bundle/ + rules: + - if: '$CI_COMMIT_TAG =~ /v\d.+/' + +build_linux_arm64_flatpak: + tags: + - saas-linux-small-arm64 + image: ubuntu:24.04 + stage: build + dependencies: [build_linux_arm64_bundle] + needs: + - job: build_linux_arm64_bundle + artifacts: true + script: + - apt-get update + - apt-get install -y --no-install-recommends flatpak flatpak-builder gnupg2 elfutils ca-certificates + - flatpak remote-add --no-gpg-verify --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo + - flatpak install -y --noninteractive org.gnome.Sdk/aarch64/46 org.gnome.Platform/aarch64/46 app/org.flathub.flatpak-external-data-checker/aarch64/stable org.freedesktop.appstream-glib + - pushd flatpak/ + - flatpak-builder --force-clean build-dir com.veilid.veilidchat.arm64.yml --repo=repo + - flatpak build-bundle repo com.veilid.veilidchat.flatpak com.veilid.veilidchat + - popd + artifacts: + paths: + - flatpak/com.veilid.veilidchat.flatpak + rules: + - if: '$CI_COMMIT_TAG =~ /v\d.+/' #test: # extends: @@ -41,4 +126,3 @@ build: # stage: test # script: # - echo "place holder for test" - diff --git a/.gitlab/issue_templates/mytemplate.md b/.gitlab/issue_templates/mytemplate.md new file mode 100644 index 0000000..759ecc2 --- /dev/null +++ b/.gitlab/issue_templates/mytemplate.md @@ -0,0 +1,21 @@ +First, please search through the existing issues on GitLab ***and*** read our [known issues](https://veilid.com/chat/knownissues) page before opening a new issue. + +Please provide the following information to the best of your ability: + +## Platform in use (Apple or Android) + + +## Network type (Wifi or Cell) +### If you know it, what type of NAT? + + +## Paste in relevant logs +1. Long press the signal meter in VeilidChat to open the console logs +2. Switch the logs to debug +3. Make the issue happen again +4. Go back into the logs and hit the copy all button +5. Paste the logs somewhere you can make edits -- remove all IPs (v4 and v6) +6. Paste or attach that redacted log here + + +## Description of the issue \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..21576d9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,97 @@ +## v0.4.8 ## + +- Fix reconciliation `advance()` +- Add `pool stats` command +- Fixed issue with Android 'back' button exiting the app (#331) +- Deprecated accounts no longer crash application at startup +- Simplify SingleContactMessagesCubit and MessageReconciliation +- Ensure first messages get received when opening a new chat +- Update flutter_chat_ui to 2.0.0 +- Accessibility improvements + - Text scaling + - Keyboard shortcuts Ctrl + / Ctrl - to change font size + - Keyboard shortcut Ctrl+Alt+C - to change color scheme + - Keyboard shortcut Ctrl+Alt+B - to change theme brightness + +- _Community Contributions_ + - Refactor README.md for clarity and structure; enhance setup instructions and Flutter installation guidance @iKranium-Labs // @iKranium + +## v0.4.7 ## +- _Community Contributions_ + - Fix getting stuck on splash screen when veilid is already started @bmv437 / @bgrift + - Fix routing to home after initial account creation @bmv437 / @bgrift + - edit_account_form visual improvements @bmv437 / @bgrift + - Flatpak CI Update @sajattack + - Build Arm64 flatpaks @sajattack +- Dependency updates + +## v0.4.6 ## +- Updated veilid-core to v0.4.4 + - See Veilid changelog for specifics +- UI improvements: Theme fixes, wallpaper option added +- Responsiveness improved +- Contacts workflow more consistent +- Safe-area fixes +- Make layout more mobile-friendly +- Improved contact invitation menus +- Deadlock fixes in veilid_support +- _pollWatch was degenerate and only watched first subkey + +## v0.4.5 ## +- Updated veilid-core to v0.4.1 + - See Veilid changelog for specifics +- DHT speed and reliability improvements + +## v0.4.4 ## +- Update beta dialog with expectations page +- Temporarily disable relay selection aggressiveness + +## v0.4.3 ## +- Flutter upgrade to 3.24.0 +- Contacts UI cleanup +- Incorporate symmetric NAT fix from veilid-core + +## v0.4.2 ## +- Dialogs cleanup +- Incremental chat state work +- Exception handling work +- Cancellable waiting page +- Fix DHTRecordCubit open() retry bug +- Log fix for iOS +- Add ability to delete orphaned chats +- Fix deadlock + +## v0.4.1 ## +- Fix creating new accounts +- Switch to non-bounce scroll physics because a lot of views want 'stick to bottom' scroll behavior + +## v0.4.0 ## +- Long conversation support +- Account and consistency update + - Account and identity system upgrades + - Eventual consistency works better now + - Speed improvements + - High speed messaging torture test passes +- Multiple accounts support + - start of recovery key UI + - multiple accounts support + - many bugfixes + - improved watches + - ui improvements +- UI work + - Support away/busy/free state + - Support away/busy/free messages + - Start of UI for auto-away feature (incomplete) +- _Community Contributions Shoutouts_ + - @ethnh + - @sajattack + - @jasikpark + - @kyanha + - @lpmi-13 + - @rivkasegan + - @SalvatoreT + - @hweslin + +## v0.3.0 ## +- Beginning of changelog +- See commits/merges for history prior to v0.3.0 diff --git a/README.md b/README.md index 5437152..c20c08e 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,174 @@ # VeilidChat -VeilidChat is a chat application written for the Veilid (https://www.veilid.com) distributed application platform. It has a familiar and simple interface and is designed for private, and secure person-to-person communications. +## Overview -For more information about VeilidChat: https://veilid.chat +VeilidChat is a decentralized, secure, and private chat application built upon the [Veilid](https://www.veilid.com) distributed application platform. It offers a familiar messaging interface while leveraging Veilid's underlying end-to-end encrypted and peer-to-peer communication capabilities to ensure privacy and security for person-to-person communications without relying on centralized servers. -For more information about the Veilid network protocol and app development platform: https://veilid.com +For more information about VeilidChat: + +For more information about the Veilid network protocol and app development platform: ## Setup -While this is still in development, you must have a clone of the Veilid source checked out at `../veilid` relative to the working directory of this repository. +### Prerequisites -### For Linux Systems: -``` -./setup_linux.sh +VeilidChat is a Flutter application that interacts with the core Veilid library via Foreign Function Interface (FFI). While this is still in development, you **must** have a clone of the Veilid source checked out at `../veilid` relative to the working directory of this repository for the setup scripts to work. This is because the veilid-core source and build setup (including FFI components), which Veilidchat relies on, reside there. + +```shell +your_workspace/ +├── veilid/ <-- Veilid core repository +└── veilidchat/ <-- This repository ``` -### For Mac Systems: -``` -./setup_macos.sh +Refer to the main [Veilid repository](https://gitlab.com/veilid/veilid) for instructions. + +### Flutter Installation + +VeilidChat requires the Flutter SDK to build and run. Ensure Flutter is installed and available in your system's PATH. Choose one of the methods below: + +**Option 1: Standard Installation (Recommended)** + +Follow the official Flutter documentation for your operating system to install the SDK directly. + +* **Official Flutter Install Guides:** + * [Windows](https://docs.flutter.dev/get-started/install/windows) + * [macOS](https://docs.flutter.dev/get-started/install/macos) + * [Linux](https://docs.flutter.dev/get-started/install/linux) + * [ChromeOS](https://docs.flutter.dev/get-started/install/chromeos) + +**Option 2: Installation via IDE Extension (Beginner-friendly for VS Code)** + +Various IDEs may offer Flutter extensions that can assist with setup. For VS Code, the official Flutter extension can assist you through the SDK installation process. + +1. Open VS Code. +2. Go to the Extensions view (`Ctrl+Shift+X` or `Cmd+Shift+X`). +3. Search for "Flutter" and install the official extension published by Dart Code. +4. Follow the prompts from the extension to install the Flutter SDK. + +**Running Veilid Core Setup Scripts:** + +In order to run the VeilidChat application, you will need the [Veilid repository](https://gitlab.com/veilid/veilid) repository set up correctly as mentioned in [Prerequisites](#prerequisites) above. The veilidchat setup scripts in [`./dev-setup`](./dev-setup) handle building the necessary Veilid components and checking for or installing other required tools like `protoc`. + +**Note:** These scripts require Flutter to be **already installed and accessible in your system's PATH** before they are run. + +To run these setup scripts (from the [`veilidchat`](veilidchat) directory): + +* For Linux Systems: Run [./dev-setup/setup_linux.sh](./dev-setup/setup_linux.sh) (Installs protoc and protoc-gen-dart) +* For Mac Systems: Run [./dev-setup/setup_macos.sh](./dev-setup/setup_macos.sh) (Installs protoc and protoc-gen-dart) +* For Windows Systems: Run [./dev-setup/setup_windows.bat](./dev-setup/setup_windows.bat) (**Requires manual installation of protoc beforehand**) + (check [`./dev-setup`](./dev-setup) for other platforms) + +These scripts will check for required dependencies and set up the environment. For Windows users, please ensure you have manually downloaded and installed the protoc compiler (version 25.3 or higher is recommended) and added its directory to your system's PATH *before* running the setup script. The Windows script will verify its presence. + +**Note on Python Environments:** The dev-setup scripts in the main Veilid repository may utilize Python virtual environments (`venv`) for managing dependencies needed for build processes (like FFI generation scripts). If your system uses a system-managed Python environment or you encounter permission issues, ensure you follow any instructions provided by the setup scripts regarding environment activation or configuration. + +## Verifying Installation + +After installing Flutter and running the ./dev-setup scripts, verify that everything is set up correctly. Open a terminal and run the following command from anywhere accessible by your PATH: + +`$ flutter doctor` + +This command checks your environment and displays a report of the status of your Flutter installation and connected devices. It will list any missing dependencies or configuration issues for common development platforms (Android, iOS, Web, Desktop). + +**Example Output (Partial):** + +```shell + +$ flutter doctor +Doctor summary (to see all details, run flutter doctor -v): +[√] Flutter (Channel stable, 3.x.x, on macOS 13.x.x 22Gxxx darwin-x64, locale en-US) +[√] Android toolchain - develop for Android devices (Android SDK version 3x.x.x) +[!] Xcode - develop for iOS and macOS + ✗ Xcode installation is incomplete. + Install Xcode from the App Store. +[√] Chrome - develop for the web +[√] Linux toolchain - develop for Linux desktop +[√] VS Code (version 1.xx.x) +[√] Connected device (1 available) + +! Doctor found issues in 1 category. + +Address any issues reported by `flutter doctor` before proceeding. ``` -## Updating Code +## Building and Launching -### To update the WASM binary from `veilid-wasm`: -* Debug WASM: run `./wasm_update.sh` -* Release WASM: run `/wasm_update.sh release` +VeilidChat is a Flutter application and can be built and launched on various platforms supported by Flutter, provided you have the necessary SDKs and devices/emulators configured as verified by `flutter doctor`. +1. **Ensure Flutter dependencies are installed**: + From the `veilidchat` directory, run: + + ```bash + flutter pub get + ``` + +2. **List available devices**: + To see which devices (simulators, emulators, connected physical devices, desktop targets, web browsers) are available to run the application on, use the command: + + ```bash + flutter devices + ``` + +3. **Run on a specific device**: + Use the `flutter run` command followed by the `-d` flag and the device ID from the `flutter devices` list. + + * **Example (Android emulator/device):** + Assuming an Android device ID like `emulator-5554`: + + ```bash + flutter run -d emulator-5554 + ``` + + If only one device is connected, you can often omit the `-d` flag. + + * **Example (iOS simulator/device):** + Assuming an iOS simulator ID like `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`: + + ```bash + flutter run -d xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` (replace with actual ID) + ``` + + Or, to target the default iOS simulator: + + ```bash + flutter run -d simulator + ``` + + * **Example (Linux Desktop):** + + ```bash + flutter run -d linux + ``` + + * **Example (macOS Desktop):** + + ```bash + flutter run -d macos + ``` + + * **Example (Windows Desktop):** + + ```bash + flutter run -d windows + ``` + + * **Example (Web):** + + ```bash + flutter run -d web # Or a specific browser like 'chrome' or 'firefox' if listed by `flutter devices` + ``` + + This will typically launch the application in your selected web browser. + +## Updating the WASM Binary + +### To update the WASM binary + +[from the `veilid-wasm` package located in [`../veilid/veilid-wasm`](../veilid/veilid-wasm)] + +From the VeilidChat repository working directory ([`./veilidchat`](./veilidchat)), run the appropriate script: + +* Debug WASM: [`./dev-setup/wasm_update.sh`](./dev-setup/wasm_update.sh) +* Release WASM: [`./dev-setup/wasm_update.sh release`](./dev-setup/wasm_update.sh) + +Refer to the official [Flutter documentation](https://docs.flutter.dev/) for more detailed information on building and deployment with Flutter. \ No newline at end of file diff --git a/android/app/.gitignore b/android/app/.gitignore new file mode 100644 index 0000000..0e60033 --- /dev/null +++ b/android/app/.gitignore @@ -0,0 +1 @@ +.cxx diff --git a/android/app/build.gradle b/android/app/build.gradle index 11a434c..9d84346 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,3 +1,9 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" +} + def localProperties = new Properties() def localPropertiesFile = rootProject.file('local.properties') if (localPropertiesFile.exists()) { @@ -6,11 +12,6 @@ if (localPropertiesFile.exists()) { } } -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' @@ -21,27 +22,26 @@ 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 buildConfig = 'debug' def keystoreProperties = new Properties() def keystorePropertiesFile = rootProject.file('key.properties') if (keystorePropertiesFile.exists()) { keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) + buildConfig = 'release' } android { - ndkVersion "25.1.8937393" + ndkVersion "27.0.12077973" compileSdkVersion flutter.compileSdkVersion - + compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = '1.8' + jvmTarget = '17' } sourceSets { @@ -67,15 +67,19 @@ android { storePassword keystoreProperties['storePassword'] } } - + buildTypes { release { - shrinkResources false - minifyEnabled false - signingConfig signingConfigs.release + shrinkResources false + minifyEnabled false + if (buildConfig == 'release') { + signingConfig signingConfigs.release + } else { + signingConfig signingConfigs.debug + } } } - + namespace 'com.veilid.veilidchat' } @@ -83,6 +87,4 @@ flutter { source '../..' } -dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" -} +dependencies {} diff --git a/android/build.gradle b/android/build.gradle index dbb249e..bc157bd 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,16 +1,3 @@ -buildscript { - ext.kotlin_version = '1.9.10' - repositories { - google() - mavenCentral() - } - - dependencies { - classpath 'com.android.tools.build:gradle:7.4.2' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - } -} - allprojects { repositories { google() diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 3c472b9..afa1e8e 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.10.2-all.zip diff --git a/android/settings.gradle b/android/settings.gradle index 44e62bc..b1ae36a 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -1,11 +1,25 @@ -include ':app' +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() -def localPropertiesFile = new File(rootProject.projectDir, "local.properties") -def properties = new Properties() + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") -assert localPropertiesFile.exists() -localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} -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" +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "8.8.0" apply false + id "org.jetbrains.kotlin.android" version "1.9.25" apply false +} + +include ":app" \ No newline at end of file diff --git a/assets/i18n/en.json b/assets/i18n/en.json index 2b8a5ce..f3348b5 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -2,37 +2,106 @@ "app": { "title": "VeilidChat" }, - "app_bar": { - "settings_tooltip": "Settings" + "menu": { + "accounts_menu_tooltip": "Accounts Menu", + "settings_tooltip": "Settings", + "contacts_tooltip": "Contacts List", + "new_chat_tooltip": "Start New Chat", + "add_account_tooltip": "Add Account", + "accounts": "Accounts", + "version": "Version" }, - "pager": { - "account": "Account", - "chats": "Chats", - "contacts": "Contacts" + "splash": { + "beta_title": "VeilidChat is BETA SOFTWARE", + "beta_text": "DO NOT USE THIS FOR ANYTHING IMPORTANT\n\nUntil 1.0 is released:\n\n• You should have no expectations of actual privacy, or guarantees of security.\n• You will likely lose accounts, contacts, and messages and need to recreate them.\n\nTo know what to expect, review our known issues located here:\n\n" }, "account": { "form_name": "Name", - "form_pronouns": "Pronouns (optional)", + "empty_name": "Your name (required)", + "form_pronouns": "Pronouns", + "empty_pronouns": "(optional pronouns)", + "form_about": "About Me", + "empty_about": "Tell your contacts about yourself", + "form_free_message": "Free Message", + "empty_free_message": "Status when availability is 'Free'", + "form_away_message": "Away Message", + "empty_away_message": "Status when availability is 'Away'", + "form_busy_message": "Busy Message", + "empty_busy_message": "Status when availability is 'Busy'", + "form_availability": "Availability", + "form_avatar": "Avatar", + "form_auto_away": "Automatic 'away' detection", + "form_auto_away_timeout": "Auto-away timeout (in minutes)", "form_lock_type": "Lock Type", "lock_type_none": "none", "lock_type_pin": "pin", "lock_type_password": "password" }, "new_account_page": { - "titlebar": "Create a new account", + "titlebar": "Create A New Account", "header": "Account Profile", "create": "Create", "instructions": "This information will be shared with the people you invite to connect with you on VeilidChat.", "error": "Account creation error", + "network_is_offline": "Network is offline, try again when you're connected", "name": "Name", "pronouns": "Pronouns" }, + "edit_account_page": { + "titlebar": "Edit Account", + "header": "Account Profile", + "instructions": "This information will be shared with the people you invite to connect with you on VeilidChat.", + "error": "Account modification error", + "name": "Name", + "pronouns": "Pronouns", + "remove_account": "Remove Account", + "destroy_account": "Destroy Account", + "remove_account_confirm": "Confirm Account Removal", + "remove_account_description": "Remove account from this device only", + "remove_account_confirm_message": " • Your account will be removed from this device ONLY\n • Your identity will remain recoverable with the recovery key\n • Your messages and contacts will remain available on other devices\n", + "destroy_account_confirm": "Confirm Account Destruction", + "destroy_account_description": "Destroy account, removing it completely from all devices everywhere", + "destroy_account_confirm_message": "This action is PERMANENT, and your VeilidChat account will no longer be recoverable with the recovery key. Restoring from backups will not recover your account!", + "destroy_account_confirm_message_details": "You will lose access to:\n • Your entire message history\n • Your contacts\n • This will not remove your messages you have sent from other people's devices\n", + "failed_to_remove_title": "Failed to remove account", + "try_again_network": "Try again when you have a more stable network connection", + "failed_to_destroy_title": "Failed to destroy account", + "account_removed": "Account removed successfully", + "account_destroyed": "Account destroyed successfully" + }, + "show_recovery_key_page": { + "titlebar": "Save Recovery Key", + "recovery_key": "Recovery Key", + "instructions": "Your recovery key is important!", + "instructions_details": "This key is the ONLY way to recover your VeilidChat account in the event of a forgotton password or a lost, stolen, or compromised device.", + "instructions_options": "Here are some options for your recovery key:", + "instructions_print": "Print the recovery key and keep it somewhere safe", + "instructions_view": "View the recovery key and take a screenshot", + "instructions_share": "Share the recovery key to another app to save it", + "print": "Print", + "view": "View", + "share": "Share" + }, + "confirmation": { + "confirm": "Confirm", + "discard_changes": "Discard changes?", + "are_you_sure_discard": "Are you sure you want to discard your changes?", + "are_you_sure": "Are you sure you want to do this?" + }, "button": { "ok": "Ok", "cancel": "Cancel", + "edit": "Edit", "delete": "Delete", "accept": "Accept", - "reject": "Reject" + "reject": "Reject", + "finish": "Finish", + "close": "Close", + "yes": "Yes", + "no": "No", + "update": "Update", + "waiting_for_network": "Waiting For Network", + "chat": "Chat" }, "toast": { "error": "Error", @@ -55,28 +124,71 @@ "invalid_account_title": "Invalid Account", "invalid_account_text": "Account is invalid, removing from list" }, - "account_page": { - "contact_invitations": "Contact Invitations" + "contacts_dialog": { + "contacts": "Contacts", + "edit_contact": "Edit Contact", + "invitations": "Invitations", + "no_contact_selected": "Select a contact to view or edit", + "new_chat": "Open Chat", + "close_contact": "Close Contact" }, - "accounts_menu": { - "invite_contact": "Invite Contact", - "create_invite": "Create Invite", - "scan_invite": "Scan Invite", - "paste_invite": "Paste Invite" + "contact_list": { + "contacts": "Contacts", + "invite_people": "No contacts\n\nPress 'Create Invitation' to invite a contact to VeilidChat", + "search": "Search contacts", + "invitation": "Invitation", + "loading_contacts": "Loading contacts..." }, - "send_invite_dialog": { - "title": "Send Contact Invite", - "connect_with_me": "Connect with me on VeilidChat!", - "enter_message_hint": "enter message for contact (optional)", - "message_to_contact": "Message to send with invitation (not encrypted)", - "generate": "Generate Invite", - "message": "Message", + "contact_form": { + "form_name": "Name", + "form_pronouns": "Pronouns", + "form_about": "About", + "form_status": "Status", + "form_nickname": "Nickname", + "form_notes": "Notes", + "form_fingerprint": "Fingerprint", + "form_show_availability": "Show availability" + }, + "availability": { + "unspecified": "Unspecified", + "offline": "Offline", + "always_show_offline": "Always Show Offline", + "free": "Free", + "busy": "Busy", + "away": "Away" + }, + "add_contact_sheet": { + "add_contact": "Add Contact", + "create_invite": "Create\nInvitation", + "scan_invite": "Scan\nInvitation", + "paste_invite": "Paste\nInvitation" + }, + "add_chat_sheet": { + "new_chat": "New Chat" + }, + "chat": { + "start_a_conversation": "Start A Conversation", + "say_something": "Say Something", + "message_too_long": "Message too long" + }, + "create_invitation_dialog": { + "title": "Create Contact Invitation", + "me": "me", + "recipient_name": "Contact Name", + "recipient_hint": "Enter the recipient's name", + "recipient_helper": "Name of the person you are inviting to chat", + "message_hint": "Enter message for contact (optional)", + "message_label": "Message", + "message_helper": "Message to send with invitation", + "fingerprint": "Fingerprint", + "connect_with_me": "Connect with {name} on VeilidChat!", + "generate": "Generate Invitation", "unlocked": "Unlocked", "pin": "PIN", "password": "Password", "protect_this_invitation": "Protect this invitation:", "note": "Note:", - "note_text": "Contact invitations can be used by anyone. Make sure you send the invitation to your contact over a secure medium, and preferably use a password or pin to ensure that they are the only ones who can unlock the invitation and accept it.", + "note_text": "Do not post contact invitations publicly.\n\nContact invitations can be used by anyone. Make sure you send the invitation to your contact over a secure medium, and preferably use a password or pin to ensure that they are the only ones who can unlock the invitation and accept it.", "pin_description": "Choose a PIN to protect the contact invite.\n\nThis level of security is appropriate only for casual connections in public environments for 'shoulder surfing' protection.", "password_description": "Choose a strong password to protect the contact invite.\n\nThis level of security is appropriate when you must be sure the contact invitation is only accepted by its intended recipient. Share this password over a different medium than the invite itself.", "pin_does_not_match": "PIN does not match", @@ -85,34 +197,45 @@ "copy_invitation": "Copy Invitation", "invitation_copied": "Invitation Copied" }, - "invite_dialog": { + "invitation_dialog": { + "to": "To", "message_from_contact": "Message from contact", "validating": "Validating...", - "failed_to_accept": "Failed to accept contact invite", - "failed_to_reject": "Failed to reject contact invite", + "failed_to_accept": "Failed to accept contact invitation", + "failed_to_reject": "Failed to reject contact invitation", "invalid_invitation": "Invalid invitation", - "protected_with_pin": "Contact invite is protected with a PIN", - "protected_with_password": "Contact invite is protected with a password", + "try_again_online": "Invitation could not be reached, try again when online", + "key_not_found": "Invitation could not be found, it may not be on the network yet", + "protected_with_pin": "Contact invitation is protected with a PIN", + "protected_with_password": "Contact invitation is protected with a password", "invalid_pin": "Invalid PIN", - "invalid_password": "Invalid password" + "invalid_password": "Invalid password", + "invalid_identity": "Contact invitation is for an missing or unavailable identity" }, - "paste_invite_dialog": { + "waiting_invitation": { + "accepted": "Contact invitation accepted from {name}", + "rejected": "Contact invitation was rejected", + "invalid": "Contact invitation was not valid", + "init_failed": "Contact initialization failed" + }, + "paste_invitation_dialog": { "title": "Paste Contact Invite", "paste_invite_here": "Paste your contact invite here:", "paste": "Paste" }, - "scan_invite_dialog": { + "scan_invitation_dialog": { "title": "Scan Contact Invite", "instructions": "Position the contact invite QR code in the frame", - "scan_qr_here": "Click here to scan a contact invite QR code:", - "paste_qr_here": "Camera scanning is only available on mobile devices. You can copy a QR code image and paste it here:", + "scan_qr_here": "Click here to scan a contact invite QR code with your device's camera:", + "paste_qr_here": "You can copy a QR code image and paste it by clicking here:", "scan": "Scan", "paste": "Paste", "not_an_image": "Pasted data is not an image", "could_not_decode_image": "Could not decode pasted image", "not_a_valid_qr_code": "Not a valid QR code", "error": "Failed to capture QR code", - "permission_error": "Capturing QR codes requires camera permisions. Allow camera permissions for VeilidChat in your settings." + "permission_error": "Capturing QR codes requires camera permisions. Allow camera permissions for VeilidChat in your settings.", + "camera_error": "Camera error" }, "enter_pin_dialog": { "enter_pin": "Enter PIN", @@ -124,15 +247,10 @@ "reenter_password": "Re-Enter Password To Confirm", "password_does_not_match": "Password does not match" }, - "contact_list": { - "title": "Contact List", - "invite_people": "Invite people to VeilidChat", - "search": "Search contacts", - "invitation": "Invitation" - }, "chat_list": { - "search": "Search", - "start_a_conversation": "Start a conversation", + "deleted_contact": "Deleted Contact", + "search": "Search chats", + "start_a_conversation": "Start A Conversation", "chats": "Chats", "groups": "Groups" }, @@ -148,6 +266,7 @@ "eggplant": "Eggplant", "lime": "Lime", "grim": "Grim", + "elite": "31337", "contrast": "Contrast" }, "brightness": { @@ -158,12 +277,38 @@ "settings_page": { "titlebar": "Settings", "color_theme": "Color Theme", - "brightness_mode": "Brightness Mode" + "brightness_mode": "Brightness Mode", + "display_scale": "Display Scale", + "display_beta_warning": "Display beta warning on startup", + "none": "None", + "in_app": "In-app", + "push": "Push", + "in_app_or_push": "In-app or Push", + "notifications": "Notifications", + "event": "Event", + "sound": "Sound", + "delivery": "Delivery", + "enable_badge": "Enable icon 'badge' bubble", + "enable_notifications": "Enable notifications", + "enable_wallpaper": "Enable wallpaper", + "message_notification_content": "Message notification content", + "invitation_accepted": "On invitation accept/reject", + "message_received": "On message received", + "message_sent": "On message sent", + "name_and_content": "Name and content", + "name_only": "Name only", + "nothing": "Nothing", + "bonk": "Bonk", + "boop": "Boop", + "badeep": "Badeep", + "beep_badeep": "Beep-Badeep", + "custom": "Custom" }, "developer": { "title": "Developer Logs", "command": "Command", "copied": "Selection copied", + "copied_all": "Copied all content", "cleared": "Logs cleared", "are_you_sure_clear": "Are you sure you want to clear the logs?" }, @@ -173,5 +318,9 @@ "info": "Info", "debug": "Debug", "trace": "Trace" + }, + "date_formatter": { + "just_now": "Just now", + "yesterday": "Yesterday" } } \ No newline at end of file diff --git a/assets/images/grid.svg b/assets/images/grid.svg new file mode 100644 index 0000000..f30d577 --- /dev/null +++ b/assets/images/grid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/toilet.svg b/assets/images/toilet.svg new file mode 100644 index 0000000..0984274 --- /dev/null +++ b/assets/images/toilet.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/images/wallpaper/arctic.svg b/assets/images/wallpaper/arctic.svg new file mode 100644 index 0000000..ebcaee4 --- /dev/null +++ b/assets/images/wallpaper/arctic.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/assets/images/wallpaper/babydoll.svg b/assets/images/wallpaper/babydoll.svg new file mode 100644 index 0000000..55f28a3 --- /dev/null +++ b/assets/images/wallpaper/babydoll.svg @@ -0,0 +1,663 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/wallpaper/eggplant.svg b/assets/images/wallpaper/eggplant.svg new file mode 100644 index 0000000..48e6ad3 --- /dev/null +++ b/assets/images/wallpaper/eggplant.svg @@ -0,0 +1,495 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/wallpaper/elite.svg b/assets/images/wallpaper/elite.svg new file mode 100644 index 0000000..606d21f --- /dev/null +++ b/assets/images/wallpaper/elite.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/images/wallpaper/forest.svg b/assets/images/wallpaper/forest.svg new file mode 100644 index 0000000..fb61069 --- /dev/null +++ b/assets/images/wallpaper/forest.svg @@ -0,0 +1,6888 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/wallpaper/garden.svg b/assets/images/wallpaper/garden.svg new file mode 100644 index 0000000..f4e6372 --- /dev/null +++ b/assets/images/wallpaper/garden.svg @@ -0,0 +1,8182 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/wallpaper/gold.svg b/assets/images/wallpaper/gold.svg new file mode 100644 index 0000000..16e5ab5 --- /dev/null +++ b/assets/images/wallpaper/gold.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/wallpaper/grim.svg b/assets/images/wallpaper/grim.svg new file mode 100644 index 0000000..7c3968f --- /dev/null +++ b/assets/images/wallpaper/grim.svg @@ -0,0 +1,928 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/wallpaper/lapis.svg b/assets/images/wallpaper/lapis.svg new file mode 100644 index 0000000..c78fa58 --- /dev/null +++ b/assets/images/wallpaper/lapis.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/wallpaper/lime.svg b/assets/images/wallpaper/lime.svg new file mode 100644 index 0000000..d65222e --- /dev/null +++ b/assets/images/wallpaper/lime.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/wallpaper/scarlet.svg b/assets/images/wallpaper/scarlet.svg new file mode 100644 index 0000000..7047ca7 --- /dev/null +++ b/assets/images/wallpaper/scarlet.svg @@ -0,0 +1,349 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/wallpaper/vapor.svg b/assets/images/wallpaper/vapor.svg new file mode 100644 index 0000000..34bfe59 --- /dev/null +++ b/assets/images/wallpaper/vapor.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/js/pdf/3.2.146/pdf.min.js b/assets/js/pdf/3.2.146/pdf.min.js new file mode 100644 index 0000000..4d43020 --- /dev/null +++ b/assets/js/pdf/3.2.146/pdf.min.js @@ -0,0 +1,22 @@ +/** + * @licstart The following is the entire license notice for the + * JavaScript code in this page + * + * Copyright 2022 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * @licend The above is the entire license notice for the + * JavaScript code in this page + */ +!function webpackUniversalModuleDefinition(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define("pdfjs-dist/build/pdf",[],e):"object"==typeof exports?exports["pdfjs-dist/build/pdf"]=e():t["pdfjs-dist/build/pdf"]=t.pdfjsLib=e()}(globalThis,(()=>(()=>{"use strict";var __webpack_modules__=[,(t,e)=>{Object.defineProperty(e,"__esModule",{value:!0});e.VerbosityLevel=e.Util=e.UnknownErrorException=e.UnexpectedResponseException=e.UNSUPPORTED_FEATURES=e.TextRenderingMode=e.StreamType=e.RenderingIntentFlag=e.PermissionFlag=e.PasswordResponses=e.PasswordException=e.PageActionEventType=e.OPS=e.MissingPDFException=e.LINE_FACTOR=e.LINE_DESCENT_FACTOR=e.InvalidPDFException=e.ImageKind=e.IDENTITY_MATRIX=e.FormatError=e.FontType=e.FeatureTest=e.FONT_IDENTITY_MATRIX=e.DocumentActionEventType=e.CMapCompressionType=e.BaseException=e.BASELINE_FACTOR=e.AnnotationType=e.AnnotationStateModelType=e.AnnotationReviewState=e.AnnotationReplyType=e.AnnotationMode=e.AnnotationMarkedState=e.AnnotationFlag=e.AnnotationFieldFlag=e.AnnotationEditorType=e.AnnotationEditorPrefix=e.AnnotationEditorParamsType=e.AnnotationBorderStyleType=e.AnnotationActionEventType=e.AbortException=void 0;e.arrayByteLength=arrayByteLength;e.arraysToBytes=function arraysToBytes(t){const e=t.length;if(1===e&&t[0]instanceof Uint8Array)return t[0];let s=0;for(let n=0;ne});t.promise=new Promise((function(s,n){t.resolve=function(t){e=!0;s(t)};t.reject=function(t){e=!0;n(t)}}));return t};e.createValidAbsoluteUrl=function createValidAbsoluteUrl(t,e=null,s=null){if(!t)return null;try{if(s&&"string"==typeof t){if(s.addDefaultProtocol&&t.startsWith("www.")){const e=t.match(/\./g);e&&e.length>=2&&(t=`http://${t}`)}if(s.tryConvertEncoding)try{t=stringToUTF8String(t)}catch(t){}}const n=e?new URL(t,e):new URL(t);if(function _isValidProtocol(t){if(!t)return!1;switch(t.protocol){case"http:":case"https:":case"ftp:":case"mailto:":case"tel:":return!0;default:return!1}}(n))return n}catch(t){}return null};e.getModificationDate=function getModificationDate(t=new Date){return[t.getUTCFullYear().toString(),(t.getUTCMonth()+1).toString().padStart(2,"0"),t.getUTCDate().toString().padStart(2,"0"),t.getUTCHours().toString().padStart(2,"0"),t.getUTCMinutes().toString().padStart(2,"0"),t.getUTCSeconds().toString().padStart(2,"0")].join("")};e.getVerbosityLevel=function getVerbosityLevel(){return n};e.info=function info(t){n>=s.INFOS&&console.log(`Info: ${t}`)};e.isArrayBuffer=function isArrayBuffer(t){return"object"==typeof t&&null!==t&&void 0!==t.byteLength};e.isArrayEqual=function isArrayEqual(t,e){if(t.length!==e.length)return!1;for(let s=0,n=t.length;s>24&255,t>>16&255,t>>8&255,255&t)};e.stringToBytes=stringToBytes;e.stringToPDFString=function stringToPDFString(t){if(t[0]>="ï"){let e;"þ"===t[0]&&"ÿ"===t[1]?e="utf-16be":"ÿ"===t[0]&&"þ"===t[1]?e="utf-16le":"ï"===t[0]&&"»"===t[1]&&"¿"===t[2]&&(e="utf-8");if(e)try{const s=new TextDecoder(e,{fatal:!0}),n=stringToBytes(t);return s.decode(n)}catch(t){warn(`stringToPDFString: "${t}".`)}}const e=[];for(let s=0,n=t.length;s=s.WARNINGS&&console.log(`Warning: ${t}`)}function unreachable(t){throw new Error(t)}function shadow(t,e,s,n=!1){Object.defineProperty(t,e,{value:s,enumerable:!n,configurable:!0,writable:!1});return s}const i=function BaseExceptionClosure(){function BaseException(t,e){this.constructor===BaseException&&unreachable("Cannot initialize BaseException.");this.message=t;this.name=e}BaseException.prototype=new Error;BaseException.constructor=BaseException;return BaseException}();e.BaseException=i;e.PasswordException=class PasswordException extends i{constructor(t,e){super(t,"PasswordException");this.code=e}};e.UnknownErrorException=class UnknownErrorException extends i{constructor(t,e){super(t,"UnknownErrorException");this.details=e}};e.InvalidPDFException=class InvalidPDFException extends i{constructor(t){super(t,"InvalidPDFException")}};e.MissingPDFException=class MissingPDFException extends i{constructor(t){super(t,"MissingPDFException")}};e.UnexpectedResponseException=class UnexpectedResponseException extends i{constructor(t,e){super(t,"UnexpectedResponseException");this.status=e}};e.FormatError=class FormatError extends i{constructor(t){super(t,"FormatError")}};e.AbortException=class AbortException extends i{constructor(t){super(t,"AbortException")}};function stringToBytes(t){"string"!=typeof t&&unreachable("Invalid argument for stringToBytes");const e=t.length,s=new Uint8Array(e);for(let n=0;nt.toString(16).padStart(2,"0")));class Util{static makeHexColor(t,e,s){return`#${a[t]}${a[e]}${a[s]}`}static scaleMinMax(t,e){let s;if(t[0]){if(t[0]<0){s=e[0];e[0]=e[1];e[1]=s}e[0]*=t[0];e[1]*=t[0];if(t[3]<0){s=e[2];e[2]=e[3];e[3]=s}e[2]*=t[3];e[3]*=t[3]}else{s=e[0];e[0]=e[2];e[2]=s;s=e[1];e[1]=e[3];e[3]=s;if(t[1]<0){s=e[2];e[2]=e[3];e[3]=s}e[2]*=t[1];e[3]*=t[1];if(t[2]<0){s=e[0];e[0]=e[1];e[1]=s}e[0]*=t[2];e[1]*=t[2]}e[0]+=t[4];e[1]+=t[4];e[2]+=t[5];e[3]+=t[5]}static transform(t,e){return[t[0]*e[0]+t[2]*e[1],t[1]*e[0]+t[3]*e[1],t[0]*e[2]+t[2]*e[3],t[1]*e[2]+t[3]*e[3],t[0]*e[4]+t[2]*e[5]+t[4],t[1]*e[4]+t[3]*e[5]+t[5]]}static applyTransform(t,e){return[t[0]*e[0]+t[1]*e[2]+e[4],t[0]*e[1]+t[1]*e[3]+e[5]]}static applyInverseTransform(t,e){const s=e[0]*e[3]-e[1]*e[2];return[(t[0]*e[3]-t[1]*e[2]+e[2]*e[5]-e[4]*e[3])/s,(-t[0]*e[1]+t[1]*e[0]+e[4]*e[1]-e[5]*e[0])/s]}static getAxialAlignedBoundingBox(t,e){const s=Util.applyTransform(t,e),n=Util.applyTransform(t.slice(2,4),e),i=Util.applyTransform([t[0],t[3]],e),a=Util.applyTransform([t[2],t[1]],e);return[Math.min(s[0],n[0],i[0],a[0]),Math.min(s[1],n[1],i[1],a[1]),Math.max(s[0],n[0],i[0],a[0]),Math.max(s[1],n[1],i[1],a[1])]}static inverseTransform(t){const e=t[0]*t[3]-t[1]*t[2];return[t[3]/e,-t[1]/e,-t[2]/e,t[0]/e,(t[2]*t[5]-t[4]*t[3])/e,(t[4]*t[1]-t[5]*t[0])/e]}static singularValueDecompose2dScale(t){const e=[t[0],t[2],t[1],t[3]],s=t[0]*e[0]+t[1]*e[2],n=t[0]*e[1]+t[1]*e[3],i=t[2]*e[0]+t[3]*e[2],a=t[2]*e[1]+t[3]*e[3],r=(s+a)/2,o=Math.sqrt((s+a)**2-4*(s*a-i*n))/2,l=r+o||1,c=r-o||1;return[Math.sqrt(l),Math.sqrt(c)]}static normalizeRect(t){const e=t.slice(0);if(t[0]>t[2]){e[0]=t[2];e[2]=t[0]}if(t[1]>t[3]){e[1]=t[3];e[3]=t[1]}return e}static intersect(t,e){const s=Math.max(Math.min(t[0],t[2]),Math.min(e[0],e[2])),n=Math.min(Math.max(t[0],t[2]),Math.max(e[0],e[2]));if(s>n)return null;const i=Math.max(Math.min(t[1],t[3]),Math.min(e[1],e[3])),a=Math.min(Math.max(t[1],t[3]),Math.max(e[1],e[3]));return i>a?null:[s,i,n,a]}static bezierBoundingBox(t,e,s,n,i,a,r,o){const l=[],c=[[],[]];let h,d,u,p,g,m,f,b;for(let c=0;c<2;++c){if(0===c){d=6*t-12*s+6*i;h=-3*t+9*s-9*i+3*r;u=3*s-3*t}else{d=6*e-12*n+6*a;h=-3*e+9*n-9*a+3*o;u=3*n-3*e}if(Math.abs(h)<1e-12){if(Math.abs(d)<1e-12)continue;p=-u/d;0{Object.defineProperty(exports,"__esModule",{value:!0});exports.build=exports.RenderTask=exports.PDFWorkerUtil=exports.PDFWorker=exports.PDFPageProxy=exports.PDFDocumentProxy=exports.PDFDocumentLoadingTask=exports.PDFDataRangeTransport=exports.LoopbackPort=exports.DefaultStandardFontDataFactory=exports.DefaultCanvasFactory=exports.DefaultCMapReaderFactory=void 0;exports.getDocument=getDocument;exports.setPDFNetworkStreamFactory=setPDFNetworkStreamFactory;exports.version=void 0;var _util=__w_pdfjs_require__(1),_annotation_storage=__w_pdfjs_require__(3),_display_utils=__w_pdfjs_require__(6),_font_loader=__w_pdfjs_require__(9),_canvas=__w_pdfjs_require__(11),_worker_options=__w_pdfjs_require__(14),_is_node=__w_pdfjs_require__(10),_message_handler=__w_pdfjs_require__(15),_metadata=__w_pdfjs_require__(16),_optional_content_config=__w_pdfjs_require__(17),_transport_stream=__w_pdfjs_require__(18),_xfa_text=__w_pdfjs_require__(19);const DEFAULT_RANGE_CHUNK_SIZE=65536,RENDERING_CANCELLED_TIMEOUT=100;let DefaultCanvasFactory=_display_utils.DOMCanvasFactory;exports.DefaultCanvasFactory=DefaultCanvasFactory;let DefaultCMapReaderFactory=_display_utils.DOMCMapReaderFactory;exports.DefaultCMapReaderFactory=DefaultCMapReaderFactory;let DefaultStandardFontDataFactory=_display_utils.DOMStandardFontDataFactory,createPDFNetworkStream;exports.DefaultStandardFontDataFactory=DefaultStandardFontDataFactory;if(_is_node.isNodeJS){const{NodeCanvasFactory:t,NodeCMapReaderFactory:e,NodeStandardFontDataFactory:s}=__w_pdfjs_require__(20);exports.DefaultCanvasFactory=DefaultCanvasFactory=t;exports.DefaultCMapReaderFactory=DefaultCMapReaderFactory=e;exports.DefaultStandardFontDataFactory=DefaultStandardFontDataFactory=s}function setPDFNetworkStreamFactory(t){createPDFNetworkStream=t}function getDocument(t){const e=new PDFDocumentLoadingTask;let s;if("string"==typeof t||t instanceof URL)s={url:t};else if((0,_util.isArrayBuffer)(t))s={data:t};else if(t instanceof PDFDataRangeTransport)s={range:t};else{if("object"!=typeof t)throw new Error("Invalid parameter in getDocument, need either string, URL, TypedArray, or parameter object.");if(!t.url&&!t.data&&!t.range)throw new Error("Invalid parameter object: need either .data, .range or .url");s=t}const n=Object.create(null);let i=null,a=null;for(const t in s){const e=s[t];switch(t){case"url":if("undefined"!=typeof window)try{n[t]=new URL(e,window.location).href;continue}catch(t){(0,_util.warn)(`Cannot create valid URL: "${t}".`)}else if("string"==typeof e||e instanceof URL){n[t]=e.toString();continue}throw new Error("Invalid PDF url data: either string or URL-object is expected in the url property.");case"range":i=e;continue;case"worker":a=e;continue;case"data":if(_is_node.isNodeJS&&"undefined"!=typeof Buffer&&e instanceof Buffer)n[t]=new Uint8Array(e);else{if(e instanceof Uint8Array)break;if("string"==typeof e)n[t]=(0,_util.stringToBytes)(e);else if("object"!=typeof e||null===e||isNaN(e.length)){if(!(0,_util.isArrayBuffer)(e))throw new Error("Invalid PDF binary data: either TypedArray, string, or array-like object is expected in the data property.");n[t]=new Uint8Array(e)}else n[t]=new Uint8Array(e)}continue}n[t]=e}n.CMapReaderFactory=n.CMapReaderFactory||DefaultCMapReaderFactory;n.StandardFontDataFactory=n.StandardFontDataFactory||DefaultStandardFontDataFactory;n.ignoreErrors=!0!==n.stopAtErrors;n.fontExtraProperties=!0===n.fontExtraProperties;n.pdfBug=!0===n.pdfBug;n.enableXfa=!0===n.enableXfa;(!Number.isInteger(n.rangeChunkSize)||n.rangeChunkSize<1)&&(n.rangeChunkSize=DEFAULT_RANGE_CHUNK_SIZE);("string"!=typeof n.docBaseUrl||(0,_display_utils.isDataScheme)(n.docBaseUrl))&&(n.docBaseUrl=null);(!Number.isInteger(n.maxImageSize)||n.maxImageSize<-1)&&(n.maxImageSize=-1);"string"!=typeof n.cMapUrl&&(n.cMapUrl=null);"string"!=typeof n.standardFontDataUrl&&(n.standardFontDataUrl=null);"boolean"!=typeof n.useWorkerFetch&&(n.useWorkerFetch=n.CMapReaderFactory===_display_utils.DOMCMapReaderFactory&&n.StandardFontDataFactory===_display_utils.DOMStandardFontDataFactory);"boolean"!=typeof n.isEvalSupported&&(n.isEvalSupported=!0);"boolean"!=typeof n.isOffscreenCanvasSupported&&(n.isOffscreenCanvasSupported=!_is_node.isNodeJS);"boolean"!=typeof n.disableFontFace&&(n.disableFontFace=_is_node.isNodeJS);"boolean"!=typeof n.useSystemFonts&&(n.useSystemFonts=!_is_node.isNodeJS&&!n.disableFontFace);"object"==typeof n.ownerDocument&&null!==n.ownerDocument||(n.ownerDocument=globalThis.document);"boolean"!=typeof n.disableRange&&(n.disableRange=!1);"boolean"!=typeof n.disableStream&&(n.disableStream=!1);"boolean"!=typeof n.disableAutoFetch&&(n.disableAutoFetch=!1);(0,_util.setVerbosityLevel)(n.verbosity);if(!a){const t={verbosity:n.verbosity,port:_worker_options.GlobalWorkerOptions.workerPort};a=t.port?PDFWorker.fromPort(t):new PDFWorker(t);e._worker=a}const r=e.docId;a.promise.then((function(){if(e.destroyed)throw new Error("Loading aborted");const t=_fetchDocument(a,n,i,r),s=new Promise((function(t){let e;i?e=new _transport_stream.PDFDataTransportStream({length:n.length,initialData:n.initialData,progressiveDone:n.progressiveDone,contentDispositionFilename:n.contentDispositionFilename,disableRange:n.disableRange,disableStream:n.disableStream},i):n.data||(e=createPDFNetworkStream({url:n.url,length:n.length,httpHeaders:n.httpHeaders,withCredentials:n.withCredentials,rangeChunkSize:n.rangeChunkSize,disableRange:n.disableRange,disableStream:n.disableStream}));t(e)}));return Promise.all([t,s]).then((function([t,s]){if(e.destroyed)throw new Error("Loading aborted");const i=new _message_handler.MessageHandler(r,t,a.port),o=new WorkerTransport(i,e,s,n);e._transport=o;i.send("Ready",null)}))})).catch(e._capability.reject);return e}async function _fetchDocument(t,e,s,n){if(t.destroyed)throw new Error("Worker was destroyed");if(s){e.length=s.length;e.initialData=s.initialData;e.progressiveDone=s.progressiveDone;e.contentDispositionFilename=s.contentDispositionFilename}const i=await t.messageHandler.sendWithPromise("GetDocRequest",{docId:n,apiVersion:"3.2.146",data:e.data,password:e.password,disableAutoFetch:e.disableAutoFetch,rangeChunkSize:e.rangeChunkSize,length:e.length,docBaseUrl:e.docBaseUrl,enableXfa:e.enableXfa,evaluatorOptions:{maxImageSize:e.maxImageSize,disableFontFace:e.disableFontFace,ignoreErrors:e.ignoreErrors,isEvalSupported:e.isEvalSupported,isOffscreenCanvasSupported:e.isOffscreenCanvasSupported,fontExtraProperties:e.fontExtraProperties,useSystemFonts:e.useSystemFonts,cMapUrl:e.useWorkerFetch?e.cMapUrl:null,standardFontDataUrl:e.useWorkerFetch?e.standardFontDataUrl:null}});e.data&&(e.data=null);if(t.destroyed)throw new Error("Worker was destroyed");return i}class PDFDocumentLoadingTask{static#t=0;#e=null;constructor(){this._capability=(0,_util.createPromiseCapability)();this._transport=null;this._worker=null;this.docId="d"+PDFDocumentLoadingTask.#t++;this.destroyed=!1;this.onPassword=null;this.onProgress=null}get onUnsupportedFeature(){return this.#e}set onUnsupportedFeature(t){(0,_display_utils.deprecated)("The PDFDocumentLoadingTask onUnsupportedFeature property will be removed in the future.");this.#e=t}get promise(){return this._capability.promise}async destroy(){this.destroyed=!0;await(this._transport?.destroy());this._transport=null;if(this._worker){this._worker.destroy();this._worker=null}}}exports.PDFDocumentLoadingTask=PDFDocumentLoadingTask;class PDFDataRangeTransport{constructor(t,e,s=!1,n=null){this.length=t;this.initialData=e;this.progressiveDone=s;this.contentDispositionFilename=n;this._rangeListeners=[];this._progressListeners=[];this._progressiveReadListeners=[];this._progressiveDoneListeners=[];this._readyCapability=(0,_util.createPromiseCapability)()}addRangeListener(t){this._rangeListeners.push(t)}addProgressListener(t){this._progressListeners.push(t)}addProgressiveReadListener(t){this._progressiveReadListeners.push(t)}addProgressiveDoneListener(t){this._progressiveDoneListeners.push(t)}onDataRange(t,e){for(const s of this._rangeListeners)s(t,e)}onDataProgress(t,e){this._readyCapability.promise.then((()=>{for(const s of this._progressListeners)s(t,e)}))}onDataProgressiveRead(t){this._readyCapability.promise.then((()=>{for(const e of this._progressiveReadListeners)e(t)}))}onDataProgressiveDone(){this._readyCapability.promise.then((()=>{for(const t of this._progressiveDoneListeners)t()}))}transportReady(){this._readyCapability.resolve()}requestDataRange(t,e){(0,_util.unreachable)("Abstract method PDFDataRangeTransport.requestDataRange")}abort(){}}exports.PDFDataRangeTransport=PDFDataRangeTransport;class PDFDocumentProxy{constructor(t,e){this._pdfInfo=t;this._transport=e}get annotationStorage(){return this._transport.annotationStorage}get numPages(){return this._pdfInfo.numPages}get fingerprints(){return this._pdfInfo.fingerprints}get stats(){(0,_display_utils.deprecated)("The PDFDocumentProxy stats property will be removed in the future.");return this._transport.stats}get isPureXfa(){return(0,_util.shadow)(this,"isPureXfa",!!this._transport._htmlForXfa)}get allXfaHtml(){return this._transport._htmlForXfa}getPage(t){return this._transport.getPage(t)}getPageIndex(t){return this._transport.getPageIndex(t)}getDestinations(){return this._transport.getDestinations()}getDestination(t){return this._transport.getDestination(t)}getPageLabels(){return this._transport.getPageLabels()}getPageLayout(){return this._transport.getPageLayout()}getPageMode(){return this._transport.getPageMode()}getViewerPreferences(){return this._transport.getViewerPreferences()}getOpenAction(){return this._transport.getOpenAction()}getAttachments(){return this._transport.getAttachments()}getJavaScript(){return this._transport.getJavaScript()}getJSActions(){return this._transport.getDocJSActions()}getOutline(){return this._transport.getOutline()}getOptionalContentConfig(){return this._transport.getOptionalContentConfig()}getPermissions(){return this._transport.getPermissions()}getMetadata(){return this._transport.getMetadata()}getMarkInfo(){return this._transport.getMarkInfo()}getData(){return this._transport.getData()}saveDocument(){return this._transport.saveDocument()}getDownloadInfo(){return this._transport.downloadInfoCapability.promise}cleanup(t=!1){return this._transport.startCleanup(t||this.isPureXfa)}destroy(){return this.loadingTask.destroy()}get loadingParams(){return this._transport.loadingParams}get loadingTask(){return this._transport.loadingTask}getFieldObjects(){return this._transport.getFieldObjects()}hasJSActions(){return this._transport.hasJSActions()}getCalculationOrderIds(){return this._transport.getCalculationOrderIds()}}exports.PDFDocumentProxy=PDFDocumentProxy;class PDFPageProxy{constructor(t,e,s,n,i=!1){this._pageIndex=t;this._pageInfo=e;this._ownerDocument=n;this._transport=s;this._stats=i?new _display_utils.StatTimer:null;this._pdfBug=i;this.commonObjs=s.commonObjs;this.objs=new PDFObjects;this._bitmaps=new Set;this.cleanupAfterRender=!1;this.pendingCleanup=!1;this._intentStates=new Map;this.destroyed=!1}get pageNumber(){return this._pageIndex+1}get rotate(){return this._pageInfo.rotate}get ref(){return this._pageInfo.ref}get userUnit(){return this._pageInfo.userUnit}get view(){return this._pageInfo.view}getViewport({scale:t,rotation:e=this.rotate,offsetX:s=0,offsetY:n=0,dontFlip:i=!1}={}){return new _display_utils.PageViewport({viewBox:this.view,scale:t,rotation:e,offsetX:s,offsetY:n,dontFlip:i})}getAnnotations({intent:t="display"}={}){const e=this._transport.getRenderingIntent(t);return this._transport.getAnnotations(this._pageIndex,e.renderingIntent)}getJSActions(){return this._transport.getPageJSActions(this._pageIndex)}get isPureXfa(){return(0,_util.shadow)(this,"isPureXfa",!!this._transport._htmlForXfa)}async getXfa(){return this._transport._htmlForXfa?.children[this._pageIndex]||null}render({canvasContext:t,viewport:e,intent:s="display",annotationMode:n=_util.AnnotationMode.ENABLE,transform:i=null,canvasFactory:a=null,background:r=null,optionalContentConfigPromise:o=null,annotationCanvasMap:l=null,pageColors:c=null,printAnnotationStorage:h=null}){this._stats?.time("Overall");const d=this._transport.getRenderingIntent(s,n,h);this.pendingCleanup=!1;o||(o=this._transport.getOptionalContentConfig());let u=this._intentStates.get(d.cacheKey);if(!u){u=Object.create(null);this._intentStates.set(d.cacheKey,u)}if(u.streamReaderCancelTimeout){clearTimeout(u.streamReaderCancelTimeout);u.streamReaderCancelTimeout=null}const p=a||new DefaultCanvasFactory({ownerDocument:this._ownerDocument}),g=!!(d.renderingIntent&_util.RenderingIntentFlag.PRINT);if(!u.displayReadyCapability){u.displayReadyCapability=(0,_util.createPromiseCapability)();u.operatorList={fnArray:[],argsArray:[],lastChunk:!1,separateAnnots:null};this._stats?.time("Page Request");this._pumpOperatorList(d)}const complete=t=>{u.renderTasks.delete(m);(this.cleanupAfterRender||g)&&(this.pendingCleanup=!0);this._tryCleanup();if(t){m.capability.reject(t);this._abortOperatorList({intentState:u,reason:t instanceof Error?t:new Error(t)})}else m.capability.resolve();this._stats?.timeEnd("Rendering");this._stats?.timeEnd("Overall")},m=new InternalRenderTask({callback:complete,params:{canvasContext:t,viewport:e,transform:i,background:r},objs:this.objs,commonObjs:this.commonObjs,annotationCanvasMap:l,operatorList:u.operatorList,pageIndex:this._pageIndex,canvasFactory:p,useRequestAnimationFrame:!g,pdfBug:this._pdfBug,pageColors:c});(u.renderTasks||=new Set).add(m);const f=m.task;Promise.all([u.displayReadyCapability.promise,o]).then((([t,e])=>{if(this.pendingCleanup)complete();else{this._stats?.time("Rendering");m.initializeGraphics({transparency:t,optionalContentConfig:e});m.operatorListChanged()}})).catch(complete);return f}getOperatorList({intent:t="display",annotationMode:e=_util.AnnotationMode.ENABLE,printAnnotationStorage:s=null}={}){const n=this._transport.getRenderingIntent(t,e,s,!0);let i,a=this._intentStates.get(n.cacheKey);if(!a){a=Object.create(null);this._intentStates.set(n.cacheKey,a)}if(!a.opListReadCapability){i=Object.create(null);i.operatorListChanged=function operatorListChanged(){if(a.operatorList.lastChunk){a.opListReadCapability.resolve(a.operatorList);a.renderTasks.delete(i)}};a.opListReadCapability=(0,_util.createPromiseCapability)();(a.renderTasks||=new Set).add(i);a.operatorList={fnArray:[],argsArray:[],lastChunk:!1,separateAnnots:null};this._stats?.time("Page Request");this._pumpOperatorList(n)}return a.opListReadCapability.promise}streamTextContent({disableCombineTextItems:t=!1,includeMarkedContent:e=!1}={}){return this._transport.messageHandler.sendWithStream("GetTextContent",{pageIndex:this._pageIndex,combineTextItems:!0!==t,includeMarkedContent:!0===e},{highWaterMark:100,size:t=>t.items.length})}getTextContent(t={}){if(this._transport._htmlForXfa)return this.getXfa().then((t=>_xfa_text.XfaText.textContent(t)));const e=this.streamTextContent(t);return new Promise((function(t,s){const n=e.getReader(),i={items:[],styles:Object.create(null)};!function pump(){n.read().then((function({value:e,done:s}){if(s)t(i);else{Object.assign(i.styles,e.styles);i.items.push(...e.items);pump()}}),s)}()}))}getStructTree(){return this._transport.getStructTree(this._pageIndex)}_destroy(){this.destroyed=!0;const t=[];for(const e of this._intentStates.values()){this._abortOperatorList({intentState:e,reason:new Error("Page was destroyed."),force:!0});if(!e.opListReadCapability)for(const s of e.renderTasks){t.push(s.completed);s.cancel()}}this.objs.clear();for(const t of this._bitmaps)t.close();this._bitmaps.clear();this.pendingCleanup=!1;return Promise.all(t)}cleanup(t=!1){this.pendingCleanup=!0;return this._tryCleanup(t)}_tryCleanup(t=!1){if(!this.pendingCleanup)return!1;for(const{renderTasks:t,operatorList:e}of this._intentStates.values())if(t.size>0||!e.lastChunk)return!1;this._intentStates.clear();this.objs.clear();t&&this._stats&&(this._stats=new _display_utils.StatTimer);for(const t of this._bitmaps)t.close();this._bitmaps.clear();this.pendingCleanup=!1;return!0}_startRenderPage(t,e){const s=this._intentStates.get(e);if(s){this._stats?.timeEnd("Page Request");s.displayReadyCapability?.resolve(t)}}_renderPageChunk(t,e){for(let s=0,n=t.length;s{n.read().then((({value:t,done:e})=>{if(e)i.streamReader=null;else if(!this._transport.destroyed){this._renderPageChunk(t,i);pump()}}),(t=>{i.streamReader=null;if(!this._transport.destroyed){if(i.operatorList){i.operatorList.lastChunk=!0;for(const t of i.renderTasks)t.operatorListChanged();this._tryCleanup()}if(i.displayReadyCapability)i.displayReadyCapability.reject(t);else{if(!i.opListReadCapability)throw t;i.opListReadCapability.reject(t)}}}))};pump()}_abortOperatorList({intentState:t,reason:e,force:s=!1}){if(t.streamReader){if(t.streamReaderCancelTimeout){clearTimeout(t.streamReaderCancelTimeout);t.streamReaderCancelTimeout=null}if(!s){if(t.renderTasks.size>0)return;if(e instanceof _display_utils.RenderingCancelledException){let s=RENDERING_CANCELLED_TIMEOUT;e.extraDelay>0&&e.extraDelay<1e3&&(s+=e.extraDelay);t.streamReaderCancelTimeout=setTimeout((()=>{t.streamReaderCancelTimeout=null;this._abortOperatorList({intentState:t,reason:e,force:!0})}),s);return}}t.streamReader.cancel(new _util.AbortException(e.message)).catch((()=>{}));t.streamReader=null;if(!this._transport.destroyed){for(const[e,s]of this._intentStates)if(s===t){this._intentStates.delete(e);break}this.cleanup()}}}get stats(){return this._stats}}exports.PDFPageProxy=PDFPageProxy;class LoopbackPort{#s=[];#n=Promise.resolve();postMessage(t,e){const s={data:structuredClone(t,e)};this.#n.then((()=>{for(const t of this.#s)t.call(this,s)}))}addEventListener(t,e){this.#s.push(e)}removeEventListener(t,e){const s=this.#s.indexOf(e);this.#s.splice(s,1)}terminate(){this.#s.length=0}}exports.LoopbackPort=LoopbackPort;const PDFWorkerUtil={isWorkerDisabled:!1,fallbackWorkerSrc:null,fakeWorkerId:0};exports.PDFWorkerUtil=PDFWorkerUtil;if(_is_node.isNodeJS&&"function"==typeof require){PDFWorkerUtil.isWorkerDisabled=!0;PDFWorkerUtil.fallbackWorkerSrc="./pdf.worker.js"}else if("object"==typeof document){const t=document?.currentScript?.src;t&&(PDFWorkerUtil.fallbackWorkerSrc=t.replace(/(\.(?:min\.)?js)(\?.*)?$/i,".worker$1$2"))}PDFWorkerUtil.isSameOrigin=function(t,e){let s;try{s=new URL(t);if(!s.origin||"null"===s.origin)return!1}catch(t){return!1}const n=new URL(e,s);return s.origin===n.origin};PDFWorkerUtil.createCDNWrapper=function(t){const e=`importScripts("${t}");`;return URL.createObjectURL(new Blob([e]))};class PDFWorker{static#i=new WeakMap;constructor({name:t=null,port:e=null,verbosity:s=(0,_util.getVerbosityLevel)()}={}){if(e&&PDFWorker.#i.has(e))throw new Error("Cannot use more than one PDFWorker per port.");this.name=t;this.destroyed=!1;this.verbosity=s;this._readyCapability=(0,_util.createPromiseCapability)();this._port=null;this._webWorker=null;this._messageHandler=null;if(e){PDFWorker.#i.set(e,this);this._initializeFromPort(e)}else this._initialize()}get promise(){return this._readyCapability.promise}get port(){return this._port}get messageHandler(){return this._messageHandler}_initializeFromPort(t){this._port=t;this._messageHandler=new _message_handler.MessageHandler("main","worker",t);this._messageHandler.on("ready",(function(){}));this._readyCapability.resolve();this._messageHandler.send("configure",{verbosity:this.verbosity})}_initialize(){if(!PDFWorkerUtil.isWorkerDisabled&&!PDFWorker._mainThreadWorkerMessageHandler){let{workerSrc:t}=PDFWorker;try{PDFWorkerUtil.isSameOrigin(window.location.href,t)||(t=PDFWorkerUtil.createCDNWrapper(new URL(t,window.location).href));const e=new Worker(t),s=new _message_handler.MessageHandler("main","worker",e),terminateEarly=()=>{e.removeEventListener("error",onWorkerError);s.destroy();e.terminate();this.destroyed?this._readyCapability.reject(new Error("Worker was destroyed")):this._setupFakeWorker()},onWorkerError=()=>{this._webWorker||terminateEarly()};e.addEventListener("error",onWorkerError);s.on("test",(t=>{e.removeEventListener("error",onWorkerError);if(this.destroyed)terminateEarly();else if(t){this._messageHandler=s;this._port=e;this._webWorker=e;this._readyCapability.resolve();s.send("configure",{verbosity:this.verbosity})}else{this._setupFakeWorker();s.destroy();e.terminate()}}));s.on("ready",(t=>{e.removeEventListener("error",onWorkerError);if(this.destroyed)terminateEarly();else try{sendTest()}catch(t){this._setupFakeWorker()}}));const sendTest=()=>{const t=new Uint8Array;s.send("test",t,[t.buffer])};sendTest();return}catch(t){(0,_util.info)("The worker has been disabled.")}}this._setupFakeWorker()}_setupFakeWorker(){if(!PDFWorkerUtil.isWorkerDisabled){(0,_util.warn)("Setting up fake worker.");PDFWorkerUtil.isWorkerDisabled=!0}PDFWorker._setupFakeWorkerGlobal.then((t=>{if(this.destroyed){this._readyCapability.reject(new Error("Worker was destroyed"));return}const e=new LoopbackPort;this._port=e;const s="fake"+PDFWorkerUtil.fakeWorkerId++,n=new _message_handler.MessageHandler(s+"_worker",s,e);t.setup(n,e);const i=new _message_handler.MessageHandler(s,s+"_worker",e);this._messageHandler=i;this._readyCapability.resolve();i.send("configure",{verbosity:this.verbosity})})).catch((t=>{this._readyCapability.reject(new Error(`Setting up fake worker failed: "${t.message}".`))}))}destroy(){this.destroyed=!0;if(this._webWorker){this._webWorker.terminate();this._webWorker=null}PDFWorker.#i.delete(this._port);this._port=null;if(this._messageHandler){this._messageHandler.destroy();this._messageHandler=null}}static fromPort(t){if(!t?.port)throw new Error("PDFWorker.fromPort - invalid method signature.");return this.#i.has(t.port)?this.#i.get(t.port):new PDFWorker(t)}static get workerSrc(){if(_worker_options.GlobalWorkerOptions.workerSrc)return _worker_options.GlobalWorkerOptions.workerSrc;if(null!==PDFWorkerUtil.fallbackWorkerSrc){_is_node.isNodeJS||(0,_display_utils.deprecated)('No "GlobalWorkerOptions.workerSrc" specified.');return PDFWorkerUtil.fallbackWorkerSrc}throw new Error('No "GlobalWorkerOptions.workerSrc" specified.')}static get _mainThreadWorkerMessageHandler(){try{return globalThis.pdfjsWorker?.WorkerMessageHandler||null}catch(t){return null}}static get _setupFakeWorkerGlobal(){const loader=async()=>{const mainWorkerMessageHandler=this._mainThreadWorkerMessageHandler;if(mainWorkerMessageHandler)return mainWorkerMessageHandler;if(_is_node.isNodeJS&&"function"==typeof require){const worker=eval("require")(this.workerSrc);return worker.WorkerMessageHandler}await(0,_display_utils.loadScript)(this.workerSrc);return window.pdfjsWorker.WorkerMessageHandler};return(0,_util.shadow)(this,"_setupFakeWorkerGlobal",loader())}}exports.PDFWorker=PDFWorker;class WorkerTransport{#a=null;#r=new Map;#o=new Map;#l=null;constructor(t,e,s,n){this.messageHandler=t;this.loadingTask=e;this.commonObjs=new PDFObjects;this.fontLoader=new _font_loader.FontLoader({onUnsupportedFeature:this._onUnsupportedFeature.bind(this),ownerDocument:n.ownerDocument,styleElement:n.styleElement});this._params=n;if(!n.useWorkerFetch){this.CMapReaderFactory=new n.CMapReaderFactory({baseUrl:n.cMapUrl,isCompressed:n.cMapPacked});this.StandardFontDataFactory=new n.StandardFontDataFactory({baseUrl:n.standardFontDataUrl})}this.destroyed=!1;this.destroyCapability=null;this._passwordCapability=null;this._networkStream=s;this._fullReader=null;this._lastProgress=null;this.downloadInfoCapability=(0,_util.createPromiseCapability)();this.setupMessageHandler()}get annotationStorage(){return(0,_util.shadow)(this,"annotationStorage",new _annotation_storage.AnnotationStorage)}get stats(){return this.#a}getRenderingIntent(t,e=_util.AnnotationMode.ENABLE,s=null,n=!1){let i=_util.RenderingIntentFlag.DISPLAY,a=null;switch(t){case"any":i=_util.RenderingIntentFlag.ANY;break;case"display":break;case"print":i=_util.RenderingIntentFlag.PRINT;break;default:(0,_util.warn)(`getRenderingIntent - invalid intent: ${t}`)}switch(e){case _util.AnnotationMode.DISABLE:i+=_util.RenderingIntentFlag.ANNOTATIONS_DISABLE;break;case _util.AnnotationMode.ENABLE:break;case _util.AnnotationMode.ENABLE_FORMS:i+=_util.RenderingIntentFlag.ANNOTATIONS_FORMS;break;case _util.AnnotationMode.ENABLE_STORAGE:i+=_util.RenderingIntentFlag.ANNOTATIONS_STORAGE;a=(i&_util.RenderingIntentFlag.PRINT&&s instanceof _annotation_storage.PrintAnnotationStorage?s:this.annotationStorage).serializable;break;default:(0,_util.warn)(`getRenderingIntent - invalid annotationMode: ${e}`)}n&&(i+=_util.RenderingIntentFlag.OPLIST);return{renderingIntent:i,cacheKey:`${i}_${_annotation_storage.AnnotationStorage.getHash(a)}`,annotationStorageMap:a}}destroy(){if(this.destroyCapability)return this.destroyCapability.promise;this.destroyed=!0;this.destroyCapability=(0,_util.createPromiseCapability)();this._passwordCapability&&this._passwordCapability.reject(new Error("Worker was destroyed during onPassword callback"));const t=[];for(const e of this.#r.values())t.push(e._destroy());this.#r.clear();this.#o.clear();this.hasOwnProperty("annotationStorage")&&this.annotationStorage.resetModified();const e=this.messageHandler.sendWithPromise("Terminate",null);t.push(e);Promise.all(t).then((()=>{this.commonObjs.clear();this.fontLoader.clear();this.#l=null;this._getFieldObjectsPromise=null;this._hasJSActionsPromise=null;this._networkStream&&this._networkStream.cancelAllRequests(new _util.AbortException("Worker was terminated."));if(this.messageHandler){this.messageHandler.destroy();this.messageHandler=null}this.destroyCapability.resolve()}),this.destroyCapability.reject);return this.destroyCapability.promise}setupMessageHandler(){const{messageHandler:t,loadingTask:e}=this;t.on("GetReader",((t,e)=>{(0,_util.assert)(this._networkStream,"GetReader - no `IPDFStream` instance available.");this._fullReader=this._networkStream.getFullReader();this._fullReader.onProgress=t=>{this._lastProgress={loaded:t.loaded,total:t.total}};e.onPull=()=>{this._fullReader.read().then((function({value:t,done:s}){if(s)e.close();else{(0,_util.assert)((0,_util.isArrayBuffer)(t),"GetReader - expected an ArrayBuffer.");e.enqueue(new Uint8Array(t),1,[t])}})).catch((t=>{e.error(t)}))};e.onCancel=t=>{this._fullReader.cancel(t);e.ready.catch((t=>{if(!this.destroyed)throw t}))}}));t.on("ReaderHeadersReady",(t=>{const s=(0,_util.createPromiseCapability)(),n=this._fullReader;n.headersReady.then((()=>{if(!n.isStreamingSupported||!n.isRangeSupported){this._lastProgress&&e.onProgress?.(this._lastProgress);n.onProgress=t=>{e.onProgress?.({loaded:t.loaded,total:t.total})}}s.resolve({isStreamingSupported:n.isStreamingSupported,isRangeSupported:n.isRangeSupported,contentLength:n.contentLength})}),s.reject);return s.promise}));t.on("GetRangeReader",((t,e)=>{(0,_util.assert)(this._networkStream,"GetRangeReader - no `IPDFStream` instance available.");const s=this._networkStream.getRangeReader(t.begin,t.end);if(s){e.onPull=()=>{s.read().then((function({value:t,done:s}){if(s)e.close();else{(0,_util.assert)((0,_util.isArrayBuffer)(t),"GetRangeReader - expected an ArrayBuffer.");e.enqueue(new Uint8Array(t),1,[t])}})).catch((t=>{e.error(t)}))};e.onCancel=t=>{s.cancel(t);e.ready.catch((t=>{if(!this.destroyed)throw t}))}}else e.close()}));t.on("GetDoc",(({pdfInfo:t})=>{this._numPages=t.numPages;this._htmlForXfa=t.htmlForXfa;delete t.htmlForXfa;e._capability.resolve(new PDFDocumentProxy(t,this))}));t.on("DocException",(function(t){let s;switch(t.name){case"PasswordException":s=new _util.PasswordException(t.message,t.code);break;case"InvalidPDFException":s=new _util.InvalidPDFException(t.message);break;case"MissingPDFException":s=new _util.MissingPDFException(t.message);break;case"UnexpectedResponseException":s=new _util.UnexpectedResponseException(t.message,t.status);break;case"UnknownErrorException":s=new _util.UnknownErrorException(t.message,t.details);break;default:(0,_util.unreachable)("DocException - expected a valid Error.")}e._capability.reject(s)}));t.on("PasswordRequest",(t=>{this._passwordCapability=(0,_util.createPromiseCapability)();if(e.onPassword){const updatePassword=t=>{t instanceof Error?this._passwordCapability.reject(t):this._passwordCapability.resolve({password:t})};try{e.onPassword(updatePassword,t.code)}catch(t){this._passwordCapability.reject(t)}}else this._passwordCapability.reject(new _util.PasswordException(t.message,t.code));return this._passwordCapability.promise}));t.on("DataLoaded",(t=>{e.onProgress?.({loaded:t.length,total:t.length});this.downloadInfoCapability.resolve(t)}));t.on("StartRenderPage",(t=>{if(this.destroyed)return;this.#r.get(t.pageIndex)._startRenderPage(t.transparency,t.cacheKey)}));t.on("commonobj",(([e,s,n])=>{if(!this.destroyed&&!this.commonObjs.has(e))switch(s){case"Font":const i=this._params;if("error"in n){const t=n.error;(0,_util.warn)(`Error during font loading: ${t}`);this.commonObjs.resolve(e,t);break}let a=null;i.pdfBug&&globalThis.FontInspector?.enabled&&(a={registerFont(t,e){globalThis.FontInspector.fontAdded(t,e)}});const r=new _font_loader.FontFaceObject(n,{isEvalSupported:i.isEvalSupported,disableFontFace:i.disableFontFace,ignoreErrors:i.ignoreErrors,onUnsupportedFeature:this._onUnsupportedFeature.bind(this),fontRegistry:a});this.fontLoader.bind(r).catch((s=>t.sendWithPromise("FontFallback",{id:e}))).finally((()=>{!i.fontExtraProperties&&r.data&&(r.data=null);this.commonObjs.resolve(e,r)}));break;case"FontPath":case"Image":this.commonObjs.resolve(e,n);break;default:throw new Error(`Got unknown common object type ${s}`)}}));t.on("obj",(([t,e,s,n])=>{if(this.destroyed)return;const i=this.#r.get(e);if(!i.objs.has(t))switch(s){case"Image":i.objs.resolve(t,n);const e=8e6;if(n){let t;if(n.bitmap){const{bitmap:e,width:s,height:a}=n;t=s*a*4;i._bitmaps.add(e)}else t=n.data?.length||0;t>e&&(i.cleanupAfterRender=!0)}break;case"Pattern":i.objs.resolve(t,n);break;default:throw new Error(`Got unknown object type ${s}`)}}));t.on("DocProgress",(t=>{this.destroyed||e.onProgress?.({loaded:t.loaded,total:t.total})}));t.on("DocStats",(t=>{this.destroyed||(this.#a=Object.freeze({streamTypes:Object.freeze(t.streamTypes),fontTypes:Object.freeze(t.fontTypes)}))}));t.on("UnsupportedFeature",this._onUnsupportedFeature.bind(this));t.on("FetchBuiltInCMap",(t=>this.destroyed?Promise.reject(new Error("Worker was destroyed.")):this.CMapReaderFactory?this.CMapReaderFactory.fetch(t):Promise.reject(new Error("CMapReaderFactory not initialized, see the `useWorkerFetch` parameter."))));t.on("FetchStandardFontData",(t=>this.destroyed?Promise.reject(new Error("Worker was destroyed.")):this.StandardFontDataFactory?this.StandardFontDataFactory.fetch(t):Promise.reject(new Error("StandardFontDataFactory not initialized, see the `useWorkerFetch` parameter."))))}_onUnsupportedFeature({featureId:t}){this.destroyed||this.loadingTask.onUnsupportedFeature?.(t)}getData(){return this.messageHandler.sendWithPromise("GetData",null)}saveDocument(){this.annotationStorage.size<=0&&(0,_util.warn)("saveDocument called while `annotationStorage` is empty, please use the getData-method instead.");return this.messageHandler.sendWithPromise("SaveDocument",{isPureXfa:!!this._htmlForXfa,numPages:this._numPages,annotationStorage:this.annotationStorage.serializable,filename:this._fullReader?.filename??null}).finally((()=>{this.annotationStorage.resetModified()}))}getPage(t){if(!Number.isInteger(t)||t<=0||t>this._numPages)return Promise.reject(new Error("Invalid page request."));const e=t-1,s=this.#o.get(e);if(s)return s;const n=this.messageHandler.sendWithPromise("GetPage",{pageIndex:e}).then((t=>{if(this.destroyed)throw new Error("Transport destroyed");const s=new PDFPageProxy(e,t,this,this._params.ownerDocument,this._params.pdfBug);this.#r.set(e,s);return s}));this.#o.set(e,n);return n}getPageIndex(t){return"object"!=typeof t||null===t||!Number.isInteger(t.num)||t.num<0||!Number.isInteger(t.gen)||t.gen<0?Promise.reject(new Error("Invalid pageIndex request.")):this.messageHandler.sendWithPromise("GetPageIndex",{num:t.num,gen:t.gen})}getAnnotations(t,e){return this.messageHandler.sendWithPromise("GetAnnotations",{pageIndex:t,intent:e})}getFieldObjects(){return this._getFieldObjectsPromise||=this.messageHandler.sendWithPromise("GetFieldObjects",null)}hasJSActions(){return this._hasJSActionsPromise||=this.messageHandler.sendWithPromise("HasJSActions",null)}getCalculationOrderIds(){return this.messageHandler.sendWithPromise("GetCalculationOrderIds",null)}getDestinations(){return this.messageHandler.sendWithPromise("GetDestinations",null)}getDestination(t){return"string"!=typeof t?Promise.reject(new Error("Invalid destination request.")):this.messageHandler.sendWithPromise("GetDestination",{id:t})}getPageLabels(){return this.messageHandler.sendWithPromise("GetPageLabels",null)}getPageLayout(){return this.messageHandler.sendWithPromise("GetPageLayout",null)}getPageMode(){return this.messageHandler.sendWithPromise("GetPageMode",null)}getViewerPreferences(){return this.messageHandler.sendWithPromise("GetViewerPreferences",null)}getOpenAction(){return this.messageHandler.sendWithPromise("GetOpenAction",null)}getAttachments(){return this.messageHandler.sendWithPromise("GetAttachments",null)}getJavaScript(){return this.messageHandler.sendWithPromise("GetJavaScript",null)}getDocJSActions(){return this.messageHandler.sendWithPromise("GetDocJSActions",null)}getPageJSActions(t){return this.messageHandler.sendWithPromise("GetPageJSActions",{pageIndex:t})}getStructTree(t){return this.messageHandler.sendWithPromise("GetStructTree",{pageIndex:t})}getOutline(){return this.messageHandler.sendWithPromise("GetOutline",null)}getOptionalContentConfig(){return this.messageHandler.sendWithPromise("GetOptionalContentConfig",null).then((t=>new _optional_content_config.OptionalContentConfig(t)))}getPermissions(){return this.messageHandler.sendWithPromise("GetPermissions",null)}getMetadata(){return this.#l||=this.messageHandler.sendWithPromise("GetMetadata",null).then((t=>({info:t[0],metadata:t[1]?new _metadata.Metadata(t[1]):null,contentDispositionFilename:this._fullReader?.filename??null,contentLength:this._fullReader?.contentLength??null})))}getMarkInfo(){return this.messageHandler.sendWithPromise("GetMarkInfo",null)}async startCleanup(t=!1){if(!this.destroyed){await this.messageHandler.sendWithPromise("Cleanup",null);for(const t of this.#r.values()){if(!t.cleanup())throw new Error(`startCleanup: Page ${t.pageNumber} is currently rendering.`)}this.commonObjs.clear();t||this.fontLoader.clear();this.#l=null;this._getFieldObjectsPromise=null;this._hasJSActionsPromise=null}}get loadingParams(){const t=this._params;return(0,_util.shadow)(this,"loadingParams",{disableAutoFetch:t.disableAutoFetch,enableXfa:t.enableXfa})}}class PDFObjects{#c=Object.create(null);#h(t){const e=this.#c[t];return e||(this.#c[t]={capability:(0,_util.createPromiseCapability)(),data:null})}get(t,e=null){if(e){const s=this.#h(t);s.capability.promise.then((()=>e(s.data)));return null}const s=this.#c[t];if(!s?.capability.settled)throw new Error(`Requesting object that isn't resolved yet ${t}.`);return s.data}has(t){return this.#c[t]?.capability.settled||!1}resolve(t,e=null){const s=this.#h(t);s.data=e;s.capability.resolve()}clear(){this.#c=Object.create(null)}}class RenderTask{#d=null;constructor(t){this.#d=t;this.onContinue=null}get promise(){return this.#d.capability.promise}cancel(t=0){this.#d.cancel(null,t)}get separateAnnots(){const{separateAnnots:t}=this.#d.operatorList;if(!t)return!1;const{annotationCanvasMap:e}=this.#d;return t.form||t.canvas&&e?.size>0}}exports.RenderTask=RenderTask;class InternalRenderTask{static#u=new WeakSet;constructor({callback:t,params:e,objs:s,commonObjs:n,annotationCanvasMap:i,operatorList:a,pageIndex:r,canvasFactory:o,useRequestAnimationFrame:l=!1,pdfBug:c=!1,pageColors:h=null}){this.callback=t;this.params=e;this.objs=s;this.commonObjs=n;this.annotationCanvasMap=i;this.operatorListIdx=null;this.operatorList=a;this._pageIndex=r;this.canvasFactory=o;this._pdfBug=c;this.pageColors=h;this.running=!1;this.graphicsReadyCallback=null;this.graphicsReady=!1;this._useRequestAnimationFrame=!0===l&&"undefined"!=typeof window;this.cancelled=!1;this.capability=(0,_util.createPromiseCapability)();this.task=new RenderTask(this);this._cancelBound=this.cancel.bind(this);this._continueBound=this._continue.bind(this);this._scheduleNextBound=this._scheduleNext.bind(this);this._nextBound=this._next.bind(this);this._canvas=e.canvasContext.canvas}get completed(){return this.capability.promise.catch((function(){}))}initializeGraphics({transparency:t=!1,optionalContentConfig:e}){if(this.cancelled)return;if(this._canvas){if(InternalRenderTask.#u.has(this._canvas))throw new Error("Cannot use the same canvas during multiple render() operations. Use different canvas or ensure previous operations were cancelled or completed.");InternalRenderTask.#u.add(this._canvas)}if(this._pdfBug&&globalThis.StepperManager?.enabled){this.stepper=globalThis.StepperManager.create(this._pageIndex);this.stepper.init(this.operatorList);this.stepper.nextBreakPoint=this.stepper.getNextBreakPoint()}const{canvasContext:s,viewport:n,transform:i,background:a}=this.params;this.gfx=new _canvas.CanvasGraphics(s,this.commonObjs,this.objs,this.canvasFactory,{optionalContentConfig:e},this.annotationCanvasMap,this.pageColors);this.gfx.beginDrawing({transform:i,viewport:n,transparency:t,background:a});this.operatorListIdx=0;this.graphicsReady=!0;this.graphicsReadyCallback?.()}cancel(t=null,e=0){this.running=!1;this.cancelled=!0;this.gfx?.endDrawing();this._canvas&&InternalRenderTask.#u.delete(this._canvas);this.callback(t||new _display_utils.RenderingCancelledException(`Rendering cancelled, page ${this._pageIndex+1}`,"canvas",e))}operatorListChanged(){if(this.graphicsReady){this.stepper?.updateOperatorList(this.operatorList);this.running||this._continue()}else this.graphicsReadyCallback||(this.graphicsReadyCallback=this._continueBound)}_continue(){this.running=!0;this.cancelled||(this.task.onContinue?this.task.onContinue(this._scheduleNextBound):this._scheduleNext())}_scheduleNext(){this._useRequestAnimationFrame?window.requestAnimationFrame((()=>{this._nextBound().catch(this._cancelBound)})):Promise.resolve().then(this._nextBound).catch(this._cancelBound)}async _next(){if(!this.cancelled){this.operatorListIdx=this.gfx.executeOperatorList(this.operatorList,this.operatorListIdx,this._continueBound,this.stepper);if(this.operatorListIdx===this.operatorList.argsArray.length){this.running=!1;if(this.operatorList.lastChunk){this.gfx.endDrawing();this._canvas&&InternalRenderTask.#u.delete(this._canvas);this.callback()}}}}}const version="3.2.146";exports.version=version;const build="3fd2a3548";exports.build=build},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.PrintAnnotationStorage=e.AnnotationStorage=void 0;var n=s(1),i=s(4),a=s(8);class AnnotationStorage{#p=!1;#g=new Map;constructor(){this.onSetModified=null;this.onResetModified=null;this.onAnnotationEditor=null}getValue(t,e){const s=this.#g.get(t);return void 0===s?e:Object.assign(e,s)}getRawValue(t){return this.#g.get(t)}remove(t){this.#g.delete(t);0===this.#g.size&&this.resetModified();if("function"==typeof this.onAnnotationEditor){for(const t of this.#g.values())if(t instanceof i.AnnotationEditor)return;this.onAnnotationEditor(null)}}setValue(t,e){const s=this.#g.get(t);let n=!1;if(void 0!==s){for(const[t,i]of Object.entries(e))if(s[t]!==i){n=!0;s[t]=i}}else{n=!0;this.#g.set(t,e)}n&&this.#m();e instanceof i.AnnotationEditor&&"function"==typeof this.onAnnotationEditor&&this.onAnnotationEditor(e.constructor._type)}has(t){return this.#g.has(t)}getAll(){return this.#g.size>0?(0,n.objectFromMap)(this.#g):null}get size(){return this.#g.size}#m(){if(!this.#p){this.#p=!0;"function"==typeof this.onSetModified&&this.onSetModified()}}resetModified(){if(this.#p){this.#p=!1;"function"==typeof this.onResetModified&&this.onResetModified()}}get print(){return new PrintAnnotationStorage(this)}get serializable(){if(0===this.#g.size)return null;const t=new Map;for(const[e,s]of this.#g){const n=s instanceof i.AnnotationEditor?s.serialize():s;n&&t.set(e,n)}return t}static getHash(t){if(!t)return"";const e=new a.MurmurHash3_64;for(const[s,n]of t)e.update(`${s}:${JSON.stringify(n)}`);return e.hexdigest()}}e.AnnotationStorage=AnnotationStorage;class PrintAnnotationStorage extends AnnotationStorage{#f=null;constructor(t){super();this.#f=structuredClone(t.serializable)}get print(){(0,n.unreachable)("Should not call PrintAnnotationStorage.print")}get serializable(){return this.#f}}e.PrintAnnotationStorage=PrintAnnotationStorage},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.AnnotationEditor=void 0;var n=s(5),i=s(1);class AnnotationEditor{#b=this.focusin.bind(this);#A=this.focusout.bind(this);#_=!1;#y=!1;#v=!1;_uiManager=null;#S=AnnotationEditor._zIndex++;static _colorManager=new n.ColorManager;static _zIndex=1;constructor(t){this.constructor===AnnotationEditor&&(0,i.unreachable)("Cannot initialize AnnotationEditor.");this.parent=t.parent;this.id=t.id;this.width=this.height=null;this.pageIndex=t.parent.pageIndex;this.name=t.name;this.div=null;this._uiManager=t.uiManager;const{rotation:e,rawDims:{pageWidth:s,pageHeight:n,pageX:a,pageY:r}}=this.parent.viewport;this.rotation=e;this.pageDimensions=[s,n];this.pageTranslation=[a,r];const[o,l]=this.parentDimensions;this.x=t.x/o;this.y=t.y/l;this.isAttachedToDOM=!1}static get _defaultLineColor(){return(0,i.shadow)(this,"_defaultLineColor",this._colorManager.getHexCode("CanvasText"))}addCommands(t){this._uiManager.addCommands(t)}get currentLayer(){return this._uiManager.currentLayer}setInBackground(){this.div.style.zIndex=0}setInForeground(){this.div.style.zIndex=this.#S}setParent(t){if(null!==t){this.pageIndex=t.pageIndex;this.pageDimensions=t.pageDimensions}this.parent=t}focusin(t){this.#_?this.#_=!1:this.parent.setSelected(this)}focusout(t){if(!this.isAttachedToDOM)return;if(!t.relatedTarget?.closest(`#${this.id}`)){t.preventDefault();this.parent?.isMultipleSelection||this.commitOrRemove()}}commitOrRemove(){this.isEmpty()?this.remove():this.commit()}commit(){this.addToAnnotationStorage()}addToAnnotationStorage(){this._uiManager.addToAnnotationStorage(this)}dragstart(t){const e=this.parent.div.getBoundingClientRect();this.startX=t.clientX-e.x;this.startY=t.clientY-e.y;t.dataTransfer.setData("text/plain",this.id);t.dataTransfer.effectAllowed="move"}setAt(t,e,s,n){const[i,a]=this.parentDimensions;[s,n]=this.screenToPageTranslation(s,n);this.x=(t+s)/i;this.y=(e+n)/a;this.div.style.left=100*this.x+"%";this.div.style.top=100*this.y+"%"}translate(t,e){const[s,n]=this.parentDimensions;[t,e]=this.screenToPageTranslation(t,e);this.x+=t/s;this.y+=e/n;this.div.style.left=100*this.x+"%";this.div.style.top=100*this.y+"%"}screenToPageTranslation(t,e){switch(this.parentRotation){case 90:return[e,-t];case 180:return[-t,-e];case 270:return[-e,t];default:return[t,e]}}get parentScale(){return this._uiManager.viewParameters.realScale}get parentRotation(){return this._uiManager.viewParameters.rotation}get parentDimensions(){const{realScale:t}=this._uiManager.viewParameters,[e,s]=this.pageDimensions;return[e*t,s*t]}setDims(t,e){const[s,n]=this.parentDimensions;this.div.style.width=100*t/s+"%";this.div.style.height=100*e/n+"%"}fixDims(){const{style:t}=this.div,{height:e,width:s}=t,n=s.endsWith("%"),i=e.endsWith("%");if(n&&i)return;const[a,r]=this.parentDimensions;n||(t.width=100*parseFloat(s)/a+"%");i||(t.height=100*parseFloat(e)/r+"%")}getInitialTranslation(){return[0,0]}render(){this.div=document.createElement("div");this.div.setAttribute("data-editor-rotation",(360-this.rotation)%360);this.div.className=this.name;this.div.setAttribute("id",this.id);this.div.setAttribute("tabIndex",0);this.setInForeground();this.div.addEventListener("focusin",this.#b);this.div.addEventListener("focusout",this.#A);const[t,e]=this.getInitialTranslation();this.translate(t,e);(0,n.bindEvents)(this,this.div,["dragstart","pointerdown"]);return this.div}pointerdown(t){const{isMac:e}=i.FeatureTest.platform;if(0!==t.button||t.ctrlKey&&e)t.preventDefault();else{t.ctrlKey&&!e||t.shiftKey||t.metaKey&&e?this.parent.toggleSelected(this):this.parent.setSelected(this);this.#_=!0}}getRect(t,e){const s=this.parentScale,[n,i]=this.pageDimensions,[a,r]=this.pageTranslation,o=t/s,l=e/s,c=this.x*n,h=this.y*i,d=this.width*n,u=this.height*i;switch(this.rotation){case 0:return[c+o+a,i-h-l-u+r,c+o+d+a,i-h-l+r];case 90:return[c+l+a,i-h+o+r,c+l+u+a,i-h+o+d+r];case 180:return[c-o-d+a,i-h+l+r,c-o+a,i-h+l+u+r];case 270:return[c-l-u+a,i-h-o-d+r,c-l+a,i-h-o+r];default:throw new Error("Invalid rotation")}}getRectInCurrentCoords(t,e){const[s,n,i,a]=t,r=i-s,o=a-n;switch(this.rotation){case 0:return[s,e-a,r,o];case 90:return[s,e-n,o,r];case 180:return[i,e-n,r,o];case 270:return[i,e-a,o,r];default:throw new Error("Invalid rotation")}}onceAdded(){}isEmpty(){return!1}enableEditMode(){this.#v=!0}disableEditMode(){this.#v=!1}isInEditMode(){return this.#v}shouldGetKeyboardEvents(){return!1}needsToBeRebuilt(){return this.div&&!this.isAttachedToDOM}rebuild(){this.div?.addEventListener("focusin",this.#b)}serialize(){(0,i.unreachable)("An editor must be serializable")}static deserialize(t,e,s){const n=new this.prototype.constructor({parent:e,id:e.getNextId(),uiManager:s});n.rotation=t.rotation;const[i,a]=n.pageDimensions,[r,o,l,c]=n.getRectInCurrentCoords(t.rect,a);n.x=r/i;n.y=o/a;n.width=l/i;n.height=c/a;return n}remove(){this.div.removeEventListener("focusin",this.#b);this.div.removeEventListener("focusout",this.#A);this.isEmpty()||this.commit();this.parent.remove(this)}select(){this.div?.classList.add("selectedEditor")}unselect(){this.div?.classList.remove("selectedEditor")}updateParams(t,e){}disableEditing(){}enableEditing(){}get propertiesToUpdate(){return{}}get contentDiv(){return this.div}get isEditing(){return this.#y}set isEditing(t){this.#y=t;if(t){this.parent.setSelected(this);this.parent.setActiveEditor(this)}else this.parent.setActiveEditor(null)}}e.AnnotationEditor=AnnotationEditor},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.KeyboardManager=e.CommandManager=e.ColorManager=e.AnnotationEditorUIManager=void 0;e.bindEvents=function bindEvents(t,e,s){for(const n of s)e.addEventListener(n,t[n].bind(t))};e.opacityToHex=function opacityToHex(t){return Math.round(Math.min(255,Math.max(1,255*t))).toString(16).padStart(2,"0")};var n=s(1),i=s(6);class IdManager{#x=0;getId(){return`${n.AnnotationEditorPrefix}${this.#x++}`}}class CommandManager{#E=[];#C=!1;#P;#T=-1;constructor(t=128){this.#P=t}add({cmd:t,undo:e,mustExec:s,type:n=NaN,overwriteIfSameType:i=!1,keepUndo:a=!1}){s&&t();if(this.#C)return;const r={cmd:t,undo:e,type:n};if(-1===this.#T){this.#E.length>0&&(this.#E.length=0);this.#T=0;this.#E.push(r);return}if(i&&this.#E[this.#T].type===n){a&&(r.undo=this.#E[this.#T].undo);this.#E[this.#T]=r;return}const o=this.#T+1;if(o===this.#P)this.#E.splice(0,1);else{this.#T=o;ot===e[s])))return ColorManager._colorsMapping.get(t);return e}getHexCode(t){const e=this._colors.get(t);return e?n.Util.makeHexColor(...e):t}}e.ColorManager=ColorManager;class AnnotationEditorUIManager{#k=null;#F=new Map;#R=new Map;#M=null;#D=new CommandManager;#I=0;#O=null;#L=new Set;#N=null;#j=new IdManager;#U=!1;#B=n.AnnotationEditorType.NONE;#q=new Set;#W=this.copy.bind(this);#H=this.cut.bind(this);#G=this.paste.bind(this);#z=this.keydown.bind(this);#V=this.onEditingAction.bind(this);#X=this.onPageChanging.bind(this);#$=this.onScaleChanging.bind(this);#Y=this.onRotationChanging.bind(this);#K={isEditing:!1,isEmpty:!0,hasSomethingToUndo:!1,hasSomethingToRedo:!1,hasSelectedEditor:!1};#J=null;static _keyboardManager=new KeyboardManager([[["ctrl+a","mac+meta+a"],AnnotationEditorUIManager.prototype.selectAll],[["ctrl+z","mac+meta+z"],AnnotationEditorUIManager.prototype.undo],[["ctrl+y","ctrl+shift+Z","mac+meta+shift+Z"],AnnotationEditorUIManager.prototype.redo],[["Backspace","alt+Backspace","ctrl+Backspace","shift+Backspace","mac+Backspace","mac+alt+Backspace","mac+ctrl+Backspace","Delete","ctrl+Delete","shift+Delete"],AnnotationEditorUIManager.prototype.delete],[["Escape","mac+Escape"],AnnotationEditorUIManager.prototype.unselectAll]]);constructor(t,e,s){this.#J=t;this.#N=e;this.#N._on("editingaction",this.#V);this.#N._on("pagechanging",this.#X);this.#N._on("scalechanging",this.#$);this.#N._on("rotationchanging",this.#Y);this.#M=s;this.viewParameters={realScale:i.PixelsPerInch.PDF_TO_CSS_UNITS,rotation:0}}destroy(){this.#Q();this.#N._off("editingaction",this.#V);this.#N._off("pagechanging",this.#X);this.#N._off("scalechanging",this.#$);this.#N._off("rotationchanging",this.#Y);for(const t of this.#R.values())t.destroy();this.#R.clear();this.#F.clear();this.#L.clear();this.#k=null;this.#q.clear();this.#D.destroy()}onPageChanging({pageNumber:t}){this.#I=t-1}focusMainContainer(){this.#J.focus()}addShouldRescale(t){this.#L.add(t)}removeShouldRescale(t){this.#L.delete(t)}onScaleChanging({scale:t}){this.commitOrRemove();this.viewParameters.realScale=t*i.PixelsPerInch.PDF_TO_CSS_UNITS;for(const t of this.#L)t.onScaleChanging()}onRotationChanging({pagesRotation:t}){this.commitOrRemove();this.viewParameters.rotation=t}addToAnnotationStorage(t){t.isEmpty()||!this.#M||this.#M.has(t.id)||this.#M.setValue(t.id,t)}#Z(){this.#J.addEventListener("keydown",this.#z)}#Q(){this.#J.removeEventListener("keydown",this.#z)}#tt(){document.addEventListener("copy",this.#W);document.addEventListener("cut",this.#H);document.addEventListener("paste",this.#G)}#et(){document.removeEventListener("copy",this.#W);document.removeEventListener("cut",this.#H);document.removeEventListener("paste",this.#G)}copy(t){t.preventDefault();this.#k&&this.#k.commitOrRemove();if(!this.hasSelection)return;const e=[];for(const t of this.#q)t.isEmpty()||e.push(t.serialize());0!==e.length&&t.clipboardData.setData("application/pdfjs",JSON.stringify(e))}cut(t){this.copy(t);this.delete()}paste(t){t.preventDefault();let e=t.clipboardData.getData("application/pdfjs");if(!e)return;try{e=JSON.parse(e)}catch(t){(0,n.warn)(`paste: "${t.message}".`);return}if(!Array.isArray(e))return;this.unselectAll();const s=this.#R.get(this.#I);try{const t=[];for(const n of e){const e=s.deserialize(n);if(!e)return;t.push(e)}const cmd=()=>{for(const e of t)this.#st(e);this.#nt(t)},undo=()=>{for(const e of t)e.remove()};this.addCommands({cmd:cmd,undo:undo,mustExec:!0})}catch(t){(0,n.warn)(`paste: "${t.message}".`)}}keydown(t){this.getActive()?.shouldGetKeyboardEvents()||AnnotationEditorUIManager._keyboardManager.exec(this,t)}onEditingAction(t){["undo","redo","delete","selectAll"].includes(t.name)&&this[t.name]()}#it(t){Object.entries(t).some((([t,e])=>this.#K[t]!==e))&&this.#N.dispatch("annotationeditorstateschanged",{source:this,details:Object.assign(this.#K,t)})}#at(t){this.#N.dispatch("annotationeditorparamschanged",{source:this,details:t})}setEditingState(t){if(t){this.#Z();this.#tt();this.#it({isEditing:this.#B!==n.AnnotationEditorType.NONE,isEmpty:this.#rt(),hasSomethingToUndo:this.#D.hasSomethingToUndo(),hasSomethingToRedo:this.#D.hasSomethingToRedo(),hasSelectedEditor:!1})}else{this.#Q();this.#et();this.#it({isEditing:!1})}}registerEditorTypes(t){if(!this.#O){this.#O=t;for(const t of this.#O)this.#at(t.defaultPropertiesToUpdate)}}getId(){return this.#j.getId()}get currentLayer(){return this.#R.get(this.#I)}get currentPageIndex(){return this.#I}addLayer(t){this.#R.set(t.pageIndex,t);this.#U?t.enable():t.disable()}removeLayer(t){this.#R.delete(t.pageIndex)}updateMode(t){this.#B=t;if(t===n.AnnotationEditorType.NONE){this.setEditingState(!1);this.#ot()}else{this.setEditingState(!0);this.#lt();for(const e of this.#R.values())e.updateMode(t)}}updateToolbar(t){t!==this.#B&&this.#N.dispatch("switchannotationeditormode",{source:this,mode:t})}updateParams(t,e){if(this.#O){for(const s of this.#q)s.updateParams(t,e);for(const s of this.#O)s.updateDefaultParams(t,e)}}#lt(){if(!this.#U){this.#U=!0;for(const t of this.#R.values())t.enable()}}#ot(){this.unselectAll();if(this.#U){this.#U=!1;for(const t of this.#R.values())t.disable()}}getEditors(t){const e=[];for(const s of this.#F.values())s.pageIndex===t&&e.push(s);return e}getEditor(t){return this.#F.get(t)}addEditor(t){this.#F.set(t.id,t)}removeEditor(t){this.#F.delete(t.id);this.unselect(t);this.#M?.remove(t.id)}#st(t){const e=this.#R.get(t.pageIndex);e?e.addOrRebuild(t):this.addEditor(t)}setActiveEditor(t){if(this.#k!==t){this.#k=t;t&&this.#at(t.propertiesToUpdate)}}toggleSelected(t){if(this.#q.has(t)){this.#q.delete(t);t.unselect();this.#it({hasSelectedEditor:this.hasSelection})}else{this.#q.add(t);t.select();this.#at(t.propertiesToUpdate);this.#it({hasSelectedEditor:!0})}}setSelected(t){for(const e of this.#q)e!==t&&e.unselect();this.#q.clear();this.#q.add(t);t.select();this.#at(t.propertiesToUpdate);this.#it({hasSelectedEditor:!0})}isSelected(t){return this.#q.has(t)}unselect(t){t.unselect();this.#q.delete(t);this.#it({hasSelectedEditor:this.hasSelection})}get hasSelection(){return 0!==this.#q.size}undo(){this.#D.undo();this.#it({hasSomethingToUndo:this.#D.hasSomethingToUndo(),hasSomethingToRedo:!0,isEmpty:this.#rt()})}redo(){this.#D.redo();this.#it({hasSomethingToUndo:!0,hasSomethingToRedo:this.#D.hasSomethingToRedo(),isEmpty:this.#rt()})}addCommands(t){this.#D.add(t);this.#it({hasSomethingToUndo:!0,hasSomethingToRedo:!1,isEmpty:this.#rt()})}#rt(){if(0===this.#F.size)return!0;if(1===this.#F.size)for(const t of this.#F.values())return t.isEmpty();return!1}delete(){this.commitOrRemove();if(!this.hasSelection)return;const t=[...this.#q];this.addCommands({cmd:()=>{for(const e of t)e.remove()},undo:()=>{for(const e of t)this.#st(e)},mustExec:!0})}commitOrRemove(){this.#k?.commitOrRemove()}#nt(t){this.#q.clear();for(const e of t)if(!e.isEmpty()){this.#q.add(e);e.select()}this.#it({hasSelectedEditor:!0})}selectAll(){for(const t of this.#q)t.commit();this.#nt(this.#F.values())}unselectAll(){if(this.#k)this.#k.commitOrRemove();else if(0!==this.#q.size){for(const t of this.#q)t.unselect();this.#q.clear();this.#it({hasSelectedEditor:!1})}}isActive(t){return this.#k===t}getActive(){return this.#k}getMode(){return this.#B}}e.AnnotationEditorUIManager=AnnotationEditorUIManager},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.StatTimer=e.RenderingCancelledException=e.PixelsPerInch=e.PageViewport=e.PDFDateString=e.DOMStandardFontDataFactory=e.DOMSVGFactory=e.DOMCanvasFactory=e.DOMCMapReaderFactory=e.AnnotationPrefix=void 0;e.deprecated=function deprecated(t){console.log("Deprecated API usage: "+t)};e.getColorValues=function getColorValues(t){const e=document.createElement("span");e.style.visibility="hidden";document.body.append(e);for(const s of t.keys()){e.style.color=s;const n=window.getComputedStyle(e).color;t.set(s,getRGB(n))}e.remove()};e.getCurrentTransform=function getCurrentTransform(t){const{a:e,b:s,c:n,d:i,e:a,f:r}=t.getTransform();return[e,s,n,i,a,r]};e.getCurrentTransformInverse=function getCurrentTransformInverse(t){const{a:e,b:s,c:n,d:i,e:a,f:r}=t.getTransform().invertSelf();return[e,s,n,i,a,r]};e.getFilenameFromUrl=function getFilenameFromUrl(t,e=!1){e||([t]=t.split(/[#?]/,1));return t.substring(t.lastIndexOf("/")+1)};e.getPdfFilenameFromUrl=function getPdfFilenameFromUrl(t,e="document.pdf"){if("string"!=typeof t)return e;if(isDataScheme(t)){(0,i.warn)('getPdfFilenameFromUrl: ignore "data:"-URL for performance reasons.');return e}const s=/[^/?#=]+\.pdf\b(?!.*\.pdf\b)/i,n=/^(?:(?:[^:]+:)?\/\/[^/]+)?([^?#]*)(\?[^#]*)?(#.*)?$/.exec(t);let a=s.exec(n[1])||s.exec(n[2])||s.exec(n[3]);if(a){a=a[0];if(a.includes("%"))try{a=s.exec(decodeURIComponent(a))[0]}catch(t){}}return a||e};e.getRGB=getRGB;e.getXfaPageViewport=function getXfaPageViewport(t,{scale:e=1,rotation:s=0}){const{width:n,height:i}=t.attributes.style,a=[0,0,parseInt(n),parseInt(i)];return new PageViewport({viewBox:a,scale:e,rotation:s})};e.isDataScheme=isDataScheme;e.isPdfFile=function isPdfFile(t){return"string"==typeof t&&/\.pdf$/i.test(t)};e.isValidFetchUrl=isValidFetchUrl;e.loadScript=function loadScript(t,e=!1){return new Promise(((s,n)=>{const i=document.createElement("script");i.src=t;i.onload=function(t){e&&i.remove();s(t)};i.onerror=function(){n(new Error(`Cannot load script at: ${i.src}`))};(document.head||document.documentElement).append(i)}))};e.setLayerDimensions=function setLayerDimensions(t,e,s=!1,n=!0){if(e instanceof PageViewport){const{pageWidth:n,pageHeight:i}=e.rawDims,{style:a}=t,r=`calc(var(--scale-factor) * ${n}px)`,o=`calc(var(--scale-factor) * ${i}px)`;if(s&&e.rotation%180!=0){a.width=o;a.height=r}else{a.width=r;a.height=o}}n&&t.setAttribute("data-main-rotation",e.rotation)};var n=s(7),i=s(1);e.AnnotationPrefix="pdfjs_internal_id_";class PixelsPerInch{static CSS=96;static PDF=72;static PDF_TO_CSS_UNITS=this.CSS/this.PDF}e.PixelsPerInch=PixelsPerInch;class DOMCanvasFactory extends n.BaseCanvasFactory{constructor({ownerDocument:t=globalThis.document}={}){super();this._document=t}_createCanvas(t,e){const s=this._document.createElement("canvas");s.width=t;s.height=e;return s}}e.DOMCanvasFactory=DOMCanvasFactory;async function fetchData(t,e=!1){if(isValidFetchUrl(t,document.baseURI)){const s=await fetch(t);if(!s.ok)throw new Error(s.statusText);return e?new Uint8Array(await s.arrayBuffer()):(0,i.stringToBytes)(await s.text())}return new Promise(((s,n)=>{const a=new XMLHttpRequest;a.open("GET",t,!0);e&&(a.responseType="arraybuffer");a.onreadystatechange=()=>{if(a.readyState===XMLHttpRequest.DONE){if(200===a.status||0===a.status){let t;e&&a.response?t=new Uint8Array(a.response):!e&&a.responseText&&(t=(0,i.stringToBytes)(a.responseText));if(t){s(t);return}}n(new Error(a.statusText))}};a.send(null)}))}class DOMCMapReaderFactory extends n.BaseCMapReaderFactory{_fetchData(t,e){return fetchData(t,this.isCompressed).then((t=>({cMapData:t,compressionType:e})))}}e.DOMCMapReaderFactory=DOMCMapReaderFactory;class DOMStandardFontDataFactory extends n.BaseStandardFontDataFactory{_fetchData(t){return fetchData(t,!0)}}e.DOMStandardFontDataFactory=DOMStandardFontDataFactory;class DOMSVGFactory extends n.BaseSVGFactory{_createSVG(t){return document.createElementNS("http://www.w3.org/2000/svg",t)}}e.DOMSVGFactory=DOMSVGFactory;class PageViewport{constructor({viewBox:t,scale:e,rotation:s,offsetX:n=0,offsetY:i=0,dontFlip:a=!1}){this.viewBox=t;this.scale=e;this.rotation=s;this.offsetX=n;this.offsetY=i;const r=(t[2]+t[0])/2,o=(t[3]+t[1])/2;let l,c,h,d,u,p,g,m;(s%=360)<0&&(s+=360);switch(s){case 180:l=-1;c=0;h=0;d=1;break;case 90:l=0;c=1;h=1;d=0;break;case 270:l=0;c=-1;h=-1;d=0;break;case 0:l=1;c=0;h=0;d=-1;break;default:throw new Error("PageViewport: Invalid rotation, must be a multiple of 90 degrees.")}if(a){h=-h;d=-d}if(0===l){u=Math.abs(o-t[1])*e+n;p=Math.abs(r-t[0])*e+i;g=(t[3]-t[1])*e;m=(t[2]-t[0])*e}else{u=Math.abs(r-t[0])*e+n;p=Math.abs(o-t[1])*e+i;g=(t[2]-t[0])*e;m=(t[3]-t[1])*e}this.transform=[l*e,c*e,h*e,d*e,u-l*e*r-h*e*o,p-c*e*r-d*e*o];this.width=g;this.height=m}get rawDims(){const{viewBox:t}=this;return(0,i.shadow)(this,"rawDims",{pageWidth:t[2]-t[0],pageHeight:t[3]-t[1],pageX:t[0],pageY:t[1]})}clone({scale:t=this.scale,rotation:e=this.rotation,offsetX:s=this.offsetX,offsetY:n=this.offsetY,dontFlip:i=!1}={}){return new PageViewport({viewBox:this.viewBox.slice(),scale:t,rotation:e,offsetX:s,offsetY:n,dontFlip:i})}convertToViewportPoint(t,e){return i.Util.applyTransform([t,e],this.transform)}convertToViewportRectangle(t){const e=i.Util.applyTransform([t[0],t[1]],this.transform),s=i.Util.applyTransform([t[2],t[3]],this.transform);return[e[0],e[1],s[0],s[1]]}convertToPdfPoint(t,e){return i.Util.applyInverseTransform([t,e],this.transform)}}e.PageViewport=PageViewport;class RenderingCancelledException extends i.BaseException{constructor(t,e,s=0){super(t,"RenderingCancelledException");this.type=e;this.extraDelay=s}}e.RenderingCancelledException=RenderingCancelledException;function isDataScheme(t){const e=t.length;let s=0;for(;s=1&&n<=12?n-1:0;let i=parseInt(e[3],10);i=i>=1&&i<=31?i:1;let r=parseInt(e[4],10);r=r>=0&&r<=23?r:0;let o=parseInt(e[5],10);o=o>=0&&o<=59?o:0;let l=parseInt(e[6],10);l=l>=0&&l<=59?l:0;const c=e[7]||"Z";let h=parseInt(e[8],10);h=h>=0&&h<=23?h:0;let d=parseInt(e[9],10)||0;d=d>=0&&d<=59?d:0;if("-"===c){r+=h;o+=d}else if("+"===c){r-=h;o-=d}return new Date(Date.UTC(s,n,i,r,o,l))}};function getRGB(t){if(t.startsWith("#")){const e=parseInt(t.slice(1),16);return[(16711680&e)>>16,(65280&e)>>8,255&e]}if(t.startsWith("rgb("))return t.slice(4,-1).split(",").map((t=>parseInt(t)));if(t.startsWith("rgba("))return t.slice(5,-1).split(",").map((t=>parseInt(t))).slice(0,3);(0,i.warn)(`Not a valid color format: "${t}"`);return[0,0,0]}},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.BaseStandardFontDataFactory=e.BaseSVGFactory=e.BaseCanvasFactory=e.BaseCMapReaderFactory=void 0;var n=s(1);class BaseCanvasFactory{constructor(){this.constructor===BaseCanvasFactory&&(0,n.unreachable)("Cannot initialize BaseCanvasFactory.")}create(t,e){if(t<=0||e<=0)throw new Error("Invalid canvas size");const s=this._createCanvas(t,e);return{canvas:s,context:s.getContext("2d")}}reset(t,e,s){if(!t.canvas)throw new Error("Canvas is not specified");if(e<=0||s<=0)throw new Error("Invalid canvas size");t.canvas.width=e;t.canvas.height=s}destroy(t){if(!t.canvas)throw new Error("Canvas is not specified");t.canvas.width=0;t.canvas.height=0;t.canvas=null;t.context=null}_createCanvas(t,e){(0,n.unreachable)("Abstract method `_createCanvas` called.")}}e.BaseCanvasFactory=BaseCanvasFactory;class BaseCMapReaderFactory{constructor({baseUrl:t=null,isCompressed:e=!1}){this.constructor===BaseCMapReaderFactory&&(0,n.unreachable)("Cannot initialize BaseCMapReaderFactory.");this.baseUrl=t;this.isCompressed=e}async fetch({name:t}){if(!this.baseUrl)throw new Error('The CMap "baseUrl" parameter must be specified, ensure that the "cMapUrl" and "cMapPacked" API parameters are provided.');if(!t)throw new Error("CMap name must be specified.");const e=this.baseUrl+t+(this.isCompressed?".bcmap":""),s=this.isCompressed?n.CMapCompressionType.BINARY:n.CMapCompressionType.NONE;return this._fetchData(e,s).catch((t=>{throw new Error(`Unable to load ${this.isCompressed?"binary ":""}CMap at: ${e}`)}))}_fetchData(t,e){(0,n.unreachable)("Abstract method `_fetchData` called.")}}e.BaseCMapReaderFactory=BaseCMapReaderFactory;class BaseStandardFontDataFactory{constructor({baseUrl:t=null}){this.constructor===BaseStandardFontDataFactory&&(0,n.unreachable)("Cannot initialize BaseStandardFontDataFactory.");this.baseUrl=t}async fetch({filename:t}){if(!this.baseUrl)throw new Error('The standard font "baseUrl" parameter must be specified, ensure that the "standardFontDataUrl" API parameter is provided.');if(!t)throw new Error("Font filename must be specified.");const e=`${this.baseUrl}${t}`;return this._fetchData(e).catch((t=>{throw new Error(`Unable to load font data at: ${e}`)}))}_fetchData(t){(0,n.unreachable)("Abstract method `_fetchData` called.")}}e.BaseStandardFontDataFactory=BaseStandardFontDataFactory;class BaseSVGFactory{constructor(){this.constructor===BaseSVGFactory&&(0,n.unreachable)("Cannot initialize BaseSVGFactory.")}create(t,e,s=!1){if(t<=0||e<=0)throw new Error("Invalid SVG dimensions");const n=this._createSVG("svg:svg");n.setAttribute("version","1.1");if(!s){n.setAttribute("width",`${t}px`);n.setAttribute("height",`${e}px`)}n.setAttribute("preserveAspectRatio","none");n.setAttribute("viewBox",`0 0 ${t} ${e}`);return n}createElement(t){if("string"!=typeof t)throw new Error("Invalid SVG element type");return this._createSVG(t)}_createSVG(t){(0,n.unreachable)("Abstract method `_createSVG` called.")}}e.BaseSVGFactory=BaseSVGFactory},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.MurmurHash3_64=void 0;var n=s(1);const i=3285377520,a=4294901760,r=65535;e.MurmurHash3_64=class MurmurHash3_64{constructor(t){this.h1=t?4294967295&t:i;this.h2=t?4294967295&t:i}update(t){let e,s;if("string"==typeof t){e=new Uint8Array(2*t.length);s=0;for(let n=0,i=t.length;n>>8;e[s++]=255&i}}}else{if(!(0,n.isArrayBuffer)(t))throw new Error("Wrong data format in MurmurHash3_64_update. Input must be a string or array.");e=t.slice();s=e.byteLength}const i=s>>2,o=s-4*i,l=new Uint32Array(e.buffer,0,i);let c=0,h=0,d=this.h1,u=this.h2;const p=3432918353,g=461845907,m=11601,f=13715;for(let t=0;t>>17;c=c*g&a|c*f&r;d^=c;d=d<<13|d>>>19;d=5*d+3864292196}else{h=l[t];h=h*p&a|h*m&r;h=h<<15|h>>>17;h=h*g&a|h*f&r;u^=h;u=u<<13|u>>>19;u=5*u+3864292196}c=0;switch(o){case 3:c^=e[4*i+2]<<16;case 2:c^=e[4*i+1]<<8;case 1:c^=e[4*i];c=c*p&a|c*m&r;c=c<<15|c>>>17;c=c*g&a|c*f&r;1&i?d^=c:u^=c}this.h1=d;this.h2=u}hexdigest(){let t=this.h1,e=this.h2;t^=e>>>1;t=3981806797*t&a|36045*t&r;e=4283543511*e&a|(2950163797*(e<<16|t>>>16)&a)>>>16;t^=e>>>1;t=444984403*t&a|60499*t&r;e=3301882366*e&a|(3120437893*(e<<16|t>>>16)&a)>>>16;t^=e>>>1;return(t>>>0).toString(16).padStart(8,"0")+(e>>>0).toString(16).padStart(8,"0")}}},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.FontLoader=e.FontFaceObject=void 0;var n=s(1),i=s(10);e.FontLoader=class FontLoader{constructor({onUnsupportedFeature:t,ownerDocument:e=globalThis.document,styleElement:s=null}){this._onUnsupportedFeature=t;this._document=e;this.nativeFontFaces=[];this.styleElement=null;this.loadingRequests=[];this.loadTestFontId=0}addNativeFontFace(t){this.nativeFontFaces.push(t);this._document.fonts.add(t)}insertRule(t){if(!this.styleElement){this.styleElement=this._document.createElement("style");this._document.documentElement.getElementsByTagName("head")[0].append(this.styleElement)}const e=this.styleElement.sheet;e.insertRule(t,e.cssRules.length)}clear(){for(const t of this.nativeFontFaces)this._document.fonts.delete(t);this.nativeFontFaces.length=0;if(this.styleElement){this.styleElement.remove();this.styleElement=null}}async bind(t){if(t.attached||t.missingFile)return;t.attached=!0;if(this.isFontLoadingAPISupported){const e=t.createNativeFontFace();if(e){this.addNativeFontFace(e);try{await e.loaded}catch(s){this._onUnsupportedFeature({featureId:n.UNSUPPORTED_FEATURES.errorFontLoadNative});(0,n.warn)(`Failed to load font '${e.family}': '${s}'.`);t.disableFontFace=!0;throw s}}return}const e=t.createFontFaceRule();if(e){this.insertRule(e);if(this.isSyncFontLoadingSupported)return;await new Promise((e=>{const s=this._queueLoadingCallback(e);this._prepareFontLoadEvent(t,s)}))}}get isFontLoadingAPISupported(){const t=!!this._document?.fonts;return(0,n.shadow)(this,"isFontLoadingAPISupported",t)}get isSyncFontLoadingSupported(){let t=!1;(i.isNodeJS||"undefined"!=typeof navigator&&/Mozilla\/5.0.*?rv:\d+.*? Gecko/.test(navigator.userAgent))&&(t=!0);return(0,n.shadow)(this,"isSyncFontLoadingSupported",t)}_queueLoadingCallback(t){const{loadingRequests:e}=this,s={done:!1,complete:function completeRequest(){(0,n.assert)(!s.done,"completeRequest() cannot be called twice.");s.done=!0;for(;e.length>0&&e[0].done;){const t=e.shift();setTimeout(t.callback,0)}},callback:t};e.push(s);return s}get _loadTestFont(){const t=atob("T1RUTwALAIAAAwAwQ0ZGIDHtZg4AAAOYAAAAgUZGVE1lkzZwAAAEHAAAABxHREVGABQAFQAABDgAAAAeT1MvMlYNYwkAAAEgAAAAYGNtYXABDQLUAAACNAAAAUJoZWFk/xVFDQAAALwAAAA2aGhlYQdkA+oAAAD0AAAAJGhtdHgD6AAAAAAEWAAAAAZtYXhwAAJQAAAAARgAAAAGbmFtZVjmdH4AAAGAAAAAsXBvc3T/hgAzAAADeAAAACAAAQAAAAEAALZRFsRfDzz1AAsD6AAAAADOBOTLAAAAAM4KHDwAAAAAA+gDIQAAAAgAAgAAAAAAAAABAAADIQAAAFoD6AAAAAAD6AABAAAAAAAAAAAAAAAAAAAAAQAAUAAAAgAAAAQD6AH0AAUAAAKKArwAAACMAooCvAAAAeAAMQECAAACAAYJAAAAAAAAAAAAAQAAAAAAAAAAAAAAAFBmRWQAwAAuAC4DIP84AFoDIQAAAAAAAQAAAAAAAAAAACAAIAABAAAADgCuAAEAAAAAAAAAAQAAAAEAAAAAAAEAAQAAAAEAAAAAAAIAAQAAAAEAAAAAAAMAAQAAAAEAAAAAAAQAAQAAAAEAAAAAAAUAAQAAAAEAAAAAAAYAAQAAAAMAAQQJAAAAAgABAAMAAQQJAAEAAgABAAMAAQQJAAIAAgABAAMAAQQJAAMAAgABAAMAAQQJAAQAAgABAAMAAQQJAAUAAgABAAMAAQQJAAYAAgABWABYAAAAAAAAAwAAAAMAAAAcAAEAAAAAADwAAwABAAAAHAAEACAAAAAEAAQAAQAAAC7//wAAAC7////TAAEAAAAAAAABBgAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMAAAAAAAD/gwAyAAAAAQAAAAAAAAAAAAAAAAAAAAABAAQEAAEBAQJYAAEBASH4DwD4GwHEAvgcA/gXBIwMAYuL+nz5tQXkD5j3CBLnEQACAQEBIVhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYAAABAQAADwACAQEEE/t3Dov6fAH6fAT+fPp8+nwHDosMCvm1Cvm1DAz6fBQAAAAAAAABAAAAAMmJbzEAAAAAzgTjFQAAAADOBOQpAAEAAAAAAAAADAAUAAQAAAABAAAAAgABAAAAAAAAAAAD6AAAAAAAAA==");return(0,n.shadow)(this,"_loadTestFont",t)}_prepareFontLoadEvent(t,e){function int32(t,e){return t.charCodeAt(e)<<24|t.charCodeAt(e+1)<<16|t.charCodeAt(e+2)<<8|255&t.charCodeAt(e+3)}function spliceString(t,e,s,n){return t.substring(0,e)+n+t.substring(e+s)}let s,i;const a=this._document.createElement("canvas");a.width=1;a.height=1;const r=a.getContext("2d");let o=0;const l=`lt${Date.now()}${this.loadTestFontId++}`;let c=this._loadTestFont;c=spliceString(c,976,l.length,l);const h=1482184792;let d=int32(c,16);for(s=0,i=l.length-3;s30){(0,n.warn)("Load test font never loaded.");e();return}r.font="30px "+t;r.fillText(".",0,20);r.getImageData(0,0,1,1).data[3]>0?e():setTimeout(isFontReady.bind(null,t,e))}(l,(()=>{p.remove();e.complete()}))}};e.FontFaceObject=class FontFaceObject{constructor(t,{isEvalSupported:e=!0,disableFontFace:s=!1,ignoreErrors:n=!1,onUnsupportedFeature:i,fontRegistry:a=null}){this.compiledGlyphs=Object.create(null);for(const e in t)this[e]=t[e];this.isEvalSupported=!1!==e;this.disableFontFace=!0===s;this.ignoreErrors=!0===n;this._onUnsupportedFeature=i;this.fontRegistry=a}createNativeFontFace(){if(!this.data||this.disableFontFace)return null;let t;if(this.cssFontInfo){const e={weight:this.cssFontInfo.fontWeight};this.cssFontInfo.italicAngle&&(e.style=`oblique ${this.cssFontInfo.italicAngle}deg`);t=new FontFace(this.cssFontInfo.fontFamily,this.data,e)}else t=new FontFace(this.loadedName,this.data,{});this.fontRegistry?.registerFont(this);return t}createFontFaceRule(){if(!this.data||this.disableFontFace)return null;const t=(0,n.bytesToString)(this.data),e=`url(data:${this.mimetype};base64,${btoa(t)});`;let s;if(this.cssFontInfo){let t=`font-weight: ${this.cssFontInfo.fontWeight};`;this.cssFontInfo.italicAngle&&(t+=`font-style: oblique ${this.cssFontInfo.italicAngle}deg;`);s=`@font-face {font-family:"${this.cssFontInfo.fontFamily}";${t}src:${e}}`}else s=`@font-face {font-family:"${this.loadedName}";src:${e}}`;this.fontRegistry?.registerFont(this,e);return s}getPathGenerator(t,e){if(void 0!==this.compiledGlyphs[e])return this.compiledGlyphs[e];let s;try{s=t.get(this.loadedName+"_path_"+e)}catch(t){if(!this.ignoreErrors)throw t;this._onUnsupportedFeature({featureId:n.UNSUPPORTED_FEATURES.errorFontGetPath});(0,n.warn)(`getPathGenerator - ignoring character: "${t}".`);return this.compiledGlyphs[e]=function(t,e){}}if(this.isEvalSupported&&n.FeatureTest.isEvalSupported){const t=[];for(const e of s){const s=void 0!==e.args?e.args.join(","):"";t.push("c.",e.cmd,"(",s,");\n")}return this.compiledGlyphs[e]=new Function("c","size",t.join(""))}return this.compiledGlyphs[e]=function(t,e){for(const n of s){"scale"===n.cmd&&(n.args=[e,-e]);t[n.cmd].apply(t,n.args)}}}}},(t,e)=>{Object.defineProperty(e,"__esModule",{value:!0});e.isNodeJS=void 0;const s=!("object"!=typeof process||process+""!="[object process]"||process.versions.nw||process.versions.electron&&process.type&&"browser"!==process.type);e.isNodeJS=s},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.CanvasGraphics=void 0;var n=s(1),i=s(6),a=s(12),r=s(13),o=s(10);const l=4096,c=o.isNodeJS&&"undefined"==typeof Path2D?-1:1e3,h=16;class CachedCanvases{constructor(t){this.canvasFactory=t;this.cache=Object.create(null)}getCanvas(t,e,s){let n;if(void 0!==this.cache[t]){n=this.cache[t];this.canvasFactory.reset(n,e,s)}else{n=this.canvasFactory.create(e,s);this.cache[t]=n}return n}delete(t){delete this.cache[t]}clear(){for(const t in this.cache){const e=this.cache[t];this.canvasFactory.destroy(e);delete this.cache[t]}}}function drawImageAtIntegerCoords(t,e,s,n,a,r,o,l,c,h){const[d,u,p,g,m,f]=(0,i.getCurrentTransform)(t);if(0===u&&0===p){const i=o*d+m,b=Math.round(i),A=l*g+f,_=Math.round(A),y=(o+c)*d+m,v=Math.abs(Math.round(y)-b)||1,S=(l+h)*g+f,x=Math.abs(Math.round(S)-_)||1;t.setTransform(Math.sign(d),0,0,Math.sign(g),b,_);t.drawImage(e,s,n,a,r,0,0,v,x);t.setTransform(d,u,p,g,m,f);return[v,x]}if(0===d&&0===g){const i=l*p+m,b=Math.round(i),A=o*u+f,_=Math.round(A),y=(l+h)*p+m,v=Math.abs(Math.round(y)-b)||1,S=(o+c)*u+f,x=Math.abs(Math.round(S)-_)||1;t.setTransform(0,Math.sign(u),Math.sign(p),0,b,_);t.drawImage(e,s,n,a,r,0,0,x,v);t.setTransform(d,u,p,g,m,f);return[x,v]}t.drawImage(e,s,n,a,r,o,l,c,h);return[Math.hypot(d,u)*c,Math.hypot(p,g)*h]}class CanvasExtraState{constructor(t,e){this.alphaIsShape=!1;this.fontSize=0;this.fontSizeScale=1;this.textMatrix=n.IDENTITY_MATRIX;this.textMatrixScale=1;this.fontMatrix=n.FONT_IDENTITY_MATRIX;this.leading=0;this.x=0;this.y=0;this.lineX=0;this.lineY=0;this.charSpacing=0;this.wordSpacing=0;this.textHScale=1;this.textRenderingMode=n.TextRenderingMode.FILL;this.textRise=0;this.fillColor="#000000";this.strokeColor="#000000";this.patternFill=!1;this.fillAlpha=1;this.strokeAlpha=1;this.lineWidth=1;this.activeSMask=null;this.transferMaps=null;this.startNewPathAndClipBox([0,0,t,e])}clone(){const t=Object.create(this);t.clipBox=this.clipBox.slice();return t}setCurrentPoint(t,e){this.x=t;this.y=e}updatePathMinMax(t,e,s){[e,s]=n.Util.applyTransform([e,s],t);this.minX=Math.min(this.minX,e);this.minY=Math.min(this.minY,s);this.maxX=Math.max(this.maxX,e);this.maxY=Math.max(this.maxY,s)}updateRectMinMax(t,e){const s=n.Util.applyTransform(e,t),i=n.Util.applyTransform(e.slice(2),t);this.minX=Math.min(this.minX,s[0],i[0]);this.minY=Math.min(this.minY,s[1],i[1]);this.maxX=Math.max(this.maxX,s[0],i[0]);this.maxY=Math.max(this.maxY,s[1],i[1])}updateScalingPathMinMax(t,e){n.Util.scaleMinMax(t,e);this.minX=Math.min(this.minX,e[0]);this.maxX=Math.max(this.maxX,e[1]);this.minY=Math.min(this.minY,e[2]);this.maxY=Math.max(this.maxY,e[3])}updateCurvePathMinMax(t,e,s,i,a,r,o,l,c,h){const d=n.Util.bezierBoundingBox(e,s,i,a,r,o,l,c);if(h){h[0]=Math.min(h[0],d[0],d[2]);h[1]=Math.max(h[1],d[0],d[2]);h[2]=Math.min(h[2],d[1],d[3]);h[3]=Math.max(h[3],d[1],d[3])}else this.updateRectMinMax(t,d)}getPathBoundingBox(t=a.PathType.FILL,e=null){const s=[this.minX,this.minY,this.maxX,this.maxY];if(t===a.PathType.STROKE){e||(0,n.unreachable)("Stroke bounding box must include transform.");const t=n.Util.singularValueDecompose2dScale(e),i=t[0]*this.lineWidth/2,a=t[1]*this.lineWidth/2;s[0]-=i;s[1]-=a;s[2]+=i;s[3]+=a}return s}updateClipFromPath(){const t=n.Util.intersect(this.clipBox,this.getPathBoundingBox());this.startNewPathAndClipBox(t||[0,0,0,0])}isEmptyClip(){return this.minX===1/0}startNewPathAndClipBox(t){this.clipBox=t;this.minX=1/0;this.minY=1/0;this.maxX=0;this.maxY=0}getClippedPathBoundingBox(t=a.PathType.FILL,e=null){return n.Util.intersect(this.clipBox,this.getPathBoundingBox(t,e))}}function putBinaryImageData(t,e,s=null){if("undefined"!=typeof ImageData&&e instanceof ImageData){t.putImageData(e,0,0);return}const i=e.height,a=e.width,r=i%h,o=(i-r)/h,l=0===r?o:o+1,c=t.createImageData(a,h);let d,u=0;const p=e.data,g=c.data;let m,f,b,A,_,y,v,S;if(s)switch(s.length){case 1:_=s[0];y=s[0];v=s[0];S=s[0];break;case 4:_=s[0];y=s[1];v=s[2];S=s[3]}if(e.kind===n.ImageKind.GRAYSCALE_1BPP){const e=p.byteLength,s=new Uint32Array(g.buffer,0,g.byteLength>>2),i=s.length,A=a+7>>3;let _=4294967295,y=n.FeatureTest.isLittleEndian?4278190080:255;S&&255===S[0]&&0===S[255]&&([_,y]=[y,_]);for(m=0;mA?a:8*t-7,r=-8&i;let o=0,l=0;for(;n>=1}}for(;d=o){b=r;A=a*b}d=0;for(f=A;f--;){g[d++]=p[u++];g[d++]=p[u++];g[d++]=p[u++];g[d++]=255}if(e)for(let t=0;t>8;t[a-2]=t[a-2]*i+s*r>>8;t[a-1]=t[a-1]*i+n*r>>8}}}function composeSMaskAlpha(t,e,s){const n=t.length;for(let i=3;i>8]>>8:e[i]*n>>16}}function composeSMask(t,e,s,n){const i=n[0],a=n[1],r=n[2]-i,o=n[3]-a;if(0!==r&&0!==o){!function genericComposeSMask(t,e,s,n,i,a,r,o,l,c,h){const d=!!a,u=d?a[0]:0,p=d?a[1]:0,g=d?a[2]:0;let m;m="Luminosity"===i?composeSMaskLuminosity:composeSMaskAlpha;const f=Math.min(n,Math.ceil(1048576/s));for(let i=0;i(t/=255)<=.03928?t/12.92:((t+.055)/1.055)**2.4,o=Math.round(.2126*newComp(s)+.7152*newComp(a)+.0722*newComp(r));this.selectColor=(s,n,i)=>{const a=.2126*newComp(s)+.7152*newComp(n)+.0722*newComp(i);return Math.round(a)===o?e:t}}}this.ctx.fillStyle=this.backgroundColor||o;this.ctx.fillRect(0,0,a,r);this.ctx.restore();if(s){const t=this.cachedCanvases.getCanvas("transparent",a,r);this.compositeCtx=this.ctx;this.transparentCanvas=t.canvas;this.ctx=t.context;this.ctx.save();this.ctx.transform(...(0,i.getCurrentTransform)(this.compositeCtx))}this.ctx.save();resetCtxToDefault(this.ctx,this.foregroundColor);if(t){this.ctx.transform(...t);this.outputScaleX=t[0];this.outputScaleY=t[0]}this.ctx.transform(...e.transform);this.viewportScale=e.scale;this.baseTransform=(0,i.getCurrentTransform)(this.ctx)}executeOperatorList(t,e,s,i){const a=t.argsArray,r=t.fnArray;let o=e||0;const l=a.length;if(l===o)return o;const c=l-o>10&&"function"==typeof s,h=c?Date.now()+15:0;let d=0;const u=this.commonObjs,p=this.objs;let g;for(;;){if(void 0!==i&&o===i.nextBreakPoint){i.breakIt(o,s);return o}g=r[o];if(g!==n.OPS.dependency)this[g].apply(this,a[o]);else for(const t of a[o]){const e=t.startsWith("g_")?u:p;if(!e.has(t)){e.get(t,s);return o}}o++;if(o===l)return o;if(c&&++d>10){if(Date.now()>h){s();return o}d=0}}}#ct(){for(;this.stateStack.length||this.inSMaskMode;)this.restore();this.ctx.restore();if(this.transparentCanvas){this.ctx=this.compositeCtx;this.ctx.save();this.ctx.setTransform(1,0,0,1,0,0);this.ctx.drawImage(this.transparentCanvas,0,0);this.ctx.restore();this.transparentCanvas=null}}endDrawing(){this.#ct();this.cachedCanvases.clear();this.cachedPatterns.clear();for(const t of this._cachedBitmapsMap.values()){for(const e of t.values())"undefined"!=typeof HTMLCanvasElement&&e instanceof HTMLCanvasElement&&(e.width=e.height=0);t.clear()}this._cachedBitmapsMap.clear()}_scaleImage(t,e){const s=t.width,n=t.height;let i,a,r=Math.max(Math.hypot(e[0],e[1]),1),o=Math.max(Math.hypot(e[2],e[3]),1),l=s,c=n,h="prescale1";for(;r>2&&l>1||o>2&&c>1;){let e=l,s=c;if(r>2&&l>1){e=Math.ceil(l/2);r/=l/e}if(o>2&&c>1){s=Math.ceil(c/2);o/=c/s}i=this.cachedCanvases.getCanvas(h,e,s);a=i.context;a.clearRect(0,0,e,s);a.drawImage(t,0,0,l,c,0,0,e,s);t=i.canvas;l=e;c=s;h="prescale1"===h?"prescale2":"prescale1"}return{img:t,paintWidth:l,paintHeight:c}}_createMaskCanvas(t){const e=this.ctx,{width:s,height:r}=t,o=this.current.fillColor,l=this.current.patternFill,c=(0,i.getCurrentTransform)(e);let h,d,u,p;if((t.bitmap||t.data)&&t.count>1){const e=t.bitmap||t.data.buffer;d=JSON.stringify(l?c:[c.slice(0,4),o]);h=this._cachedBitmapsMap.get(e);if(!h){h=new Map;this._cachedBitmapsMap.set(e,h)}const s=h.get(d);if(s&&!l){return{canvas:s,offsetX:Math.round(Math.min(c[0],c[2])+c[4]),offsetY:Math.round(Math.min(c[1],c[3])+c[5])}}u=s}if(!u){p=this.cachedCanvases.getCanvas("maskCanvas",s,r);putBinaryImageMask(p.context,t)}let g=n.Util.transform(c,[1/s,0,0,-1/r,0,0]);g=n.Util.transform(g,[1,0,0,1,0,-r]);const m=n.Util.applyTransform([0,0],g),f=n.Util.applyTransform([s,r],g),b=n.Util.normalizeRect([m[0],m[1],f[0],f[1]]),A=Math.round(b[2]-b[0])||1,_=Math.round(b[3]-b[1])||1,y=this.cachedCanvases.getCanvas("fillCanvas",A,_),v=y.context,S=Math.min(m[0],f[0]),x=Math.min(m[1],f[1]);v.translate(-S,-x);v.transform(...g);if(!u){u=this._scaleImage(p.canvas,(0,i.getCurrentTransformInverse)(v));u=u.img;h&&l&&h.set(d,u)}v.imageSmoothingEnabled=getImageSmoothingEnabled((0,i.getCurrentTransform)(v),t.interpolate);drawImageAtIntegerCoords(v,u,0,0,u.width,u.height,0,0,s,r);v.globalCompositeOperation="source-in";const E=n.Util.transform((0,i.getCurrentTransformInverse)(v),[1,0,0,1,-S,-x]);v.fillStyle=l?o.getPattern(e,this,E,a.PathType.FILL):o;v.fillRect(0,0,s,r);if(h&&!l){this.cachedCanvases.delete("fillCanvas");h.set(d,y.canvas)}return{canvas:y.canvas,offsetX:Math.round(S),offsetY:Math.round(x)}}setLineWidth(t){t!==this.current.lineWidth&&(this._cachedScaleForStroking=null);this.current.lineWidth=t;this.ctx.lineWidth=t}setLineCap(t){this.ctx.lineCap=d[t]}setLineJoin(t){this.ctx.lineJoin=u[t]}setMiterLimit(t){this.ctx.miterLimit=t}setDash(t,e){const s=this.ctx;if(void 0!==s.setLineDash){s.setLineDash(t);s.lineDashOffset=e}}setRenderingIntent(t){}setFlatness(t){}setGState(t){for(const[e,s]of t)switch(e){case"LW":this.setLineWidth(s);break;case"LC":this.setLineCap(s);break;case"LJ":this.setLineJoin(s);break;case"ML":this.setMiterLimit(s);break;case"D":this.setDash(s[0],s[1]);break;case"RI":this.setRenderingIntent(s);break;case"FL":this.setFlatness(s);break;case"Font":this.setFont(s[0],s[1]);break;case"CA":this.current.strokeAlpha=s;break;case"ca":this.current.fillAlpha=s;this.ctx.globalAlpha=s;break;case"BM":this.ctx.globalCompositeOperation=s;break;case"SMask":this.current.activeSMask=s?this.tempSMask:null;this.tempSMask=null;this.checkSMaskState();break;case"TR":this.current.transferMaps=s}}get inSMaskMode(){return!!this.suspendedCtx}checkSMaskState(){const t=this.inSMaskMode;this.current.activeSMask&&!t?this.beginSMaskMode():!this.current.activeSMask&&t&&this.endSMaskMode()}beginSMaskMode(){if(this.inSMaskMode)throw new Error("beginSMaskMode called while already in smask mode");const t=this.ctx.canvas.width,e=this.ctx.canvas.height,s="smaskGroupAt"+this.groupLevel,n=this.cachedCanvases.getCanvas(s,t,e);this.suspendedCtx=this.ctx;this.ctx=n.context;const a=this.ctx;a.setTransform(...(0,i.getCurrentTransform)(this.suspendedCtx));copyCtxState(this.suspendedCtx,a);!function mirrorContextOperations(t,e){if(t._removeMirroring)throw new Error("Context is already forwarding operations.");t.__originalSave=t.save;t.__originalRestore=t.restore;t.__originalRotate=t.rotate;t.__originalScale=t.scale;t.__originalTranslate=t.translate;t.__originalTransform=t.transform;t.__originalSetTransform=t.setTransform;t.__originalResetTransform=t.resetTransform;t.__originalClip=t.clip;t.__originalMoveTo=t.moveTo;t.__originalLineTo=t.lineTo;t.__originalBezierCurveTo=t.bezierCurveTo;t.__originalRect=t.rect;t.__originalClosePath=t.closePath;t.__originalBeginPath=t.beginPath;t._removeMirroring=()=>{t.save=t.__originalSave;t.restore=t.__originalRestore;t.rotate=t.__originalRotate;t.scale=t.__originalScale;t.translate=t.__originalTranslate;t.transform=t.__originalTransform;t.setTransform=t.__originalSetTransform;t.resetTransform=t.__originalResetTransform;t.clip=t.__originalClip;t.moveTo=t.__originalMoveTo;t.lineTo=t.__originalLineTo;t.bezierCurveTo=t.__originalBezierCurveTo;t.rect=t.__originalRect;t.closePath=t.__originalClosePath;t.beginPath=t.__originalBeginPath;delete t._removeMirroring};t.save=function ctxSave(){e.save();this.__originalSave()};t.restore=function ctxRestore(){e.restore();this.__originalRestore()};t.translate=function ctxTranslate(t,s){e.translate(t,s);this.__originalTranslate(t,s)};t.scale=function ctxScale(t,s){e.scale(t,s);this.__originalScale(t,s)};t.transform=function ctxTransform(t,s,n,i,a,r){e.transform(t,s,n,i,a,r);this.__originalTransform(t,s,n,i,a,r)};t.setTransform=function ctxSetTransform(t,s,n,i,a,r){e.setTransform(t,s,n,i,a,r);this.__originalSetTransform(t,s,n,i,a,r)};t.resetTransform=function ctxResetTransform(){e.resetTransform();this.__originalResetTransform()};t.rotate=function ctxRotate(t){e.rotate(t);this.__originalRotate(t)};t.clip=function ctxRotate(t){e.clip(t);this.__originalClip(t)};t.moveTo=function(t,s){e.moveTo(t,s);this.__originalMoveTo(t,s)};t.lineTo=function(t,s){e.lineTo(t,s);this.__originalLineTo(t,s)};t.bezierCurveTo=function(t,s,n,i,a,r){e.bezierCurveTo(t,s,n,i,a,r);this.__originalBezierCurveTo(t,s,n,i,a,r)};t.rect=function(t,s,n,i){e.rect(t,s,n,i);this.__originalRect(t,s,n,i)};t.closePath=function(){e.closePath();this.__originalClosePath()};t.beginPath=function(){e.beginPath();this.__originalBeginPath()}}(a,this.suspendedCtx);this.setGState([["BM","source-over"],["ca",1],["CA",1]])}endSMaskMode(){if(!this.inSMaskMode)throw new Error("endSMaskMode called while not in smask mode");this.ctx._removeMirroring();copyCtxState(this.ctx,this.suspendedCtx);this.ctx=this.suspendedCtx;this.suspendedCtx=null}compose(t){if(!this.current.activeSMask)return;if(t){t[0]=Math.floor(t[0]);t[1]=Math.floor(t[1]);t[2]=Math.ceil(t[2]);t[3]=Math.ceil(t[3])}else t=[0,0,this.ctx.canvas.width,this.ctx.canvas.height];const e=this.current.activeSMask;composeSMask(this.suspendedCtx,e,this.ctx,t);this.ctx.save();this.ctx.setTransform(1,0,0,1,0,0);this.ctx.clearRect(0,0,this.ctx.canvas.width,this.ctx.canvas.height);this.ctx.restore()}save(){if(this.inSMaskMode){copyCtxState(this.ctx,this.suspendedCtx);this.suspendedCtx.save()}else this.ctx.save();const t=this.current;this.stateStack.push(t);this.current=t.clone()}restore(){0===this.stateStack.length&&this.inSMaskMode&&this.endSMaskMode();if(0!==this.stateStack.length){this.current=this.stateStack.pop();if(this.inSMaskMode){this.suspendedCtx.restore();copyCtxState(this.suspendedCtx,this.ctx)}else this.ctx.restore();this.checkSMaskState();this.pendingClip=null;this._cachedScaleForStroking=null;this._cachedGetSinglePixelWidth=null}}transform(t,e,s,n,i,a){this.ctx.transform(t,e,s,n,i,a);this._cachedScaleForStroking=null;this._cachedGetSinglePixelWidth=null}constructPath(t,e,s){const a=this.ctx,r=this.current;let o,l,c=r.x,h=r.y;const d=(0,i.getCurrentTransform)(a),u=0===d[0]&&0===d[3]||0===d[1]&&0===d[2],p=u?s.slice(0):null;for(let s=0,i=0,g=t.length;s100&&(c=100);this.current.fontSizeScale=e/c;this.ctx.font=`${o} ${r} ${c}px ${l}`}setTextRenderingMode(t){this.current.textRenderingMode=t}setTextRise(t){this.current.textRise=t}moveText(t,e){this.current.x=this.current.lineX+=t;this.current.y=this.current.lineY+=e}setLeadingMoveText(t,e){this.setLeading(-e);this.moveText(t,e)}setTextMatrix(t,e,s,n,i,a){this.current.textMatrix=[t,e,s,n,i,a];this.current.textMatrixScale=Math.hypot(t,e);this.current.x=this.current.lineX=0;this.current.y=this.current.lineY=0}nextLine(){this.moveText(0,this.current.leading)}paintChar(t,e,s,a){const r=this.ctx,o=this.current,l=o.font,c=o.textRenderingMode,h=o.fontSize/o.fontSizeScale,d=c&n.TextRenderingMode.FILL_STROKE_MASK,u=!!(c&n.TextRenderingMode.ADD_TO_PATH_FLAG),p=o.patternFill&&!l.missingFile;let g;(l.disableFontFace||u||p)&&(g=l.getPathGenerator(this.commonObjs,t));if(l.disableFontFace||p){r.save();r.translate(e,s);r.beginPath();g(r,h);a&&r.setTransform(...a);d!==n.TextRenderingMode.FILL&&d!==n.TextRenderingMode.FILL_STROKE||r.fill();d!==n.TextRenderingMode.STROKE&&d!==n.TextRenderingMode.FILL_STROKE||r.stroke();r.restore()}else{d!==n.TextRenderingMode.FILL&&d!==n.TextRenderingMode.FILL_STROKE||r.fillText(t,e,s);d!==n.TextRenderingMode.STROKE&&d!==n.TextRenderingMode.FILL_STROKE||r.strokeText(t,e,s)}if(u){(this.pendingTextPaths||(this.pendingTextPaths=[])).push({transform:(0,i.getCurrentTransform)(r),x:e,y:s,fontSize:h,addToPath:g})}}get isFontSubpixelAAEnabled(){const{context:t}=this.cachedCanvases.getCanvas("isFontSubpixelAAEnabled",10,10);t.scale(1.5,1);t.fillText("I",0,10);const e=t.getImageData(0,0,10,10).data;let s=!1;for(let t=3;t0&&e[t]<255){s=!0;break}return(0,n.shadow)(this,"isFontSubpixelAAEnabled",s)}showText(t){const e=this.current,s=e.font;if(s.isType3Font)return this.showType3Text(t);const r=e.fontSize;if(0===r)return;const o=this.ctx,l=e.fontSizeScale,c=e.charSpacing,h=e.wordSpacing,d=e.fontDirection,u=e.textHScale*d,p=t.length,g=s.vertical,m=g?1:-1,f=s.defaultVMetrics,b=r*e.fontMatrix[0],A=e.textRenderingMode===n.TextRenderingMode.FILL&&!s.disableFontFace&&!e.patternFill;o.save();o.transform(...e.textMatrix);o.translate(e.x,e.y+e.textRise);d>0?o.scale(u,-1):o.scale(u,1);let _;if(e.patternFill){o.save();const t=e.fillColor.getPattern(o,this,(0,i.getCurrentTransformInverse)(o),a.PathType.FILL);_=(0,i.getCurrentTransform)(o);o.restore();o.fillStyle=t}let y=e.lineWidth;const v=e.textMatrixScale;if(0===v||0===y){const t=e.textRenderingMode&n.TextRenderingMode.FILL_STROKE_MASK;t!==n.TextRenderingMode.STROKE&&t!==n.TextRenderingMode.FILL_STROKE||(y=this.getSinglePixelWidth())}else y/=v;if(1!==l){o.scale(l,l);y/=l}o.lineWidth=y;if(s.isInvalidPDFjsFont){const s=[];let n=0;for(const e of t){s.push(e.unicode);n+=e.width}o.fillText(s.join(""),0,0);e.x+=n*b*u;o.restore();this.compose();return}let S,x=0;for(S=0;S0){const t=1e3*o.measureText(a).width/r*l;if(Enew CanvasGraphics(t,this.commonObjs,this.objs,this.canvasFactory,{optionalContentConfig:this.optionalContentConfig,markedContentStack:this.markedContentStack})};e=new a.TilingPattern(t,s,this.ctx,r,n)}else e=this._getPattern(t[1],t[2]);return e}setStrokeColorN(){this.current.strokeColor=this.getColorN_Pattern(arguments)}setFillColorN(){this.current.fillColor=this.getColorN_Pattern(arguments);this.current.patternFill=!0}setStrokeRGBColor(t,e,s){const i=this.selectColor?.(t,e,s)||n.Util.makeHexColor(t,e,s);this.ctx.strokeStyle=i;this.current.strokeColor=i}setFillRGBColor(t,e,s){const i=this.selectColor?.(t,e,s)||n.Util.makeHexColor(t,e,s);this.ctx.fillStyle=i;this.current.fillColor=i;this.current.patternFill=!1}_getPattern(t,e=null){let s;if(this.cachedPatterns.has(t))s=this.cachedPatterns.get(t);else{s=(0,a.getShadingPattern)(this.objs.get(t));this.cachedPatterns.set(t,s)}e&&(s.matrix=e);return s}shadingFill(t){if(!this.contentVisible)return;const e=this.ctx;this.save();const s=this._getPattern(t);e.fillStyle=s.getPattern(e,this,(0,i.getCurrentTransformInverse)(e),a.PathType.SHADING);const r=(0,i.getCurrentTransformInverse)(e);if(r){const t=e.canvas,s=t.width,i=t.height,a=n.Util.applyTransform([0,0],r),o=n.Util.applyTransform([0,i],r),l=n.Util.applyTransform([s,0],r),c=n.Util.applyTransform([s,i],r),h=Math.min(a[0],o[0],l[0],c[0]),d=Math.min(a[1],o[1],l[1],c[1]),u=Math.max(a[0],o[0],l[0],c[0]),p=Math.max(a[1],o[1],l[1],c[1]);this.ctx.fillRect(h,d,u-h,p-d)}else this.ctx.fillRect(-1e10,-1e10,2e10,2e10);this.compose(this.current.getClippedPathBoundingBox());this.restore()}beginInlineImage(){(0,n.unreachable)("Should not call beginInlineImage")}beginImageData(){(0,n.unreachable)("Should not call beginImageData")}paintFormXObjectBegin(t,e){if(this.contentVisible){this.save();this.baseTransformStack.push(this.baseTransform);Array.isArray(t)&&6===t.length&&this.transform(...t);this.baseTransform=(0,i.getCurrentTransform)(this.ctx);if(e){const t=e[2]-e[0],s=e[3]-e[1];this.ctx.rect(e[0],e[1],t,s);this.current.updateRectMinMax((0,i.getCurrentTransform)(this.ctx),e);this.clip();this.endPath()}}}paintFormXObjectEnd(){if(this.contentVisible){this.restore();this.baseTransform=this.baseTransformStack.pop()}}beginGroup(t){if(!this.contentVisible)return;this.save();if(this.inSMaskMode){this.endSMaskMode();this.current.activeSMask=null}const e=this.ctx;t.isolated||(0,n.info)("TODO: Support non-isolated groups.");t.knockout&&(0,n.warn)("Knockout groups not supported.");const s=(0,i.getCurrentTransform)(e);t.matrix&&e.transform(...t.matrix);if(!t.bbox)throw new Error("Bounding box is required.");let a=n.Util.getAxialAlignedBoundingBox(t.bbox,(0,i.getCurrentTransform)(e));const r=[0,0,e.canvas.width,e.canvas.height];a=n.Util.intersect(a,r)||[0,0,0,0];const o=Math.floor(a[0]),c=Math.floor(a[1]);let h=Math.max(Math.ceil(a[2])-o,1),d=Math.max(Math.ceil(a[3])-c,1),u=1,p=1;if(h>l){u=h/l;h=l}if(d>l){p=d/l;d=l}this.current.startNewPathAndClipBox([0,0,h,d]);let g="groupAt"+this.groupLevel;t.smask&&(g+="_smask_"+this.smaskCounter++%2);const m=this.cachedCanvases.getCanvas(g,h,d),f=m.context;f.scale(1/u,1/p);f.translate(-o,-c);f.transform(...s);if(t.smask)this.smaskStack.push({canvas:m.canvas,context:f,offsetX:o,offsetY:c,scaleX:u,scaleY:p,subtype:t.smask.subtype,backdrop:t.smask.backdrop,transferMap:t.smask.transferMap||null,startTransformInverse:null});else{e.setTransform(1,0,0,1,0,0);e.translate(o,c);e.scale(u,p);e.save()}copyCtxState(e,f);this.ctx=f;this.setGState([["BM","source-over"],["ca",1],["CA",1]]);this.groupStack.push(e);this.groupLevel++}endGroup(t){if(!this.contentVisible)return;this.groupLevel--;const e=this.ctx,s=this.groupStack.pop();this.ctx=s;this.ctx.imageSmoothingEnabled=!1;if(t.smask){this.tempSMask=this.smaskStack.pop();this.restore()}else{this.ctx.restore();const t=(0,i.getCurrentTransform)(this.ctx);this.restore();this.ctx.save();this.ctx.setTransform(...t);const s=n.Util.getAxialAlignedBoundingBox([0,0,e.canvas.width,e.canvas.height],t);this.ctx.drawImage(e.canvas,0,0);this.ctx.restore();this.compose(s)}}beginAnnotation(t,e,s,a,r){this.#ct();resetCtxToDefault(this.ctx,this.foregroundColor);this.ctx.save();this.save();this.baseTransform&&this.ctx.setTransform(...this.baseTransform);if(Array.isArray(e)&&4===e.length){const a=e[2]-e[0],o=e[3]-e[1];if(r&&this.annotationCanvasMap){(s=s.slice())[4]-=e[0];s[5]-=e[1];(e=e.slice())[0]=e[1]=0;e[2]=a;e[3]=o;const[r,l]=n.Util.singularValueDecompose2dScale((0,i.getCurrentTransform)(this.ctx)),{viewportScale:c}=this,h=Math.ceil(a*this.outputScaleX*c),d=Math.ceil(o*this.outputScaleY*c);this.annotationCanvas=this.canvasFactory.create(h,d);const{canvas:u,context:p}=this.annotationCanvas;this.annotationCanvasMap.set(t,u);this.annotationCanvas.savedCtx=this.ctx;this.ctx=p;this.ctx.setTransform(r,0,0,-l,0,o*l);resetCtxToDefault(this.ctx,this.foregroundColor)}else{resetCtxToDefault(this.ctx,this.foregroundColor);this.ctx.rect(e[0],e[1],a,o);this.ctx.clip();this.endPath()}}this.current=new CanvasExtraState(this.ctx.canvas.width,this.ctx.canvas.height);this.transform(...s);this.transform(...a)}endAnnotation(){if(this.annotationCanvas){this.ctx=this.annotationCanvas.savedCtx;delete this.annotationCanvas.savedCtx;delete this.annotationCanvas}}paintImageMaskXObject(t){if(!this.contentVisible)return;const e=t.count;(t=this.getObject(t.data,t)).count=e;const s=this.ctx,n=this.processingType3;if(n){void 0===n.compiled&&(n.compiled=function compileType3Glyph(t){const{width:e,height:s}=t;if(e>c||s>c)return null;const n=new Uint8Array([0,2,4,0,1,0,5,4,8,10,0,8,0,2,1,0]),i=e+1;let a,r,o,l=new Uint8Array(i*(s+1));const h=e+7&-8;let d=new Uint8Array(h*s),u=0;for(const e of t.data){let t=128;for(;t>0;){d[u++]=e&t?0:255;t>>=1}}let p=0;u=0;if(0!==d[u]){l[0]=1;++p}for(r=1;r>2)+(d[u+1]?4:0)+(d[u-h+1]?8:0);if(n[t]){l[o+r]=n[t];++p}u++}if(d[u-h]!==d[u]){l[o+r]=d[u]?2:4;++p}if(p>1e3)return null}u=h*(s-1);o=a*i;if(0!==d[u]){l[o]=8;++p}for(r=1;r1e3)return null;const g=new Int32Array([0,i,-1,0,-i,0,0,0,1]),m=new Path2D;for(a=0;p&&a<=s;a++){let t=a*i;const s=t+e;for(;t>4;l[t]&=r>>2|r<<2}m.lineTo(t%i,t/i|0);l[t]||--p}while(n!==t);--a}d=null;l=null;return function(t){t.save();t.scale(1/e,-1/s);t.translate(0,-s);t.fill(m);t.beginPath();t.restore()}}(t));if(n.compiled){n.compiled(s);return}}const i=this._createMaskCanvas(t),a=i.canvas;s.save();s.setTransform(1,0,0,1,0,0);s.drawImage(a,i.offsetX,i.offsetY);s.restore();this.compose()}paintImageMaskXObjectRepeat(t,e,s=0,a=0,r,o){if(!this.contentVisible)return;t=this.getObject(t.data,t);const l=this.ctx;l.save();const c=(0,i.getCurrentTransform)(l);l.transform(e,s,a,r,0,0);const h=this._createMaskCanvas(t);l.setTransform(1,0,0,1,h.offsetX-c[4],h.offsetY-c[5]);for(let t=0,i=o.length;te?r/e:1;n=a>e?a/e:1}}this._cachedScaleForStroking=[s,n]}return this._cachedScaleForStroking}rescaleAndStroke(t){const{ctx:e}=this,{lineWidth:s}=this.current,[n,a]=this.getScaleForStroking();e.lineWidth=s||1;if(1===n&&1===a){e.stroke();return}let r,o,l;if(t){r=(0,i.getCurrentTransform)(e);o=e.getLineDash().slice();l=e.lineDashOffset}e.scale(n,a);const c=Math.max(n,a);e.setLineDash(e.getLineDash().map((t=>t/c)));e.lineDashOffset/=c;e.stroke();if(t){e.setTransform(...r);e.setLineDash(o);e.lineDashOffset=l}}isContentVisible(){for(let t=this.markedContentStack.length-1;t>=0;t--)if(!this.markedContentStack[t].visible)return!1;return!0}}e.CanvasGraphics=CanvasGraphics;for(const t in n.OPS)void 0!==CanvasGraphics.prototype[t]&&(CanvasGraphics.prototype[n.OPS[t]]=CanvasGraphics.prototype[t])},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.TilingPattern=e.PathType=void 0;e.getShadingPattern=function getShadingPattern(t){switch(t[0]){case"RadialAxial":return new RadialAxialShadingPattern(t);case"Mesh":return new MeshShadingPattern(t);case"Dummy":return new DummyShadingPattern}throw new Error(`Unknown IR type: ${t[0]}`)};var n=s(1),i=s(6),a=s(10);const r={FILL:"Fill",STROKE:"Stroke",SHADING:"Shading"};e.PathType=r;function applyBoundingBox(t,e){if(!e||a.isNodeJS)return;const s=e[2]-e[0],n=e[3]-e[1],i=new Path2D;i.rect(e[0],e[1],s,n);t.clip(i)}class BaseShadingPattern{constructor(){this.constructor===BaseShadingPattern&&(0,n.unreachable)("Cannot initialize BaseShadingPattern.")}getPattern(){(0,n.unreachable)("Abstract method `getPattern` called.")}}class RadialAxialShadingPattern extends BaseShadingPattern{constructor(t){super();this._type=t[1];this._bbox=t[2];this._colorStops=t[3];this._p0=t[4];this._p1=t[5];this._r0=t[6];this._r1=t[7];this.matrix=null}_createGradient(t){let e;"axial"===this._type?e=t.createLinearGradient(this._p0[0],this._p0[1],this._p1[0],this._p1[1]):"radial"===this._type&&(e=t.createRadialGradient(this._p0[0],this._p0[1],this._r0,this._p1[0],this._p1[1],this._r1));for(const t of this._colorStops)e.addColorStop(t[0],t[1]);return e}getPattern(t,e,s,a){let o;if(a===r.STROKE||a===r.FILL){const r=e.current.getClippedPathBoundingBox(a,(0,i.getCurrentTransform)(t))||[0,0,0,0],l=Math.ceil(r[2]-r[0])||1,c=Math.ceil(r[3]-r[1])||1,h=e.cachedCanvases.getCanvas("pattern",l,c,!0),d=h.context;d.clearRect(0,0,d.canvas.width,d.canvas.height);d.beginPath();d.rect(0,0,d.canvas.width,d.canvas.height);d.translate(-r[0],-r[1]);s=n.Util.transform(s,[1,0,0,1,r[0],r[1]]);d.transform(...e.baseTransform);this.matrix&&d.transform(...this.matrix);applyBoundingBox(d,this._bbox);d.fillStyle=this._createGradient(d);d.fill();o=t.createPattern(h.canvas,"no-repeat");const u=new DOMMatrix(s);o.setTransform(u)}else{applyBoundingBox(t,this._bbox);o=this._createGradient(t)}return o}}function drawTriangle(t,e,s,n,i,a,r,o){const l=e.coords,c=e.colors,h=t.data,d=4*t.width;let u;if(l[s+1]>l[n+1]){u=s;s=n;n=u;u=a;a=r;r=u}if(l[n+1]>l[i+1]){u=n;n=i;i=u;u=r;r=o;o=u}if(l[s+1]>l[n+1]){u=s;s=n;n=u;u=a;a=r;r=u}const p=(l[s]+e.offsetX)*e.scaleX,g=(l[s+1]+e.offsetY)*e.scaleY,m=(l[n]+e.offsetX)*e.scaleX,f=(l[n+1]+e.offsetY)*e.scaleY,b=(l[i]+e.offsetX)*e.scaleX,A=(l[i+1]+e.offsetY)*e.scaleY;if(g>=A)return;const _=c[a],y=c[a+1],v=c[a+2],S=c[r],x=c[r+1],E=c[r+2],C=c[o],P=c[o+1],T=c[o+2],w=Math.round(g),k=Math.round(A);let F,R,M,D,I,O,L,N;for(let t=w;t<=k;t++){if(tA?1:f===A?0:(f-t)/(f-A);F=m-(m-b)*e;R=S-(S-C)*e;M=x-(x-P)*e;D=E-(E-T)*e}let e;e=tA?1:(g-t)/(g-A);I=p-(p-b)*e;O=_-(_-C)*e;L=y-(y-P)*e;N=v-(v-T)*e;const s=Math.round(Math.min(F,I)),n=Math.round(Math.max(F,I));let i=d*t+4*s;for(let t=s;t<=n;t++){e=(F-t)/(F-I);e<0?e=0:e>1&&(e=1);h[i++]=R-(R-O)*e|0;h[i++]=M-(M-L)*e|0;h[i++]=D-(D-N)*e|0;h[i++]=255}}}function drawFigure(t,e,s){const n=e.coords,i=e.colors;let a,r;switch(e.type){case"lattice":const o=e.verticesPerRow,l=Math.floor(n.length/o)-1,c=o-1;for(a=0;a=n?i=n:s=i/t;return{scale:s,size:i}}clipBbox(t,e,s,n,a){const r=n-e,o=a-s;t.ctx.rect(e,s,r,o);t.current.updateRectMinMax((0,i.getCurrentTransform)(t.ctx),[e,s,n,a]);t.clip();t.endPath()}setFillAndStrokeStyleToContext(t,e,s){const i=t.ctx,a=t.current;switch(e){case o:const t=this.ctx;i.fillStyle=t.fillStyle;i.strokeStyle=t.strokeStyle;a.fillColor=t.fillStyle;a.strokeColor=t.strokeStyle;break;case l:const r=n.Util.makeHexColor(s[0],s[1],s[2]);i.fillStyle=r;i.strokeStyle=r;a.fillColor=r;a.strokeColor=r;break;default:throw new n.FormatError(`Unsupported paint type: ${e}`)}}getPattern(t,e,s,i){let a=s;if(i!==r.SHADING){a=n.Util.transform(a,e.baseTransform);this.matrix&&(a=n.Util.transform(a,this.matrix))}const o=this.createPatternCanvas(e);let l=new DOMMatrix(a);l=l.translate(o.offsetX,o.offsetY);l=l.scale(1/o.scaleX,1/o.scaleY);const c=t.createPattern(o.canvas,"repeat");c.setTransform(l);return c}}e.TilingPattern=TilingPattern},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.applyMaskImageData=function applyMaskImageData({src:t,srcPos:e=0,dest:s,destPos:i=0,width:a,height:r,inverseDecode:o=!1}){const l=n.FeatureTest.isLittleEndian?4278190080:255,[c,h]=o?[0,l]:[l,0],d=a>>3,u=7&a,p=t.length;s=new Uint32Array(s.buffer);for(let n=0;n{Object.defineProperty(e,"__esModule",{value:!0});e.GlobalWorkerOptions=void 0;const s=Object.create(null);e.GlobalWorkerOptions=s;s.workerPort=void 0===s.workerPort?null:s.workerPort;s.workerSrc=void 0===s.workerSrc?"":s.workerSrc},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.MessageHandler=void 0;var n=s(1);const i=1,a=2,r=1,o=2,l=3,c=4,h=5,d=6,u=7,p=8;function wrapReason(t){t instanceof Error||"object"==typeof t&&null!==t||(0,n.unreachable)('wrapReason: Expected "reason" to be a (possibly cloned) Error.');switch(t.name){case"AbortException":return new n.AbortException(t.message);case"MissingPDFException":return new n.MissingPDFException(t.message);case"PasswordException":return new n.PasswordException(t.message,t.code);case"UnexpectedResponseException":return new n.UnexpectedResponseException(t.message,t.status);case"UnknownErrorException":return new n.UnknownErrorException(t.message,t.details);default:return new n.UnknownErrorException(t.message,t.toString())}}e.MessageHandler=class MessageHandler{constructor(t,e,s){this.sourceName=t;this.targetName=e;this.comObj=s;this.callbackId=1;this.streamId=1;this.streamSinks=Object.create(null);this.streamControllers=Object.create(null);this.callbackCapabilities=Object.create(null);this.actionHandler=Object.create(null);this._onComObjOnMessage=t=>{const e=t.data;if(e.targetName!==this.sourceName)return;if(e.stream){this._processStreamMessage(e);return}if(e.callback){const t=e.callbackId,s=this.callbackCapabilities[t];if(!s)throw new Error(`Cannot resolve callback ${t}`);delete this.callbackCapabilities[t];if(e.callback===i)s.resolve(e.data);else{if(e.callback!==a)throw new Error("Unexpected callback case");s.reject(wrapReason(e.reason))}return}const n=this.actionHandler[e.action];if(!n)throw new Error(`Unknown action from worker: ${e.action}`);if(e.callbackId){const t=this.sourceName,r=e.sourceName;new Promise((function(t){t(n(e.data))})).then((function(n){s.postMessage({sourceName:t,targetName:r,callback:i,callbackId:e.callbackId,data:n})}),(function(n){s.postMessage({sourceName:t,targetName:r,callback:a,callbackId:e.callbackId,reason:wrapReason(n)})}))}else e.streamId?this._createStreamSink(e):n(e.data)};s.addEventListener("message",this._onComObjOnMessage)}on(t,e){const s=this.actionHandler;if(s[t])throw new Error(`There is already an actionName called "${t}"`);s[t]=e}send(t,e,s){this.comObj.postMessage({sourceName:this.sourceName,targetName:this.targetName,action:t,data:e},s)}sendWithPromise(t,e,s){const i=this.callbackId++,a=(0,n.createPromiseCapability)();this.callbackCapabilities[i]=a;try{this.comObj.postMessage({sourceName:this.sourceName,targetName:this.targetName,action:t,callbackId:i,data:e},s)}catch(t){a.reject(t)}return a.promise}sendWithStream(t,e,s,i){const a=this.streamId++,o=this.sourceName,l=this.targetName,c=this.comObj;return new ReadableStream({start:s=>{const r=(0,n.createPromiseCapability)();this.streamControllers[a]={controller:s,startCall:r,pullCall:null,cancelCall:null,isClosed:!1};c.postMessage({sourceName:o,targetName:l,action:t,streamId:a,data:e,desiredSize:s.desiredSize},i);return r.promise},pull:t=>{const e=(0,n.createPromiseCapability)();this.streamControllers[a].pullCall=e;c.postMessage({sourceName:o,targetName:l,stream:d,streamId:a,desiredSize:t.desiredSize});return e.promise},cancel:t=>{(0,n.assert)(t instanceof Error,"cancel must have a valid reason");const e=(0,n.createPromiseCapability)();this.streamControllers[a].cancelCall=e;this.streamControllers[a].isClosed=!0;c.postMessage({sourceName:o,targetName:l,stream:r,streamId:a,reason:wrapReason(t)});return e.promise}},s)}_createStreamSink(t){const e=t.streamId,s=this.sourceName,i=t.sourceName,a=this.comObj,r=this,o=this.actionHandler[t.action],d={enqueue(t,r=1,o){if(this.isCancelled)return;const l=this.desiredSize;this.desiredSize-=r;if(l>0&&this.desiredSize<=0){this.sinkCapability=(0,n.createPromiseCapability)();this.ready=this.sinkCapability.promise}a.postMessage({sourceName:s,targetName:i,stream:c,streamId:e,chunk:t},o)},close(){if(!this.isCancelled){this.isCancelled=!0;a.postMessage({sourceName:s,targetName:i,stream:l,streamId:e});delete r.streamSinks[e]}},error(t){(0,n.assert)(t instanceof Error,"error must have a valid reason");if(!this.isCancelled){this.isCancelled=!0;a.postMessage({sourceName:s,targetName:i,stream:h,streamId:e,reason:wrapReason(t)})}},sinkCapability:(0,n.createPromiseCapability)(),onPull:null,onCancel:null,isCancelled:!1,desiredSize:t.desiredSize,ready:null};d.sinkCapability.resolve();d.ready=d.sinkCapability.promise;this.streamSinks[e]=d;new Promise((function(e){e(o(t.data,d))})).then((function(){a.postMessage({sourceName:s,targetName:i,stream:p,streamId:e,success:!0})}),(function(t){a.postMessage({sourceName:s,targetName:i,stream:p,streamId:e,reason:wrapReason(t)})}))}_processStreamMessage(t){const e=t.streamId,s=this.sourceName,i=t.sourceName,a=this.comObj,g=this.streamControllers[e],m=this.streamSinks[e];switch(t.stream){case p:t.success?g.startCall.resolve():g.startCall.reject(wrapReason(t.reason));break;case u:t.success?g.pullCall.resolve():g.pullCall.reject(wrapReason(t.reason));break;case d:if(!m){a.postMessage({sourceName:s,targetName:i,stream:u,streamId:e,success:!0});break}m.desiredSize<=0&&t.desiredSize>0&&m.sinkCapability.resolve();m.desiredSize=t.desiredSize;new Promise((function(t){t(m.onPull&&m.onPull())})).then((function(){a.postMessage({sourceName:s,targetName:i,stream:u,streamId:e,success:!0})}),(function(t){a.postMessage({sourceName:s,targetName:i,stream:u,streamId:e,reason:wrapReason(t)})}));break;case c:(0,n.assert)(g,"enqueue should have stream controller");if(g.isClosed)break;g.controller.enqueue(t.chunk);break;case l:(0,n.assert)(g,"close should have stream controller");if(g.isClosed)break;g.isClosed=!0;g.controller.close();this._deleteStreamController(g,e);break;case h:(0,n.assert)(g,"error should have stream controller");g.controller.error(wrapReason(t.reason));this._deleteStreamController(g,e);break;case o:t.success?g.cancelCall.resolve():g.cancelCall.reject(wrapReason(t.reason));this._deleteStreamController(g,e);break;case r:if(!m)break;new Promise((function(e){e(m.onCancel&&m.onCancel(wrapReason(t.reason)))})).then((function(){a.postMessage({sourceName:s,targetName:i,stream:o,streamId:e,success:!0})}),(function(t){a.postMessage({sourceName:s,targetName:i,stream:o,streamId:e,reason:wrapReason(t)})}));m.sinkCapability.reject(wrapReason(t.reason));m.isCancelled=!0;delete this.streamSinks[e];break;default:throw new Error("Unexpected stream case")}}async _deleteStreamController(t,e){await Promise.allSettled([t.startCall&&t.startCall.promise,t.pullCall&&t.pullCall.promise,t.cancelCall&&t.cancelCall.promise]);delete this.streamControllers[e]}destroy(){this.comObj.removeEventListener("message",this._onComObjOnMessage)}}},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.Metadata=void 0;var n=s(1);e.Metadata=class Metadata{#ht;#dt;constructor({parsedData:t,rawData:e}){this.#ht=t;this.#dt=e}getRaw(){return this.#dt}get(t){return this.#ht.get(t)??null}getAll(){return(0,n.objectFromMap)(this.#ht)}has(t){return this.#ht.has(t)}}},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.OptionalContentConfig=void 0;var n=s(1),i=s(8);const a=Symbol("INTERNAL");class OptionalContentGroup{#ut=!0;constructor(t,e){this.name=t;this.intent=e}get visible(){return this.#ut}_setVisible(t,e){t!==a&&(0,n.unreachable)("Internal method `_setVisible` called.");this.#ut=e}}e.OptionalContentConfig=class OptionalContentConfig{#pt=null;#gt=new Map;#mt=null;#ft=null;constructor(t){this.name=null;this.creator=null;if(null!==t){this.name=t.name;this.creator=t.creator;this.#ft=t.order;for(const e of t.groups)this.#gt.set(e.id,new OptionalContentGroup(e.name,e.intent));if("OFF"===t.baseState)for(const t of this.#gt.values())t._setVisible(a,!1);for(const e of t.on)this.#gt.get(e)._setVisible(a,!0);for(const e of t.off)this.#gt.get(e)._setVisible(a,!1);this.#mt=this.getHash()}}#bt(t){const e=t.length;if(e<2)return!0;const s=t[0];for(let i=1;i0?(0,n.objectFromMap)(this.#gt):null}getGroup(t){return this.#gt.get(t)||null}getHash(){if(null!==this.#pt)return this.#pt;const t=new i.MurmurHash3_64;for(const[e,s]of this.#gt)t.update(`${e}:${s.visible}`);return this.#pt=t.hexdigest()}}},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.PDFDataTransportStream=void 0;var n=s(1),i=s(6);e.PDFDataTransportStream=class PDFDataTransportStream{constructor(t,e){(0,n.assert)(e,'PDFDataTransportStream - missing required "pdfDataRangeTransport" argument.');this._queuedChunks=[];this._progressiveDone=t.progressiveDone||!1;this._contentDispositionFilename=t.contentDispositionFilename||null;const s=t.initialData;if(s?.length>0){const t=new Uint8Array(s).buffer;this._queuedChunks.push(t)}this._pdfDataRangeTransport=e;this._isStreamingSupported=!t.disableStream;this._isRangeSupported=!t.disableRange;this._contentLength=t.length;this._fullRequestReader=null;this._rangeReaders=[];this._pdfDataRangeTransport.addRangeListener(((t,e)=>{this._onReceiveData({begin:t,chunk:e})}));this._pdfDataRangeTransport.addProgressListener(((t,e)=>{this._onProgress({loaded:t,total:e})}));this._pdfDataRangeTransport.addProgressiveReadListener((t=>{this._onReceiveData({chunk:t})}));this._pdfDataRangeTransport.addProgressiveDoneListener((()=>{this._onProgressiveDone()}));this._pdfDataRangeTransport.transportReady()}_onReceiveData(t){const e=new Uint8Array(t.chunk).buffer;if(void 0===t.begin)this._fullRequestReader?this._fullRequestReader._enqueue(e):this._queuedChunks.push(e);else{const s=this._rangeReaders.some((function(s){if(s._begin!==t.begin)return!1;s._enqueue(e);return!0}));(0,n.assert)(s,"_onReceiveData - no `PDFDataTransportStreamRangeReader` instance found.")}}get _progressiveDataLength(){return this._fullRequestReader?._loaded??0}_onProgress(t){void 0===t.total?this._rangeReaders[0]?.onProgress?.({loaded:t.loaded}):this._fullRequestReader?.onProgress?.({loaded:t.loaded,total:t.total})}_onProgressiveDone(){this._fullRequestReader?.progressiveDone();this._progressiveDone=!0}_removeRangeReader(t){const e=this._rangeReaders.indexOf(t);e>=0&&this._rangeReaders.splice(e,1)}getFullReader(){(0,n.assert)(!this._fullRequestReader,"PDFDataTransportStream.getFullReader can only be called once.");const t=this._queuedChunks;this._queuedChunks=null;return new PDFDataTransportStreamReader(this,t,this._progressiveDone,this._contentDispositionFilename)}getRangeReader(t,e){if(e<=this._progressiveDataLength)return null;const s=new PDFDataTransportStreamRangeReader(this,t,e);this._pdfDataRangeTransport.requestDataRange(t,e);this._rangeReaders.push(s);return s}cancelAllRequests(t){this._fullRequestReader?.cancel(t);for(const e of this._rangeReaders.slice(0))e.cancel(t);this._pdfDataRangeTransport.abort()}};class PDFDataTransportStreamReader{constructor(t,e,s=!1,n=null){this._stream=t;this._done=s||!1;this._filename=(0,i.isPdfFile)(n)?n:null;this._queuedChunks=e||[];this._loaded=0;for(const t of this._queuedChunks)this._loaded+=t.byteLength;this._requests=[];this._headersReady=Promise.resolve();t._fullRequestReader=this;this.onProgress=null}_enqueue(t){if(!this._done){if(this._requests.length>0){this._requests.shift().resolve({value:t,done:!1})}else this._queuedChunks.push(t);this._loaded+=t.byteLength}}get headersReady(){return this._headersReady}get filename(){return this._filename}get isRangeSupported(){return this._stream._isRangeSupported}get isStreamingSupported(){return this._stream._isStreamingSupported}get contentLength(){return this._stream._contentLength}async read(){if(this._queuedChunks.length>0){return{value:this._queuedChunks.shift(),done:!1}}if(this._done)return{value:void 0,done:!0};const t=(0,n.createPromiseCapability)();this._requests.push(t);return t.promise}cancel(t){this._done=!0;for(const t of this._requests)t.resolve({value:void 0,done:!0});this._requests.length=0}progressiveDone(){this._done||(this._done=!0)}}class PDFDataTransportStreamRangeReader{constructor(t,e,s){this._stream=t;this._begin=e;this._end=s;this._queuedChunk=null;this._requests=[];this._done=!1;this.onProgress=null}_enqueue(t){if(!this._done){if(0===this._requests.length)this._queuedChunk=t;else{this._requests.shift().resolve({value:t,done:!1});for(const t of this._requests)t.resolve({value:void 0,done:!0});this._requests.length=0}this._done=!0;this._stream._removeRangeReader(this)}}get isStreamingSupported(){return!1}async read(){if(this._queuedChunk){const t=this._queuedChunk;this._queuedChunk=null;return{value:t,done:!1}}if(this._done)return{value:void 0,done:!0};const t=(0,n.createPromiseCapability)();this._requests.push(t);return t.promise}cancel(t){this._done=!0;for(const t of this._requests)t.resolve({value:void 0,done:!0});this._requests.length=0;this._stream._removeRangeReader(this)}}},(t,e)=>{Object.defineProperty(e,"__esModule",{value:!0});e.XfaText=void 0;class XfaText{static textContent(t){const e=[],s={items:e,styles:Object.create(null)};!function walk(t){if(!t)return;let s=null;const n=t.name;if("#text"===n)s=t.value;else{if(!XfaText.shouldBuildText(n))return;t?.attributes?.textContent?s=t.attributes.textContent:t.value&&(s=t.value)}null!==s&&e.push({str:s});if(t.children)for(const e of t.children)walk(e)}(t);return s}static shouldBuildText(t){return!("textarea"===t||"input"===t||"option"===t||"select"===t)}}e.XfaText=XfaText},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.NodeStandardFontDataFactory=e.NodeCanvasFactory=e.NodeCMapReaderFactory=void 0;var n=s(7);const fetchData=function(t){return new Promise(((e,s)=>{require("fs").readFile(t,((t,n)=>{!t&&n?e(new Uint8Array(n)):s(new Error(t))}))}))};class NodeCanvasFactory extends n.BaseCanvasFactory{_createCanvas(t,e){return require("canvas").createCanvas(t,e)}}e.NodeCanvasFactory=NodeCanvasFactory;class NodeCMapReaderFactory extends n.BaseCMapReaderFactory{_fetchData(t,e){return fetchData(t).then((t=>({cMapData:t,compressionType:e})))}}e.NodeCMapReaderFactory=NodeCMapReaderFactory;class NodeStandardFontDataFactory extends n.BaseStandardFontDataFactory{_fetchData(t){return fetchData(t)}}e.NodeStandardFontDataFactory=NodeStandardFontDataFactory},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.TextLayerRenderTask=void 0;e.renderTextLayer=function renderTextLayer(t){if(!t.textContentSource&&(t.textContent||t.textContentStream)){(0,i.deprecated)("The TextLayerRender `textContent`/`textContentStream` parameters will be removed in the future, please use `textContentSource` instead.");t.textContentSource=t.textContent||t.textContentStream}const e=new TextLayerRenderTask(t);e._render();return e};e.updateTextLayer=function updateTextLayer({container:t,viewport:e,textDivs:s,textDivProperties:n,isOffscreenCanvasSupported:a,mustRotate:r=!0,mustRescale:o=!0}){r&&(0,i.setLayerDimensions)(t,{rotation:e.rotation});if(o){const t=getCtx(0,a),i={prevFontSize:null,prevFontFamily:null,div:null,scale:e.scale*(globalThis.devicePixelRatio||1),properties:null,ctx:t};for(const t of s){i.properties=n.get(t);i.div=t;layout(i)}}};var n=s(1),i=s(6);const a=30,r=new Map;function getCtx(t,e){let s;if(e&&n.FeatureTest.isOffscreenCanvasSupported)s=new OffscreenCanvas(t,t).getContext("2d",{alpha:!1});else{const e=document.createElement("canvas");e.width=e.height=t;s=e.getContext("2d",{alpha:!1})}return s}function appendText(t,e,s){const i=document.createElement("span"),o={angle:0,canvasWidth:0,hasText:""!==e.str,hasEOL:e.hasEOL,fontSize:0};t._textDivs.push(i);const l=n.Util.transform(t._transform,e.transform);let c=Math.atan2(l[1],l[0]);const h=s[e.fontName];h.vertical&&(c+=Math.PI/2);const d=Math.hypot(l[2],l[3]),u=d*function getAscent(t,e){const s=r.get(t);if(s)return s;const n=getCtx(a,e);n.font=`30px ${t}`;const i=n.measureText("");let o=i.fontBoundingBoxAscent,l=Math.abs(i.fontBoundingBoxDescent);if(o){const e=o/(o+l);r.set(t,e);n.canvas.width=n.canvas.height=0;return e}n.strokeStyle="red";n.clearRect(0,0,a,a);n.strokeText("g",0,0);let c=n.getImageData(0,0,a,a).data;l=0;for(let t=c.length-1-3;t>=0;t-=4)if(c[t]>0){l=Math.ceil(t/4/a);break}n.clearRect(0,0,a,a);n.strokeText("A",0,a);c=n.getImageData(0,0,a,a).data;o=0;for(let t=0,e=c.length;t0){o=a-Math.floor(t/4/a);break}n.canvas.width=n.canvas.height=0;if(o){const e=o/(o+l);r.set(t,e);return e}r.set(t,.8);return.8}(h.fontFamily,t._isOffscreenCanvasSupported);let p,g;if(0===c){p=l[4];g=l[5]-u}else{p=l[4]+u*Math.sin(c);g=l[5]-u*Math.cos(c)}const m="calc(var(--scale-factor)*",f=i.style;if(t._container===t._rootContainer){f.left=`${(100*p/t._pageWidth).toFixed(2)}%`;f.top=`${(100*g/t._pageHeight).toFixed(2)}%`}else{f.left=`${m}${p.toFixed(2)}px)`;f.top=`${m}${g.toFixed(2)}px)`}f.fontSize=`${m}${d.toFixed(2)}px)`;f.fontFamily=h.fontFamily;o.fontSize=d;i.setAttribute("role","presentation");i.textContent=e.str;i.dir=e.dir;t._fontInspectorEnabled&&(i.dataset.fontName=e.fontName);0!==c&&(o.angle=c*(180/Math.PI));let b=!1;if(e.str.length>1)b=!0;else if(" "!==e.str&&e.transform[0]!==e.transform[3]){const t=Math.abs(e.transform[0]),s=Math.abs(e.transform[3]);t!==s&&Math.max(t,s)/Math.min(t,s)>1.5&&(b=!0)}b&&(o.canvasWidth=h.vertical?e.height:e.width);t._textDivProperties.set(i,o);t._isReadableStream&&t._layoutText(i)}function layout(t){const{div:e,scale:s,properties:n,ctx:i,prevFontSize:a,prevFontFamily:r}=t,{style:o}=e;let l="";if(0!==n.canvasWidth&&n.hasText){const{fontFamily:c}=o,{canvasWidth:h,fontSize:d}=n;if(a!==d||r!==c){i.font=`${d*s}px ${c}`;t.prevFontSize=d;t.prevFontFamily=c}const{width:u}=i.measureText(e.textContent);u>0&&(l=`scaleX(${h*s/u})`)}0!==n.angle&&(l=`rotate(${n.angle}deg) ${l}`);l.length>0&&(o.transform=l)}class TextLayerRenderTask{constructor({textContentSource:t,container:e,viewport:s,textDivs:a,textDivProperties:r,textContentItemsStr:o,isOffscreenCanvasSupported:l}){this._textContentSource=t;this._isReadableStream=t instanceof ReadableStream;this._container=this._rootContainer=e;this._textDivs=a||[];this._textContentItemsStr=o||[];this._fontInspectorEnabled=!!globalThis.FontInspector?.enabled;this._reader=null;this._textDivProperties=r||new WeakMap;this._canceled=!1;this._capability=(0,n.createPromiseCapability)();this._layoutTextParams={prevFontSize:null,prevFontFamily:null,div:null,scale:s.scale*(globalThis.devicePixelRatio||1),properties:null,ctx:getCtx(0,l)};const{pageWidth:c,pageHeight:h,pageX:d,pageY:u}=s.rawDims;this._transform=[1,0,0,-1,-d,u+h];this._pageWidth=c;this._pageHeight=h;(0,i.setLayerDimensions)(e,s);this._capability.promise.finally((()=>{this._layoutTextParams=null})).catch((()=>{}))}get promise(){return this._capability.promise}cancel(){this._canceled=!0;if(this._reader){this._reader.cancel(new n.AbortException("TextLayer task cancelled.")).catch((()=>{}));this._reader=null}this._capability.reject(new n.AbortException("TextLayer task cancelled."))}_processItems(t,e){for(const s of t)if(void 0!==s.str){this._textContentItemsStr.push(s.str);appendText(this,s,e)}else if("beginMarkedContentProps"===s.type||"beginMarkedContent"===s.type){const t=this._container;this._container=document.createElement("span");this._container.classList.add("markedContent");null!==s.id&&this._container.setAttribute("id",`${s.id}`);t.append(this._container)}else"endMarkedContent"===s.type&&(this._container=this._container.parentNode)}_layoutText(t){const e=this._layoutTextParams.properties=this._textDivProperties.get(t);this._layoutTextParams.div=t;layout(this._layoutTextParams);e.hasText&&this._container.append(t);if(e.hasEOL){const t=document.createElement("br");t.setAttribute("role","presentation");this._container.append(t)}}_render(){const t=(0,n.createPromiseCapability)();let e=Object.create(null);if(this._isReadableStream){const pump=()=>{this._reader.read().then((({value:s,done:n})=>{if(n)t.resolve();else{Object.assign(e,s.styles);this._processItems(s.items,e);pump()}}),t.reject)};this._reader=this._textContentSource.getReader();pump()}else{if(!this._textContentSource)throw new Error('No "textContentSource" parameter specified.');{const{items:e,styles:s}=this._textContentSource;this._processItems(e,s);t.resolve()}}t.promise.then((()=>{e=null;!function render(t){if(t._canceled)return;const e=t._textDivs,s=t._capability;if(e.length>1e5)s.resolve();else{if(!t._isReadableStream)for(const s of e)t._layoutText(s);s.resolve()}}(this)}),this._capability.reject)}}e.TextLayerRenderTask=TextLayerRenderTask},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.AnnotationEditorLayer=void 0;var n=s(1),i=s(5),a=s(23),r=s(24),o=s(6);class AnnotationEditorLayer{#At;#_t=!1;#yt=this.pointerup.bind(this);#vt=this.pointerdown.bind(this);#St=new Map;#xt=!1;#Et=!1;#Ct;static _initialized=!1;constructor(t){if(!AnnotationEditorLayer._initialized){AnnotationEditorLayer._initialized=!0;a.FreeTextEditor.initialize(t.l10n);r.InkEditor.initialize(t.l10n)}t.uiManager.registerEditorTypes([a.FreeTextEditor,r.InkEditor]);this.#Ct=t.uiManager;this.pageIndex=t.pageIndex;this.div=t.div;this.#At=t.accessibilityManager;this.#Ct.addLayer(this)}updateToolbar(t){this.#Ct.updateToolbar(t)}updateMode(t=this.#Ct.getMode()){this.#Pt();if(t===n.AnnotationEditorType.INK){this.addInkEditorIfNeeded(!1);this.disableClick()}else this.enableClick();this.#Ct.unselectAll();this.div.classList.toggle("freeTextEditing",t===n.AnnotationEditorType.FREETEXT);this.div.classList.toggle("inkEditing",t===n.AnnotationEditorType.INK)}addInkEditorIfNeeded(t){if(!t&&this.#Ct.getMode()!==n.AnnotationEditorType.INK)return;if(!t)for(const t of this.#St.values())if(t.isEmpty()){t.setInBackground();return}this.#Tt({offsetX:0,offsetY:0}).setInBackground()}setEditingState(t){this.#Ct.setEditingState(t)}addCommands(t){this.#Ct.addCommands(t)}enable(){this.div.style.pointerEvents="auto";for(const t of this.#St.values())t.enableEditing()}disable(){this.div.style.pointerEvents="none";for(const t of this.#St.values())t.disableEditing()}setActiveEditor(t){this.#Ct.getActive()!==t&&this.#Ct.setActiveEditor(t)}enableClick(){this.div.addEventListener("pointerdown",this.#vt);this.div.addEventListener("pointerup",this.#yt)}disableClick(){this.div.removeEventListener("pointerdown",this.#vt);this.div.removeEventListener("pointerup",this.#yt)}attach(t){this.#St.set(t.id,t)}detach(t){this.#St.delete(t.id);this.#At?.removePointerInTextLayer(t.contentDiv)}remove(t){this.#Ct.removeEditor(t);this.detach(t);t.div.style.display="none";setTimeout((()=>{t.div.style.display="";t.div.remove();t.isAttachedToDOM=!1;document.activeElement===document.body&&this.#Ct.focusMainContainer()}),0);this.#Et||this.addInkEditorIfNeeded(!1)}#wt(t){if(t.parent!==this){this.attach(t);t.parent?.detach(t);t.setParent(this);if(t.div&&t.isAttachedToDOM){t.div.remove();this.div.append(t.div)}}}add(t){this.#wt(t);this.#Ct.addEditor(t);this.attach(t);if(!t.isAttachedToDOM){const e=t.render();this.div.append(e);t.isAttachedToDOM=!0}this.moveEditorInDOM(t);t.onceAdded();this.#Ct.addToAnnotationStorage(t)}moveEditorInDOM(t){this.#At?.moveElementInDOM(this.div,t.div,t.contentDiv,!0)}addOrRebuild(t){t.needsToBeRebuilt()?t.rebuild():this.add(t)}addANewEditor(t){this.addCommands({cmd:()=>{this.addOrRebuild(t)},undo:()=>{t.remove()},mustExec:!0})}addUndoableEditor(t){this.addCommands({cmd:()=>{this.addOrRebuild(t)},undo:()=>{t.remove()},mustExec:!1})}getNextId(){return this.#Ct.getId()}#kt(t){switch(this.#Ct.getMode()){case n.AnnotationEditorType.FREETEXT:return new a.FreeTextEditor(t);case n.AnnotationEditorType.INK:return new r.InkEditor(t)}return null}deserialize(t){switch(t.annotationType){case n.AnnotationEditorType.FREETEXT:return a.FreeTextEditor.deserialize(t,this,this.#Ct);case n.AnnotationEditorType.INK:return r.InkEditor.deserialize(t,this,this.#Ct)}return null}#Tt(t){const e=this.getNextId(),s=this.#kt({parent:this,id:e,x:t.offsetX,y:t.offsetY,uiManager:this.#Ct});s&&this.add(s);return s}setSelected(t){this.#Ct.setSelected(t)}toggleSelected(t){this.#Ct.toggleSelected(t)}isSelected(t){return this.#Ct.isSelected(t)}unselect(t){this.#Ct.unselect(t)}pointerup(t){const{isMac:e}=n.FeatureTest.platform;if(!(0!==t.button||t.ctrlKey&&e)&&t.target===this.div&&this.#xt){this.#xt=!1;this.#_t?this.#Tt(t):this.#_t=!0}}pointerdown(t){const{isMac:e}=n.FeatureTest.platform;if(0!==t.button||t.ctrlKey&&e)return;if(t.target!==this.div)return;this.#xt=!0;const s=this.#Ct.getActive();this.#_t=!s||s.isEmpty()}drop(t){const e=t.dataTransfer.getData("text/plain"),s=this.#Ct.getEditor(e);if(!s)return;t.preventDefault();t.dataTransfer.dropEffect="move";this.#wt(s);const n=this.div.getBoundingClientRect(),i=t.clientX-n.x,a=t.clientY-n.y;s.translate(i-s.startX,a-s.startY);this.moveEditorInDOM(s);s.div.focus()}dragover(t){t.preventDefault()}destroy(){this.#Ct.getActive()?.parent===this&&this.#Ct.setActiveEditor(null);for(const t of this.#St.values()){this.#At?.removePointerInTextLayer(t.contentDiv);t.setParent(null);t.isAttachedToDOM=!1;t.div.remove()}this.div=null;this.#St.clear();this.#Ct.removeLayer(this)}#Pt(){this.#Et=!0;for(const t of this.#St.values())t.isEmpty()&&t.remove();this.#Et=!1}render({viewport:t}){this.viewport=t;(0,o.setLayerDimensions)(this.div,t);(0,i.bindEvents)(this,this.div,["dragover","drop"]);for(const t of this.#Ct.getEditors(this.pageIndex))this.add(t);this.updateMode()}update({viewport:t}){this.#Ct.commitOrRemove();this.viewport=t;(0,o.setLayerDimensions)(this.div,{rotation:t.rotation});this.updateMode()}get pageDimensions(){const{pageWidth:t,pageHeight:e}=this.viewport.rawDims;return[t,e]}}e.AnnotationEditorLayer=AnnotationEditorLayer},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.FreeTextEditor=void 0;var n=s(1),i=s(5),a=s(4);class FreeTextEditor extends a.AnnotationEditor{#Ft=this.editorDivBlur.bind(this);#Rt=this.editorDivFocus.bind(this);#Mt=this.editorDivInput.bind(this);#Dt=this.editorDivKeydown.bind(this);#It;#Ot="";#Lt=`${this.id}-editor`;#Nt=!1;#jt;static _freeTextDefaultContent="";static _l10nPromise;static _internalPadding=0;static _defaultColor=null;static _defaultFontSize=10;static _keyboardManager=new i.KeyboardManager([[["ctrl+Enter","mac+meta+Enter","Escape","mac+Escape"],FreeTextEditor.prototype.commitOrRemove]]);static _type="freetext";constructor(t){super({...t,name:"freeTextEditor"});this.#It=t.color||FreeTextEditor._defaultColor||a.AnnotationEditor._defaultLineColor;this.#jt=t.fontSize||FreeTextEditor._defaultFontSize}static initialize(t){this._l10nPromise=new Map(["free_text2_default_content","editor_free_text2_aria_label"].map((e=>[e,t.get(e)])));const e=getComputedStyle(document.documentElement);this._internalPadding=parseFloat(e.getPropertyValue("--freetext-padding"))}static updateDefaultParams(t,e){switch(t){case n.AnnotationEditorParamsType.FREETEXT_SIZE:FreeTextEditor._defaultFontSize=e;break;case n.AnnotationEditorParamsType.FREETEXT_COLOR:FreeTextEditor._defaultColor=e}}updateParams(t,e){switch(t){case n.AnnotationEditorParamsType.FREETEXT_SIZE:this.#Ut(e);break;case n.AnnotationEditorParamsType.FREETEXT_COLOR:this.#Bt(e)}}static get defaultPropertiesToUpdate(){return[[n.AnnotationEditorParamsType.FREETEXT_SIZE,FreeTextEditor._defaultFontSize],[n.AnnotationEditorParamsType.FREETEXT_COLOR,FreeTextEditor._defaultColor||a.AnnotationEditor._defaultLineColor]]}get propertiesToUpdate(){return[[n.AnnotationEditorParamsType.FREETEXT_SIZE,this.#jt],[n.AnnotationEditorParamsType.FREETEXT_COLOR,this.#It]]}#Ut(t){const setFontsize=t=>{this.editorDiv.style.fontSize=`calc(${t}px * var(--scale-factor))`;this.translate(0,-(t-this.#jt)*this.parentScale);this.#jt=t;this.#qt()},e=this.#jt;this.addCommands({cmd:()=>{setFontsize(t)},undo:()=>{setFontsize(e)},mustExec:!0,type:n.AnnotationEditorParamsType.FREETEXT_SIZE,overwriteIfSameType:!0,keepUndo:!0})}#Bt(t){const e=this.#It;this.addCommands({cmd:()=>{this.#It=this.editorDiv.style.color=t},undo:()=>{this.#It=this.editorDiv.style.color=e},mustExec:!0,type:n.AnnotationEditorParamsType.FREETEXT_COLOR,overwriteIfSameType:!0,keepUndo:!0})}getInitialTranslation(){const t=this.parentScale;return[-FreeTextEditor._internalPadding*t,-(FreeTextEditor._internalPadding+this.#jt)*t]}rebuild(){super.rebuild();null!==this.div&&(this.isAttachedToDOM||this.parent.add(this))}enableEditMode(){if(!this.isInEditMode()){this.parent.setEditingState(!1);this.parent.updateToolbar(n.AnnotationEditorType.FREETEXT);super.enableEditMode();this.overlayDiv.classList.remove("enabled");this.editorDiv.contentEditable=!0;this.div.draggable=!1;this.div.removeAttribute("aria-activedescendant");this.editorDiv.addEventListener("keydown",this.#Dt);this.editorDiv.addEventListener("focus",this.#Rt);this.editorDiv.addEventListener("blur",this.#Ft);this.editorDiv.addEventListener("input",this.#Mt)}}disableEditMode(){if(this.isInEditMode()){this.parent.setEditingState(!0);super.disableEditMode();this.overlayDiv.classList.add("enabled");this.editorDiv.contentEditable=!1;this.div.setAttribute("aria-activedescendant",this.#Lt);this.div.draggable=!0;this.editorDiv.removeEventListener("keydown",this.#Dt);this.editorDiv.removeEventListener("focus",this.#Rt);this.editorDiv.removeEventListener("blur",this.#Ft);this.editorDiv.removeEventListener("input",this.#Mt);this.div.focus({preventScroll:!0});this.isEditing=!1;this.parent.div.classList.add("freeTextEditing")}}focusin(t){super.focusin(t);t.target!==this.editorDiv&&this.editorDiv.focus()}onceAdded(){if(!this.width){this.enableEditMode();this.editorDiv.focus()}}isEmpty(){return!this.editorDiv||""===this.editorDiv.innerText.trim()}remove(){this.isEditing=!1;this.parent.setEditingState(!0);this.parent.div.classList.add("freeTextEditing");super.remove()}#Wt(){const t=this.editorDiv.getElementsByTagName("div");if(0===t.length)return this.editorDiv.innerText;const e=[];for(const s of t)e.push(s.innerText.replace(/\r\n?|\n/,""));return e.join("\n")}#qt(){const[t,e]=this.parentDimensions;let s;if(this.isAttachedToDOM)s=this.div.getBoundingClientRect();else{const{currentLayer:t,div:e}=this,n=e.style.display;e.style.display="hidden";t.div.append(this.div);s=e.getBoundingClientRect();e.remove();e.style.display=n}this.width=s.width/t;this.height=s.height/e}commit(){if(this.isInEditMode()){super.commit();if(!this.#Nt){this.#Nt=!0;this.parent.addUndoableEditor(this)}this.disableEditMode();this.#Ot=this.#Wt().trimEnd();this.#qt()}}shouldGetKeyboardEvents(){return this.isInEditMode()}dblclick(t){this.enableEditMode();this.editorDiv.focus()}keydown(t){if(t.target===this.div&&"Enter"===t.key){this.enableEditMode();this.editorDiv.focus()}}editorDivKeydown(t){FreeTextEditor._keyboardManager.exec(this,t)}editorDivFocus(t){this.isEditing=!0}editorDivBlur(t){this.isEditing=!1}editorDivInput(t){this.parent.div.classList.toggle("freeTextEditing",this.isEmpty())}disableEditing(){this.editorDiv.setAttribute("role","comment");this.editorDiv.removeAttribute("aria-multiline")}enableEditing(){this.editorDiv.setAttribute("role","textbox");this.editorDiv.setAttribute("aria-multiline",!0)}render(){if(this.div)return this.div;let t,e;if(this.width){t=this.x;e=this.y}super.render();this.editorDiv=document.createElement("div");this.editorDiv.className="internal";this.editorDiv.setAttribute("id",this.#Lt);this.enableEditing();FreeTextEditor._l10nPromise.get("editor_free_text2_aria_label").then((t=>this.editorDiv?.setAttribute("aria-label",t)));FreeTextEditor._l10nPromise.get("free_text2_default_content").then((t=>this.editorDiv?.setAttribute("default-content",t)));this.editorDiv.contentEditable=!0;const{style:s}=this.editorDiv;s.fontSize=`calc(${this.#jt}px * var(--scale-factor))`;s.color=this.#It;this.div.append(this.editorDiv);this.overlayDiv=document.createElement("div");this.overlayDiv.classList.add("overlay","enabled");this.div.append(this.overlayDiv);(0,i.bindEvents)(this,this.div,["dblclick","keydown"]);if(this.width){const[s,n]=this.parentDimensions;this.setAt(t*s,e*n,this.width*s,this.height*n);for(const t of this.#Ot.split("\n")){const e=document.createElement("div");e.append(t?document.createTextNode(t):document.createElement("br"));this.editorDiv.append(e)}this.div.draggable=!0;this.editorDiv.contentEditable=!1}else{this.div.draggable=!1;this.editorDiv.contentEditable=!0}return this.div}get contentDiv(){return this.editorDiv}static deserialize(t,e,s){const i=super.deserialize(t,e,s);i.#jt=t.fontSize;i.#It=n.Util.makeHexColor(...t.color);i.#Ot=t.value;return i}serialize(){if(this.isEmpty())return null;const t=FreeTextEditor._internalPadding*this.parentScale,e=this.getRect(t,t),s=a.AnnotationEditor._colorManager.convert(this.isAttachedToDOM?getComputedStyle(this.editorDiv).color:this.#It);return{annotationType:n.AnnotationEditorType.FREETEXT,color:s,fontSize:this.#jt,value:this.#Ot,pageIndex:this.pageIndex,rect:e,rotation:this.rotation}}}e.FreeTextEditor=FreeTextEditor},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.InkEditor=void 0;Object.defineProperty(e,"fitCurve",{enumerable:!0,get:function(){return a.fitCurve}});var n=s(1),i=s(4),a=s(25),r=s(5);const o=16;class InkEditor extends i.AnnotationEditor{#Ht=0;#Gt=0;#zt=0;#Vt=this.canvasPointermove.bind(this);#Xt=this.canvasPointerleave.bind(this);#$t=this.canvasPointerup.bind(this);#Yt=this.canvasPointerdown.bind(this);#Kt=!1;#Jt=!1;#Qt=null;#Zt=null;#te=0;#ee=0;#se=null;static _defaultColor=null;static _defaultOpacity=1;static _defaultThickness=1;static _l10nPromise;static _type="ink";constructor(t){super({...t,name:"inkEditor"});this.color=t.color||null;this.thickness=t.thickness||null;this.opacity=t.opacity||null;this.paths=[];this.bezierPath2D=[];this.currentPath=[];this.scaleFactor=1;this.translationX=this.translationY=0;this.x=0;this.y=0}static initialize(t){this._l10nPromise=new Map(["editor_ink_canvas_aria_label","editor_ink2_aria_label"].map((e=>[e,t.get(e)])))}static updateDefaultParams(t,e){switch(t){case n.AnnotationEditorParamsType.INK_THICKNESS:InkEditor._defaultThickness=e;break;case n.AnnotationEditorParamsType.INK_COLOR:InkEditor._defaultColor=e;break;case n.AnnotationEditorParamsType.INK_OPACITY:InkEditor._defaultOpacity=e/100}}updateParams(t,e){switch(t){case n.AnnotationEditorParamsType.INK_THICKNESS:this.#ne(e);break;case n.AnnotationEditorParamsType.INK_COLOR:this.#Bt(e);break;case n.AnnotationEditorParamsType.INK_OPACITY:this.#ie(e)}}static get defaultPropertiesToUpdate(){return[[n.AnnotationEditorParamsType.INK_THICKNESS,InkEditor._defaultThickness],[n.AnnotationEditorParamsType.INK_COLOR,InkEditor._defaultColor||i.AnnotationEditor._defaultLineColor],[n.AnnotationEditorParamsType.INK_OPACITY,Math.round(100*InkEditor._defaultOpacity)]]}get propertiesToUpdate(){return[[n.AnnotationEditorParamsType.INK_THICKNESS,this.thickness||InkEditor._defaultThickness],[n.AnnotationEditorParamsType.INK_COLOR,this.color||InkEditor._defaultColor||i.AnnotationEditor._defaultLineColor],[n.AnnotationEditorParamsType.INK_OPACITY,Math.round(100*(this.opacity??InkEditor._defaultOpacity))]]}#ne(t){const e=this.thickness;this.addCommands({cmd:()=>{this.thickness=t;this.#ae()},undo:()=>{this.thickness=e;this.#ae()},mustExec:!0,type:n.AnnotationEditorParamsType.INK_THICKNESS,overwriteIfSameType:!0,keepUndo:!0})}#Bt(t){const e=this.color;this.addCommands({cmd:()=>{this.color=t;this.#re()},undo:()=>{this.color=e;this.#re()},mustExec:!0,type:n.AnnotationEditorParamsType.INK_COLOR,overwriteIfSameType:!0,keepUndo:!0})}#ie(t){t/=100;const e=this.opacity;this.addCommands({cmd:()=>{this.opacity=t;this.#re()},undo:()=>{this.opacity=e;this.#re()},mustExec:!0,type:n.AnnotationEditorParamsType.INK_OPACITY,overwriteIfSameType:!0,keepUndo:!0})}rebuild(){super.rebuild();if(null!==this.div){if(!this.canvas){this.#oe();this.#le()}if(!this.isAttachedToDOM){this.parent.add(this);this.#ce()}this.#ae()}}remove(){if(null!==this.canvas){this.isEmpty()||this.commit();this.canvas.width=this.canvas.height=0;this.canvas.remove();this.canvas=null;this.#Zt.disconnect();this.#Zt=null;super.remove()}}setParent(t){!this.parent&&t?this._uiManager.removeShouldRescale(this):this.parent&&null===t&&this._uiManager.addShouldRescale(this);super.setParent(t)}onScaleChanging(){const[t,e]=this.parentDimensions,s=this.width*t,n=this.height*e;this.setDimensions(s,n)}enableEditMode(){if(!this.#Kt&&null!==this.canvas){super.enableEditMode();this.div.draggable=!1;this.canvas.addEventListener("pointerdown",this.#Yt);this.canvas.addEventListener("pointerup",this.#$t)}}disableEditMode(){if(this.isInEditMode()&&null!==this.canvas){super.disableEditMode();this.div.draggable=!this.isEmpty();this.div.classList.remove("editing");this.canvas.removeEventListener("pointerdown",this.#Yt);this.canvas.removeEventListener("pointerup",this.#$t)}}onceAdded(){this.div.draggable=!this.isEmpty()}isEmpty(){return 0===this.paths.length||1===this.paths.length&&0===this.paths[0].length}#he(){const{parentRotation:t,parentDimensions:[e,s]}=this;switch(t){case 90:return[0,s,s,e];case 180:return[e,s,e,s];case 270:return[e,0,s,e];default:return[0,0,e,s]}}#de(){const{ctx:t,color:e,opacity:s,thickness:n,parentScale:i,scaleFactor:a}=this;t.lineWidth=n*i/a;t.lineCap="round";t.lineJoin="round";t.miterLimit=10;t.strokeStyle=`${e}${(0,r.opacityToHex)(s)}`}#ue(t,e){this.isEditing=!0;if(!this.#Jt){this.#Jt=!0;this.#ce();this.thickness||=InkEditor._defaultThickness;this.color||=InkEditor._defaultColor||i.AnnotationEditor._defaultLineColor;this.opacity??=InkEditor._defaultOpacity}this.currentPath.push([t,e]);this.#Qt=null;this.#de();this.ctx.beginPath();this.ctx.moveTo(t,e);this.#se=()=>{if(this.#se){if(this.#Qt){if(this.isEmpty()){this.ctx.setTransform(1,0,0,1,0,0);this.ctx.clearRect(0,0,this.canvas.width,this.canvas.height)}else this.#re();this.ctx.lineTo(...this.#Qt);this.#Qt=null;this.ctx.stroke()}window.requestAnimationFrame(this.#se)}};window.requestAnimationFrame(this.#se)}#pe(t,e){const[s,n]=this.currentPath.at(-1);if(t!==s||e!==n){this.currentPath.push([t,e]);this.#Qt=[t,e]}}#ge(t,e){this.ctx.closePath();this.#se=null;t=Math.min(Math.max(t,0),this.canvas.width);e=Math.min(Math.max(e,0),this.canvas.height);const[s,n]=this.currentPath.at(-1);t===s&&e===n||this.currentPath.push([t,e]);let i;if(1!==this.currentPath.length)i=(0,a.fitCurve)(this.currentPath,30,null);else{const s=[t,e];i=[[s,s.slice(),s.slice(),s]]}const r=InkEditor.#me(i);this.currentPath.length=0;this.addCommands({cmd:()=>{this.paths.push(i);this.bezierPath2D.push(r);this.rebuild()},undo:()=>{this.paths.pop();this.bezierPath2D.pop();if(0===this.paths.length)this.remove();else{if(!this.canvas){this.#oe();this.#le()}this.#ae()}},mustExec:!0})}#re(){if(this.isEmpty()){this.#fe();return}this.#de();const{canvas:t,ctx:e}=this;e.setTransform(1,0,0,1,0,0);e.clearRect(0,0,t.width,t.height);this.#fe();for(const t of this.bezierPath2D)e.stroke(t)}commit(){if(!this.#Kt){super.commit();this.isEditing=!1;this.disableEditMode();this.setInForeground();this.#Kt=!0;this.div.classList.add("disabled");this.#ae(!0);this.parent.addInkEditorIfNeeded(!0);this.parent.moveEditorInDOM(this);this.div.focus({preventScroll:!0})}}focusin(t){super.focusin(t);this.enableEditMode()}canvasPointerdown(t){if(0===t.button&&this.isInEditMode()&&!this.#Kt){this.setInForeground();"mouse"!==t.type&&this.div.focus();t.stopPropagation();this.canvas.addEventListener("pointerleave",this.#Xt);this.canvas.addEventListener("pointermove",this.#Vt);this.#ue(t.offsetX,t.offsetY)}}canvasPointermove(t){t.stopPropagation();this.#pe(t.offsetX,t.offsetY)}canvasPointerup(t){if(0===t.button&&this.isInEditMode()&&0!==this.currentPath.length){t.stopPropagation();this.#be(t);this.setInBackground()}}canvasPointerleave(t){this.#be(t);this.setInBackground()}#be(t){this.#ge(t.offsetX,t.offsetY);this.canvas.removeEventListener("pointerleave",this.#Xt);this.canvas.removeEventListener("pointermove",this.#Vt);this.addToAnnotationStorage()}#oe(){this.canvas=document.createElement("canvas");this.canvas.width=this.canvas.height=0;this.canvas.className="inkEditorCanvas";InkEditor._l10nPromise.get("editor_ink_canvas_aria_label").then((t=>this.canvas?.setAttribute("aria-label",t)));this.div.append(this.canvas);this.ctx=this.canvas.getContext("2d")}#le(){let t=null;this.#Zt=new ResizeObserver((e=>{const s=e[0].contentRect;if(s.width&&s.height){null!==t&&clearTimeout(t);t=setTimeout((()=>{this.fixDims();t=null}),100);this.setDimensions(s.width,s.height)}}));this.#Zt.observe(this.div)}render(){if(this.div)return this.div;let t,e;if(this.width){t=this.x;e=this.y}super.render();InkEditor._l10nPromise.get("editor_ink2_aria_label").then((t=>this.div?.setAttribute("aria-label",t)));const[s,n,i,a]=this.#he();this.setAt(s,n,0,0);this.setDims(i,a);this.#oe();if(this.width){const[s,n]=this.parentDimensions;this.setAt(t*s,e*n,this.width*s,this.height*n);this.#Jt=!0;this.#ce();this.setDims(this.width*s,this.height*n);this.#re();this.#Ae();this.div.classList.add("disabled")}else{this.div.classList.add("editing");this.enableEditMode()}this.#le();return this.div}#ce(){if(!this.#Jt)return;const[t,e]=this.parentDimensions;this.canvas.width=Math.ceil(this.width*t);this.canvas.height=Math.ceil(this.height*e);this.#fe()}setDimensions(t,e){const s=Math.round(t),n=Math.round(e);if(this.#te===s&&this.#ee===n)return;this.#te=s;this.#ee=n;this.canvas.style.visibility="hidden";if(this.#Ht&&Math.abs(this.#Ht-t/e)>.01){e=Math.ceil(t/this.#Ht);this.setDims(t,e)}const[i,a]=this.parentDimensions;this.width=t/i;this.height=e/a;this.#Kt&&this.#_e(t,e);this.#ce();this.#re();this.canvas.style.visibility="visible"}#_e(t,e){const s=this.#ye(),n=(t-s)/this.#zt,i=(e-s)/this.#Gt;this.scaleFactor=Math.min(n,i)}#fe(){const t=this.#ye()/2;this.ctx.setTransform(this.scaleFactor,0,0,this.scaleFactor,this.translationX*this.scaleFactor+t,this.translationY*this.scaleFactor+t)}static#me(t){const e=new Path2D;for(let s=0,n=t.length;s=1){t.minHeight="16px";t.minWidth=`${Math.round(this.#Ht*o)}px`}else{t.minWidth="16px";t.minHeight=`${Math.round(o/this.#Ht)}px`}}static deserialize(t,e,s){const i=super.deserialize(t,e,s);i.thickness=t.thickness;i.color=n.Util.makeHexColor(...t.color);i.opacity=t.opacity;const[a,r]=i.pageDimensions,l=i.width*a,c=i.height*r,h=i.parentScale,d=t.thickness/2;i.#Ht=l/c;i.#Kt=!0;i.#te=Math.round(l);i.#ee=Math.round(c);for(const{bezier:e}of t.paths){const t=[];i.paths.push(t);let s=h*(e[0]-d),n=h*(c-e[1]-d);for(let i=2,a=e.length;i{Object.defineProperty(e,"__esModule",{value:!0});e.fitCurve=void 0;const n=s(26);e.fitCurve=n},t=>{function fitCubic(t,e,s,n,i){var a,r,o,l,c,h,d,u,p,g,m,f,b;if(2===t.length){f=maths.vectorLen(maths.subtract(t[0],t[1]))/3;return[a=[t[0],maths.addArrays(t[0],maths.mulItems(e,f)),maths.addArrays(t[1],maths.mulItems(s,f)),t[1]]]}r=function chordLengthParameterize(t){var e,s,n,i=[];t.forEach(((t,a)=>{e=a?s+maths.vectorLen(maths.subtract(t,n)):0;i.push(e);s=e;n=t}));i=i.map((t=>t/s));return i}(t);[a,l,h]=generateAndReport(t,r,r,e,s,i);if(0===l||l.9999&&t<1.0001)break}c=l;d=h}}m=[];if((u=maths.subtract(t[h-1],t[h+1])).every((t=>0===t))){u=maths.subtract(t[h-1],t[h]);[u[0],u[1]]=[-u[1],u[0]]}p=maths.normalize(u);g=maths.mulItems(p,-1);return m=(m=m.concat(fitCubic(t.slice(0,h+1),e,p,n,i))).concat(fitCubic(t.slice(h),g,s,n,i))}function generateAndReport(t,e,s,n,i,a){var r,o,l;r=function generateBezier(t,e,s,n){var i,a,r,o,l,c,h,d,u,p,g,m,f,b,A,_,y,v=t[0],S=t[t.length-1];i=[v,null,null,S];a=maths.zeros_Xx2x2(e.length);for(f=0,b=e.length;fi){i=n;a=o}}return[i,a]}(t,r,e);a&&a({bez:r,points:t,params:e,maxErr:o,maxPoint:l});return[r,o,l]}function reparameterize(t,e,s){return s.map(((s,n)=>newtonRaphsonRootFind(t,e[n],s)))}function newtonRaphsonRootFind(t,e,s){var n=maths.subtract(bezier.q(t,s),e),i=bezier.qprime(t,s),a=maths.mulMatrix(n,i),r=maths.sum(maths.squareItems(i))+2*maths.mulMatrix(n,bezier.qprimeprime(t,s));return 0===r?s:s-a/r}var mapTtoRelativeDistances=function(t,e){for(var s,n=[0],i=t[0],a=0,r=1;r<=e;r++){s=bezier.q(t,r/e);a+=maths.vectorLen(maths.subtract(s,i));n.push(a);i=s}return n=n.map((t=>t/a))};function find_t(t,e,s,n){if(e<0)return 0;if(e>1)return 1;for(var i,a,r,o,l=1;l<=n;l++)if(e<=s[l]){r=(l-1)/n;a=l/n;o=(e-(i=s[l-1]))/(s[l]-i)*(a-r)+r;break}return o}function createTangent(t,e){return maths.normalize(maths.subtract(t,e))}class maths{static zeros_Xx2x2(t){for(var e=[];t--;)e.push([0,0]);return e}static mulItems(t,e){return t.map((t=>t*e))}static mulMatrix(t,e){return t.reduce(((t,s,n)=>t+s*e[n]),0)}static subtract(t,e){return t.map(((t,s)=>t-e[s]))}static addArrays(t,e){return t.map(((t,s)=>t+e[s]))}static addItems(t,e){return t.map((t=>t+e))}static sum(t){return t.reduce(((t,e)=>t+e))}static dot(t,e){return maths.mulMatrix(t,e)}static vectorLen(t){return Math.hypot(...t)}static divItems(t,e){return t.map((t=>t/e))}static squareItems(t){return t.map((t=>t*t))}static normalize(t){return this.divItems(t,this.vectorLen(t))}}class bezier{static q(t,e){var s=1-e,n=maths.mulItems(t[0],s*s*s),i=maths.mulItems(t[1],3*s*s*e),a=maths.mulItems(t[2],3*s*e*e),r=maths.mulItems(t[3],e*e*e);return maths.addArrays(maths.addArrays(n,i),maths.addArrays(a,r))}static qprime(t,e){var s=1-e,n=maths.mulItems(maths.subtract(t[1],t[0]),3*s*s),i=maths.mulItems(maths.subtract(t[2],t[1]),6*s*e),a=maths.mulItems(maths.subtract(t[3],t[2]),3*e*e);return maths.addArrays(maths.addArrays(n,i),a)}static qprimeprime(t,e){return maths.addArrays(maths.mulItems(maths.addArrays(maths.subtract(t[2],maths.mulItems(t[1],2)),t[0]),6*(1-e)),maths.mulItems(maths.addArrays(maths.subtract(t[3],maths.mulItems(t[2],2)),t[1]),6*e))}}t.exports=function fitCurve(t,e,s){if(!Array.isArray(t))throw new TypeError("First argument should be an array");t.forEach((e=>{if(!Array.isArray(e)||e.some((t=>"number"!=typeof t))||e.length!==t[0].length)throw Error("Each point should be an array of numbers. Each point should have the same amount of numbers.")}));if((t=t.filter(((e,s)=>0===s||!e.every(((e,n)=>e===t[s-1][n]))))).length<2)return[];const n=t.length,i=createTangent(t[1],t[0]),a=createTangent(t[n-2],t[n-1]);return fitCubic(t,i,a,e,s)};t.exports.fitCubic=fitCubic;t.exports.createTangent=createTangent},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.AnnotationLayer=void 0;var n=s(1),i=s(6),a=s(3),r=s(28),o=s(29);const l=1e3,c=new WeakSet;function getRectDims(t){return{width:t[2]-t[0],height:t[3]-t[1]}}class AnnotationElementFactory{static create(t){switch(t.data.annotationType){case n.AnnotationType.LINK:return new LinkAnnotationElement(t);case n.AnnotationType.TEXT:return new TextAnnotationElement(t);case n.AnnotationType.WIDGET:switch(t.data.fieldType){case"Tx":return new TextWidgetAnnotationElement(t);case"Btn":return t.data.radioButton?new RadioButtonWidgetAnnotationElement(t):t.data.checkBox?new CheckboxWidgetAnnotationElement(t):new PushButtonWidgetAnnotationElement(t);case"Ch":return new ChoiceWidgetAnnotationElement(t)}return new WidgetAnnotationElement(t);case n.AnnotationType.POPUP:return new PopupAnnotationElement(t);case n.AnnotationType.FREETEXT:return new FreeTextAnnotationElement(t);case n.AnnotationType.LINE:return new LineAnnotationElement(t);case n.AnnotationType.SQUARE:return new SquareAnnotationElement(t);case n.AnnotationType.CIRCLE:return new CircleAnnotationElement(t);case n.AnnotationType.POLYLINE:return new PolylineAnnotationElement(t);case n.AnnotationType.CARET:return new CaretAnnotationElement(t);case n.AnnotationType.INK:return new InkAnnotationElement(t);case n.AnnotationType.POLYGON:return new PolygonAnnotationElement(t);case n.AnnotationType.HIGHLIGHT:return new HighlightAnnotationElement(t);case n.AnnotationType.UNDERLINE:return new UnderlineAnnotationElement(t);case n.AnnotationType.SQUIGGLY:return new SquigglyAnnotationElement(t);case n.AnnotationType.STRIKEOUT:return new StrikeOutAnnotationElement(t);case n.AnnotationType.STAMP:return new StampAnnotationElement(t);case n.AnnotationType.FILEATTACHMENT:return new FileAttachmentAnnotationElement(t);default:return new AnnotationElement(t)}}}class AnnotationElement{constructor(t,{isRenderable:e=!1,ignoreBorder:s=!1,createQuadrilaterals:n=!1}={}){this.isRenderable=e;this.data=t.data;this.layer=t.layer;this.page=t.page;this.viewport=t.viewport;this.linkService=t.linkService;this.downloadManager=t.downloadManager;this.imageResourcesPath=t.imageResourcesPath;this.renderForms=t.renderForms;this.svgFactory=t.svgFactory;this.annotationStorage=t.annotationStorage;this.enableScripting=t.enableScripting;this.hasJSActions=t.hasJSActions;this._fieldObjects=t.fieldObjects;e&&(this.container=this._createContainer(s));n&&(this.quadrilaterals=this._createQuadrilaterals(s))}_createContainer(t=!1){const{data:e,page:s,viewport:i}=this,a=document.createElement("section");a.setAttribute("data-annotation-id",e.id);const{pageWidth:r,pageHeight:o,pageX:l,pageY:c}=i.rawDims,{width:h,height:d}=getRectDims(e.rect),u=n.Util.normalizeRect([e.rect[0],s.view[3]-e.rect[1]+s.view[1],e.rect[2],s.view[3]-e.rect[3]+s.view[1]]);if(!t&&e.borderStyle.width>0){a.style.borderWidth=`${e.borderStyle.width}px`;const t=e.borderStyle.horizontalCornerRadius,s=e.borderStyle.verticalCornerRadius;if(t>0||s>0){const e=`calc(${t}px * var(--scale-factor)) / calc(${s}px * var(--scale-factor))`;a.style.borderRadius=e}else if(this instanceof RadioButtonWidgetAnnotationElement){const t=`calc(${h}px * var(--scale-factor)) / calc(${d}px * var(--scale-factor))`;a.style.borderRadius=t}switch(e.borderStyle.style){case n.AnnotationBorderStyleType.SOLID:a.style.borderStyle="solid";break;case n.AnnotationBorderStyleType.DASHED:a.style.borderStyle="dashed";break;case n.AnnotationBorderStyleType.BEVELED:(0,n.warn)("Unimplemented border style: beveled");break;case n.AnnotationBorderStyleType.INSET:(0,n.warn)("Unimplemented border style: inset");break;case n.AnnotationBorderStyleType.UNDERLINE:a.style.borderBottomStyle="solid"}const i=e.borderColor||null;i?a.style.borderColor=n.Util.makeHexColor(0|i[0],0|i[1],0|i[2]):a.style.borderWidth=0}a.style.left=100*(u[0]-l)/r+"%";a.style.top=100*(u[1]-c)/o+"%";const{rotation:p}=e;if(e.hasOwnCanvas||0===p){a.style.width=100*h/r+"%";a.style.height=100*d/o+"%"}else this.setRotation(p,a);return a}setRotation(t,e=this.container){const{pageWidth:s,pageHeight:n}=this.viewport.rawDims,{width:i,height:a}=getRectDims(this.data.rect);let r,o;if(t%180==0){r=100*i/s;o=100*a/n}else{r=100*a/s;o=100*i/n}e.style.width=`${r}%`;e.style.height=`${o}%`;e.setAttribute("data-main-rotation",(360-t)%360)}get _commonActions(){const setColor=(t,e,s)=>{const n=s.detail[t];s.target.style[e]=r.ColorConverters[`${n[0]}_HTML`](n.slice(1))};return(0,n.shadow)(this,"_commonActions",{display:t=>{const e=t.detail.display%2==1;this.container.style.visibility=e?"hidden":"visible";this.annotationStorage.setValue(this.data.id,{hidden:e,print:0===t.detail.display||3===t.detail.display})},print:t=>{this.annotationStorage.setValue(this.data.id,{print:t.detail.print})},hidden:t=>{this.container.style.visibility=t.detail.hidden?"hidden":"visible";this.annotationStorage.setValue(this.data.id,{hidden:t.detail.hidden})},focus:t=>{setTimeout((()=>t.target.focus({preventScroll:!1})),0)},userName:t=>{t.target.title=t.detail.userName},readonly:t=>{t.detail.readonly?t.target.setAttribute("readonly",""):t.target.removeAttribute("readonly")},required:t=>{this._setRequired(t.target,t.detail.required)},bgColor:t=>{setColor("bgColor","backgroundColor",t)},fillColor:t=>{setColor("fillColor","backgroundColor",t)},fgColor:t=>{setColor("fgColor","color",t)},textColor:t=>{setColor("textColor","color",t)},borderColor:t=>{setColor("borderColor","borderColor",t)},strokeColor:t=>{setColor("strokeColor","borderColor",t)},rotation:t=>{const e=t.detail.rotation;this.setRotation(e);this.annotationStorage.setValue(this.data.id,{rotation:e})}})}_dispatchEventFromSandbox(t,e){const s=this._commonActions;for(const n of Object.keys(e.detail)){(t[n]||s[n])?.(e)}}_setDefaultPropertiesFromJS(t){if(!this.enableScripting)return;const e=this.annotationStorage.getRawValue(this.data.id);if(!e)return;const s=this._commonActions;for(const[n,i]of Object.entries(e)){const a=s[n];if(a){a({detail:{[n]:i},target:t});delete e[n]}}}_createQuadrilaterals(t=!1){if(!this.data.quadPoints)return null;const e=[],s=this.data.rect;for(const s of this.data.quadPoints){this.data.rect=[s[2].x,s[2].y,s[1].x,s[1].y];e.push(this._createContainer(t))}this.data.rect=s;return e}_createPopup(t,e){let s=this.container;if(this.quadrilaterals){t=t||this.quadrilaterals;s=this.quadrilaterals[0]}if(!t){(t=document.createElement("div")).className="popupTriggerArea";s.append(t)}const n=new PopupElement({container:s,trigger:t,color:e.color,titleObj:e.titleObj,modificationDate:e.modificationDate,contentsObj:e.contentsObj,richText:e.richText,hideWrapper:!0}).render();n.style.left="100%";s.append(n)}_renderQuadrilaterals(t){for(const e of this.quadrilaterals)e.className=t;return this.quadrilaterals}render(){(0,n.unreachable)("Abstract method `AnnotationElement.render` called")}_getElementsByName(t,e=null){const s=[];if(this._fieldObjects){const i=this._fieldObjects[t];if(i)for(const{page:t,id:a,exportValues:r}of i){if(-1===t)continue;if(a===e)continue;const i="string"==typeof r?r:null,o=document.querySelector(`[data-element-id="${a}"]`);!o||c.has(o)?s.push({id:a,exportValue:i,domElement:o}):(0,n.warn)(`_getElementsByName - element not allowed: ${a}`)}return s}for(const n of document.getElementsByName(t)){const{exportValue:t}=n,i=n.getAttribute("data-element-id");i!==e&&(c.has(n)&&s.push({id:i,exportValue:t,domElement:n}))}return s}}class LinkAnnotationElement extends AnnotationElement{constructor(t,e=null){super(t,{isRenderable:!0,ignoreBorder:!!e?.ignoreBorder,createQuadrilaterals:!0});this.isTooltipOnly=t.data.isTooltipOnly}render(){const{data:t,linkService:e}=this,s=document.createElement("a");s.setAttribute("data-element-id",t.id);let n=!1;if(t.url){e.addLinkAttributes(s,t.url,t.newWindow);n=!0}else if(t.action){this._bindNamedAction(s,t.action);n=!0}else if(t.attachment){this._bindAttachment(s,t.attachment);n=!0}else if(t.setOCGState){this.#Ce(s,t.setOCGState);n=!0}else if(t.dest){this._bindLink(s,t.dest);n=!0}else{if(t.actions&&(t.actions.Action||t.actions["Mouse Up"]||t.actions["Mouse Down"])&&this.enableScripting&&this.hasJSActions){this._bindJSAction(s,t);n=!0}if(t.resetForm){this._bindResetFormAction(s,t.resetForm);n=!0}else if(this.isTooltipOnly&&!n){this._bindLink(s,"");n=!0}}if(this.quadrilaterals)return this._renderQuadrilaterals("linkAnnotation").map(((t,e)=>{const n=0===e?s:s.cloneNode();t.append(n);return t}));this.container.className="linkAnnotation";n&&this.container.append(s);return this.container}#Pe(){this.container.setAttribute("data-internal-link","")}_bindLink(t,e){t.href=this.linkService.getDestinationHash(e);t.onclick=()=>{e&&this.linkService.goToDestination(e);return!1};(e||""===e)&&this.#Pe()}_bindNamedAction(t,e){t.href=this.linkService.getAnchorUrl("");t.onclick=()=>{this.linkService.executeNamedAction(e);return!1};this.#Pe()}_bindAttachment(t,e){t.href=this.linkService.getAnchorUrl("");t.onclick=()=>{this.downloadManager?.openOrDownloadData(this.container,e.content,e.filename);return!1};this.#Pe()}#Ce(t,e){t.href=this.linkService.getAnchorUrl("");t.onclick=()=>{this.linkService.executeSetOCGState(e);return!1};this.#Pe()}_bindJSAction(t,e){t.href=this.linkService.getAnchorUrl("");const s=new Map([["Action","onclick"],["Mouse Up","onmouseup"],["Mouse Down","onmousedown"]]);for(const n of Object.keys(e.actions)){const i=s.get(n);i&&(t[i]=()=>{this.linkService.eventBus?.dispatch("dispatcheventinsandbox",{source:this,detail:{id:e.id,name:n}});return!1})}t.onclick||(t.onclick=()=>!1);this.#Pe()}_bindResetFormAction(t,e){const s=t.onclick;s||(t.href=this.linkService.getAnchorUrl(""));this.#Pe();if(this._fieldObjects)t.onclick=()=>{s?.();const{fields:t,refs:i,include:a}=e,r=[];if(0!==t.length||0!==i.length){const e=new Set(i);for(const s of t){const t=this._fieldObjects[s]||[];for(const{id:s}of t)e.add(s)}for(const t of Object.values(this._fieldObjects))for(const s of t)e.has(s.id)===a&&r.push(s)}else for(const t of Object.values(this._fieldObjects))r.push(...t);const o=this.annotationStorage,l=[];for(const t of r){const{id:e}=t;l.push(e);switch(t.type){case"text":{const s=t.defaultValue||"";o.setValue(e,{value:s});break}case"checkbox":case"radiobutton":{const s=t.defaultValue===t.exportValues;o.setValue(e,{value:s});break}case"combobox":case"listbox":{const s=t.defaultValue||"";o.setValue(e,{value:s});break}default:continue}const s=document.querySelector(`[data-element-id="${e}"]`);s&&(c.has(s)?s.dispatchEvent(new Event("resetform")):(0,n.warn)(`_bindResetFormAction - element not allowed: ${e}`))}this.enableScripting&&this.linkService.eventBus?.dispatch("dispatcheventinsandbox",{source:this,detail:{id:"app",ids:l,name:"ResetForm"}});return!1};else{(0,n.warn)('_bindResetFormAction - "resetForm" action not supported, ensure that the `fieldObjects` parameter is provided.');s||(t.onclick=()=>!1)}}}class TextAnnotationElement extends AnnotationElement{constructor(t){super(t,{isRenderable:!!(t.data.hasPopup||t.data.titleObj?.str||t.data.contentsObj?.str||t.data.richText?.str)})}render(){this.container.className="textAnnotation";const t=document.createElement("img");t.src=this.imageResourcesPath+"annotation-"+this.data.name.toLowerCase()+".svg";t.alt="[{{type}} Annotation]";t.dataset.l10nId="text_annotation_type";t.dataset.l10nArgs=JSON.stringify({type:this.data.name});this.data.hasPopup||this._createPopup(t,this.data);this.container.append(t);return this.container}}class WidgetAnnotationElement extends AnnotationElement{render(){this.data.alternativeText&&(this.container.title=this.data.alternativeText);return this.container}_getKeyModifier(t){const{isWin:e,isMac:s}=n.FeatureTest.platform;return e&&t.ctrlKey||s&&t.metaKey}_setEventListener(t,e,s,n){e.includes("mouse")?t.addEventListener(e,(t=>{this.linkService.eventBus?.dispatch("dispatcheventinsandbox",{source:this,detail:{id:this.data.id,name:s,value:n(t),shift:t.shiftKey,modifier:this._getKeyModifier(t)}})})):t.addEventListener(e,(t=>{this.linkService.eventBus?.dispatch("dispatcheventinsandbox",{source:this,detail:{id:this.data.id,name:s,value:n(t)}})}))}_setEventListeners(t,e,s){for(const[n,i]of e)("Action"===i||this.data.actions?.[i])&&this._setEventListener(t,n,i,s)}_setBackgroundColor(t){const e=this.data.backgroundColor||null;t.style.backgroundColor=null===e?"transparent":n.Util.makeHexColor(e[0],e[1],e[2])}_setTextStyle(t){const e=["left","center","right"],{fontColor:s}=this.data.defaultAppearanceData,i=this.data.defaultAppearanceData.fontSize||9,a=t.style;let r;const roundToOneDecimal=t=>Math.round(10*t)/10;if(this.data.multiLine){const t=Math.abs(this.data.rect[3]-this.data.rect[1]-2),e=t/(Math.round(t/(n.LINE_FACTOR*i))||1);r=Math.min(i,roundToOneDecimal(e/n.LINE_FACTOR))}else{const t=Math.abs(this.data.rect[3]-this.data.rect[1]-2);r=Math.min(i,roundToOneDecimal(t/n.LINE_FACTOR))}a.fontSize=`calc(${r}px * var(--scale-factor))`;a.color=n.Util.makeHexColor(s[0],s[1],s[2]);null!==this.data.textAlignment&&(a.textAlign=e[this.data.textAlignment])}_setRequired(t,e){e?t.setAttribute("required",!0):t.removeAttribute("required");t.setAttribute("aria-required",e)}}class TextWidgetAnnotationElement extends WidgetAnnotationElement{constructor(t){super(t,{isRenderable:t.renderForms||!t.data.hasAppearance&&!!t.data.fieldValue})}setPropertyOnSiblings(t,e,s,n){const i=this.annotationStorage;for(const a of this._getElementsByName(t.name,t.id)){a.domElement&&(a.domElement[e]=s);i.setValue(a.id,{[n]:s})}}render(){const t=this.annotationStorage,e=this.data.id;this.container.className="textWidgetAnnotation";let s=null;if(this.renderForms){const n=t.getValue(e,{value:this.data.fieldValue});let i=n.formattedValue||n.value||"";const a=t.getValue(e,{charLimit:this.data.maxLen}).charLimit;a&&i.length>a&&(i=i.slice(0,a));const r={userValue:i,formattedValue:null,lastCommittedValue:null,commitKey:1};if(this.data.multiLine){s=document.createElement("textarea");s.textContent=i;this.data.doNotScroll&&(s.style.overflowY="hidden")}else{s=document.createElement("input");s.type="text";s.setAttribute("value",i);this.data.doNotScroll&&(s.style.overflowX="hidden")}c.add(s);s.setAttribute("data-element-id",e);s.disabled=this.data.readOnly;s.name=this.data.fieldName;s.tabIndex=l;this._setRequired(s,this.data.required);a&&(s.maxLength=a);s.addEventListener("input",(n=>{t.setValue(e,{value:n.target.value});this.setPropertyOnSiblings(s,"value",n.target.value,"value")}));s.addEventListener("resetform",(t=>{const e=this.data.defaultFieldValue??"";s.value=r.userValue=e;r.formattedValue=null}));let blurListener=t=>{const{formattedValue:e}=r;null!=e&&(t.target.value=e);t.target.scrollLeft=0};if(this.enableScripting&&this.hasJSActions){s.addEventListener("focus",(t=>{const{target:e}=t;r.userValue&&(e.value=r.userValue);r.lastCommittedValue=e.value;r.commitKey=1}));s.addEventListener("updatefromsandbox",(s=>{const n={value(s){r.userValue=s.detail.value??"";t.setValue(e,{value:r.userValue.toString()});s.target.value=r.userValue},formattedValue(s){const{formattedValue:n}=s.detail;r.formattedValue=n;null!=n&&s.target!==document.activeElement&&(s.target.value=n);t.setValue(e,{formattedValue:n})},selRange(t){t.target.setSelectionRange(...t.detail.selRange)},charLimit:s=>{const{charLimit:n}=s.detail,{target:i}=s;if(0===n){i.removeAttribute("maxLength");return}i.setAttribute("maxLength",n);let a=r.userValue;if(a&&!(a.length<=n)){a=a.slice(0,n);i.value=r.userValue=a;t.setValue(e,{value:a});this.linkService.eventBus?.dispatch("dispatcheventinsandbox",{source:this,detail:{id:e,name:"Keystroke",value:a,willCommit:!0,commitKey:1,selStart:i.selectionStart,selEnd:i.selectionEnd}})}}};this._dispatchEventFromSandbox(n,s)}));s.addEventListener("keydown",(t=>{r.commitKey=1;let s=-1;"Escape"===t.key?s=0:"Enter"!==t.key||this.data.multiLine?"Tab"===t.key&&(r.commitKey=3):s=2;if(-1===s)return;const{value:n}=t.target;if(r.lastCommittedValue!==n){r.lastCommittedValue=n;r.userValue=n;this.linkService.eventBus?.dispatch("dispatcheventinsandbox",{source:this,detail:{id:e,name:"Keystroke",value:n,willCommit:!0,commitKey:s,selStart:t.target.selectionStart,selEnd:t.target.selectionEnd}})}}));const n=blurListener;blurListener=null;s.addEventListener("blur",(t=>{if(!t.relatedTarget)return;const{value:s}=t.target;r.userValue=s;r.lastCommittedValue!==s&&this.linkService.eventBus?.dispatch("dispatcheventinsandbox",{source:this,detail:{id:e,name:"Keystroke",value:s,willCommit:!0,commitKey:r.commitKey,selStart:t.target.selectionStart,selEnd:t.target.selectionEnd}});n(t)}));this.data.actions?.Keystroke&&s.addEventListener("beforeinput",(t=>{r.lastCommittedValue=null;const{data:s,target:n}=t,{value:i,selectionStart:a,selectionEnd:o}=n;let l=a,c=o;switch(t.inputType){case"deleteWordBackward":{const t=i.substring(0,a).match(/\w*[^\w]*$/);t&&(l-=t[0].length);break}case"deleteWordForward":{const t=i.substring(a).match(/^[^\w]*\w*/);t&&(c+=t[0].length);break}case"deleteContentBackward":a===o&&(l-=1);break;case"deleteContentForward":a===o&&(c+=1)}t.preventDefault();this.linkService.eventBus?.dispatch("dispatcheventinsandbox",{source:this,detail:{id:e,name:"Keystroke",value:i,change:s||"",willCommit:!1,selStart:l,selEnd:c}})}));this._setEventListeners(s,[["focus","Focus"],["blur","Blur"],["mousedown","Mouse Down"],["mouseenter","Mouse Enter"],["mouseleave","Mouse Exit"],["mouseup","Mouse Up"]],(t=>t.target.value))}blurListener&&s.addEventListener("blur",blurListener);if(this.data.comb){const t=(this.data.rect[2]-this.data.rect[0])/a;s.classList.add("comb");s.style.letterSpacing=`calc(${t}px * var(--scale-factor) - 1ch)`}}else{s=document.createElement("div");s.textContent=this.data.fieldValue;s.style.verticalAlign="middle";s.style.display="table-cell"}this._setTextStyle(s);this._setBackgroundColor(s);this._setDefaultPropertiesFromJS(s);this.container.append(s);return this.container}}class CheckboxWidgetAnnotationElement extends WidgetAnnotationElement{constructor(t){super(t,{isRenderable:t.renderForms})}render(){const t=this.annotationStorage,e=this.data,s=e.id;let n=t.getValue(s,{value:e.exportValue===e.fieldValue}).value;if("string"==typeof n){n="Off"!==n;t.setValue(s,{value:n})}this.container.className="buttonWidgetAnnotation checkBox";const i=document.createElement("input");c.add(i);i.setAttribute("data-element-id",s);i.disabled=e.readOnly;this._setRequired(i,this.data.required);i.type="checkbox";i.name=e.fieldName;n&&i.setAttribute("checked",!0);i.setAttribute("exportValue",e.exportValue);i.tabIndex=l;i.addEventListener("change",(n=>{const{name:i,checked:a}=n.target;for(const n of this._getElementsByName(i,s)){const s=a&&n.exportValue===e.exportValue;n.domElement&&(n.domElement.checked=s);t.setValue(n.id,{value:s})}t.setValue(s,{value:a})}));i.addEventListener("resetform",(t=>{const s=e.defaultFieldValue||"Off";t.target.checked=s===e.exportValue}));if(this.enableScripting&&this.hasJSActions){i.addEventListener("updatefromsandbox",(e=>{const n={value(e){e.target.checked="Off"!==e.detail.value;t.setValue(s,{value:e.target.checked})}};this._dispatchEventFromSandbox(n,e)}));this._setEventListeners(i,[["change","Validate"],["change","Action"],["focus","Focus"],["blur","Blur"],["mousedown","Mouse Down"],["mouseenter","Mouse Enter"],["mouseleave","Mouse Exit"],["mouseup","Mouse Up"]],(t=>t.target.checked))}this._setBackgroundColor(i);this._setDefaultPropertiesFromJS(i);this.container.append(i);return this.container}}class RadioButtonWidgetAnnotationElement extends WidgetAnnotationElement{constructor(t){super(t,{isRenderable:t.renderForms})}render(){this.container.className="buttonWidgetAnnotation radioButton";const t=this.annotationStorage,e=this.data,s=e.id;let n=t.getValue(s,{value:e.fieldValue===e.buttonValue}).value;if("string"==typeof n){n=n!==e.buttonValue;t.setValue(s,{value:n})}const i=document.createElement("input");c.add(i);i.setAttribute("data-element-id",s);i.disabled=e.readOnly;this._setRequired(i,this.data.required);i.type="radio";i.name=e.fieldName;n&&i.setAttribute("checked",!0);i.tabIndex=l;i.addEventListener("change",(e=>{const{name:n,checked:i}=e.target;for(const e of this._getElementsByName(n,s))t.setValue(e.id,{value:!1});t.setValue(s,{value:i})}));i.addEventListener("resetform",(t=>{const s=e.defaultFieldValue;t.target.checked=null!=s&&s===e.buttonValue}));if(this.enableScripting&&this.hasJSActions){const n=e.buttonValue;i.addEventListener("updatefromsandbox",(e=>{const i={value:e=>{const i=n===e.detail.value;for(const n of this._getElementsByName(e.target.name)){const e=i&&n.id===s;n.domElement&&(n.domElement.checked=e);t.setValue(n.id,{value:e})}}};this._dispatchEventFromSandbox(i,e)}));this._setEventListeners(i,[["change","Validate"],["change","Action"],["focus","Focus"],["blur","Blur"],["mousedown","Mouse Down"],["mouseenter","Mouse Enter"],["mouseleave","Mouse Exit"],["mouseup","Mouse Up"]],(t=>t.target.checked))}this._setBackgroundColor(i);this._setDefaultPropertiesFromJS(i);this.container.append(i);return this.container}}class PushButtonWidgetAnnotationElement extends LinkAnnotationElement{constructor(t){super(t,{ignoreBorder:t.data.hasAppearance})}render(){const t=super.render();t.className="buttonWidgetAnnotation pushButton";this.data.alternativeText&&(t.title=this.data.alternativeText);const e=t.lastChild;if(this.enableScripting&&this.hasJSActions&&e){this._setDefaultPropertiesFromJS(e);e.addEventListener("updatefromsandbox",(t=>{this._dispatchEventFromSandbox({},t)}))}return t}}class ChoiceWidgetAnnotationElement extends WidgetAnnotationElement{constructor(t){super(t,{isRenderable:t.renderForms})}render(){this.container.className="choiceWidgetAnnotation";const t=this.annotationStorage,e=this.data.id,s=t.getValue(e,{value:this.data.fieldValue}),n=document.createElement("select");c.add(n);n.setAttribute("data-element-id",e);n.disabled=this.data.readOnly;this._setRequired(n,this.data.required);n.name=this.data.fieldName;n.tabIndex=l;let i=this.data.combo&&this.data.options.length>0;if(!this.data.combo){n.size=this.data.options.length;this.data.multiSelect&&(n.multiple=!0)}n.addEventListener("resetform",(t=>{const e=this.data.defaultFieldValue;for(const t of n.options)t.selected=t.value===e}));for(const t of this.data.options){const e=document.createElement("option");e.textContent=t.displayValue;e.value=t.exportValue;if(s.value.includes(t.exportValue)){e.setAttribute("selected",!0);i=!1}n.append(e)}let a=null;if(i){const t=document.createElement("option");t.value=" ";t.setAttribute("hidden",!0);t.setAttribute("selected",!0);n.prepend(t);a=()=>{t.remove();n.removeEventListener("input",a);a=null};n.addEventListener("input",a)}const getValue=t=>{const e=t?"value":"textContent",{options:s,multiple:i}=n;return i?Array.prototype.filter.call(s,(t=>t.selected)).map((t=>t[e])):-1===s.selectedIndex?null:s[s.selectedIndex][e]};let r=getValue(!1);const getItems=t=>{const e=t.target.options;return Array.prototype.map.call(e,(t=>({displayValue:t.textContent,exportValue:t.value})))};if(this.enableScripting&&this.hasJSActions){n.addEventListener("updatefromsandbox",(s=>{const i={value(s){a?.();const i=s.detail.value,o=new Set(Array.isArray(i)?i:[i]);for(const t of n.options)t.selected=o.has(t.value);t.setValue(e,{value:getValue(!0)});r=getValue(!1)},multipleSelection(t){n.multiple=!0},remove(s){const i=n.options,a=s.detail.remove;i[a].selected=!1;n.remove(a);if(i.length>0){-1===Array.prototype.findIndex.call(i,(t=>t.selected))&&(i[0].selected=!0)}t.setValue(e,{value:getValue(!0),items:getItems(s)});r=getValue(!1)},clear(s){for(;0!==n.length;)n.remove(0);t.setValue(e,{value:null,items:[]});r=getValue(!1)},insert(s){const{index:i,displayValue:a,exportValue:o}=s.detail.insert,l=n.children[i],c=document.createElement("option");c.textContent=a;c.value=o;l?l.before(c):n.append(c);t.setValue(e,{value:getValue(!0),items:getItems(s)});r=getValue(!1)},items(s){const{items:i}=s.detail;for(;0!==n.length;)n.remove(0);for(const t of i){const{displayValue:e,exportValue:s}=t,i=document.createElement("option");i.textContent=e;i.value=s;n.append(i)}n.options.length>0&&(n.options[0].selected=!0);t.setValue(e,{value:getValue(!0),items:getItems(s)});r=getValue(!1)},indices(s){const n=new Set(s.detail.indices);for(const t of s.target.options)t.selected=n.has(t.index);t.setValue(e,{value:getValue(!0)});r=getValue(!1)},editable(t){t.target.disabled=!t.detail.editable}};this._dispatchEventFromSandbox(i,s)}));n.addEventListener("input",(s=>{const n=getValue(!0);t.setValue(e,{value:n});s.preventDefault();this.linkService.eventBus?.dispatch("dispatcheventinsandbox",{source:this,detail:{id:e,name:"Keystroke",value:r,changeEx:n,willCommit:!1,commitKey:1,keyDown:!1}})}));this._setEventListeners(n,[["focus","Focus"],["blur","Blur"],["mousedown","Mouse Down"],["mouseenter","Mouse Enter"],["mouseleave","Mouse Exit"],["mouseup","Mouse Up"],["input","Action"]],(t=>t.target.checked))}else n.addEventListener("input",(function(s){t.setValue(e,{value:getValue(!0)})}));this.data.combo&&this._setTextStyle(n);this._setBackgroundColor(n);this._setDefaultPropertiesFromJS(n);this.container.append(n);return this.container}}class PopupAnnotationElement extends AnnotationElement{static IGNORE_TYPES=new Set(["Line","Square","Circle","PolyLine","Polygon","Ink"]);constructor(t){const{data:e}=t;super(t,{isRenderable:!PopupAnnotationElement.IGNORE_TYPES.has(e.parentType)&&!!(e.titleObj?.str||e.contentsObj?.str||e.richText?.str)})}render(){this.container.className="popupAnnotation";const t=this.layer.querySelectorAll(`[data-annotation-id="${this.data.parentId}"]`);if(0===t.length)return this.container;const e=new PopupElement({container:this.container,trigger:Array.from(t),color:this.data.color,titleObj:this.data.titleObj,modificationDate:this.data.modificationDate,contentsObj:this.data.contentsObj,richText:this.data.richText}),s=this.page,i=n.Util.normalizeRect([this.data.parentRect[0],s.view[3]-this.data.parentRect[1]+s.view[1],this.data.parentRect[2],s.view[3]-this.data.parentRect[3]+s.view[1]]),a=i[0]+this.data.parentRect[2]-this.data.parentRect[0],r=i[1],{pageWidth:o,pageHeight:l,pageX:c,pageY:h}=this.viewport.rawDims;this.container.style.left=100*(a-c)/o+"%";this.container.style.top=100*(r-h)/l+"%";this.container.append(e.render());return this.container}}class PopupElement{constructor(t){this.container=t.container;this.trigger=t.trigger;this.color=t.color;this.titleObj=t.titleObj;this.modificationDate=t.modificationDate;this.contentsObj=t.contentsObj;this.richText=t.richText;this.hideWrapper=t.hideWrapper||!1;this.pinned=!1}render(){const t=document.createElement("div");t.className="popupWrapper";this.hideElement=this.hideWrapper?t:this.container;this.hideElement.hidden=!0;const e=document.createElement("div");e.className="popup";const s=this.color;if(s){const t=.7*(255-s[0])+s[0],i=.7*(255-s[1])+s[1],a=.7*(255-s[2])+s[2];e.style.backgroundColor=n.Util.makeHexColor(0|t,0|i,0|a)}const a=document.createElement("h1");a.dir=this.titleObj.dir;a.textContent=this.titleObj.str;e.append(a);const r=i.PDFDateString.toDateObject(this.modificationDate);if(r){const t=document.createElement("span");t.className="popupDate";t.textContent="{{date}}, {{time}}";t.dataset.l10nId="annotation_date_string";t.dataset.l10nArgs=JSON.stringify({date:r.toLocaleDateString(),time:r.toLocaleTimeString()});e.append(t)}if(!this.richText?.str||this.contentsObj?.str&&this.contentsObj.str!==this.richText.str){const t=this._formatContents(this.contentsObj);e.append(t)}else{o.XfaLayer.render({xfaHtml:this.richText.html,intent:"richText",div:e});e.lastChild.className="richText popupContent"}Array.isArray(this.trigger)||(this.trigger=[this.trigger]);for(const t of this.trigger){t.addEventListener("click",this._toggle.bind(this));t.addEventListener("mouseover",this._show.bind(this,!1));t.addEventListener("mouseout",this._hide.bind(this,!1))}e.addEventListener("click",this._hide.bind(this,!0));t.append(e);return t}_formatContents({str:t,dir:e}){const s=document.createElement("p");s.className="popupContent";s.dir=e;const n=t.split(/(?:\r\n?|\n)/);for(let t=0,e=n.length;t{Object.defineProperty(e,"__esModule",{value:!0});e.ColorConverters=void 0;function makeColorComp(t){return Math.floor(255*Math.max(0,Math.min(1,t))).toString(16).padStart(2,"0")}e.ColorConverters=class ColorConverters{static CMYK_G([t,e,s,n]){return["G",1-Math.min(1,.3*t+.59*s+.11*e+n)]}static G_CMYK([t]){return["CMYK",0,0,0,1-t]}static G_RGB([t]){return["RGB",t,t,t]}static G_HTML([t]){const e=makeColorComp(t);return`#${e}${e}${e}`}static RGB_G([t,e,s]){return["G",.3*t+.59*e+.11*s]}static RGB_HTML([t,e,s]){return`#${makeColorComp(t)}${makeColorComp(e)}${makeColorComp(s)}`}static T_HTML(){return"#00000000"}static CMYK_RGB([t,e,s,n]){return["RGB",1-Math.min(1,t+n),1-Math.min(1,s+n),1-Math.min(1,e+n)]}static CMYK_HTML(t){const e=this.CMYK_RGB(t).slice(1);return this.RGB_HTML(e)}static RGB_CMYK([t,e,s]){const n=1-t,i=1-e,a=1-s;return["CMYK",n,i,a,Math.min(n,i,a)]}}},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.XfaLayer=void 0;var n=s(19);e.XfaLayer=class XfaLayer{static setupStorage(t,e,s,n,i){const a=n.getValue(e,{value:null});switch(s.name){case"textarea":null!==a.value&&(t.textContent=a.value);if("print"===i)break;t.addEventListener("input",(t=>{n.setValue(e,{value:t.target.value})}));break;case"input":if("radio"===s.attributes.type||"checkbox"===s.attributes.type){a.value===s.attributes.xfaOn?t.setAttribute("checked",!0):a.value===s.attributes.xfaOff&&t.removeAttribute("checked");if("print"===i)break;t.addEventListener("change",(t=>{n.setValue(e,{value:t.target.checked?t.target.getAttribute("xfaOn"):t.target.getAttribute("xfaOff")})}))}else{null!==a.value&&t.setAttribute("value",a.value);if("print"===i)break;t.addEventListener("input",(t=>{n.setValue(e,{value:t.target.value})}))}break;case"select":if(null!==a.value)for(const t of s.children)t.attributes.value===a.value&&(t.attributes.selected=!0);t.addEventListener("input",(t=>{const s=t.target.options,i=-1===s.selectedIndex?"":s[s.selectedIndex].value;n.setValue(e,{value:i})}))}}static setAttributes({html:t,element:e,storage:s=null,intent:n,linkService:i}){const{attributes:a}=e,r=t instanceof HTMLAnchorElement;"radio"===a.type&&(a.name=`${a.name}-${n}`);for(const[e,s]of Object.entries(a))if(null!=s)switch(e){case"class":s.length&&t.setAttribute(e,s.join(" "));break;case"dataId":break;case"id":t.setAttribute("data-element-id",s);break;case"style":Object.assign(t.style,s);break;case"textContent":t.textContent=s;break;default:(!r||"href"!==e&&"newWindow"!==e)&&t.setAttribute(e,s)}r&&i.addLinkAttributes(t,a.href,a.newWindow);s&&a.dataId&&this.setupStorage(t,a.dataId,e,s)}static render(t){const e=t.annotationStorage,s=t.linkService,i=t.xfaHtml,a=t.intent||"display",r=document.createElement(i.name);i.attributes&&this.setAttributes({html:r,element:i,intent:a,linkService:s});const o=[[i,-1,r]],l=t.div;l.append(r);if(t.viewport){const e=`matrix(${t.viewport.transform.join(",")})`;l.style.transform=e}"richText"!==a&&l.setAttribute("class","xfaLayer xfaFont");const c=[];for(;o.length>0;){const[t,i,r]=o.at(-1);if(i+1===t.children.length){o.pop();continue}const l=t.children[++o.at(-1)[1]];if(null===l)continue;const{name:h}=l;if("#text"===h){const t=document.createTextNode(l.value);c.push(t);r.append(t);continue}let d;d=l?.attributes?.xmlns?document.createElementNS(l.attributes.xmlns,h):document.createElement(h);r.append(d);l.attributes&&this.setAttributes({html:d,element:l,storage:e,intent:a,linkService:s});if(l.children&&l.children.length>0)o.push([l,-1,d]);else if(l.value){const t=document.createTextNode(l.value);n.XfaText.shouldBuildText(h)&&c.push(t);d.append(t)}}for(const t of l.querySelectorAll(".xfaNonInteractive input, .xfaNonInteractive textarea"))t.setAttribute("readOnly",!0);return{textDivs:c}}static update(t){const e=`matrix(${t.viewport.transform.join(",")})`;t.div.style.transform=e;t.div.hidden=!1}}},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.SVGGraphics=void 0;var n=s(6),i=s(1),a=s(10);let r=class{constructor(){(0,i.unreachable)("Not implemented: SVGGraphics")}};e.SVGGraphics=r;{const o={fontStyle:"normal",fontWeight:"normal",fillColor:"#000000"},l="http://www.w3.org/XML/1998/namespace",c="http://www.w3.org/1999/xlink",h=["butt","round","square"],d=["miter","round","bevel"],createObjectURL=function(t,e="",s=!1){if(URL.createObjectURL&&"undefined"!=typeof Blob&&!s)return URL.createObjectURL(new Blob([t],{type:e}));const n="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";let i=`data:${e};base64,`;for(let e=0,s=t.length;e>2]+n[(3&a)<<4|r>>4]+n[e+1>6:64]+n[e+2>1&2147483647:s>>1&2147483647;e[t]=s}function writePngChunk(t,s,n,i){let a=i;const r=s.length;n[a]=r>>24&255;n[a+1]=r>>16&255;n[a+2]=r>>8&255;n[a+3]=255&r;a+=4;n[a]=255&t.charCodeAt(0);n[a+1]=255&t.charCodeAt(1);n[a+2]=255&t.charCodeAt(2);n[a+3]=255&t.charCodeAt(3);a+=4;n.set(s,a);a+=s.length;const o=function crc32(t,s,n){let i=-1;for(let a=s;a>>8^e[s]}return-1^i}(n,i+4,a);n[a]=o>>24&255;n[a+1]=o>>16&255;n[a+2]=o>>8&255;n[a+3]=255&o}function deflateSyncUncompressed(t){let e=t.length;const s=65535,n=Math.ceil(e/s),i=new Uint8Array(2+e+5*n+4);let a=0;i[a++]=120;i[a++]=156;let r=0;for(;e>s;){i[a++]=0;i[a++]=255;i[a++]=255;i[a++]=0;i[a++]=0;i.set(t.subarray(r,r+s),a);a+=s;r+=s;e-=s}i[a++]=1;i[a++]=255&e;i[a++]=e>>8&255;i[a++]=255&~e;i[a++]=(65535&~e)>>8&255;i.set(t.subarray(r),a);a+=t.length-r;const o=function adler32(t,e,s){let n=1,i=0;for(let a=e;a>24&255;i[a++]=o>>16&255;i[a++]=o>>8&255;i[a++]=255&o;return i}function encode(e,s,n,r){const o=e.width,l=e.height;let c,h,d;const u=e.data;switch(s){case i.ImageKind.GRAYSCALE_1BPP:h=0;c=1;d=o+7>>3;break;case i.ImageKind.RGB_24BPP:h=2;c=8;d=3*o;break;case i.ImageKind.RGBA_32BPP:h=6;c=8;d=4*o;break;default:throw new Error("invalid format")}const p=new Uint8Array((1+d)*l);let g=0,m=0;for(let t=0;t>24&255,o>>16&255,o>>8&255,255&o,l>>24&255,l>>16&255,l>>8&255,255&l,c,h,0,0,0]),b=function deflateSync(t){if(!a.isNodeJS)return deflateSyncUncompressed(t);try{let e;e=parseInt(process.versions.node)>=8?t:Buffer.from(t);const s=require("zlib").deflateSync(e,{level:9});return s instanceof Uint8Array?s:new Uint8Array(s)}catch(t){(0,i.warn)("Not compressing PNG because zlib.deflateSync is unavailable: "+t)}return deflateSyncUncompressed(t)}(p),A=t.length+36+f.length+b.length,_=new Uint8Array(A);let y=0;_.set(t,y);y+=t.length;writePngChunk("IHDR",f,_,y);y+=12+f.length;writePngChunk("IDATA",b,_,y);y+=12+b.length;writePngChunk("IEND",new Uint8Array(0),_,y);return createObjectURL(_,"image/png",n)}return function convertImgDataToPng(t,e,s){return encode(t,void 0===t.kind?i.ImageKind.GRAYSCALE_1BPP:t.kind,e,s)}}();class SVGExtraState{constructor(){this.fontSizeScale=1;this.fontWeight=o.fontWeight;this.fontSize=0;this.textMatrix=i.IDENTITY_MATRIX;this.fontMatrix=i.FONT_IDENTITY_MATRIX;this.leading=0;this.textRenderingMode=i.TextRenderingMode.FILL;this.textMatrixScale=1;this.x=0;this.y=0;this.lineX=0;this.lineY=0;this.charSpacing=0;this.wordSpacing=0;this.textHScale=1;this.textRise=0;this.fillColor=o.fillColor;this.strokeColor="#000000";this.fillAlpha=1;this.strokeAlpha=1;this.lineWidth=1;this.lineJoin="";this.lineCap="";this.miterLimit=0;this.dashArray=[];this.dashPhase=0;this.dependencies=[];this.activeClipUrl=null;this.clipGroup=null;this.maskId=""}clone(){return Object.create(this)}setCurrentPoint(t,e){this.x=t;this.y=e}}function opListToTree(t){let e=[];const s=[];for(const n of t)if("save"!==n.fn)"restore"===n.fn?e=s.pop():e.push(n);else{e.push({fnId:92,fn:"group",items:[]});s.push(e);e=e.at(-1).items}return e}function pf(t){if(Number.isInteger(t))return t.toString();const e=t.toFixed(10);let s=e.length-1;if("0"!==e[s])return e;do{s--}while("0"===e[s]);return e.substring(0,"."===e[s]?s:s+1)}function pm(t){if(0===t[4]&&0===t[5]){if(0===t[1]&&0===t[2])return 1===t[0]&&1===t[3]?"":`scale(${pf(t[0])} ${pf(t[3])})`;if(t[0]===t[3]&&t[1]===-t[2]){return`rotate(${pf(180*Math.acos(t[0])/Math.PI)})`}}else if(1===t[0]&&0===t[1]&&0===t[2]&&1===t[3])return`translate(${pf(t[4])} ${pf(t[5])})`;return`matrix(${pf(t[0])} ${pf(t[1])} ${pf(t[2])} ${pf(t[3])} ${pf(t[4])} ${pf(t[5])})`}let p=0,g=0,m=0;e.SVGGraphics=r=class{constructor(t,e,s=!1){(0,n.deprecated)("The SVG back-end is no longer maintained and *may* be removed in the future.");this.svgFactory=new n.DOMSVGFactory;this.current=new SVGExtraState;this.transformMatrix=i.IDENTITY_MATRIX;this.transformStack=[];this.extraStack=[];this.commonObjs=t;this.objs=e;this.pendingClip=null;this.pendingEOFill=!1;this.embedFonts=!1;this.embeddedFonts=Object.create(null);this.cssStyle=null;this.forceDataSchema=!!s;this._operatorIdMapping=[];for(const t in i.OPS)this._operatorIdMapping[i.OPS[t]]=t}getObject(t,e=null){return"string"==typeof t?t.startsWith("g_")?this.commonObjs.get(t):this.objs.get(t):e}save(){this.transformStack.push(this.transformMatrix);const t=this.current;this.extraStack.push(t);this.current=t.clone()}restore(){this.transformMatrix=this.transformStack.pop();this.current=this.extraStack.pop();this.pendingClip=null;this.tgrp=null}group(t){this.save();this.executeOpTree(t);this.restore()}loadDependencies(t){const e=t.fnArray,s=t.argsArray;for(let t=0,n=e.length;t{t.get(e,s)}));this.current.dependencies.push(s)}return Promise.all(this.current.dependencies)}transform(t,e,s,n,a,r){const o=[t,e,s,n,a,r];this.transformMatrix=i.Util.transform(this.transformMatrix,o);this.tgrp=null}getSVG(t,e){this.viewport=e;const s=this._initialize(e);return this.loadDependencies(t).then((()=>{this.transformMatrix=i.IDENTITY_MATRIX;this.executeOpTree(this.convertOpList(t));return s}))}convertOpList(t){const e=this._operatorIdMapping,s=t.argsArray,n=t.fnArray,i=[];for(let t=0,a=n.length;t0&&(this.current.lineWidth=t)}setLineCap(t){this.current.lineCap=h[t]}setLineJoin(t){this.current.lineJoin=d[t]}setMiterLimit(t){this.current.miterLimit=t}setStrokeAlpha(t){this.current.strokeAlpha=t}setStrokeRGBColor(t,e,s){this.current.strokeColor=i.Util.makeHexColor(t,e,s)}setFillAlpha(t){this.current.fillAlpha=t}setFillRGBColor(t,e,s){this.current.fillColor=i.Util.makeHexColor(t,e,s);this.current.tspan=this.svgFactory.createElement("svg:tspan");this.current.xcoords=[];this.current.ycoords=[]}setStrokeColorN(t){this.current.strokeColor=this._makeColorN_Pattern(t)}setFillColorN(t){this.current.fillColor=this._makeColorN_Pattern(t)}shadingFill(t){const e=this.viewport.width,s=this.viewport.height,n=i.Util.inverseTransform(this.transformMatrix),a=i.Util.applyTransform([0,0],n),r=i.Util.applyTransform([0,s],n),o=i.Util.applyTransform([e,0],n),l=i.Util.applyTransform([e,s],n),c=Math.min(a[0],r[0],o[0],l[0]),h=Math.min(a[1],r[1],o[1],l[1]),d=Math.max(a[0],r[0],o[0],l[0]),u=Math.max(a[1],r[1],o[1],l[1]),p=this.svgFactory.createElement("svg:rect");p.setAttributeNS(null,"x",c);p.setAttributeNS(null,"y",h);p.setAttributeNS(null,"width",d-c);p.setAttributeNS(null,"height",u-h);p.setAttributeNS(null,"fill",this._makeShadingPattern(t));this.current.fillAlpha<1&&p.setAttributeNS(null,"fill-opacity",this.current.fillAlpha);this._ensureTransformGroup().append(p)}_makeColorN_Pattern(t){return"TilingPattern"===t[0]?this._makeTilingPattern(t):this._makeShadingPattern(t)}_makeTilingPattern(t){const e=t[1],s=t[2],n=t[3]||i.IDENTITY_MATRIX,[a,r,o,l]=t[4],c=t[5],h=t[6],d=t[7],u="shading"+m++,[p,g,f,b]=i.Util.normalizeRect([...i.Util.applyTransform([a,r],n),...i.Util.applyTransform([o,l],n)]),[A,_]=i.Util.singularValueDecompose2dScale(n),y=c*A,v=h*_,S=this.svgFactory.createElement("svg:pattern");S.setAttributeNS(null,"id",u);S.setAttributeNS(null,"patternUnits","userSpaceOnUse");S.setAttributeNS(null,"width",y);S.setAttributeNS(null,"height",v);S.setAttributeNS(null,"x",`${p}`);S.setAttributeNS(null,"y",`${g}`);const x=this.svg,E=this.transformMatrix,C=this.current.fillColor,P=this.current.strokeColor,T=this.svgFactory.create(f-p,b-g);this.svg=T;this.transformMatrix=n;if(2===d){const t=i.Util.makeHexColor(...e);this.current.fillColor=t;this.current.strokeColor=t}this.executeOpTree(this.convertOpList(s));this.svg=x;this.transformMatrix=E;this.current.fillColor=C;this.current.strokeColor=P;S.append(T.childNodes[0]);this.defs.append(S);return`url(#${u})`}_makeShadingPattern(t){"string"==typeof t&&(t=this.objs.get(t));switch(t[0]){case"RadialAxial":const e="shading"+m++,s=t[3];let n;switch(t[1]){case"axial":const s=t[4],i=t[5];n=this.svgFactory.createElement("svg:linearGradient");n.setAttributeNS(null,"id",e);n.setAttributeNS(null,"gradientUnits","userSpaceOnUse");n.setAttributeNS(null,"x1",s[0]);n.setAttributeNS(null,"y1",s[1]);n.setAttributeNS(null,"x2",i[0]);n.setAttributeNS(null,"y2",i[1]);break;case"radial":const a=t[4],r=t[5],o=t[6],l=t[7];n=this.svgFactory.createElement("svg:radialGradient");n.setAttributeNS(null,"id",e);n.setAttributeNS(null,"gradientUnits","userSpaceOnUse");n.setAttributeNS(null,"cx",r[0]);n.setAttributeNS(null,"cy",r[1]);n.setAttributeNS(null,"r",l);n.setAttributeNS(null,"fx",a[0]);n.setAttributeNS(null,"fy",a[1]);n.setAttributeNS(null,"fr",o);break;default:throw new Error(`Unknown RadialAxial type: ${t[1]}`)}for(const t of s){const e=this.svgFactory.createElement("svg:stop");e.setAttributeNS(null,"offset",t[0]);e.setAttributeNS(null,"stop-color",t[1]);n.append(e)}this.defs.append(n);return`url(#${e})`;case"Mesh":(0,i.warn)("Unimplemented pattern Mesh");return null;case"Dummy":return"hotpink";default:throw new Error(`Unknown IR type: ${t[0]}`)}}setDash(t,e){this.current.dashArray=t;this.current.dashPhase=e}constructPath(t,e){const s=this.current;let n=s.x,a=s.y,r=[],o=0;for(const s of t)switch(0|s){case i.OPS.rectangle:n=e[o++];a=e[o++];const t=n+e[o++],s=a+e[o++];r.push("M",pf(n),pf(a),"L",pf(t),pf(a),"L",pf(t),pf(s),"L",pf(n),pf(s),"Z");break;case i.OPS.moveTo:n=e[o++];a=e[o++];r.push("M",pf(n),pf(a));break;case i.OPS.lineTo:n=e[o++];a=e[o++];r.push("L",pf(n),pf(a));break;case i.OPS.curveTo:n=e[o+4];a=e[o+5];r.push("C",pf(e[o]),pf(e[o+1]),pf(e[o+2]),pf(e[o+3]),pf(n),pf(a));o+=6;break;case i.OPS.curveTo2:r.push("C",pf(n),pf(a),pf(e[o]),pf(e[o+1]),pf(e[o+2]),pf(e[o+3]));n=e[o+2];a=e[o+3];o+=4;break;case i.OPS.curveTo3:n=e[o+2];a=e[o+3];r.push("C",pf(e[o]),pf(e[o+1]),pf(n),pf(a),pf(n),pf(a));o+=4;break;case i.OPS.closePath:r.push("Z")}r=r.join(" ");if(s.path&&t.length>0&&t[0]!==i.OPS.rectangle&&t[0]!==i.OPS.moveTo)r=s.path.getAttributeNS(null,"d")+r;else{s.path=this.svgFactory.createElement("svg:path");this._ensureTransformGroup().append(s.path)}s.path.setAttributeNS(null,"d",r);s.path.setAttributeNS(null,"fill","none");s.element=s.path;s.setCurrentPoint(n,a)}endPath(){const t=this.current;t.path=null;if(!this.pendingClip)return;if(!t.element){this.pendingClip=null;return}const e="clippath"+p++,s=this.svgFactory.createElement("svg:clipPath");s.setAttributeNS(null,"id",e);s.setAttributeNS(null,"transform",pm(this.transformMatrix));const n=t.element.cloneNode(!0);"evenodd"===this.pendingClip?n.setAttributeNS(null,"clip-rule","evenodd"):n.setAttributeNS(null,"clip-rule","nonzero");this.pendingClip=null;s.append(n);this.defs.append(s);if(t.activeClipUrl){t.clipGroup=null;for(const t of this.extraStack)t.clipGroup=null;s.setAttributeNS(null,"clip-path",t.activeClipUrl)}t.activeClipUrl=`url(#${e})`;this.tgrp=null}clip(t){this.pendingClip=t}closePath(){const t=this.current;if(t.path){const e=`${t.path.getAttributeNS(null,"d")}Z`;t.path.setAttributeNS(null,"d",e)}}setLeading(t){this.current.leading=-t}setTextRise(t){this.current.textRise=t}setTextRenderingMode(t){this.current.textRenderingMode=t}setHScale(t){this.current.textHScale=t/100}setRenderingIntent(t){}setFlatness(t){}setGState(t){for(const[e,s]of t)switch(e){case"LW":this.setLineWidth(s);break;case"LC":this.setLineCap(s);break;case"LJ":this.setLineJoin(s);break;case"ML":this.setMiterLimit(s);break;case"D":this.setDash(s[0],s[1]);break;case"RI":this.setRenderingIntent(s);break;case"FL":this.setFlatness(s);break;case"Font":this.setFont(s);break;case"CA":this.setStrokeAlpha(s);break;case"ca":this.setFillAlpha(s);break;default:(0,i.warn)(`Unimplemented graphic state operator ${e}`)}}fill(){const t=this.current;if(t.element){t.element.setAttributeNS(null,"fill",t.fillColor);t.element.setAttributeNS(null,"fill-opacity",t.fillAlpha);this.endPath()}}stroke(){const t=this.current;if(t.element){this._setStrokeAttributes(t.element);t.element.setAttributeNS(null,"fill","none");this.endPath()}}_setStrokeAttributes(t,e=1){const s=this.current;let n=s.dashArray;1!==e&&n.length>0&&(n=n.map((function(t){return e*t})));t.setAttributeNS(null,"stroke",s.strokeColor);t.setAttributeNS(null,"stroke-opacity",s.strokeAlpha);t.setAttributeNS(null,"stroke-miterlimit",pf(s.miterLimit));t.setAttributeNS(null,"stroke-linecap",s.lineCap);t.setAttributeNS(null,"stroke-linejoin",s.lineJoin);t.setAttributeNS(null,"stroke-width",pf(e*s.lineWidth)+"px");t.setAttributeNS(null,"stroke-dasharray",n.map(pf).join(" "));t.setAttributeNS(null,"stroke-dashoffset",pf(e*s.dashPhase)+"px")}eoFill(){this.current.element?.setAttributeNS(null,"fill-rule","evenodd");this.fill()}fillStroke(){this.stroke();this.fill()}eoFillStroke(){this.current.element?.setAttributeNS(null,"fill-rule","evenodd");this.fillStroke()}closeStroke(){this.closePath();this.stroke()}closeFillStroke(){this.closePath();this.fillStroke()}closeEOFillStroke(){this.closePath();this.eoFillStroke()}paintSolidColorImageMask(){const t=this.svgFactory.createElement("svg:rect");t.setAttributeNS(null,"x","0");t.setAttributeNS(null,"y","0");t.setAttributeNS(null,"width","1px");t.setAttributeNS(null,"height","1px");t.setAttributeNS(null,"fill",this.current.fillColor);this._ensureTransformGroup().append(t)}paintImageXObject(t){const e=this.getObject(t);e?this.paintInlineImageXObject(e):(0,i.warn)(`Dependent image with object ID ${t} is not ready yet`)}paintInlineImageXObject(t,e){const s=t.width,n=t.height,i=u(t,this.forceDataSchema,!!e),a=this.svgFactory.createElement("svg:rect");a.setAttributeNS(null,"x","0");a.setAttributeNS(null,"y","0");a.setAttributeNS(null,"width",pf(s));a.setAttributeNS(null,"height",pf(n));this.current.element=a;this.clip("nonzero");const r=this.svgFactory.createElement("svg:image");r.setAttributeNS(c,"xlink:href",i);r.setAttributeNS(null,"x","0");r.setAttributeNS(null,"y",pf(-n));r.setAttributeNS(null,"width",pf(s)+"px");r.setAttributeNS(null,"height",pf(n)+"px");r.setAttributeNS(null,"transform",`scale(${pf(1/s)} ${pf(-1/n)})`);e?e.append(r):this._ensureTransformGroup().append(r)}paintImageMaskXObject(t){const e=this.getObject(t.data,t);if(e.bitmap){(0,i.warn)("paintImageMaskXObject: ImageBitmap support is not implemented, ensure that the `isOffscreenCanvasSupported` API parameter is disabled.");return}const s=this.current,n=e.width,a=e.height,r=s.fillColor;s.maskId="mask"+g++;const o=this.svgFactory.createElement("svg:mask");o.setAttributeNS(null,"id",s.maskId);const l=this.svgFactory.createElement("svg:rect");l.setAttributeNS(null,"x","0");l.setAttributeNS(null,"y","0");l.setAttributeNS(null,"width",pf(n));l.setAttributeNS(null,"height",pf(a));l.setAttributeNS(null,"fill",r);l.setAttributeNS(null,"mask",`url(#${s.maskId})`);this.defs.append(o);this._ensureTransformGroup().append(l);this.paintInlineImageXObject(e,o)}paintFormXObjectBegin(t,e){Array.isArray(t)&&6===t.length&&this.transform(t[0],t[1],t[2],t[3],t[4],t[5]);if(e){const t=e[2]-e[0],s=e[3]-e[1],n=this.svgFactory.createElement("svg:rect");n.setAttributeNS(null,"x",e[0]);n.setAttributeNS(null,"y",e[1]);n.setAttributeNS(null,"width",pf(t));n.setAttributeNS(null,"height",pf(s));this.current.element=n;this.clip("nonzero");this.endPath()}}paintFormXObjectEnd(){}_initialize(t){const e=this.svgFactory.create(t.width,t.height),s=this.svgFactory.createElement("svg:defs");e.append(s);this.defs=s;const n=this.svgFactory.createElement("svg:g");n.setAttributeNS(null,"transform",pm(t.transform));e.append(n);this.svg=n;return e}_ensureClipGroup(){if(!this.current.clipGroup){const t=this.svgFactory.createElement("svg:g");t.setAttributeNS(null,"clip-path",this.current.activeClipUrl);this.svg.append(t);this.current.clipGroup=t}return this.current.clipGroup}_ensureTransformGroup(){if(!this.tgrp){this.tgrp=this.svgFactory.createElement("svg:g");this.tgrp.setAttributeNS(null,"transform",pm(this.transformMatrix));this.current.activeClipUrl?this._ensureClipGroup().append(this.tgrp):this.svg.append(this.tgrp)}return this.tgrp}}}},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.PDFNodeStream=void 0;var n=s(1),i=s(32);const a=require("fs"),r=require("http"),o=require("https"),l=require("url"),c=/^file:\/\/\/[a-zA-Z]:\//;e.PDFNodeStream=class PDFNodeStream{constructor(t){this.source=t;this.url=function parseUrl(t){const e=l.parse(t);if("file:"===e.protocol||e.host)return e;if(/^[a-z]:[/\\]/i.test(t))return l.parse(`file:///${t}`);e.host||(e.protocol="file:");return e}(t.url);this.isHttp="http:"===this.url.protocol||"https:"===this.url.protocol;this.isFsUrl="file:"===this.url.protocol;this.httpHeaders=this.isHttp&&t.httpHeaders||{};this._fullRequestReader=null;this._rangeRequestReaders=[]}get _progressiveDataLength(){return this._fullRequestReader?._loaded??0}getFullReader(){(0,n.assert)(!this._fullRequestReader,"PDFNodeStream.getFullReader can only be called once.");this._fullRequestReader=this.isFsUrl?new PDFNodeStreamFsFullReader(this):new PDFNodeStreamFullReader(this);return this._fullRequestReader}getRangeReader(t,e){if(e<=this._progressiveDataLength)return null;const s=this.isFsUrl?new PDFNodeStreamFsRangeReader(this,t,e):new PDFNodeStreamRangeReader(this,t,e);this._rangeRequestReaders.push(s);return s}cancelAllRequests(t){this._fullRequestReader?.cancel(t);for(const e of this._rangeRequestReaders.slice(0))e.cancel(t)}};class BaseFullReader{constructor(t){this._url=t.url;this._done=!1;this._storedError=null;this.onProgress=null;const e=t.source;this._contentLength=e.length;this._loaded=0;this._filename=null;this._disableRange=e.disableRange||!1;this._rangeChunkSize=e.rangeChunkSize;this._rangeChunkSize||this._disableRange||(this._disableRange=!0);this._isStreamingSupported=!e.disableStream;this._isRangeSupported=!e.disableRange;this._readableStream=null;this._readCapability=(0,n.createPromiseCapability)();this._headersCapability=(0,n.createPromiseCapability)()}get headersReady(){return this._headersCapability.promise}get filename(){return this._filename}get contentLength(){return this._contentLength}get isRangeSupported(){return this._isRangeSupported}get isStreamingSupported(){return this._isStreamingSupported}async read(){await this._readCapability.promise;if(this._done)return{value:void 0,done:!0};if(this._storedError)throw this._storedError;const t=this._readableStream.read();if(null===t){this._readCapability=(0,n.createPromiseCapability)();return this.read()}this._loaded+=t.length;this.onProgress?.({loaded:this._loaded,total:this._contentLength});return{value:new Uint8Array(t).buffer,done:!1}}cancel(t){this._readableStream?this._readableStream.destroy(t):this._error(t)}_error(t){this._storedError=t;this._readCapability.resolve()}_setReadableStream(t){this._readableStream=t;t.on("readable",(()=>{this._readCapability.resolve()}));t.on("end",(()=>{t.destroy();this._done=!0;this._readCapability.resolve()}));t.on("error",(t=>{this._error(t)}));!this._isStreamingSupported&&this._isRangeSupported&&this._error(new n.AbortException("streaming is disabled"));this._storedError&&this._readableStream.destroy(this._storedError)}}class BaseRangeReader{constructor(t){this._url=t.url;this._done=!1;this._storedError=null;this.onProgress=null;this._loaded=0;this._readableStream=null;this._readCapability=(0,n.createPromiseCapability)();const e=t.source;this._isStreamingSupported=!e.disableStream}get isStreamingSupported(){return this._isStreamingSupported}async read(){await this._readCapability.promise;if(this._done)return{value:void 0,done:!0};if(this._storedError)throw this._storedError;const t=this._readableStream.read();if(null===t){this._readCapability=(0,n.createPromiseCapability)();return this.read()}this._loaded+=t.length;this.onProgress?.({loaded:this._loaded});return{value:new Uint8Array(t).buffer,done:!1}}cancel(t){this._readableStream?this._readableStream.destroy(t):this._error(t)}_error(t){this._storedError=t;this._readCapability.resolve()}_setReadableStream(t){this._readableStream=t;t.on("readable",(()=>{this._readCapability.resolve()}));t.on("end",(()=>{t.destroy();this._done=!0;this._readCapability.resolve()}));t.on("error",(t=>{this._error(t)}));this._storedError&&this._readableStream.destroy(this._storedError)}}function createRequestOptions(t,e){return{protocol:t.protocol,auth:t.auth,host:t.hostname,port:t.port,path:t.path,method:"GET",headers:e}}class PDFNodeStreamFullReader extends BaseFullReader{constructor(t){super(t);const handleResponse=e=>{if(404===e.statusCode){const t=new n.MissingPDFException(`Missing PDF "${this._url}".`);this._storedError=t;this._headersCapability.reject(t);return}this._headersCapability.resolve();this._setReadableStream(e);const getResponseHeader=t=>this._readableStream.headers[t.toLowerCase()],{allowRangeRequests:s,suggestedLength:a}=(0,i.validateRangeRequestCapabilities)({getResponseHeader:getResponseHeader,isHttp:t.isHttp,rangeChunkSize:this._rangeChunkSize,disableRange:this._disableRange});this._isRangeSupported=s;this._contentLength=a||this._contentLength;this._filename=(0,i.extractFilenameFromHeader)(getResponseHeader)};this._request=null;"http:"===this._url.protocol?this._request=r.request(createRequestOptions(this._url,t.httpHeaders),handleResponse):this._request=o.request(createRequestOptions(this._url,t.httpHeaders),handleResponse);this._request.on("error",(t=>{this._storedError=t;this._headersCapability.reject(t)}));this._request.end()}}class PDFNodeStreamRangeReader extends BaseRangeReader{constructor(t,e,s){super(t);this._httpHeaders={};for(const e in t.httpHeaders){const s=t.httpHeaders[e];void 0!==s&&(this._httpHeaders[e]=s)}this._httpHeaders.Range=`bytes=${e}-${s-1}`;const handleResponse=t=>{if(404!==t.statusCode)this._setReadableStream(t);else{const t=new n.MissingPDFException(`Missing PDF "${this._url}".`);this._storedError=t}};this._request=null;"http:"===this._url.protocol?this._request=r.request(createRequestOptions(this._url,this._httpHeaders),handleResponse):this._request=o.request(createRequestOptions(this._url,this._httpHeaders),handleResponse);this._request.on("error",(t=>{this._storedError=t}));this._request.end()}}class PDFNodeStreamFsFullReader extends BaseFullReader{constructor(t){super(t);let e=decodeURIComponent(this._url.path);c.test(this._url.href)&&(e=e.replace(/^\//,""));a.lstat(e,((t,s)=>{if(t){"ENOENT"===t.code&&(t=new n.MissingPDFException(`Missing PDF "${e}".`));this._storedError=t;this._headersCapability.reject(t)}else{this._contentLength=s.size;this._setReadableStream(a.createReadStream(e));this._headersCapability.resolve()}}))}}class PDFNodeStreamFsRangeReader extends BaseRangeReader{constructor(t,e,s){super(t);let n=decodeURIComponent(this._url.path);c.test(this._url.href)&&(n=n.replace(/^\//,""));this._setReadableStream(a.createReadStream(n,{start:e,end:s-1}))}}},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.createResponseStatusError=function createResponseStatusError(t,e){if(404===t||0===t&&e.startsWith("file:"))return new n.MissingPDFException('Missing PDF "'+e+'".');return new n.UnexpectedResponseException(`Unexpected server response (${t}) while retrieving PDF "${e}".`,t)};e.extractFilenameFromHeader=function extractFilenameFromHeader(t){const e=t("Content-Disposition");if(e){let t=(0,i.getFilenameFromContentDispositionHeader)(e);if(t.includes("%"))try{t=decodeURIComponent(t)}catch(t){}if((0,a.isPdfFile)(t))return t}return null};e.validateRangeRequestCapabilities=function validateRangeRequestCapabilities({getResponseHeader:t,isHttp:e,rangeChunkSize:s,disableRange:n}){const i={allowRangeRequests:!1,suggestedLength:void 0},a=parseInt(t("Content-Length"),10);if(!Number.isInteger(a))return i;i.suggestedLength=a;if(a<=2*s)return i;if(n||!e)return i;if("bytes"!==t("Accept-Ranges"))return i;if("identity"!==(t("Content-Encoding")||"identity"))return i;i.allowRangeRequests=!0;return i};e.validateResponseStatus=function validateResponseStatus(t){return 200===t||206===t};var n=s(1),i=s(33),a=s(6)},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.getFilenameFromContentDispositionHeader=function getFilenameFromContentDispositionHeader(t){let e=!0,s=toParamRegExp("filename\\*","i").exec(t);if(s){s=s[1];let t=rfc2616unquote(s);t=unescape(t);t=rfc5987decode(t);t=rfc2047decode(t);return fixupEncoding(t)}s=function rfc2231getparam(t){const e=[];let s;const n=toParamRegExp("filename\\*((?!0\\d)\\d+)(\\*?)","ig");for(;null!==(s=n.exec(t));){let[,t,n,i]=s;t=parseInt(t,10);if(t in e){if(0===t)break}else e[t]=[n,i]}const i=[];for(let t=0;t{Object.defineProperty(e,"__esModule",{value:!0});e.PDFNetworkStream=void 0;var n=s(1),i=s(32);class NetworkManager{constructor(t,e={}){this.url=t;this.isHttp=/^https?:/i.test(t);this.httpHeaders=this.isHttp&&e.httpHeaders||Object.create(null);this.withCredentials=e.withCredentials||!1;this.getXhr=e.getXhr||function NetworkManager_getXhr(){return new XMLHttpRequest};this.currXhrId=0;this.pendingRequests=Object.create(null)}requestRange(t,e,s){const n={begin:t,end:e};for(const t in s)n[t]=s[t];return this.request(n)}requestFull(t){return this.request(t)}request(t){const e=this.getXhr(),s=this.currXhrId++,n=this.pendingRequests[s]={xhr:e};e.open("GET",this.url);e.withCredentials=this.withCredentials;for(const t in this.httpHeaders){const s=this.httpHeaders[t];void 0!==s&&e.setRequestHeader(t,s)}if(this.isHttp&&"begin"in t&&"end"in t){e.setRequestHeader("Range",`bytes=${t.begin}-${t.end-1}`);n.expectedStatus=206}else n.expectedStatus=200;e.responseType="arraybuffer";t.onError&&(e.onerror=function(s){t.onError(e.status)});e.onreadystatechange=this.onStateChange.bind(this,s);e.onprogress=this.onProgress.bind(this,s);n.onHeadersReceived=t.onHeadersReceived;n.onDone=t.onDone;n.onError=t.onError;n.onProgress=t.onProgress;e.send(null);return s}onProgress(t,e){const s=this.pendingRequests[t];s&&s.onProgress?.(e)}onStateChange(t,e){const s=this.pendingRequests[t];if(!s)return;const i=s.xhr;if(i.readyState>=2&&s.onHeadersReceived){s.onHeadersReceived();delete s.onHeadersReceived}if(4!==i.readyState)return;if(!(t in this.pendingRequests))return;delete this.pendingRequests[t];if(0===i.status&&this.isHttp){s.onError?.(i.status);return}const a=i.status||200;if(!(200===a&&206===s.expectedStatus)&&a!==s.expectedStatus){s.onError?.(i.status);return}const r=function getArrayBuffer(t){const e=t.response;return"string"!=typeof e?e:(0,n.stringToBytes)(e).buffer}(i);if(206===a){const t=i.getResponseHeader("Content-Range"),e=/bytes (\d+)-(\d+)\/(\d+)/.exec(t);s.onDone({begin:parseInt(e[1],10),chunk:r})}else r?s.onDone({begin:0,chunk:r}):s.onError?.(i.status)}getRequestXhr(t){return this.pendingRequests[t].xhr}isPendingRequest(t){return t in this.pendingRequests}abortRequest(t){const e=this.pendingRequests[t].xhr;delete this.pendingRequests[t];e.abort()}}e.PDFNetworkStream=class PDFNetworkStream{constructor(t){this._source=t;this._manager=new NetworkManager(t.url,{httpHeaders:t.httpHeaders,withCredentials:t.withCredentials});this._rangeChunkSize=t.rangeChunkSize;this._fullRequestReader=null;this._rangeRequestReaders=[]}_onRangeRequestReaderClosed(t){const e=this._rangeRequestReaders.indexOf(t);e>=0&&this._rangeRequestReaders.splice(e,1)}getFullReader(){(0,n.assert)(!this._fullRequestReader,"PDFNetworkStream.getFullReader can only be called once.");this._fullRequestReader=new PDFNetworkStreamFullRequestReader(this._manager,this._source);return this._fullRequestReader}getRangeReader(t,e){const s=new PDFNetworkStreamRangeRequestReader(this._manager,t,e);s.onClosed=this._onRangeRequestReaderClosed.bind(this);this._rangeRequestReaders.push(s);return s}cancelAllRequests(t){this._fullRequestReader?.cancel(t);for(const e of this._rangeRequestReaders.slice(0))e.cancel(t)}};class PDFNetworkStreamFullRequestReader{constructor(t,e){this._manager=t;const s={onHeadersReceived:this._onHeadersReceived.bind(this),onDone:this._onDone.bind(this),onError:this._onError.bind(this),onProgress:this._onProgress.bind(this)};this._url=e.url;this._fullRequestId=t.requestFull(s);this._headersReceivedCapability=(0,n.createPromiseCapability)();this._disableRange=e.disableRange||!1;this._contentLength=e.length;this._rangeChunkSize=e.rangeChunkSize;this._rangeChunkSize||this._disableRange||(this._disableRange=!0);this._isStreamingSupported=!1;this._isRangeSupported=!1;this._cachedChunks=[];this._requests=[];this._done=!1;this._storedError=void 0;this._filename=null;this.onProgress=null}_onHeadersReceived(){const t=this._fullRequestId,e=this._manager.getRequestXhr(t),getResponseHeader=t=>e.getResponseHeader(t),{allowRangeRequests:s,suggestedLength:n}=(0,i.validateRangeRequestCapabilities)({getResponseHeader:getResponseHeader,isHttp:this._manager.isHttp,rangeChunkSize:this._rangeChunkSize,disableRange:this._disableRange});s&&(this._isRangeSupported=!0);this._contentLength=n||this._contentLength;this._filename=(0,i.extractFilenameFromHeader)(getResponseHeader);this._isRangeSupported&&this._manager.abortRequest(t);this._headersReceivedCapability.resolve()}_onDone(t){if(t)if(this._requests.length>0){this._requests.shift().resolve({value:t.chunk,done:!1})}else this._cachedChunks.push(t.chunk);this._done=!0;if(!(this._cachedChunks.length>0)){for(const t of this._requests)t.resolve({value:void 0,done:!0});this._requests.length=0}}_onError(t){this._storedError=(0,i.createResponseStatusError)(t,this._url);this._headersReceivedCapability.reject(this._storedError);for(const t of this._requests)t.reject(this._storedError);this._requests.length=0;this._cachedChunks.length=0}_onProgress(t){this.onProgress?.({loaded:t.loaded,total:t.lengthComputable?t.total:this._contentLength})}get filename(){return this._filename}get isRangeSupported(){return this._isRangeSupported}get isStreamingSupported(){return this._isStreamingSupported}get contentLength(){return this._contentLength}get headersReady(){return this._headersReceivedCapability.promise}async read(){if(this._storedError)throw this._storedError;if(this._cachedChunks.length>0){return{value:this._cachedChunks.shift(),done:!1}}if(this._done)return{value:void 0,done:!0};const t=(0,n.createPromiseCapability)();this._requests.push(t);return t.promise}cancel(t){this._done=!0;this._headersReceivedCapability.reject(t);for(const t of this._requests)t.resolve({value:void 0,done:!0});this._requests.length=0;this._manager.isPendingRequest(this._fullRequestId)&&this._manager.abortRequest(this._fullRequestId);this._fullRequestReader=null}}class PDFNetworkStreamRangeRequestReader{constructor(t,e,s){this._manager=t;const n={onDone:this._onDone.bind(this),onError:this._onError.bind(this),onProgress:this._onProgress.bind(this)};this._url=t.url;this._requestId=t.requestRange(e,s,n);this._requests=[];this._queuedChunk=null;this._done=!1;this._storedError=void 0;this.onProgress=null;this.onClosed=null}_close(){this.onClosed?.(this)}_onDone(t){const e=t.chunk;if(this._requests.length>0){this._requests.shift().resolve({value:e,done:!1})}else this._queuedChunk=e;this._done=!0;for(const t of this._requests)t.resolve({value:void 0,done:!0});this._requests.length=0;this._close()}_onError(t){this._storedError=(0,i.createResponseStatusError)(t,this._url);for(const t of this._requests)t.reject(this._storedError);this._requests.length=0;this._queuedChunk=null}_onProgress(t){this.isStreamingSupported||this.onProgress?.({loaded:t.loaded})}get isStreamingSupported(){return!1}async read(){if(this._storedError)throw this._storedError;if(null!==this._queuedChunk){const t=this._queuedChunk;this._queuedChunk=null;return{value:t,done:!1}}if(this._done)return{value:void 0,done:!0};const t=(0,n.createPromiseCapability)();this._requests.push(t);return t.promise}cancel(t){this._done=!0;for(const t of this._requests)t.resolve({value:void 0,done:!0});this._requests.length=0;this._manager.isPendingRequest(this._requestId)&&this._manager.abortRequest(this._requestId);this._close()}}},(t,e,s)=>{Object.defineProperty(e,"__esModule",{value:!0});e.PDFFetchStream=void 0;var n=s(1),i=s(32);function createFetchOptions(t,e,s){return{method:"GET",headers:t,signal:s.signal,mode:"cors",credentials:e?"include":"same-origin",redirect:"follow"}}function createHeaders(t){const e=new Headers;for(const s in t){const n=t[s];void 0!==n&&e.append(s,n)}return e}e.PDFFetchStream=class PDFFetchStream{constructor(t){this.source=t;this.isHttp=/^https?:/i.test(t.url);this.httpHeaders=this.isHttp&&t.httpHeaders||{};this._fullRequestReader=null;this._rangeRequestReaders=[]}get _progressiveDataLength(){return this._fullRequestReader?._loaded??0}getFullReader(){(0,n.assert)(!this._fullRequestReader,"PDFFetchStream.getFullReader can only be called once.");this._fullRequestReader=new PDFFetchStreamReader(this);return this._fullRequestReader}getRangeReader(t,e){if(e<=this._progressiveDataLength)return null;const s=new PDFFetchStreamRangeReader(this,t,e);this._rangeRequestReaders.push(s);return s}cancelAllRequests(t){this._fullRequestReader?.cancel(t);for(const e of this._rangeRequestReaders.slice(0))e.cancel(t)}};class PDFFetchStreamReader{constructor(t){this._stream=t;this._reader=null;this._loaded=0;this._filename=null;const e=t.source;this._withCredentials=e.withCredentials||!1;this._contentLength=e.length;this._headersCapability=(0,n.createPromiseCapability)();this._disableRange=e.disableRange||!1;this._rangeChunkSize=e.rangeChunkSize;this._rangeChunkSize||this._disableRange||(this._disableRange=!0);this._abortController=new AbortController;this._isStreamingSupported=!e.disableStream;this._isRangeSupported=!e.disableRange;this._headers=createHeaders(this._stream.httpHeaders);const s=e.url;fetch(s,createFetchOptions(this._headers,this._withCredentials,this._abortController)).then((t=>{if(!(0,i.validateResponseStatus)(t.status))throw(0,i.createResponseStatusError)(t.status,s);this._reader=t.body.getReader();this._headersCapability.resolve();const getResponseHeader=e=>t.headers.get(e),{allowRangeRequests:e,suggestedLength:a}=(0,i.validateRangeRequestCapabilities)({getResponseHeader:getResponseHeader,isHttp:this._stream.isHttp,rangeChunkSize:this._rangeChunkSize,disableRange:this._disableRange});this._isRangeSupported=e;this._contentLength=a||this._contentLength;this._filename=(0,i.extractFilenameFromHeader)(getResponseHeader);!this._isStreamingSupported&&this._isRangeSupported&&this.cancel(new n.AbortException("Streaming is disabled."))})).catch(this._headersCapability.reject);this.onProgress=null}get headersReady(){return this._headersCapability.promise}get filename(){return this._filename}get contentLength(){return this._contentLength}get isRangeSupported(){return this._isRangeSupported}get isStreamingSupported(){return this._isStreamingSupported}async read(){await this._headersCapability.promise;const{value:t,done:e}=await this._reader.read();if(e)return{value:t,done:e};this._loaded+=t.byteLength;this.onProgress?.({loaded:this._loaded,total:this._contentLength});return{value:new Uint8Array(t).buffer,done:!1}}cancel(t){this._reader?.cancel(t);this._abortController.abort()}}class PDFFetchStreamRangeReader{constructor(t,e,s){this._stream=t;this._reader=null;this._loaded=0;const a=t.source;this._withCredentials=a.withCredentials||!1;this._readCapability=(0,n.createPromiseCapability)();this._isStreamingSupported=!a.disableStream;this._abortController=new AbortController;this._headers=createHeaders(this._stream.httpHeaders);this._headers.append("Range",`bytes=${e}-${s-1}`);const r=a.url;fetch(r,createFetchOptions(this._headers,this._withCredentials,this._abortController)).then((t=>{if(!(0,i.validateResponseStatus)(t.status))throw(0,i.createResponseStatusError)(t.status,r);this._readCapability.resolve();this._reader=t.body.getReader()})).catch(this._readCapability.reject);this.onProgress=null}get isStreamingSupported(){return this._isStreamingSupported}async read(){await this._readCapability.promise;const{value:t,done:e}=await this._reader.read();if(e)return{value:t,done:e};this._loaded+=t.byteLength;this.onProgress?.({loaded:this._loaded});return{value:new Uint8Array(t).buffer,done:!1}}cancel(t){this._reader?.cancel(t);this._abortController.abort()}}}],__webpack_module_cache__={};function __w_pdfjs_require__(t){var e=__webpack_module_cache__[t];if(void 0!==e)return e.exports;var s=__webpack_module_cache__[t]={exports:{}};__webpack_modules__[t](s,s.exports,__w_pdfjs_require__);return s.exports}var __webpack_exports__={};(()=>{var t=__webpack_exports__;Object.defineProperty(t,"__esModule",{value:!0});Object.defineProperty(t,"AbortException",{enumerable:!0,get:function(){return e.AbortException}});Object.defineProperty(t,"AnnotationEditorLayer",{enumerable:!0,get:function(){return a.AnnotationEditorLayer}});Object.defineProperty(t,"AnnotationEditorParamsType",{enumerable:!0,get:function(){return e.AnnotationEditorParamsType}});Object.defineProperty(t,"AnnotationEditorType",{enumerable:!0,get:function(){return e.AnnotationEditorType}});Object.defineProperty(t,"AnnotationEditorUIManager",{enumerable:!0,get:function(){return r.AnnotationEditorUIManager}});Object.defineProperty(t,"AnnotationLayer",{enumerable:!0,get:function(){return o.AnnotationLayer}});Object.defineProperty(t,"AnnotationMode",{enumerable:!0,get:function(){return e.AnnotationMode}});Object.defineProperty(t,"CMapCompressionType",{enumerable:!0,get:function(){return e.CMapCompressionType}});Object.defineProperty(t,"GlobalWorkerOptions",{enumerable:!0,get:function(){return l.GlobalWorkerOptions}});Object.defineProperty(t,"InvalidPDFException",{enumerable:!0,get:function(){return e.InvalidPDFException}});Object.defineProperty(t,"MissingPDFException",{enumerable:!0,get:function(){return e.MissingPDFException}});Object.defineProperty(t,"OPS",{enumerable:!0,get:function(){return e.OPS}});Object.defineProperty(t,"PDFDataRangeTransport",{enumerable:!0,get:function(){return s.PDFDataRangeTransport}});Object.defineProperty(t,"PDFDateString",{enumerable:!0,get:function(){return n.PDFDateString}});Object.defineProperty(t,"PDFWorker",{enumerable:!0,get:function(){return s.PDFWorker}});Object.defineProperty(t,"PasswordResponses",{enumerable:!0,get:function(){return e.PasswordResponses}});Object.defineProperty(t,"PermissionFlag",{enumerable:!0,get:function(){return e.PermissionFlag}});Object.defineProperty(t,"PixelsPerInch",{enumerable:!0,get:function(){return n.PixelsPerInch}});Object.defineProperty(t,"RenderingCancelledException",{enumerable:!0,get:function(){return n.RenderingCancelledException}});Object.defineProperty(t,"SVGGraphics",{enumerable:!0,get:function(){return h.SVGGraphics}});Object.defineProperty(t,"UNSUPPORTED_FEATURES",{enumerable:!0,get:function(){return e.UNSUPPORTED_FEATURES}});Object.defineProperty(t,"UnexpectedResponseException",{enumerable:!0,get:function(){return e.UnexpectedResponseException}});Object.defineProperty(t,"Util",{enumerable:!0,get:function(){return e.Util}});Object.defineProperty(t,"VerbosityLevel",{enumerable:!0,get:function(){return e.VerbosityLevel}});Object.defineProperty(t,"XfaLayer",{enumerable:!0,get:function(){return d.XfaLayer}});Object.defineProperty(t,"build",{enumerable:!0,get:function(){return s.build}});Object.defineProperty(t,"createPromiseCapability",{enumerable:!0,get:function(){return e.createPromiseCapability}});Object.defineProperty(t,"createValidAbsoluteUrl",{enumerable:!0,get:function(){return e.createValidAbsoluteUrl}});Object.defineProperty(t,"getDocument",{enumerable:!0,get:function(){return s.getDocument}});Object.defineProperty(t,"getFilenameFromUrl",{enumerable:!0,get:function(){return n.getFilenameFromUrl}});Object.defineProperty(t,"getPdfFilenameFromUrl",{enumerable:!0,get:function(){return n.getPdfFilenameFromUrl}});Object.defineProperty(t,"getXfaPageViewport",{enumerable:!0,get:function(){return n.getXfaPageViewport}});Object.defineProperty(t,"isDataScheme",{enumerable:!0,get:function(){return n.isDataScheme}});Object.defineProperty(t,"isPdfFile",{enumerable:!0,get:function(){return n.isPdfFile}});Object.defineProperty(t,"loadScript",{enumerable:!0,get:function(){return n.loadScript}});Object.defineProperty(t,"renderTextLayer",{enumerable:!0,get:function(){return i.renderTextLayer}});Object.defineProperty(t,"setLayerDimensions",{enumerable:!0,get:function(){return n.setLayerDimensions}});Object.defineProperty(t,"shadow",{enumerable:!0,get:function(){return e.shadow}});Object.defineProperty(t,"updateTextLayer",{enumerable:!0,get:function(){return i.updateTextLayer}});Object.defineProperty(t,"version",{enumerable:!0,get:function(){return s.version}});var e=__w_pdfjs_require__(1),s=__w_pdfjs_require__(2),n=__w_pdfjs_require__(6),i=__w_pdfjs_require__(21),a=__w_pdfjs_require__(22),r=__w_pdfjs_require__(5),o=__w_pdfjs_require__(27),l=__w_pdfjs_require__(14),c=__w_pdfjs_require__(10),h=__w_pdfjs_require__(30),d=__w_pdfjs_require__(29);if(c.isNodeJS){const{PDFNodeStream:t}=__w_pdfjs_require__(31);(0,s.setPDFNetworkStreamFactory)((e=>new t(e)))}else{const{PDFNetworkStream:t}=__w_pdfjs_require__(34),{PDFFetchStream:e}=__w_pdfjs_require__(35);(0,s.setPDFNetworkStreamFactory)((s=>(0,n.isValidFetchUrl)(s.url)?new e(s):new t(s)))}})();return __webpack_exports__})())); \ No newline at end of file diff --git a/assets/sounds/badeep.wav b/assets/sounds/badeep.wav new file mode 100644 index 0000000..475c210 Binary files /dev/null and b/assets/sounds/badeep.wav differ diff --git a/assets/sounds/beepbadeep.wav b/assets/sounds/beepbadeep.wav new file mode 100644 index 0000000..8577b12 Binary files /dev/null and b/assets/sounds/beepbadeep.wav differ diff --git a/assets/sounds/bonk.wav b/assets/sounds/bonk.wav new file mode 100644 index 0000000..3e9236c Binary files /dev/null and b/assets/sounds/bonk.wav differ diff --git a/assets/sounds/boop.wav b/assets/sounds/boop.wav new file mode 100644 index 0000000..df284bc Binary files /dev/null and b/assets/sounds/boop.wav differ diff --git a/build.sh b/build.sh deleted file mode 100755 index 96cce52..0000000 --- a/build.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/bash -set -e -dart run build_runner build --delete-conflicting-outputs - -pushd lib > /dev/null -protoc --dart_out=proto -I veilid_support/proto -I veilid_support/dht_support/proto -I proto veilidchat.proto -protoc --dart_out=proto -I veilid_support/proto -I veilid_support/dht_support/proto dht.proto -protoc --dart_out=proto -I veilid_support/proto veilid.proto -popd > /dev/null diff --git a/build.yaml b/build.yaml index 950fe95..77b9050 100644 --- a/build.yaml +++ b/build.yaml @@ -1,6 +1,9 @@ targets: $default: builders: + freezed: + options: + generic_argument_factories: true json_serializable: options: explicit_to_json: true diff --git a/dev-setup/_script_common b/dev-setup/_script_common index c0b656c..991088e 100644 --- a/dev-setup/_script_common +++ b/dev-setup/_script_common @@ -6,11 +6,11 @@ get_abs_filename() { } # Veilid location -VEILIDDIR=$(get_abs_filename "$SCRIPTDIR/../../veilid") +VEILIDDIR=$(get_abs_filename "$(git rev-parse --show-toplevel)/../veilid") if [ ! -d "$VEILIDDIR" ]; then echo 'Veilid git clone needs to be at $VEILIDDIR' exit 1 fi -# VeilidChat location -VEILIDCHATDIR=$(get_abs_filename "$SCRIPTDIR/../../veilid") +# App location +APPDIR=$(git rev-parse --show-toplevel) diff --git a/dev-setup/flutter_config.sh b/dev-setup/flutter_config.sh index eeec7ca..a1b8c8d 100755 --- a/dev-setup/flutter_config.sh +++ b/dev-setup/flutter_config.sh @@ -6,30 +6,30 @@ SCRIPTDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" source $SCRIPTDIR/_script_common # iOS: Set deployment target -sed -i '' 's/IPHONEOS_DEPLOYMENT_TARGET = [^;]*/IPHONEOS_DEPLOYMENT_TARGET = 12.4/g' $VEILIDCHATDIR/ios/Runner.xcodeproj/project.pbxproj -sed -i '' "s/platform :ios, '[^']*'/platform :ios, '12.4'/g" $VEILIDCHATDIR/ios/Podfile +sed -i '' 's/IPHONEOS_DEPLOYMENT_TARGET = [^;]*/IPHONEOS_DEPLOYMENT_TARGET = 12.4/g' $APPDIR/ios/Runner.xcodeproj/project.pbxproj +sed -i '' "s/platform :ios, '[^']*'/platform :ios, '12.4'/g" $APPDIR/ios/Podfile # MacOS: Set deployment target -sed -i '' 's/MACOSX_DEPLOYMENT_TARGET = [^;]*/MACOSX_DEPLOYMENT_TARGET = 10.14.6/g' $VEILIDCHATDIR/macos/Runner.xcodeproj/project.pbxproj -sed -i '' "s/platform :osx, '[^']*'/platform :osx, '10.14.6'/g" $VEILIDCHATDIR/macos/Podfile +sed -i '' 's/MACOSX_DEPLOYMENT_TARGET = [^;]*/MACOSX_DEPLOYMENT_TARGET = 10.14.6/g' $APPDIR/macos/Runner.xcodeproj/project.pbxproj +sed -i '' "s/platform :osx, '[^']*'/platform :osx, '10.14.6'/g" $APPDIR/macos/Podfile # Android: Set NDK version -if [[ "$TMPDIR" != "" ]]; then +if [[ "$TMPDIR" != "" ]]; then ANDTMP=$TMPDIR/andtmp_$(date +%s) -else +else ANDTMP=/tmp/andtmp_$(date +%s) fi cat < $ANDTMP - ndkVersion '25.1.8937393' + ndkVersion '27.0.12077973' EOF -sed -i '' -e "/android {/r $ANDTMP" $VEILIDCHATDIR/android/app/build.gradle +sed -i '' -e "/android {/r $ANDTMP" $APPDIR/android/app/build.gradle rm -- $ANDTMP # Android: Set min sdk version -sed -i '' 's/minSdkVersion .*/minSdkVersion Math.max(flutter.minSdkVersion, 24)/g' $VEILIDCHATDIR/android/app/build.gradle +sed -i '' 's/minSdkVersion .*/minSdkVersion Math.max(flutter.minSdkVersion, 24)/g' $APPDIR/android/app/build.gradle # Android: Set gradle plugin version -sed -i '' "s/classpath \'com.android.tools.build:gradle:[^\']*\'/classpath 'com.android.tools.build:gradle:7.2.0'/g" $VEILIDCHATDIR/android/build.gradle +sed -i '' "s/classpath \'com.android.tools.build:gradle:[^\']*\'/classpath 'com.android.tools.build:gradle:8.8.0'/g" $APPDIR/android/build.gradle # Android: Set gradle version -sed -i '' 's/distributionUrl=.*/distributionUrl=https:\/\/services.gradle.org\/distributions\/gradle-7.3.3-all.zip/g' $VEILIDCHATDIR/android/gradle/wrapper/gradle-wrapper.properties +sed -i '' 's/distributionUrl=.*/distributionUrl=https:\/\/services.gradle.org\/distributions\/gradle-8.10.2-all.zip/g' $APPDIR/android/gradle/wrapper/gradle-wrapper.properties diff --git a/dev-setup/install_protoc_linux.sh b/dev-setup/install_protoc_linux.sh index d01a780..8f5052e 100755 --- a/dev-setup/install_protoc_linux.sh +++ b/dev-setup/install_protoc_linux.sh @@ -1,6 +1,6 @@ #!/bin/bash SCRIPTDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" -PROTOC_VERSION="24.3" # Keep in sync with veilid-core/build.rs +PROTOC_VERSION="25.3" UNAME_M=$(uname -m) if [[ "$UNAME_M" == "x86_64" ]]; then diff --git a/dev-setup/setup_windows.bat b/dev-setup/setup_windows.bat index b7e3ef6..7338b58 100644 --- a/dev-setup/setup_windows.bat +++ b/dev-setup/setup_windows.bat @@ -17,7 +17,7 @@ IF NOT DEFINED ProgramFiles(x86) ( FOR %%X IN (protoc.exe) DO (SET PROTOC_FOUND=%%~$PATH:X) IF NOT DEFINED PROTOC_FOUND ( echo protobuf compiler ^(protoc^) is required but it's not installed. Install protoc 23.2 or higher. Ensure it is in your path. Aborting. - echo protoc is available here: https://github.com/protocolbuffers/protobuf/releases/download/v23.2/protoc-23.2-win64.zip + echo protoc is available here: https://github.com/protocolbuffers/protobuf/releases/download/v25.3/protoc-25.3-win64.zip goto end ) diff --git a/dev-setup/wasm_update.sh b/dev-setup/wasm_update.sh index 01633a3..6dec701 100755 --- a/dev-setup/wasm_update.sh +++ b/dev-setup/wasm_update.sh @@ -5,7 +5,7 @@ source $SCRIPTDIR/_script_common pushd $SCRIPTDIR >/dev/null # WASM output dir -WASMDIR=$VEILIDCHATDIR/web/wasm +WASMDIR=$APPDIR/web/wasm # Build veilid-wasm, passing any arguments here to the build script pushd $VEILIDDIR/veilid-wasm >/dev/null diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..7093540 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +extensions: + - provider: true + - shared_preferences: true \ No newline at end of file diff --git a/doc/invitations.md b/doc/invitations.md index a914dc3..f76dee8 100644 --- a/doc/invitations.md +++ b/doc/invitations.md @@ -16,27 +16,27 @@ 2. Get the ContactRequest record unicastinbox DHT record owner subkey from the network 3. Decrypt the writer secret with the password if necessary 4. Decrypt the ContactRequestPrivate chunk with the writer secret -5. Get the contact's AccountMaster record key +5. Get the contact's SuperIdentity record key 6. Verify identity signature on the SignedContactInvitation 7. Verify expiration 8. Display the profile and ask if the user wants to accept or reject the invitation ## Accepting an invitation 1. Create a Local Chat DHT record (no content yet, will be encrypted with DH of contact identity key) -2. Create ContactResponse with chat dht record and account master +2. Create ContactResponse with chat dht record and superidentity 3. Create SignedContactResponse with accept=true signed with identity 4. Set ContactRequest unicastinbox DHT record writer subkey with SignedContactResponse, encrypted with writer secret 5. Add a local contact with the remote chat dht record, updating from the remote profile in it ## Rejecting an invitation -1. Create ContactResponse with account master +1. Create ContactResponse with superidentity 2. Create SignedContactResponse with accept=false signed with identity 3. Set ContactRequest unicastinbox DHT record writer subkey with SignedContactResponse, encrypted with writer secret ## Receiving an accept/reject 1. Open and get SignedContactResponse from ContactRequest unicastinbox DHT record 2. Decrypt with writer secret -3. Get DHT record for contact's AccountMaster +3. Get DHT record for contact's SuperIdentity 4. Validate the SignedContactResponse signature If accept == false: diff --git a/flatpak/README.md b/flatpak/README.md new file mode 100644 index 0000000..f85d0fe --- /dev/null +++ b/flatpak/README.md @@ -0,0 +1,84 @@ +- [Building the Flatpak](#building-the-flatpak) + - [Prerequisites](#prereq) + - [Build](#build) + - [Create Flatpak repo of the app](#create-flatpak-repo-of-the-app) + - [Publish to app store](#publish-to-app-store) + - [Bundle the Flatpak repo into an installable `.flatpak` file](#bundle-the-flatpak-repo-into-an-installable-flatpak-file) + - [We now have a `.flatpak` file that we can install on any machine with](#we-now-have-a-flatpak-file-that-we-can-install-on-any-machine-with) + - [We can see that it is installed:](#we-can-see-that-it-is-installed) + +# Prerequisites +`flatpak install -y org.gnome.Platform/x86_64/45` +`flatpak install -y org.gnome.Sdk/x86_64/45` + +# Building the Flatpak + +We imagine this is a separate git repo containing the information specifically +for building the flatpak, as that is how an app is built for FlatHub. + +Important configuration files are as follows: + +- `com.veilid.veilidchat.yml` -- Flatpak manifest, contains the Flatpak + configuration and information on where to get the build files +- `build-flatpak.sh` -- Shell script that will be called by the manifest to assemble the flatpak + + +## Build + +**This should be built on an older version on Linux so that it will run on the +widest possible set of Linux installations. Recommend docker or a CI pipeline +like GitHub actions using the oldest supported Ubuntu LTS.** + +### Create Flatpak repo of the app + +This is esentially what will happen when being built by FlatHub. + +```bash +flatpak-builder --force-clean build-dir com.veilid.veilidchat.yml --repo=repo +``` + +#### Publish to app store + +When this succeeds you can proceed to [submit to an app store like Flathub](https://github.com/flathub/flathub/wiki/App-Submission). + + +
+ +--- + +
+ +*The remainder is optional if we want to try installing locally, however only +the first step is needed to succeed in order to publish to FlatHub.* + +### Bundle the Flatpak repo into an installable `.flatpak` file + +This part is not done when building for FlatHub. + +```bash +flatpak build-bundle repo com.veilid.veilidchat.flatpak com.veilid.veilidchat +``` + +### We now have a `.flatpak` file that we can install on any machine with + Flatpak: + +```bash +flatpak install --user com.veilid.veilidchat.flatpak +``` + +### We can see that it is installed: + +```bash +flatpak list --app | grep com.veilid.veilidchat +``` + +> Flutter App com.veilid.veilidchat 1.0.0 master flutterapp-origin user + +If we search for "Flutter App" in the system application menu there should be an +entry for the app with the proper name and icon. + +We can also uninstall our test flatpak: + +```bash +flatpak remove com.veilid.veilidchat +``` diff --git a/flatpak/build-flatpak.sh b/flatpak/build-flatpak.sh new file mode 100755 index 0000000..9cc28fe --- /dev/null +++ b/flatpak/build-flatpak.sh @@ -0,0 +1,41 @@ +#!/bin/bash + + +# Convert the archive of the Flutter app to a Flatpak. + + +# Exit if any command fails +set -e + +# Echo all commands for debug purposes +set -x + + +# No spaces in project name. +projectName=VeilidChat +projectId=com.veilid.veilidchat +executableName=veilidchat + + +# ------------------------------- Build Flatpak ----------------------------- # + +# Copy the portable app to the Flatpak-based location. +cp -r bundle/ /app/$projectName +chmod +x /app/$projectName/$executableName +mkdir -p /app/bin +ln -s /app/$projectName/$executableName /app/bin/$executableName + +# Install the icon. +iconDir=/app/share/icons/hicolor/256x256/apps +mkdir -p $iconDir +cp $projectId.png $iconDir/$projectId.png + +# Install the desktop file. +desktopFileDir=/app/share/applications +mkdir -p $desktopFileDir +cp -r $projectId.desktop $desktopFileDir/ + +# Install the AppStream metadata file. +metadataDir=/app/share/metainfo +mkdir -p $metadataDir +cp -r $projectId.metainfo.xml $metadataDir/ diff --git a/flatpak/com.veilid.veilidchat.arm64.yml b/flatpak/com.veilid.veilidchat.arm64.yml new file mode 100644 index 0000000..f1c3d59 --- /dev/null +++ b/flatpak/com.veilid.veilidchat.arm64.yml @@ -0,0 +1,35 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/flatpak/flatpak-builder/main/data/flatpak-manifest.schema.json + +--- +app-id: com.veilid.veilidchat +runtime: org.gnome.Platform +runtime-version: "46" +sdk: org.gnome.Sdk +command: veilidchat +separate-locales: false +finish-args: + - --share=ipc + - --socket=fallback-x11 + - --socket=wayland + - --device=dri + - --socket=pulseaudio + - --share=network + - --talk-name=org.freedesktop.secrets +modules: + - name: VeilidChat + buildsystem: simple + only-arches: + - aarch64 + build-commands: + - "./build-flatpak.sh" + sources: + - type: dir + path: ../build/linux/arm64/release/ + - type: file + path: build-flatpak.sh + - type: file + path: com.veilid.veilidchat.png + - type: file + path: com.veilid.veilidchat.desktop + - type: file + path: com.veilid.veilidchat.metainfo.xml diff --git a/flatpak/com.veilid.veilidchat.desktop b/flatpak/com.veilid.veilidchat.desktop new file mode 100755 index 0000000..4b8dd99 --- /dev/null +++ b/flatpak/com.veilid.veilidchat.desktop @@ -0,0 +1,9 @@ +#!/usr/bin/env xdg-open +[Desktop Entry] +Name=VeilidChat +Comment=VeilidChat Private Messaging +Exec=veilidchat +Icon=com.veilid.veilidchat +Terminal=false +Type=Application +Categories=Network; diff --git a/flatpak/com.veilid.veilidchat.metainfo.xml b/flatpak/com.veilid.veilidchat.metainfo.xml new file mode 100644 index 0000000..6df997f --- /dev/null +++ b/flatpak/com.veilid.veilidchat.metainfo.xml @@ -0,0 +1,32 @@ + + + + com.veilid.veilidchat + VeilidChat + VeilidChat Private Messaging + Veilid Foundation Inc + https://veilid.com/chat + MIT + MPL-2.0 + + pointing + keyboard + touch + + +

TODO

+
+ com.veilid.veilidchat.desktop + + + TODO + + + + + + +
diff --git a/flatpak/com.veilid.veilidchat.png b/flatpak/com.veilid.veilidchat.png new file mode 100644 index 0000000..ebcb4cf Binary files /dev/null and b/flatpak/com.veilid.veilidchat.png differ diff --git a/flatpak/com.veilid.veilidchat.yml b/flatpak/com.veilid.veilidchat.yml new file mode 100644 index 0000000..3db5a02 --- /dev/null +++ b/flatpak/com.veilid.veilidchat.yml @@ -0,0 +1,36 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/flatpak/flatpak-builder/main/data/flatpak-manifest.schema.json + +--- +app-id: com.veilid.veilidchat +runtime: org.gnome.Platform +runtime-version: "46" +sdk: org.gnome.Sdk +command: veilidchat +separate-locales: false +finish-args: + - --share=ipc + - --socket=fallback-x11 + - --socket=wayland + - --device=dri + - --socket=pulseaudio + - --share=network + - --talk-name=org.freedesktop.secrets +modules: + - name: VeilidChat + buildsystem: simple + only-arches: + - x86_64 + #- aarch64 + build-commands: + - "./build-flatpak.sh" + sources: + - type: dir + path: ../build/linux/x64/release/ + - type: file + path: build-flatpak.sh + - type: file + path: com.veilid.veilidchat.png + - type: file + path: com.veilid.veilidchat.desktop + - type: file + path: com.veilid.veilidchat.metainfo.xml diff --git a/flutter_01.png b/flutter_01.png new file mode 100644 index 0000000..e69de29 diff --git a/flutter_02.png b/flutter_02.png new file mode 100644 index 0000000..e69de29 diff --git a/flutter_03.png b/flutter_03.png new file mode 100644 index 0000000..e69de29 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 9625e10..7c56964 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 11.0 + 12.0 diff --git a/ios/Podfile b/ios/Podfile index bd3431c..572283f 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '12.4' +platform :ios, '15.6' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' @@ -44,4 +44,4 @@ post_install do |installer| File.open(xcconfig_path, "w") { |file| file << xcconfig_mod } end end -end \ No newline at end of file +end diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 125a3a8..0f5fb0f 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,84 +1,28 @@ PODS: - camera_avfoundation (0.0.1): - Flutter + - file_saver (0.0.1): + - Flutter - Flutter (1.0.0) - - flutter_native_splash (0.0.1): + - flutter_native_splash (2.4.3): - Flutter - - FMDB (2.7.5): - - FMDB/standard (= 2.7.5) - - FMDB/standard (2.7.5) - - GoogleDataTransport (9.2.5): - - GoogleUtilities/Environment (~> 7.7) - - nanopb (< 2.30910.0, >= 2.30908.0) - - PromisesObjC (< 3.0, >= 1.2) - - GoogleMLKit/BarcodeScanning (4.0.0): - - GoogleMLKit/MLKitCore - - MLKitBarcodeScanning (~> 3.0.0) - - GoogleMLKit/MLKitCore (4.0.0): - - MLKitCommon (~> 9.0.0) - - GoogleToolboxForMac/DebugUtils (2.3.2): - - GoogleToolboxForMac/Defines (= 2.3.2) - - GoogleToolboxForMac/Defines (2.3.2) - - GoogleToolboxForMac/Logger (2.3.2): - - GoogleToolboxForMac/Defines (= 2.3.2) - - "GoogleToolboxForMac/NSData+zlib (2.3.2)": - - GoogleToolboxForMac/Defines (= 2.3.2) - - "GoogleToolboxForMac/NSDictionary+URLArguments (2.3.2)": - - GoogleToolboxForMac/DebugUtils (= 2.3.2) - - GoogleToolboxForMac/Defines (= 2.3.2) - - "GoogleToolboxForMac/NSString+URLArguments (= 2.3.2)" - - "GoogleToolboxForMac/NSString+URLArguments (2.3.2)" - - GoogleUtilities/Environment (7.11.5): - - PromisesObjC (< 3.0, >= 1.2) - - GoogleUtilities/Logger (7.11.5): - - GoogleUtilities/Environment - - GoogleUtilities/UserDefaults (7.11.5): - - GoogleUtilities/Logger - - GoogleUtilitiesComponents (1.1.0): - - GoogleUtilities/Logger - - GTMSessionFetcher/Core (2.3.0) - - MLImage (1.0.0-beta4) - - MLKitBarcodeScanning (3.0.0): - - MLKitCommon (~> 9.0) - - MLKitVision (~> 5.0) - - MLKitCommon (9.0.0): - - GoogleDataTransport (~> 9.0) - - GoogleToolboxForMac/Logger (~> 2.1) - - "GoogleToolboxForMac/NSData+zlib (~> 2.1)" - - "GoogleToolboxForMac/NSDictionary+URLArguments (~> 2.1)" - - GoogleUtilities/UserDefaults (~> 7.0) - - GoogleUtilitiesComponents (~> 1.0) - - GTMSessionFetcher/Core (< 3.0, >= 1.1) - - MLKitVision (5.0.0): - - GoogleToolboxForMac/Logger (~> 2.1) - - "GoogleToolboxForMac/NSData+zlib (~> 2.1)" - - GTMSessionFetcher/Core (< 3.0, >= 1.1) - - MLImage (= 1.0.0-beta4) - - MLKitCommon (~> 9.0) - - mobile_scanner (3.2.0): + - package_info_plus (0.4.5): - Flutter - - GoogleMLKit/BarcodeScanning (~> 4.0.0) - - nanopb (2.30909.0): - - nanopb/decode (= 2.30909.0) - - nanopb/encode (= 2.30909.0) - - nanopb/decode (2.30909.0) - - nanopb/encode (2.30909.0) - pasteboard (0.0.1): - Flutter - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - - PromisesObjC (2.3.1) + - printing (1.0.0): + - Flutter - share_plus (0.0.1): - Flutter - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS - - smart_auth (0.0.1): + - sqflite_darwin (0.0.4): - Flutter - - sqflite (0.0.3): - - Flutter - - FMDB (>= 2.7.5) + - FlutterMacOS - system_info_plus (0.0.1): - Flutter - url_launcher_ios (0.0.1): @@ -88,56 +32,43 @@ PODS: DEPENDENCIES: - camera_avfoundation (from `.symlinks/plugins/camera_avfoundation/ios`) + - file_saver (from `.symlinks/plugins/file_saver/ios`) - Flutter (from `Flutter`) - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) - - mobile_scanner (from `.symlinks/plugins/mobile_scanner/ios`) + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - pasteboard (from `.symlinks/plugins/pasteboard/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - printing (from `.symlinks/plugins/printing/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - - smart_auth (from `.symlinks/plugins/smart_auth/ios`) - - sqflite (from `.symlinks/plugins/sqflite/ios`) + - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) - system_info_plus (from `.symlinks/plugins/system_info_plus/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - veilid (from `.symlinks/plugins/veilid/ios`) -SPEC REPOS: - trunk: - - FMDB - - GoogleDataTransport - - GoogleMLKit - - GoogleToolboxForMac - - GoogleUtilities - - GoogleUtilitiesComponents - - GTMSessionFetcher - - MLImage - - MLKitBarcodeScanning - - MLKitCommon - - MLKitVision - - nanopb - - PromisesObjC - EXTERNAL SOURCES: camera_avfoundation: :path: ".symlinks/plugins/camera_avfoundation/ios" + file_saver: + :path: ".symlinks/plugins/file_saver/ios" Flutter: :path: Flutter flutter_native_splash: :path: ".symlinks/plugins/flutter_native_splash/ios" - mobile_scanner: - :path: ".symlinks/plugins/mobile_scanner/ios" + package_info_plus: + :path: ".symlinks/plugins/package_info_plus/ios" pasteboard: :path: ".symlinks/plugins/pasteboard/ios" path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/darwin" + printing: + :path: ".symlinks/plugins/printing/ios" share_plus: :path: ".symlinks/plugins/share_plus/ios" shared_preferences_foundation: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" - smart_auth: - :path: ".symlinks/plugins/smart_auth/ios" - sqflite: - :path: ".symlinks/plugins/sqflite/ios" + sqflite_darwin: + :path: ".symlinks/plugins/sqflite_darwin/darwin" system_info_plus: :path: ".symlinks/plugins/system_info_plus/ios" url_launcher_ios: @@ -146,33 +77,21 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/veilid/ios" SPEC CHECKSUMS: - camera_avfoundation: 3125e8cd1a4387f6f31c6c63abb8a55892a9eeeb - Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 - flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef - FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a - GoogleDataTransport: 54dee9d48d14580407f8f5fbf2f496e92437a2f2 - GoogleMLKit: 2bd0dc6253c4d4f227aad460f69215a504b2980e - GoogleToolboxForMac: 8bef7c7c5cf7291c687cf5354f39f9db6399ad34 - GoogleUtilities: 13e2c67ede716b8741c7989e26893d151b2b2084 - GoogleUtilitiesComponents: 679b2c881db3b615a2777504623df6122dd20afe - GTMSessionFetcher: 3a63d75eecd6aa32c2fc79f578064e1214dfdec2 - MLImage: 7bb7c4264164ade9bf64f679b40fb29c8f33ee9b - MLKitBarcodeScanning: 04e264482c5f3810cb89ebc134ef6b61e67db505 - MLKitCommon: c1b791c3e667091918d91bda4bba69a91011e390 - MLKitVision: 8baa5f46ee3352614169b85250574fde38c36f49 - mobile_scanner: 47056db0c04027ea5f41a716385542da28574662 - nanopb: b552cce312b6c8484180ef47159bc0f65a1f0431 - pasteboard: 982969ebaa7c78af3e6cc7761e8f5e77565d9ce0 - path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 - PromisesObjC: c50d2056b5253dadbd6c2bea79b0674bd5a52fa4 - share_plus: 599aa54e4ea31d4b4c0e9c911bcc26c55e791028 - shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 - smart_auth: 4bedbc118723912d0e45a07e8ab34039c19e04f2 - sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a - system_info_plus: 5393c8da281d899950d751713575fbf91c7709aa - url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4 - veilid: 51243c25047dbc1ebbfd87d713560260d802b845 + camera_avfoundation: be3be85408cd4126f250386828e9b1dfa40ab436 + file_saver: 6cdbcddd690cb02b0c1a0c225b37cd805c2bf8b6 + Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 + pasteboard: 49088aeb6119d51f976a421db60d8e1ab079b63c + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + printing: 54ff03f28fe9ba3aa93358afb80a8595a071dd07 + share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a + shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + system_info_plus: 555ce7047fbbf29154726db942ae785c29211740 + url_launcher_ios: 694010445543906933d732453a59da0a173ae33d + veilid: 3ce560a4f2b568a77a9fd5e23090f2fa97581019 -PODFILE CHECKSUM: 7f4cf2154d55730d953b184299e6feee7a274740 +PODFILE CHECKSUM: c8bf5b16c34712d5790b0b8d2472cc66ac0a8487 -COCOAPODS: 1.14.2 +COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 3100ff0..3a96d3e 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -155,7 +155,8 @@ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { - LastUpgradeCheck = 1430; + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 97C146ED1CF9000F007C117D = { @@ -324,6 +325,7 @@ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; @@ -334,6 +336,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -359,9 +362,11 @@ CLANG_ENABLE_MODULES = YES; DEVELOPMENT_TEAM = XP5LBLT7M7; ENABLE_BITCODE = NO; + ENABLE_USER_SCRIPT_SANDBOXING = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = VeilidChat; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -397,6 +402,7 @@ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; @@ -407,6 +413,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -452,6 +459,7 @@ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; @@ -462,6 +470,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -489,9 +498,11 @@ CLANG_ENABLE_MODULES = YES; DEVELOPMENT_TEAM = XP5LBLT7M7; ENABLE_BITCODE = NO; + ENABLE_USER_SCRIPT_SANDBOXING = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = VeilidChat; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -513,9 +524,11 @@ CLANG_ENABLE_MODULES = YES; DEVELOPMENT_TEAM = XP5LBLT7M7; ENABLE_BITCODE = NO; + ENABLE_USER_SCRIPT_SANDBOXING = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = VeilidChat; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index a6b826d..a44fb7f 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ @@ -59,6 +62,13 @@ ReferencedContainer = "container:Runner.xcodeproj"> + + + + { + AccountInfoCubit( + {required AccountRepository accountRepository, + required TypedKey superIdentityRecordKey}) + : _accountRepository = accountRepository, + super(accountRepository.getAccountInfo(superIdentityRecordKey)!) { + // Subscribe to streams + _accountRepositorySubscription = _accountRepository.stream.listen((change) { + switch (change) { + case AccountRepositoryChange.activeLocalAccount: + case AccountRepositoryChange.localAccounts: + case AccountRepositoryChange.userLogins: + final acctInfo = + accountRepository.getAccountInfo(superIdentityRecordKey); + if (acctInfo != null) { + emit(acctInfo); + } + } + }); + } + + @override + Future close() async { + await super.close(); + await _accountRepositorySubscription.cancel(); + } + + final AccountRepository _accountRepository; + late final StreamSubscription + _accountRepositorySubscription; +} diff --git a/lib/account_manager/cubits/account_record_cubit.dart b/lib/account_manager/cubits/account_record_cubit.dart new file mode 100644 index 0000000..16ab2e0 --- /dev/null +++ b/lib/account_manager/cubits/account_record_cubit.dart @@ -0,0 +1,77 @@ +import 'dart:async'; + +import 'package:async_tools/async_tools.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../proto/proto.dart' as proto; +import '../account_manager.dart'; + +typedef AccountRecordState = proto.Account; +typedef _SspUpdateState = ( + AccountSpec accountSpec, + Future Function() onSuccess +); + +/// The saved state of a VeilidChat Account on the DHT +/// Used to synchronize status, profile, and options for a specific account +/// across multiple clients. This DHT record is the 'source of truth' for an +/// account and is privately encrypted with an owned record from the 'userLogin' +/// tabledb-local storage, encrypted by the unlock code for the account. +class AccountRecordCubit extends DefaultDHTRecordCubit { + AccountRecordCubit( + {required LocalAccount localAccount, required UserLogin userLogin}) + : super( + decodeState: proto.Account.fromBuffer, + open: () => _open(localAccount, userLogin)); + + static Future _open( + LocalAccount localAccount, UserLogin userLogin) async { + // Record not yet open, do it + final pool = DHTRecordPool.instance; + final record = await pool.openRecordOwned( + userLogin.accountRecordInfo.accountRecord, + debugName: 'AccountRecordCubit::_open::AccountRecord', + parent: localAccount.superIdentity.currentInstance.recordKey); + + return record; + } + + @override + Future close() async { + await _sspUpdate.close(); + await super.close(); + } + + //////////////////////////////////////////////////////////////////////////// + // Public Interface + + void updateAccount( + AccountSpec accountSpec, Future Function() onChanged) { + _sspUpdate.updateState((accountSpec, onChanged), (state) async { + await _updateAccountAsync(state.$1, state.$2); + }); + } + + Future _updateAccountAsync( + AccountSpec accountSpec, Future Function() onChanged) async { + var changed = true; + await record?.eventualUpdateProtobuf(proto.Account.fromBuffer, (old) async { + if (old == null) { + return null; + } + + final oldAccountSpec = AccountSpec.fromProto(old); + changed = oldAccountSpec != accountSpec; + if (!changed) { + return null; + } + + return accountSpec.updateProto(old); + }); + if (changed) { + await onChanged(); + } + } + + final _sspUpdate = SingleStateProcessor<_SspUpdateState>(); +} diff --git a/lib/account_manager/cubits/active_local_account_cubit.dart b/lib/account_manager/cubits/active_local_account_cubit.dart new file mode 100644 index 0000000..8856848 --- /dev/null +++ b/lib/account_manager/cubits/active_local_account_cubit.dart @@ -0,0 +1,34 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../repository/account_repository.dart'; + +class ActiveLocalAccountCubit extends Cubit { + ActiveLocalAccountCubit(AccountRepository accountRepository) + : _accountRepository = accountRepository, + super(accountRepository.getActiveLocalAccount()) { + // Subscribe to streams + _accountRepositorySubscription = _accountRepository.stream.listen((change) { + switch (change) { + case AccountRepositoryChange.activeLocalAccount: + emit(_accountRepository.getActiveLocalAccount()); + // Ignore these + case AccountRepositoryChange.localAccounts: + case AccountRepositoryChange.userLogins: + break; + } + }); + } + + @override + Future close() async { + await super.close(); + await _accountRepositorySubscription.cancel(); + } + + final AccountRepository _accountRepository; + late final StreamSubscription + _accountRepositorySubscription; +} diff --git a/lib/account_manager/cubits/cubits.dart b/lib/account_manager/cubits/cubits.dart new file mode 100644 index 0000000..da268ae --- /dev/null +++ b/lib/account_manager/cubits/cubits.dart @@ -0,0 +1,7 @@ +export 'account_info_cubit.dart'; +export 'account_record_cubit.dart'; +export 'active_local_account_cubit.dart'; +export 'local_accounts_cubit.dart'; +export 'per_account_collection_bloc_map_cubit.dart'; +export 'per_account_collection_cubit.dart'; +export 'user_logins_cubit.dart'; diff --git a/lib/account_manager/cubits/local_accounts_cubit.dart b/lib/account_manager/cubits/local_accounts_cubit.dart new file mode 100644 index 0000000..3781297 --- /dev/null +++ b/lib/account_manager/cubits/local_accounts_cubit.dart @@ -0,0 +1,48 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../models/models.dart'; +import '../repository/account_repository.dart'; + +typedef LocalAccountsState = IList; + +class LocalAccountsCubit extends Cubit + with StateMapFollowable { + LocalAccountsCubit(AccountRepository accountRepository) + : _accountRepository = accountRepository, + super(accountRepository.getLocalAccounts()) { + // Subscribe to streams + _accountRepositorySubscription = _accountRepository.stream.listen((change) { + switch (change) { + case AccountRepositoryChange.localAccounts: + emit(_accountRepository.getLocalAccounts()); + // Ignore these + case AccountRepositoryChange.userLogins: + case AccountRepositoryChange.activeLocalAccount: + break; + } + }); + } + + @override + Future close() async { + await super.close(); + await _accountRepositorySubscription.cancel(); + } + + /// StateMapFollowable ///////////////////////// + @override + IMap getStateMap(LocalAccountsState state) { + final stateValue = state; + return IMap.fromIterable(stateValue, + keyMapper: (e) => e.superIdentity.recordKey, valueMapper: (e) => e); + } + + final AccountRepository _accountRepository; + late final StreamSubscription + _accountRepositorySubscription; +} diff --git a/lib/account_manager/cubits/per_account_collection_bloc_map_cubit.dart b/lib/account_manager/cubits/per_account_collection_bloc_map_cubit.dart new file mode 100644 index 0000000..e63b53e --- /dev/null +++ b/lib/account_manager/cubits/per_account_collection_bloc_map_cubit.dart @@ -0,0 +1,63 @@ +import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; +import 'package:provider/provider.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../account_manager/account_manager.dart'; + +typedef PerAccountCollectionBlocMapState + = BlocMapState; + +/// Map of the logged in user accounts to their PerAccountCollectionCubit +/// Ensures there is an single account record cubit for each logged in account +class PerAccountCollectionBlocMapCubit extends BlocMapCubit + with StateMapFollower { + PerAccountCollectionBlocMapCubit({ + required Locator locator, + required AccountRepository accountRepository, + }) : _locator = locator, + _accountRepository = accountRepository { + // Follow the local accounts cubit + follow(locator()); + } + + // Add account record cubit + void _addPerAccountCollectionCubit( + {required TypedKey superIdentityRecordKey}) => + add( + superIdentityRecordKey, + () => PerAccountCollectionCubit( + locator: _locator, + accountInfoCubit: AccountInfoCubit( + accountRepository: _accountRepository, + superIdentityRecordKey: superIdentityRecordKey))); + + /// StateFollower ///////////////////////// + + @override + void removeFromState(TypedKey key) => remove(key); + + @override + void updateState( + TypedKey key, LocalAccount? oldValue, LocalAccount newValue) { + // Don't replace unless this is a totally different account + // The sub-cubit's subscription will update our state later + if (oldValue != null) { + if (oldValue.superIdentity.recordKey != + newValue.superIdentity.recordKey) { + throw StateError( + 'should remove LocalAccount and make a new one, not change it, if ' + 'the superidentity record key has changed'); + } + // This never changes anything that should result in rebuildin the + // sub-cubit + return; + } + _addPerAccountCollectionCubit( + superIdentityRecordKey: newValue.superIdentity.recordKey); + } + + //////////////////////////////////////////////////////////////////////////// + final AccountRepository _accountRepository; + final Locator _locator; +} diff --git a/lib/account_manager/cubits/per_account_collection_cubit.dart b/lib/account_manager/cubits/per_account_collection_cubit.dart new file mode 100644 index 0000000..fc2d447 --- /dev/null +++ b/lib/account_manager/cubits/per_account_collection_cubit.dart @@ -0,0 +1,310 @@ +import 'dart:async'; + +import 'package:async_tools/async_tools.dart'; +import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:provider/provider.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../chat/chat.dart'; +import '../../chat_list/chat_list.dart'; +import '../../contact_invitation/contact_invitation.dart'; +import '../../contacts/contacts.dart'; +import '../../conversation/conversation.dart'; +import '../../notifications/notifications.dart'; +import '../../proto/proto.dart' as proto; +import '../account_manager.dart'; + +const _kAccountRecordSubscriptionListenKey = + 'accountRecordSubscriptionListenKey'; + +class PerAccountCollectionCubit extends Cubit { + PerAccountCollectionCubit({ + required Locator locator, + required this.accountInfoCubit, + }) : _locator = locator, + super(_initialState(accountInfoCubit)) { + // Async Init + _initWait.add(_init); + } + + @override + Future close() async { + await _initWait(); + + await _processor.close(); + await accountInfoCubit.close(); + await _accountRecordSubscription?.cancel(); + await serialFutureClose((this, _kAccountRecordSubscriptionListenKey)); + await accountRecordCubit?.close(); + + await activeSingleContactChatBlocMapCubitUpdater.close(); + await activeConversationsBlocMapCubitUpdater.close(); + await activeChatCubitUpdater.close(); + await waitingInvitationsBlocMapCubitUpdater.close(); + await chatListCubitUpdater.close(); + await contactListCubitUpdater.close(); + await contactInvitationListCubitUpdater.close(); + + await super.close(); + } + + Future _init(Completer _cancel) async { + // subscribe to accountInfo changes + _processor.follow(accountInfoCubit.stream, accountInfoCubit.state, + _followAccountInfoState); + } + + static PerAccountCollectionState _initialState( + AccountInfoCubit accountInfoCubit) => + PerAccountCollectionState( + accountInfo: accountInfoCubit.state, + avAccountRecordState: const AsyncValue.loading(), + contactInvitationListCubit: null, + accountInfoCubit: null, + accountRecordCubit: null, + contactListCubit: null, + waitingInvitationsBlocMapCubit: null, + activeChatCubit: null, + chatListCubit: null, + activeConversationsBlocMapCubit: null, + activeSingleContactChatBlocMapCubit: null); + + Future _followAccountInfoState(AccountInfo accountInfo) async { + // Get the next state + var nextState = state.copyWith(accountInfo: accountInfo); + + // Update AccountRecordCubit + if (accountInfo.userLogin == null) { + /////////////// Not logged in ///////////////// + + // Unsubscribe AccountRecordCubit + await _accountRecordSubscription?.cancel(); + _accountRecordSubscription = null; + + // Close AccountRecordCubit + await accountRecordCubit?.close(); + accountRecordCubit = null; + + // Update state to 'loading' + nextState = await _updateAccountRecordState(nextState, null); + emit(nextState); + } else { + ///////////////// Logged in /////////////////// + + // Create AccountRecordCubit + accountRecordCubit ??= AccountRecordCubit( + localAccount: accountInfo.localAccount, + userLogin: accountInfo.userLogin!); + + // Update state to value + nextState = + await _updateAccountRecordState(nextState, accountRecordCubit!.state); + emit(nextState); + + // Subscribe AccountRecordCubit + _accountRecordSubscription ??= + accountRecordCubit!.stream.listen((avAccountRecordState) { + serialFuture((this, _kAccountRecordSubscriptionListenKey), () async { + emit(await _updateAccountRecordState(state, avAccountRecordState)); + }); + }); + } + } + + Future _updateAccountRecordState( + PerAccountCollectionState prevState, + AsyncValue? avAccountRecordState) async { + // Get next state + final nextState = + prevState.copyWith(avAccountRecordState: avAccountRecordState); + + // Get bloc parameters + final accountInfo = nextState.accountInfo; + + // ContactInvitationListCubit + final contactInvitationListRecordPointer = nextState + .avAccountRecordState?.asData?.value.contactInvitationRecords + .toVeilid(); + + final contactInvitationListCubit = await contactInvitationListCubitUpdater + .update(accountInfo.userLogin == null || + contactInvitationListRecordPointer == null + ? null + : (accountInfo, contactInvitationListRecordPointer)); + + // ContactListCubit + final contactListRecordPointer = + nextState.avAccountRecordState?.asData?.value.contactList.toVeilid(); + + final contactListCubit = await contactListCubitUpdater.update( + accountInfo.userLogin == null || contactListRecordPointer == null + ? null + : (accountInfo, contactListRecordPointer)); + + // WaitingInvitationsBlocMapCubit + final waitingInvitationsBlocMapCubit = + await waitingInvitationsBlocMapCubitUpdater.update( + accountInfo.userLogin == null || + contactInvitationListCubit == null || + contactListCubit == null + ? null + : ( + accountInfo, + accountRecordCubit!, + contactInvitationListCubit, + contactListCubit, + _locator(), + )); + + // ActiveChatCubit + final activeChatCubit = await activeChatCubitUpdater + .update((accountInfo.userLogin == null) ? null : true); + + // ChatListCubit + final chatListRecordPointer = + nextState.avAccountRecordState?.asData?.value.chatList.toVeilid(); + + final chatListCubit = await chatListCubitUpdater.update( + accountInfo.userLogin == null || + chatListRecordPointer == null || + activeChatCubit == null + ? null + : (accountInfo, chatListRecordPointer, activeChatCubit)); + + // ActiveConversationsBlocMapCubit + final activeConversationsBlocMapCubit = + await activeConversationsBlocMapCubitUpdater.update( + accountRecordCubit == null || + chatListCubit == null || + contactListCubit == null + ? null + : ( + accountInfo, + accountRecordCubit!, + chatListCubit, + contactListCubit + )); + + // ActiveSingleContactChatBlocMapCubit + final activeSingleContactChatBlocMapCubit = + await activeSingleContactChatBlocMapCubitUpdater.update( + accountInfo.userLogin == null || + activeConversationsBlocMapCubit == null + ? null + : ( + accountInfo, + activeConversationsBlocMapCubit, + )); + + // Update available blocs in our state + return nextState.copyWith( + contactInvitationListCubit: contactInvitationListCubit, + accountInfoCubit: accountInfoCubit, + accountRecordCubit: accountRecordCubit, + contactListCubit: contactListCubit, + waitingInvitationsBlocMapCubit: waitingInvitationsBlocMapCubit, + activeChatCubit: activeChatCubit, + chatListCubit: chatListCubit, + activeConversationsBlocMapCubit: activeConversationsBlocMapCubit, + activeSingleContactChatBlocMapCubit: + activeSingleContactChatBlocMapCubit); + } + + T collectionLocator() { + if (T is AccountInfoCubit) { + return accountInfoCubit as T; + } + if (T is AccountRecordCubit) { + return accountRecordCubit! as T; + } + if (T is ContactInvitationListCubit) { + return contactInvitationListCubitUpdater.bloc! as T; + } + if (T is ContactListCubit) { + return contactListCubitUpdater.bloc! as T; + } + if (T is WaitingInvitationsBlocMapCubit) { + return waitingInvitationsBlocMapCubitUpdater.bloc! as T; + } + if (T is ActiveChatCubit) { + return activeChatCubitUpdater.bloc! as T; + } + if (T is ChatListCubit) { + return chatListCubitUpdater.bloc! as T; + } + if (T is ActiveConversationsBlocMapCubit) { + return activeConversationsBlocMapCubitUpdater.bloc! as T; + } + if (T is ActiveSingleContactChatBlocMapCubit) { + return activeSingleContactChatBlocMapCubitUpdater.bloc! as T; + } + return _locator(); + } + + final Locator _locator; + final _processor = SingleStateProcessor(); + final _initWait = WaitSet(); + + // Per-account cubits regardless of login state + final AccountInfoCubit accountInfoCubit; + + // Per logged-in account cubits + AccountRecordCubit? accountRecordCubit; + StreamSubscription>? + _accountRecordSubscription; + final contactInvitationListCubitUpdater = BlocUpdater< + ContactInvitationListCubit, (AccountInfo, OwnedDHTRecordPointer)>( + create: (params) => ContactInvitationListCubit( + accountInfo: params.$1, + contactInvitationListRecordPointer: params.$2, + )); + final contactListCubitUpdater = + BlocUpdater( + create: (params) => ContactListCubit( + accountInfo: params.$1, + contactListRecordPointer: params.$2, + )); + final waitingInvitationsBlocMapCubitUpdater = BlocUpdater< + WaitingInvitationsBlocMapCubit, + ( + AccountInfo, + AccountRecordCubit, + ContactInvitationListCubit, + ContactListCubit, + NotificationsCubit, + )>( + create: (params) => WaitingInvitationsBlocMapCubit( + accountInfo: params.$1, + accountRecordCubit: params.$2, + contactInvitationListCubit: params.$3, + contactListCubit: params.$4, + notificationsCubit: params.$5, + )); + final activeChatCubitUpdater = + BlocUpdater(create: (_) => ActiveChatCubit(null)); + final chatListCubitUpdater = BlocUpdater( + create: (params) => ChatListCubit( + accountInfo: params.$1, + chatListRecordPointer: params.$2, + activeChatCubit: params.$3)); + final activeConversationsBlocMapCubitUpdater = BlocUpdater< + ActiveConversationsBlocMapCubit, + (AccountInfo, AccountRecordCubit, ChatListCubit, ContactListCubit)>( + create: (params) => ActiveConversationsBlocMapCubit( + accountInfo: params.$1, + accountRecordCubit: params.$2, + chatListCubit: params.$3, + contactListCubit: params.$4)); + final activeSingleContactChatBlocMapCubitUpdater = BlocUpdater< + ActiveSingleContactChatBlocMapCubit, + ( + AccountInfo, + ActiveConversationsBlocMapCubit, + )>( + create: (params) => ActiveSingleContactChatBlocMapCubit( + accountInfo: params.$1, + activeConversationsBlocMapCubit: params.$2, + )); +} diff --git a/lib/account_manager/cubits/user_logins_cubit.dart b/lib/account_manager/cubits/user_logins_cubit.dart new file mode 100644 index 0000000..5623a34 --- /dev/null +++ b/lib/account_manager/cubits/user_logins_cubit.dart @@ -0,0 +1,38 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; + +import '../models/models.dart'; +import '../repository/account_repository.dart'; + +typedef UserLoginsState = IList; + +class UserLoginsCubit extends Cubit { + UserLoginsCubit(AccountRepository accountRepository) + : _accountRepository = accountRepository, + super(accountRepository.getUserLogins()) { + // Subscribe to streams + _accountRepositorySubscription = _accountRepository.stream.listen((change) { + switch (change) { + case AccountRepositoryChange.userLogins: + emit(_accountRepository.getUserLogins()); + // Ignore these + case AccountRepositoryChange.localAccounts: + case AccountRepositoryChange.activeLocalAccount: + break; + } + }); + } + + @override + Future close() async { + await super.close(); + await _accountRepositorySubscription.cancel(); + } + //////////////////////////////////////////////////////////////////////////// + + final AccountRepository _accountRepository; + late final StreamSubscription + _accountRepositorySubscription; +} diff --git a/lib/account_manager/models/account_info.dart b/lib/account_manager/models/account_info.dart new file mode 100644 index 0000000..8f57add --- /dev/null +++ b/lib/account_manager/models/account_info.dart @@ -0,0 +1,69 @@ +import 'dart:convert'; + +import 'package:equatable/equatable.dart'; +import 'package:meta/meta.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../account_manager.dart'; + +enum AccountInfoStatus { + accountInvalid, + accountLocked, + accountUnlocked, +} + +@immutable +class AccountInfo extends Equatable implements ToDebugMap { + const AccountInfo({ + required this.status, + required this.localAccount, + required this.userLogin, + }); + + final AccountInfoStatus status; + final LocalAccount localAccount; + final UserLogin? userLogin; + + @override + List get props => [ + status, + localAccount, + userLogin, + ]; + + @override + Map toDebugMap() => { + 'status': status, + 'localAccount': localAccount, + 'userLogin': userLogin, + }; +} + +extension AccountInfoExt on AccountInfo { + TypedKey get superIdentityRecordKey => localAccount.superIdentity.recordKey; + TypedKey get accountRecordKey => + userLogin!.accountRecordInfo.accountRecord.recordKey; + TypedKey get identityTypedPublicKey => + localAccount.superIdentity.currentInstance.typedPublicKey; + PublicKey get identityPublicKey => + localAccount.superIdentity.currentInstance.publicKey; + SecretKey get identitySecretKey => userLogin!.identitySecret.value; + KeyPair get identityWriter => + KeyPair(key: identityPublicKey, secret: identitySecretKey); + Future get identityCryptoSystem => + localAccount.superIdentity.currentInstance.cryptoSystem; + + Future makeConversationCrypto( + TypedKey remoteIdentityPublicKey) async { + final identitySecret = userLogin!.identitySecret; + final cs = await Veilid.instance.getCryptoSystem(identitySecret.kind); + final sharedSecret = await cs.generateSharedSecret( + remoteIdentityPublicKey.value, + identitySecret.value, + utf8.encode('VeilidChat Conversation')); + + final messagesCrypto = await VeilidCryptoPrivate.fromSharedSecret( + identitySecret.kind, sharedSecret); + return messagesCrypto; + } +} diff --git a/lib/account_manager/models/account_spec.dart b/lib/account_manager/models/account_spec.dart new file mode 100644 index 0000000..918e192 --- /dev/null +++ b/lib/account_manager/models/account_spec.dart @@ -0,0 +1,121 @@ +import 'package:equatable/equatable.dart'; +import 'package:meta/meta.dart'; +import 'package:protobuf/protobuf.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../proto/proto.dart' as proto; + +/// Profile and Account configurable fields +/// Some are publicly visible via the proto.Profile +/// Some are privately held as proto.Account configurations +@immutable +class AccountSpec extends Equatable { + const AccountSpec( + {required this.name, + required this.pronouns, + required this.about, + required this.availability, + required this.invisible, + required this.freeMessage, + required this.awayMessage, + required this.busyMessage, + required this.avatar, + required this.autoAway, + required this.autoAwayTimeout}); + + const AccountSpec.empty() + : name = '', + pronouns = '', + about = '', + availability = proto.Availability.AVAILABILITY_FREE, + invisible = false, + freeMessage = '', + awayMessage = '', + busyMessage = '', + avatar = null, + autoAway = false, + autoAwayTimeout = 15; + + AccountSpec.fromProto(proto.Account p) + : name = p.profile.name, + pronouns = p.profile.pronouns, + about = p.profile.about, + availability = p.profile.availability, + invisible = p.invisible, + freeMessage = p.freeMessage, + awayMessage = p.awayMessage, + busyMessage = p.busyMessage, + avatar = p.profile.hasAvatar() ? p.profile.avatar : null, + autoAway = p.autodetectAway, + autoAwayTimeout = p.autoAwayTimeoutMin; + + String get status { + late final String status; + switch (availability) { + case proto.Availability.AVAILABILITY_AWAY: + status = awayMessage; + case proto.Availability.AVAILABILITY_BUSY: + status = busyMessage; + case proto.Availability.AVAILABILITY_FREE: + status = freeMessage; + case proto.Availability.AVAILABILITY_UNSPECIFIED: + case proto.Availability.AVAILABILITY_OFFLINE: + status = ''; + } + return status; + } + + Future updateProto(proto.Account old) async { + final newProto = old.deepCopy() + ..profile.name = name + ..profile.pronouns = pronouns + ..profile.about = about + ..profile.availability = availability + ..profile.status = status + ..profile.timestamp = Veilid.instance.now().toInt64() + ..invisible = invisible + ..autodetectAway = autoAway + ..autoAwayTimeoutMin = autoAwayTimeout + ..freeMessage = freeMessage + ..awayMessage = awayMessage + ..busyMessage = busyMessage; + + final newAvatar = avatar; + if (newAvatar != null) { + newProto.profile.avatar = newAvatar; + } else { + newProto.profile.clearAvatar(); + } + + return newProto; + } + + //////////////////////////////////////////////////////////////////////////// + + final String name; + final String pronouns; + final String about; + final proto.Availability availability; + final bool invisible; + final String freeMessage; + final String awayMessage; + final String busyMessage; + final proto.DataReference? avatar; + final bool autoAway; + final int autoAwayTimeout; + + @override + List get props => [ + name, + pronouns, + about, + availability, + invisible, + freeMessage, + awayMessage, + busyMessage, + avatar, + autoAway, + autoAwayTimeout + ]; +} diff --git a/lib/account_manager/models/encryption_key_type.dart b/lib/account_manager/models/encryption_key_type.dart new file mode 100644 index 0000000..22897b4 --- /dev/null +++ b/lib/account_manager/models/encryption_key_type.dart @@ -0,0 +1,79 @@ +// Local account identitySecretKey is potentially encrypted with a key +// using the following mechanisms +// * None : no key, bytes are unencrypted +// * Pin : Code is a numeric pin (4-256 numeric digits) hashed with Argon2 +// * Password: Code is a UTF-8 string that is hashed with Argon2 + +import 'dart:typed_data'; + +import 'package:change_case/change_case.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../../proto/proto.dart' as proto; + +enum EncryptionKeyType { + none, + pin, + password; + + factory EncryptionKeyType.fromJson(dynamic j) => + EncryptionKeyType.values.byName((j as String).toCamelCase()); + + factory EncryptionKeyType.fromProto(proto.EncryptionKeyType p) { + // ignore: exhaustive_cases + switch (p) { + case proto.EncryptionKeyType.ENCRYPTION_KEY_TYPE_NONE: + return EncryptionKeyType.none; + case proto.EncryptionKeyType.ENCRYPTION_KEY_TYPE_PIN: + return EncryptionKeyType.pin; + case proto.EncryptionKeyType.ENCRYPTION_KEY_TYPE_PASSWORD: + return EncryptionKeyType.password; + } + throw StateError('unknown EncryptionKeyType enum value'); + } + String toJson() => name.toPascalCase(); + proto.EncryptionKeyType toProto() => switch (this) { + EncryptionKeyType.none => + proto.EncryptionKeyType.ENCRYPTION_KEY_TYPE_NONE, + EncryptionKeyType.pin => + proto.EncryptionKeyType.ENCRYPTION_KEY_TYPE_PIN, + EncryptionKeyType.password => + proto.EncryptionKeyType.ENCRYPTION_KEY_TYPE_PASSWORD, + }; + + Future encryptSecretToBytes( + {required SecretKey secret, + required CryptoKind cryptoKind, + String encryptionKey = ''}) async { + late final Uint8List secretBytes; + switch (this) { + case EncryptionKeyType.none: + secretBytes = secret.decode(); + case EncryptionKeyType.pin: + case EncryptionKeyType.password: + final cs = await Veilid.instance.getCryptoSystem(cryptoKind); + + secretBytes = + await cs.encryptAeadWithPassword(secret.decode(), encryptionKey); + } + return secretBytes; + } + + Future decryptSecretFromBytes( + {required Uint8List secretBytes, + required CryptoKind cryptoKind, + String encryptionKey = ''}) async { + late final SecretKey secret; + switch (this) { + case EncryptionKeyType.none: + secret = SecretKey.fromBytes(secretBytes); + case EncryptionKeyType.pin: + case EncryptionKeyType.password: + final cs = await Veilid.instance.getCryptoSystem(cryptoKind); + + secret = SecretKey.fromBytes( + await cs.decryptAeadWithPassword(secretBytes, encryptionKey)); + } + return secret; + } +} diff --git a/lib/account_manager/models/local_account/local_account.dart b/lib/account_manager/models/local_account/local_account.dart new file mode 100644 index 0000000..81cfb8c --- /dev/null +++ b/lib/account_manager/models/local_account/local_account.dart @@ -0,0 +1,53 @@ +import 'dart:typed_data'; + +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../models/encryption_key_type.dart'; + +part 'local_account.g.dart'; +part 'local_account.freezed.dart'; + +// Local Accounts are stored in a table locally and not backed by a DHT key +// and represents the accounts that have been added/imported +// on the current device. +// Stores a copy of the most recent SuperIdentity associated with the account +// and the identitySecretKey optionally encrypted by an unlock code +// This is the root of the account information tree for VeilidChat +// +@Freezed(toJson: true) +abstract class LocalAccount with _$LocalAccount { + @JsonSerializable() + const factory LocalAccount({ + // The super identity key record for the account, + // containing the publicKey in the currentIdentity + required SuperIdentity superIdentity, + + // The encrypted currentIdentity secret that goes with + // the identityPublicKey with appended salt + @Uint8ListJsonConverter() required Uint8List identitySecretBytes, + + // The kind of encryption input used on the account + required EncryptionKeyType encryptionKeyType, + + // If account is not hidden, password can be retrieved via + required bool biometricsEnabled, + + // Keep account hidden unless account password is entered + // (tries all hidden accounts with auth method (no biometrics)) + required bool hiddenAccount, + + // Display name for account until it is unlocked + required String name, + }) = _LocalAccount; + + factory LocalAccount.fromJson(dynamic json) { + try { + return _$LocalAccountFromJson(json as Map); + // Need to catch any errors here too + // ignore: avoid_catches_without_on_clauses + } catch (e, st) { + throw Exception('invalid local account: $e\n$st'); + } + } +} diff --git a/lib/account_manager/models/local_account/local_account.freezed.dart b/lib/account_manager/models/local_account/local_account.freezed.dart new file mode 100644 index 0000000..8d7aed1 --- /dev/null +++ b/lib/account_manager/models/local_account/local_account.freezed.dart @@ -0,0 +1,319 @@ +// dart format width=80 +// 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 'local_account.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$LocalAccount { +// The super identity key record for the account, +// containing the publicKey in the currentIdentity + SuperIdentity + get superIdentity; // The encrypted currentIdentity secret that goes with +// the identityPublicKey with appended salt + @Uint8ListJsonConverter() + Uint8List + get identitySecretBytes; // The kind of encryption input used on the account + EncryptionKeyType + get encryptionKeyType; // If account is not hidden, password can be retrieved via + bool + get biometricsEnabled; // Keep account hidden unless account password is entered +// (tries all hidden accounts with auth method (no biometrics)) + bool get hiddenAccount; // Display name for account until it is unlocked + String get name; + + /// Create a copy of LocalAccount + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $LocalAccountCopyWith get copyWith => + _$LocalAccountCopyWithImpl( + this as LocalAccount, _$identity); + + /// Serializes this LocalAccount to a JSON map. + Map toJson(); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is LocalAccount && + (identical(other.superIdentity, superIdentity) || + other.superIdentity == superIdentity) && + const DeepCollectionEquality() + .equals(other.identitySecretBytes, identitySecretBytes) && + (identical(other.encryptionKeyType, encryptionKeyType) || + other.encryptionKeyType == encryptionKeyType) && + (identical(other.biometricsEnabled, biometricsEnabled) || + other.biometricsEnabled == biometricsEnabled) && + (identical(other.hiddenAccount, hiddenAccount) || + other.hiddenAccount == hiddenAccount) && + (identical(other.name, name) || other.name == name)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + superIdentity, + const DeepCollectionEquality().hash(identitySecretBytes), + encryptionKeyType, + biometricsEnabled, + hiddenAccount, + name); + + @override + String toString() { + return 'LocalAccount(superIdentity: $superIdentity, identitySecretBytes: $identitySecretBytes, encryptionKeyType: $encryptionKeyType, biometricsEnabled: $biometricsEnabled, hiddenAccount: $hiddenAccount, name: $name)'; + } +} + +/// @nodoc +abstract mixin class $LocalAccountCopyWith<$Res> { + factory $LocalAccountCopyWith( + LocalAccount value, $Res Function(LocalAccount) _then) = + _$LocalAccountCopyWithImpl; + @useResult + $Res call( + {SuperIdentity superIdentity, + @Uint8ListJsonConverter() Uint8List identitySecretBytes, + EncryptionKeyType encryptionKeyType, + bool biometricsEnabled, + bool hiddenAccount, + String name}); + + $SuperIdentityCopyWith<$Res> get superIdentity; +} + +/// @nodoc +class _$LocalAccountCopyWithImpl<$Res> implements $LocalAccountCopyWith<$Res> { + _$LocalAccountCopyWithImpl(this._self, this._then); + + final LocalAccount _self; + final $Res Function(LocalAccount) _then; + + /// Create a copy of LocalAccount + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? superIdentity = null, + Object? identitySecretBytes = null, + Object? encryptionKeyType = null, + Object? biometricsEnabled = null, + Object? hiddenAccount = null, + Object? name = null, + }) { + return _then(_self.copyWith( + superIdentity: null == superIdentity + ? _self.superIdentity + : superIdentity // ignore: cast_nullable_to_non_nullable + as SuperIdentity, + identitySecretBytes: null == identitySecretBytes + ? _self.identitySecretBytes + : identitySecretBytes // ignore: cast_nullable_to_non_nullable + as Uint8List, + encryptionKeyType: null == encryptionKeyType + ? _self.encryptionKeyType + : encryptionKeyType // ignore: cast_nullable_to_non_nullable + as EncryptionKeyType, + biometricsEnabled: null == biometricsEnabled + ? _self.biometricsEnabled + : biometricsEnabled // ignore: cast_nullable_to_non_nullable + as bool, + hiddenAccount: null == hiddenAccount + ? _self.hiddenAccount + : hiddenAccount // ignore: cast_nullable_to_non_nullable + as bool, + name: null == name + ? _self.name + : name // ignore: cast_nullable_to_non_nullable + as String, + )); + } + + /// Create a copy of LocalAccount + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $SuperIdentityCopyWith<$Res> get superIdentity { + return $SuperIdentityCopyWith<$Res>(_self.superIdentity, (value) { + return _then(_self.copyWith(superIdentity: value)); + }); + } +} + +/// @nodoc + +@JsonSerializable() +class _LocalAccount implements LocalAccount { + const _LocalAccount( + {required this.superIdentity, + @Uint8ListJsonConverter() required this.identitySecretBytes, + required this.encryptionKeyType, + required this.biometricsEnabled, + required this.hiddenAccount, + required this.name}); + +// The super identity key record for the account, +// containing the publicKey in the currentIdentity + @override + final SuperIdentity superIdentity; +// The encrypted currentIdentity secret that goes with +// the identityPublicKey with appended salt + @override + @Uint8ListJsonConverter() + final Uint8List identitySecretBytes; +// The kind of encryption input used on the account + @override + final EncryptionKeyType encryptionKeyType; +// If account is not hidden, password can be retrieved via + @override + final bool biometricsEnabled; +// Keep account hidden unless account password is entered +// (tries all hidden accounts with auth method (no biometrics)) + @override + final bool hiddenAccount; +// Display name for account until it is unlocked + @override + final String name; + + /// Create a copy of LocalAccount + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$LocalAccountCopyWith<_LocalAccount> get copyWith => + __$LocalAccountCopyWithImpl<_LocalAccount>(this, _$identity); + + @override + Map toJson() { + return _$LocalAccountToJson( + this, + ); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _LocalAccount && + (identical(other.superIdentity, superIdentity) || + other.superIdentity == superIdentity) && + const DeepCollectionEquality() + .equals(other.identitySecretBytes, identitySecretBytes) && + (identical(other.encryptionKeyType, encryptionKeyType) || + other.encryptionKeyType == encryptionKeyType) && + (identical(other.biometricsEnabled, biometricsEnabled) || + other.biometricsEnabled == biometricsEnabled) && + (identical(other.hiddenAccount, hiddenAccount) || + other.hiddenAccount == hiddenAccount) && + (identical(other.name, name) || other.name == name)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + superIdentity, + const DeepCollectionEquality().hash(identitySecretBytes), + encryptionKeyType, + biometricsEnabled, + hiddenAccount, + name); + + @override + String toString() { + return 'LocalAccount(superIdentity: $superIdentity, identitySecretBytes: $identitySecretBytes, encryptionKeyType: $encryptionKeyType, biometricsEnabled: $biometricsEnabled, hiddenAccount: $hiddenAccount, name: $name)'; + } +} + +/// @nodoc +abstract mixin class _$LocalAccountCopyWith<$Res> + implements $LocalAccountCopyWith<$Res> { + factory _$LocalAccountCopyWith( + _LocalAccount value, $Res Function(_LocalAccount) _then) = + __$LocalAccountCopyWithImpl; + @override + @useResult + $Res call( + {SuperIdentity superIdentity, + @Uint8ListJsonConverter() Uint8List identitySecretBytes, + EncryptionKeyType encryptionKeyType, + bool biometricsEnabled, + bool hiddenAccount, + String name}); + + @override + $SuperIdentityCopyWith<$Res> get superIdentity; +} + +/// @nodoc +class __$LocalAccountCopyWithImpl<$Res> + implements _$LocalAccountCopyWith<$Res> { + __$LocalAccountCopyWithImpl(this._self, this._then); + + final _LocalAccount _self; + final $Res Function(_LocalAccount) _then; + + /// Create a copy of LocalAccount + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? superIdentity = null, + Object? identitySecretBytes = null, + Object? encryptionKeyType = null, + Object? biometricsEnabled = null, + Object? hiddenAccount = null, + Object? name = null, + }) { + return _then(_LocalAccount( + superIdentity: null == superIdentity + ? _self.superIdentity + : superIdentity // ignore: cast_nullable_to_non_nullable + as SuperIdentity, + identitySecretBytes: null == identitySecretBytes + ? _self.identitySecretBytes + : identitySecretBytes // ignore: cast_nullable_to_non_nullable + as Uint8List, + encryptionKeyType: null == encryptionKeyType + ? _self.encryptionKeyType + : encryptionKeyType // ignore: cast_nullable_to_non_nullable + as EncryptionKeyType, + biometricsEnabled: null == biometricsEnabled + ? _self.biometricsEnabled + : biometricsEnabled // ignore: cast_nullable_to_non_nullable + as bool, + hiddenAccount: null == hiddenAccount + ? _self.hiddenAccount + : hiddenAccount // ignore: cast_nullable_to_non_nullable + as bool, + name: null == name + ? _self.name + : name // ignore: cast_nullable_to_non_nullable + as String, + )); + } + + /// Create a copy of LocalAccount + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $SuperIdentityCopyWith<$Res> get superIdentity { + return $SuperIdentityCopyWith<$Res>(_self.superIdentity, (value) { + return _then(_self.copyWith(superIdentity: value)); + }); + } +} + +// dart format on diff --git a/lib/entities/local_account.g.dart b/lib/account_manager/models/local_account/local_account.g.dart similarity index 75% rename from lib/entities/local_account.g.dart rename to lib/account_manager/models/local_account/local_account.g.dart index 4e8a7b2..40d55e5 100644 --- a/lib/entities/local_account.g.dart +++ b/lib/account_manager/models/local_account/local_account.g.dart @@ -6,9 +6,9 @@ part of 'local_account.dart'; // JsonSerializableGenerator // ************************************************************************** -_$LocalAccountImpl _$$LocalAccountImplFromJson(Map json) => - _$LocalAccountImpl( - identityMaster: IdentityMaster.fromJson(json['identity_master']), +_LocalAccount _$LocalAccountFromJson(Map json) => + _LocalAccount( + superIdentity: SuperIdentity.fromJson(json['super_identity']), identitySecretBytes: const Uint8ListJsonConverter() .fromJson(json['identity_secret_bytes']), encryptionKeyType: @@ -18,9 +18,9 @@ _$LocalAccountImpl _$$LocalAccountImplFromJson(Map json) => name: json['name'] as String, ); -Map _$$LocalAccountImplToJson(_$LocalAccountImpl instance) => +Map _$LocalAccountToJson(_LocalAccount instance) => { - 'identity_master': instance.identityMaster.toJson(), + 'super_identity': instance.superIdentity.toJson(), 'identity_secret_bytes': const Uint8ListJsonConverter().toJson(instance.identitySecretBytes), 'encryption_key_type': instance.encryptionKeyType.toJson(), diff --git a/lib/account_manager/models/models.dart b/lib/account_manager/models/models.dart new file mode 100644 index 0000000..1a0c809 --- /dev/null +++ b/lib/account_manager/models/models.dart @@ -0,0 +1,6 @@ +export 'account_info.dart'; +export 'account_spec.dart'; +export 'encryption_key_type.dart'; +export 'local_account/local_account.dart'; +export 'per_account_collection_state/per_account_collection_state.dart'; +export 'user_login/user_login.dart'; diff --git a/lib/account_manager/models/per_account_collection_state/per_account_collection_state.dart b/lib/account_manager/models/per_account_collection_state/per_account_collection_state.dart new file mode 100644 index 0000000..24a394c --- /dev/null +++ b/lib/account_manager/models/per_account_collection_state/per_account_collection_state.dart @@ -0,0 +1,89 @@ +import 'package:async_tools/async_tools.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../../chat/chat.dart'; +import '../../../chat_list/chat_list.dart'; +import '../../../contact_invitation/contact_invitation.dart'; +import '../../../contacts/contacts.dart'; +import '../../../conversation/conversation.dart'; +import '../../../proto/proto.dart' show Account; +import '../../account_manager.dart'; + +part 'per_account_collection_state.freezed.dart'; + +@freezed +sealed class PerAccountCollectionState + with _$PerAccountCollectionState + implements ToDebugMap { + const factory PerAccountCollectionState({ + required AccountInfo accountInfo, + required AsyncValue? avAccountRecordState, + required AccountInfoCubit? accountInfoCubit, + required AccountRecordCubit? accountRecordCubit, + required ContactInvitationListCubit? contactInvitationListCubit, + required ContactListCubit? contactListCubit, + required WaitingInvitationsBlocMapCubit? waitingInvitationsBlocMapCubit, + required ActiveChatCubit? activeChatCubit, + required ChatListCubit? chatListCubit, + required ActiveConversationsBlocMapCubit? activeConversationsBlocMapCubit, + required ActiveSingleContactChatBlocMapCubit? + activeSingleContactChatBlocMapCubit, + }) = _PerAccountCollectionState; + const PerAccountCollectionState._(); + + @override + Map toDebugMap() => { + 'accountInfo': accountInfo, + 'avAccountRecordState': avAccountRecordState, + 'accountInfoCubit': accountInfoCubit, + 'accountRecordCubit': accountRecordCubit, + 'contactInvitationListCubit': contactInvitationListCubit, + 'contactListCubit': contactListCubit, + 'waitingInvitationsBlocMapCubit': waitingInvitationsBlocMapCubit, + 'activeChatCubit': activeChatCubit, + 'chatListCubit': chatListCubit, + 'activeConversationsBlocMapCubit': activeConversationsBlocMapCubit, + 'activeSingleContactChatBlocMapCubit': + activeSingleContactChatBlocMapCubit, + }; +} + +extension PerAccountCollectionStateExt on PerAccountCollectionState { + // Returns if the account is ready and logged in + bool get isReady => + avAccountRecordState != null && + avAccountRecordState!.isData && + accountInfoCubit != null && + accountRecordCubit != null && + contactInvitationListCubit != null && + contactListCubit != null && + waitingInvitationsBlocMapCubit != null && + activeChatCubit != null && + chatListCubit != null && + activeConversationsBlocMapCubit != null && + activeSingleContactChatBlocMapCubit != null; + + /// If we have a selected account and it is ready and not locked, + /// this will provide the unlocked account's cubits to the context + Widget provideReady({required Widget child}) { + if (isReady) { + return MultiBlocProvider(providers: [ + BlocProvider.value(value: accountInfoCubit!), + BlocProvider.value(value: accountRecordCubit!), + BlocProvider.value(value: contactInvitationListCubit!), + BlocProvider.value(value: contactListCubit!), + BlocProvider.value(value: waitingInvitationsBlocMapCubit!), + BlocProvider.value(value: activeChatCubit!), + BlocProvider.value(value: chatListCubit!), + BlocProvider.value(value: activeConversationsBlocMapCubit!), + BlocProvider.value(value: activeSingleContactChatBlocMapCubit!), + ], child: child); + } else { + // Otherwise we just provide the child + return child; + } + } +} diff --git a/lib/account_manager/models/per_account_collection_state/per_account_collection_state.freezed.dart b/lib/account_manager/models/per_account_collection_state/per_account_collection_state.freezed.dart new file mode 100644 index 0000000..ac1254b --- /dev/null +++ b/lib/account_manager/models/per_account_collection_state/per_account_collection_state.freezed.dart @@ -0,0 +1,436 @@ +// dart format width=80 +// 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 'per_account_collection_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$PerAccountCollectionState { + AccountInfo get accountInfo; + AsyncValue? get avAccountRecordState; + AccountInfoCubit? get accountInfoCubit; + AccountRecordCubit? get accountRecordCubit; + ContactInvitationListCubit? get contactInvitationListCubit; + ContactListCubit? get contactListCubit; + WaitingInvitationsBlocMapCubit? get waitingInvitationsBlocMapCubit; + ActiveChatCubit? get activeChatCubit; + ChatListCubit? get chatListCubit; + ActiveConversationsBlocMapCubit? get activeConversationsBlocMapCubit; + ActiveSingleContactChatBlocMapCubit? get activeSingleContactChatBlocMapCubit; + + /// Create a copy of PerAccountCollectionState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $PerAccountCollectionStateCopyWith get copyWith => + _$PerAccountCollectionStateCopyWithImpl( + this as PerAccountCollectionState, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is PerAccountCollectionState && + (identical(other.accountInfo, accountInfo) || + other.accountInfo == accountInfo) && + (identical(other.avAccountRecordState, avAccountRecordState) || + other.avAccountRecordState == avAccountRecordState) && + (identical(other.accountInfoCubit, accountInfoCubit) || + other.accountInfoCubit == accountInfoCubit) && + (identical(other.accountRecordCubit, accountRecordCubit) || + other.accountRecordCubit == accountRecordCubit) && + (identical(other.contactInvitationListCubit, + contactInvitationListCubit) || + other.contactInvitationListCubit == + contactInvitationListCubit) && + (identical(other.contactListCubit, contactListCubit) || + other.contactListCubit == contactListCubit) && + (identical(other.waitingInvitationsBlocMapCubit, + waitingInvitationsBlocMapCubit) || + other.waitingInvitationsBlocMapCubit == + waitingInvitationsBlocMapCubit) && + (identical(other.activeChatCubit, activeChatCubit) || + other.activeChatCubit == activeChatCubit) && + (identical(other.chatListCubit, chatListCubit) || + other.chatListCubit == chatListCubit) && + (identical(other.activeConversationsBlocMapCubit, + activeConversationsBlocMapCubit) || + other.activeConversationsBlocMapCubit == + activeConversationsBlocMapCubit) && + (identical(other.activeSingleContactChatBlocMapCubit, + activeSingleContactChatBlocMapCubit) || + other.activeSingleContactChatBlocMapCubit == + activeSingleContactChatBlocMapCubit)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + accountInfo, + avAccountRecordState, + accountInfoCubit, + accountRecordCubit, + contactInvitationListCubit, + contactListCubit, + waitingInvitationsBlocMapCubit, + activeChatCubit, + chatListCubit, + activeConversationsBlocMapCubit, + activeSingleContactChatBlocMapCubit); + + @override + String toString() { + return 'PerAccountCollectionState(accountInfo: $accountInfo, avAccountRecordState: $avAccountRecordState, accountInfoCubit: $accountInfoCubit, accountRecordCubit: $accountRecordCubit, contactInvitationListCubit: $contactInvitationListCubit, contactListCubit: $contactListCubit, waitingInvitationsBlocMapCubit: $waitingInvitationsBlocMapCubit, activeChatCubit: $activeChatCubit, chatListCubit: $chatListCubit, activeConversationsBlocMapCubit: $activeConversationsBlocMapCubit, activeSingleContactChatBlocMapCubit: $activeSingleContactChatBlocMapCubit)'; + } +} + +/// @nodoc +abstract mixin class $PerAccountCollectionStateCopyWith<$Res> { + factory $PerAccountCollectionStateCopyWith(PerAccountCollectionState value, + $Res Function(PerAccountCollectionState) _then) = + _$PerAccountCollectionStateCopyWithImpl; + @useResult + $Res call( + {AccountInfo accountInfo, + AsyncValue? avAccountRecordState, + AccountInfoCubit? accountInfoCubit, + AccountRecordCubit? accountRecordCubit, + ContactInvitationListCubit? contactInvitationListCubit, + ContactListCubit? contactListCubit, + WaitingInvitationsBlocMapCubit? waitingInvitationsBlocMapCubit, + ActiveChatCubit? activeChatCubit, + ChatListCubit? chatListCubit, + ActiveConversationsBlocMapCubit? activeConversationsBlocMapCubit, + ActiveSingleContactChatBlocMapCubit? + activeSingleContactChatBlocMapCubit}); + + $AsyncValueCopyWith? get avAccountRecordState; +} + +/// @nodoc +class _$PerAccountCollectionStateCopyWithImpl<$Res> + implements $PerAccountCollectionStateCopyWith<$Res> { + _$PerAccountCollectionStateCopyWithImpl(this._self, this._then); + + final PerAccountCollectionState _self; + final $Res Function(PerAccountCollectionState) _then; + + /// Create a copy of PerAccountCollectionState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? accountInfo = null, + Object? avAccountRecordState = freezed, + Object? accountInfoCubit = freezed, + Object? accountRecordCubit = freezed, + Object? contactInvitationListCubit = freezed, + Object? contactListCubit = freezed, + Object? waitingInvitationsBlocMapCubit = freezed, + Object? activeChatCubit = freezed, + Object? chatListCubit = freezed, + Object? activeConversationsBlocMapCubit = freezed, + Object? activeSingleContactChatBlocMapCubit = freezed, + }) { + return _then(_self.copyWith( + accountInfo: null == accountInfo + ? _self.accountInfo + : accountInfo // ignore: cast_nullable_to_non_nullable + as AccountInfo, + avAccountRecordState: freezed == avAccountRecordState + ? _self.avAccountRecordState + : avAccountRecordState // ignore: cast_nullable_to_non_nullable + as AsyncValue?, + accountInfoCubit: freezed == accountInfoCubit + ? _self.accountInfoCubit + : accountInfoCubit // ignore: cast_nullable_to_non_nullable + as AccountInfoCubit?, + accountRecordCubit: freezed == accountRecordCubit + ? _self.accountRecordCubit + : accountRecordCubit // ignore: cast_nullable_to_non_nullable + as AccountRecordCubit?, + contactInvitationListCubit: freezed == contactInvitationListCubit + ? _self.contactInvitationListCubit + : contactInvitationListCubit // ignore: cast_nullable_to_non_nullable + as ContactInvitationListCubit?, + contactListCubit: freezed == contactListCubit + ? _self.contactListCubit + : contactListCubit // ignore: cast_nullable_to_non_nullable + as ContactListCubit?, + waitingInvitationsBlocMapCubit: freezed == waitingInvitationsBlocMapCubit + ? _self.waitingInvitationsBlocMapCubit + : waitingInvitationsBlocMapCubit // ignore: cast_nullable_to_non_nullable + as WaitingInvitationsBlocMapCubit?, + activeChatCubit: freezed == activeChatCubit + ? _self.activeChatCubit + : activeChatCubit // ignore: cast_nullable_to_non_nullable + as ActiveChatCubit?, + chatListCubit: freezed == chatListCubit + ? _self.chatListCubit + : chatListCubit // ignore: cast_nullable_to_non_nullable + as ChatListCubit?, + activeConversationsBlocMapCubit: freezed == + activeConversationsBlocMapCubit + ? _self.activeConversationsBlocMapCubit + : activeConversationsBlocMapCubit // ignore: cast_nullable_to_non_nullable + as ActiveConversationsBlocMapCubit?, + activeSingleContactChatBlocMapCubit: freezed == + activeSingleContactChatBlocMapCubit + ? _self.activeSingleContactChatBlocMapCubit + : activeSingleContactChatBlocMapCubit // ignore: cast_nullable_to_non_nullable + as ActiveSingleContactChatBlocMapCubit?, + )); + } + + /// Create a copy of PerAccountCollectionState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $AsyncValueCopyWith? get avAccountRecordState { + if (_self.avAccountRecordState == null) { + return null; + } + + return $AsyncValueCopyWith(_self.avAccountRecordState!, + (value) { + return _then(_self.copyWith(avAccountRecordState: value)); + }); + } +} + +/// @nodoc + +class _PerAccountCollectionState extends PerAccountCollectionState { + const _PerAccountCollectionState( + {required this.accountInfo, + required this.avAccountRecordState, + required this.accountInfoCubit, + required this.accountRecordCubit, + required this.contactInvitationListCubit, + required this.contactListCubit, + required this.waitingInvitationsBlocMapCubit, + required this.activeChatCubit, + required this.chatListCubit, + required this.activeConversationsBlocMapCubit, + required this.activeSingleContactChatBlocMapCubit}) + : super._(); + + @override + final AccountInfo accountInfo; + @override + final AsyncValue? avAccountRecordState; + @override + final AccountInfoCubit? accountInfoCubit; + @override + final AccountRecordCubit? accountRecordCubit; + @override + final ContactInvitationListCubit? contactInvitationListCubit; + @override + final ContactListCubit? contactListCubit; + @override + final WaitingInvitationsBlocMapCubit? waitingInvitationsBlocMapCubit; + @override + final ActiveChatCubit? activeChatCubit; + @override + final ChatListCubit? chatListCubit; + @override + final ActiveConversationsBlocMapCubit? activeConversationsBlocMapCubit; + @override + final ActiveSingleContactChatBlocMapCubit? + activeSingleContactChatBlocMapCubit; + + /// Create a copy of PerAccountCollectionState + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$PerAccountCollectionStateCopyWith<_PerAccountCollectionState> + get copyWith => + __$PerAccountCollectionStateCopyWithImpl<_PerAccountCollectionState>( + this, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _PerAccountCollectionState && + (identical(other.accountInfo, accountInfo) || + other.accountInfo == accountInfo) && + (identical(other.avAccountRecordState, avAccountRecordState) || + other.avAccountRecordState == avAccountRecordState) && + (identical(other.accountInfoCubit, accountInfoCubit) || + other.accountInfoCubit == accountInfoCubit) && + (identical(other.accountRecordCubit, accountRecordCubit) || + other.accountRecordCubit == accountRecordCubit) && + (identical(other.contactInvitationListCubit, + contactInvitationListCubit) || + other.contactInvitationListCubit == + contactInvitationListCubit) && + (identical(other.contactListCubit, contactListCubit) || + other.contactListCubit == contactListCubit) && + (identical(other.waitingInvitationsBlocMapCubit, + waitingInvitationsBlocMapCubit) || + other.waitingInvitationsBlocMapCubit == + waitingInvitationsBlocMapCubit) && + (identical(other.activeChatCubit, activeChatCubit) || + other.activeChatCubit == activeChatCubit) && + (identical(other.chatListCubit, chatListCubit) || + other.chatListCubit == chatListCubit) && + (identical(other.activeConversationsBlocMapCubit, + activeConversationsBlocMapCubit) || + other.activeConversationsBlocMapCubit == + activeConversationsBlocMapCubit) && + (identical(other.activeSingleContactChatBlocMapCubit, + activeSingleContactChatBlocMapCubit) || + other.activeSingleContactChatBlocMapCubit == + activeSingleContactChatBlocMapCubit)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + accountInfo, + avAccountRecordState, + accountInfoCubit, + accountRecordCubit, + contactInvitationListCubit, + contactListCubit, + waitingInvitationsBlocMapCubit, + activeChatCubit, + chatListCubit, + activeConversationsBlocMapCubit, + activeSingleContactChatBlocMapCubit); + + @override + String toString() { + return 'PerAccountCollectionState(accountInfo: $accountInfo, avAccountRecordState: $avAccountRecordState, accountInfoCubit: $accountInfoCubit, accountRecordCubit: $accountRecordCubit, contactInvitationListCubit: $contactInvitationListCubit, contactListCubit: $contactListCubit, waitingInvitationsBlocMapCubit: $waitingInvitationsBlocMapCubit, activeChatCubit: $activeChatCubit, chatListCubit: $chatListCubit, activeConversationsBlocMapCubit: $activeConversationsBlocMapCubit, activeSingleContactChatBlocMapCubit: $activeSingleContactChatBlocMapCubit)'; + } +} + +/// @nodoc +abstract mixin class _$PerAccountCollectionStateCopyWith<$Res> + implements $PerAccountCollectionStateCopyWith<$Res> { + factory _$PerAccountCollectionStateCopyWith(_PerAccountCollectionState value, + $Res Function(_PerAccountCollectionState) _then) = + __$PerAccountCollectionStateCopyWithImpl; + @override + @useResult + $Res call( + {AccountInfo accountInfo, + AsyncValue? avAccountRecordState, + AccountInfoCubit? accountInfoCubit, + AccountRecordCubit? accountRecordCubit, + ContactInvitationListCubit? contactInvitationListCubit, + ContactListCubit? contactListCubit, + WaitingInvitationsBlocMapCubit? waitingInvitationsBlocMapCubit, + ActiveChatCubit? activeChatCubit, + ChatListCubit? chatListCubit, + ActiveConversationsBlocMapCubit? activeConversationsBlocMapCubit, + ActiveSingleContactChatBlocMapCubit? + activeSingleContactChatBlocMapCubit}); + + @override + $AsyncValueCopyWith? get avAccountRecordState; +} + +/// @nodoc +class __$PerAccountCollectionStateCopyWithImpl<$Res> + implements _$PerAccountCollectionStateCopyWith<$Res> { + __$PerAccountCollectionStateCopyWithImpl(this._self, this._then); + + final _PerAccountCollectionState _self; + final $Res Function(_PerAccountCollectionState) _then; + + /// Create a copy of PerAccountCollectionState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? accountInfo = null, + Object? avAccountRecordState = freezed, + Object? accountInfoCubit = freezed, + Object? accountRecordCubit = freezed, + Object? contactInvitationListCubit = freezed, + Object? contactListCubit = freezed, + Object? waitingInvitationsBlocMapCubit = freezed, + Object? activeChatCubit = freezed, + Object? chatListCubit = freezed, + Object? activeConversationsBlocMapCubit = freezed, + Object? activeSingleContactChatBlocMapCubit = freezed, + }) { + return _then(_PerAccountCollectionState( + accountInfo: null == accountInfo + ? _self.accountInfo + : accountInfo // ignore: cast_nullable_to_non_nullable + as AccountInfo, + avAccountRecordState: freezed == avAccountRecordState + ? _self.avAccountRecordState + : avAccountRecordState // ignore: cast_nullable_to_non_nullable + as AsyncValue?, + accountInfoCubit: freezed == accountInfoCubit + ? _self.accountInfoCubit + : accountInfoCubit // ignore: cast_nullable_to_non_nullable + as AccountInfoCubit?, + accountRecordCubit: freezed == accountRecordCubit + ? _self.accountRecordCubit + : accountRecordCubit // ignore: cast_nullable_to_non_nullable + as AccountRecordCubit?, + contactInvitationListCubit: freezed == contactInvitationListCubit + ? _self.contactInvitationListCubit + : contactInvitationListCubit // ignore: cast_nullable_to_non_nullable + as ContactInvitationListCubit?, + contactListCubit: freezed == contactListCubit + ? _self.contactListCubit + : contactListCubit // ignore: cast_nullable_to_non_nullable + as ContactListCubit?, + waitingInvitationsBlocMapCubit: freezed == waitingInvitationsBlocMapCubit + ? _self.waitingInvitationsBlocMapCubit + : waitingInvitationsBlocMapCubit // ignore: cast_nullable_to_non_nullable + as WaitingInvitationsBlocMapCubit?, + activeChatCubit: freezed == activeChatCubit + ? _self.activeChatCubit + : activeChatCubit // ignore: cast_nullable_to_non_nullable + as ActiveChatCubit?, + chatListCubit: freezed == chatListCubit + ? _self.chatListCubit + : chatListCubit // ignore: cast_nullable_to_non_nullable + as ChatListCubit?, + activeConversationsBlocMapCubit: freezed == + activeConversationsBlocMapCubit + ? _self.activeConversationsBlocMapCubit + : activeConversationsBlocMapCubit // ignore: cast_nullable_to_non_nullable + as ActiveConversationsBlocMapCubit?, + activeSingleContactChatBlocMapCubit: freezed == + activeSingleContactChatBlocMapCubit + ? _self.activeSingleContactChatBlocMapCubit + : activeSingleContactChatBlocMapCubit // ignore: cast_nullable_to_non_nullable + as ActiveSingleContactChatBlocMapCubit?, + )); + } + + /// Create a copy of PerAccountCollectionState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $AsyncValueCopyWith? get avAccountRecordState { + if (_self.avAccountRecordState == null) { + return null; + } + + return $AsyncValueCopyWith(_self.avAccountRecordState!, + (value) { + return _then(_self.copyWith(avAccountRecordState: value)); + }); + } +} + +// dart format on diff --git a/lib/account_manager/models/user_login/user_login.dart b/lib/account_manager/models/user_login/user_login.dart new file mode 100644 index 0000000..4e2f680 --- /dev/null +++ b/lib/account_manager/models/user_login/user_login.dart @@ -0,0 +1,36 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'package:veilid_support/veilid_support.dart'; + +part 'user_login.freezed.dart'; +part 'user_login.g.dart'; + +// Represents a currently logged in account +// User logins are stored in the user_logins tablestore table +// indexed by the accountSuperIdentityRecordKey +@Freezed(toJson: true) +sealed class UserLogin with _$UserLogin { + @JsonSerializable() + const factory UserLogin({ + // SuperIdentity record key for the user + // used to index the local accounts table + required TypedKey superIdentityRecordKey, + // The identity secret as unlocked from the local accounts table + required TypedSecret identitySecret, + // The account record key, owner key and secret pulled from the identity + required AccountRecordInfo accountRecordInfo, + + // The time this login was most recently used + required Timestamp lastActive, + }) = _UserLogin; + + factory UserLogin.fromJson(dynamic json) { + try { + return _$UserLoginFromJson(json as Map); + // Need to catch any errors here too + // ignore: avoid_catches_without_on_clauses + } catch (e, st) { + throw Exception('invalid user login: $e\n$st'); + } + } +} diff --git a/lib/account_manager/models/user_login/user_login.freezed.dart b/lib/account_manager/models/user_login/user_login.freezed.dart new file mode 100644 index 0000000..914afb8 --- /dev/null +++ b/lib/account_manager/models/user_login/user_login.freezed.dart @@ -0,0 +1,257 @@ +// dart format width=80 +// 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 'user_login.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$UserLogin { +// SuperIdentity record key for the user +// used to index the local accounts table + TypedKey + get superIdentityRecordKey; // The identity secret as unlocked from the local accounts table + TypedSecret + get identitySecret; // The account record key, owner key and secret pulled from the identity + AccountRecordInfo + get accountRecordInfo; // The time this login was most recently used + Timestamp get lastActive; + + /// Create a copy of UserLogin + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $UserLoginCopyWith get copyWith => + _$UserLoginCopyWithImpl(this as UserLogin, _$identity); + + /// Serializes this UserLogin to a JSON map. + Map toJson(); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is UserLogin && + (identical(other.superIdentityRecordKey, superIdentityRecordKey) || + other.superIdentityRecordKey == superIdentityRecordKey) && + (identical(other.identitySecret, identitySecret) || + other.identitySecret == identitySecret) && + (identical(other.accountRecordInfo, accountRecordInfo) || + other.accountRecordInfo == accountRecordInfo) && + (identical(other.lastActive, lastActive) || + other.lastActive == lastActive)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, superIdentityRecordKey, + identitySecret, accountRecordInfo, lastActive); + + @override + String toString() { + return 'UserLogin(superIdentityRecordKey: $superIdentityRecordKey, identitySecret: $identitySecret, accountRecordInfo: $accountRecordInfo, lastActive: $lastActive)'; + } +} + +/// @nodoc +abstract mixin class $UserLoginCopyWith<$Res> { + factory $UserLoginCopyWith(UserLogin value, $Res Function(UserLogin) _then) = + _$UserLoginCopyWithImpl; + @useResult + $Res call( + {TypedKey superIdentityRecordKey, + TypedSecret identitySecret, + AccountRecordInfo accountRecordInfo, + Timestamp lastActive}); + + $AccountRecordInfoCopyWith<$Res> get accountRecordInfo; +} + +/// @nodoc +class _$UserLoginCopyWithImpl<$Res> implements $UserLoginCopyWith<$Res> { + _$UserLoginCopyWithImpl(this._self, this._then); + + final UserLogin _self; + final $Res Function(UserLogin) _then; + + /// Create a copy of UserLogin + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? superIdentityRecordKey = null, + Object? identitySecret = null, + Object? accountRecordInfo = null, + Object? lastActive = null, + }) { + return _then(_self.copyWith( + superIdentityRecordKey: null == superIdentityRecordKey + ? _self.superIdentityRecordKey + : superIdentityRecordKey // ignore: cast_nullable_to_non_nullable + as TypedKey, + identitySecret: null == identitySecret + ? _self.identitySecret + : identitySecret // ignore: cast_nullable_to_non_nullable + as TypedSecret, + accountRecordInfo: null == accountRecordInfo + ? _self.accountRecordInfo + : accountRecordInfo // ignore: cast_nullable_to_non_nullable + as AccountRecordInfo, + lastActive: null == lastActive + ? _self.lastActive + : lastActive // ignore: cast_nullable_to_non_nullable + as Timestamp, + )); + } + + /// Create a copy of UserLogin + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $AccountRecordInfoCopyWith<$Res> get accountRecordInfo { + return $AccountRecordInfoCopyWith<$Res>(_self.accountRecordInfo, (value) { + return _then(_self.copyWith(accountRecordInfo: value)); + }); + } +} + +/// @nodoc + +@JsonSerializable() +class _UserLogin implements UserLogin { + const _UserLogin( + {required this.superIdentityRecordKey, + required this.identitySecret, + required this.accountRecordInfo, + required this.lastActive}); + +// SuperIdentity record key for the user +// used to index the local accounts table + @override + final TypedKey superIdentityRecordKey; +// The identity secret as unlocked from the local accounts table + @override + final TypedSecret identitySecret; +// The account record key, owner key and secret pulled from the identity + @override + final AccountRecordInfo accountRecordInfo; +// The time this login was most recently used + @override + final Timestamp lastActive; + + /// Create a copy of UserLogin + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$UserLoginCopyWith<_UserLogin> get copyWith => + __$UserLoginCopyWithImpl<_UserLogin>(this, _$identity); + + @override + Map toJson() { + return _$UserLoginToJson( + this, + ); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _UserLogin && + (identical(other.superIdentityRecordKey, superIdentityRecordKey) || + other.superIdentityRecordKey == superIdentityRecordKey) && + (identical(other.identitySecret, identitySecret) || + other.identitySecret == identitySecret) && + (identical(other.accountRecordInfo, accountRecordInfo) || + other.accountRecordInfo == accountRecordInfo) && + (identical(other.lastActive, lastActive) || + other.lastActive == lastActive)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, superIdentityRecordKey, + identitySecret, accountRecordInfo, lastActive); + + @override + String toString() { + return 'UserLogin(superIdentityRecordKey: $superIdentityRecordKey, identitySecret: $identitySecret, accountRecordInfo: $accountRecordInfo, lastActive: $lastActive)'; + } +} + +/// @nodoc +abstract mixin class _$UserLoginCopyWith<$Res> + implements $UserLoginCopyWith<$Res> { + factory _$UserLoginCopyWith( + _UserLogin value, $Res Function(_UserLogin) _then) = + __$UserLoginCopyWithImpl; + @override + @useResult + $Res call( + {TypedKey superIdentityRecordKey, + TypedSecret identitySecret, + AccountRecordInfo accountRecordInfo, + Timestamp lastActive}); + + @override + $AccountRecordInfoCopyWith<$Res> get accountRecordInfo; +} + +/// @nodoc +class __$UserLoginCopyWithImpl<$Res> implements _$UserLoginCopyWith<$Res> { + __$UserLoginCopyWithImpl(this._self, this._then); + + final _UserLogin _self; + final $Res Function(_UserLogin) _then; + + /// Create a copy of UserLogin + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? superIdentityRecordKey = null, + Object? identitySecret = null, + Object? accountRecordInfo = null, + Object? lastActive = null, + }) { + return _then(_UserLogin( + superIdentityRecordKey: null == superIdentityRecordKey + ? _self.superIdentityRecordKey + : superIdentityRecordKey // ignore: cast_nullable_to_non_nullable + as TypedKey, + identitySecret: null == identitySecret + ? _self.identitySecret + : identitySecret // ignore: cast_nullable_to_non_nullable + as TypedSecret, + accountRecordInfo: null == accountRecordInfo + ? _self.accountRecordInfo + : accountRecordInfo // ignore: cast_nullable_to_non_nullable + as AccountRecordInfo, + lastActive: null == lastActive + ? _self.lastActive + : lastActive // ignore: cast_nullable_to_non_nullable + as Timestamp, + )); + } + + /// Create a copy of UserLogin + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $AccountRecordInfoCopyWith<$Res> get accountRecordInfo { + return $AccountRecordInfoCopyWith<$Res>(_self.accountRecordInfo, (value) { + return _then(_self.copyWith(accountRecordInfo: value)); + }); + } +} + +// dart format on diff --git a/lib/account_manager/models/user_login/user_login.g.dart b/lib/account_manager/models/user_login/user_login.g.dart new file mode 100644 index 0000000..fa5314b --- /dev/null +++ b/lib/account_manager/models/user_login/user_login.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'user_login.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_UserLogin _$UserLoginFromJson(Map json) => _UserLogin( + superIdentityRecordKey: Typed.fromJson( + json['super_identity_record_key']), + identitySecret: + Typed.fromJson(json['identity_secret']), + accountRecordInfo: + AccountRecordInfo.fromJson(json['account_record_info']), + lastActive: Timestamp.fromJson(json['last_active']), + ); + +Map _$UserLoginToJson(_UserLogin instance) => + { + 'super_identity_record_key': instance.superIdentityRecordKey.toJson(), + 'identity_secret': instance.identitySecret.toJson(), + 'account_record_info': instance.accountRecordInfo.toJson(), + 'last_active': instance.lastActive.toJson(), + }; diff --git a/lib/account_manager/repository/account_repository.dart b/lib/account_manager/repository/account_repository.dart new file mode 100644 index 0000000..c5058ba --- /dev/null +++ b/lib/account_manager/repository/account_repository.dart @@ -0,0 +1,450 @@ +import 'dart:async'; + +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../../proto/proto.dart' as proto; +import '../../tools/tools.dart'; +import '../models/models.dart'; + +const String veilidChatApplicationId = 'com.veilid.veilidchat'; + +enum AccountRepositoryChange { localAccounts, userLogins, activeLocalAccount } + +class AccountRepository { + AccountRepository._() + : _localAccounts = _initLocalAccounts(), + _userLogins = _initUserLogins(), + _activeLocalAccount = _initActiveAccount(), + _streamController = + StreamController.broadcast(); + + static TableDBValue> _initLocalAccounts() => TableDBValue( + tableName: 'local_account_manager', + tableKeyName: 'local_accounts', + valueFromJson: (obj) => obj != null + ? IList.fromJson( + obj, genericFromJson(LocalAccount.fromJson)) + : IList(), + valueToJson: (val) => val?.toJson((la) => la.toJson()), + makeInitialValue: IList.empty); + + static TableDBValue> _initUserLogins() => TableDBValue( + tableName: 'local_account_manager', + tableKeyName: 'user_logins', + valueFromJson: (obj) => obj != null + ? IList.fromJson(obj, genericFromJson(UserLogin.fromJson)) + : IList(), + valueToJson: (val) => val?.toJson((la) => la.toJson()), + makeInitialValue: IList.empty); + + static TableDBValue _initActiveAccount() => TableDBValue( + tableName: 'local_account_manager', + tableKeyName: 'active_local_account', + valueFromJson: (obj) => obj == null ? null : TypedKey.fromJson(obj), + valueToJson: (val) => val?.toJson(), + makeInitialValue: () => null); + + Future init() async { + await _localAccounts.get(); + await _userLogins.get(); + await _activeLocalAccount.get(); + } + + Future close() async { + await _localAccounts.close(); + await _userLogins.close(); + await _activeLocalAccount.close(); + } + + ////////////////////////////////////////////////////////////// + /// Public Interface + /// + Stream get stream => _streamController.stream; + + IList getLocalAccounts() => _localAccounts.value; + TypedKey? getActiveLocalAccount() => _activeLocalAccount.value; + IList getUserLogins() => _userLogins.value; + UserLogin? getActiveUserLogin() { + final activeLocalAccount = _activeLocalAccount.value; + return activeLocalAccount == null + ? null + : fetchUserLogin(activeLocalAccount); + } + + LocalAccount? fetchLocalAccount(TypedKey accountSuperIdentityRecordKey) { + final localAccounts = _localAccounts.value; + final idx = localAccounts.indexWhere( + (e) => e.superIdentity.recordKey == accountSuperIdentityRecordKey); + if (idx == -1) { + return null; + } + return localAccounts[idx]; + } + + UserLogin? fetchUserLogin(TypedKey superIdentityRecordKey) { + final userLogins = _userLogins.value; + final idx = userLogins + .indexWhere((e) => e.superIdentityRecordKey == superIdentityRecordKey); + if (idx == -1) { + return null; + } + return userLogins[idx]; + } + + AccountInfo? getAccountInfo(TypedKey superIdentityRecordKey) { + // Get which local account we want to fetch the profile for + final localAccount = fetchLocalAccount(superIdentityRecordKey); + if (localAccount == null) { + return null; + } + + // See if we've logged into this account or if it is locked + final userLogin = fetchUserLogin(superIdentityRecordKey); + if (userLogin == null) { + // Account was locked + return AccountInfo( + status: AccountInfoStatus.accountLocked, + localAccount: localAccount, + userLogin: null, + ); + } + + // Got account, decrypted and decoded + return AccountInfo( + status: AccountInfoStatus.accountUnlocked, + localAccount: localAccount, + userLogin: userLogin, + ); + } + + /// Reorder accounts + Future reorderAccount(int oldIndex, int newIndex) async { + final localAccounts = await _localAccounts.get(); + final removedItem = Output(); + final updated = localAccounts + .removeAt(oldIndex, removedItem) + .insert(newIndex, removedItem.value!); + await _localAccounts.set(updated); + _streamController.add(AccountRepositoryChange.localAccounts); + } + + /// Creates a new super identity, an identity instance, an account associated + /// with the identity instance, stores the account in the identity key and + /// then logs into that account with no password set at this time + Future createWithNewSuperIdentity( + AccountSpec accountSpec) async { + log.debug('Creating super identity'); + final wsi = await WritableSuperIdentity.create(); + try { + final localAccount = await _newLocalAccount( + superIdentity: wsi.superIdentity, + identitySecret: wsi.identitySecret, + accountSpec: accountSpec); + + // Log in the new account by default with no pin + final ok = await login( + localAccount.superIdentity.recordKey, EncryptionKeyType.none, ''); + assert(ok, 'login with none should never fail'); + + return wsi; + } on Exception catch (_) { + await wsi.delete(); + rethrow; + } + } + + Future updateLocalAccount( + TypedKey superIdentityRecordKey, AccountSpec accountSpec) async { + final localAccounts = await _localAccounts.get(); + + final newLocalAccounts = localAccounts.replaceFirstWhere( + (x) => x.superIdentity.recordKey == superIdentityRecordKey, + (localAccount) => localAccount!.copyWith(name: accountSpec.name)); + + await _localAccounts.set(newLocalAccounts); + _streamController.add(AccountRepositoryChange.localAccounts); + } + + /// Remove an account and wipe the messages for this account from this device + Future deleteLocalAccount(TypedKey superIdentityRecordKey, + OwnedDHTRecordPointer? accountRecord) async { + // Delete the account record locally which causes a deep delete + // of all the contacts, invites, chats, and messages in the dht record + // pool + if (accountRecord != null) { + await DHTRecordPool.instance.deleteRecord(accountRecord.recordKey); + } + + await logout(superIdentityRecordKey); + + final localAccounts = await _localAccounts.get(); + final newLocalAccounts = localAccounts.removeWhere( + (la) => la.superIdentity.recordKey == superIdentityRecordKey); + + await _localAccounts.set(newLocalAccounts); + _streamController.add(AccountRepositoryChange.localAccounts); + + return true; + } + + /// Import an account from another VeilidChat instance + + /// Recover an account with the master identity secret + + /// Delete an account from all devices + Future destroyAccount(TypedKey superIdentityRecordKey, + OwnedDHTRecordPointer accountRecord) async { + // Get which local account we want to fetch the profile for + final localAccount = fetchLocalAccount(superIdentityRecordKey); + if (localAccount == null) { + return false; + } + + // See if we've logged into this account or if it is locked + final userLogin = fetchUserLogin(superIdentityRecordKey); + if (userLogin == null) { + return false; + } + + final success = await localAccount.superIdentity.currentInstance + .removeAccount( + superRecordKey: localAccount.superIdentity.recordKey, + secretKey: userLogin.identitySecret.value, + applicationId: veilidChatApplicationId, + removeAccountCallback: (accountRecordInfos) async => + accountRecordInfos.singleOrNull); + if (!success) { + return false; + } + + return deleteLocalAccount(superIdentityRecordKey, accountRecord); + } + + Future switchToAccount(TypedKey? superIdentityRecordKey) async { + final activeLocalAccount = await _activeLocalAccount.get(); + + if (activeLocalAccount == superIdentityRecordKey) { + // Nothing to do + return; + } + + if (superIdentityRecordKey != null) { + // Assert the specified record key can be found, will throw if not + final _ = _userLogins.value.firstWhere( + (ul) => ul.superIdentityRecordKey == superIdentityRecordKey); + } + await _activeLocalAccount.set(superIdentityRecordKey); + _streamController.add(AccountRepositoryChange.activeLocalAccount); + } + + ////////////////////////////////////////////////////////////// + /// Internal Implementation + + /// Creates a new Account associated with the current instance of the identity + /// Adds a logged-out LocalAccount to track its existence on this device + Future _newLocalAccount( + {required SuperIdentity superIdentity, + required SecretKey identitySecret, + required AccountSpec accountSpec, + EncryptionKeyType encryptionKeyType = EncryptionKeyType.none, + String encryptionKey = ''}) async { + log.debug('Creating new local account'); + + final localAccounts = await _localAccounts.get(); + + // Add account with profile to DHT + await superIdentity.currentInstance.addAccount( + superRecordKey: superIdentity.recordKey, + secretKey: identitySecret, + applicationId: veilidChatApplicationId, + createAccountCallback: (parent) async { + // Make empty contact list + log.debug('Creating contacts list'); + final contactList = await (await DHTShortArray.create( + debugName: 'AccountRepository::_newLocalAccount::Contacts', + parent: parent)) + .scope((r) async => r.recordPointer); + + // Make empty contact invitation record list + log.debug('Creating contact invitation records list'); + final contactInvitationRecords = await (await DHTShortArray.create( + debugName: + 'AccountRepository::_newLocalAccount::ContactInvitations', + parent: parent)) + .scope((r) async => r.recordPointer); + + // Make empty chat record list + log.debug('Creating chat records list'); + final chatRecords = await (await DHTShortArray.create( + debugName: 'AccountRepository::_newLocalAccount::Chats', + parent: parent)) + .scope((r) async => r.recordPointer); + + final groupChatRecords = await (await DHTShortArray.create( + debugName: 'AccountRepository::_newLocalAccount::GroupChats', + parent: parent)) + .scope((r) async => r.recordPointer); + + // Make account object + final profile = proto.Profile() + ..name = accountSpec.name + ..pronouns = accountSpec.pronouns + ..about = accountSpec.about + ..status = accountSpec.status + ..availability = accountSpec.availability + ..timestamp = Veilid.instance.now().toInt64(); + + final account = proto.Account() + ..profile = profile + ..invisible = accountSpec.invisible + ..autoAwayTimeoutMin = accountSpec.autoAwayTimeout + ..contactList = contactList.toProto() + ..contactInvitationRecords = contactInvitationRecords.toProto() + ..chatList = chatRecords.toProto() + ..groupChatList = groupChatRecords.toProto() + ..freeMessage = accountSpec.freeMessage + ..awayMessage = accountSpec.awayMessage + ..busyMessage = accountSpec.busyMessage + ..autodetectAway = accountSpec.autoAway; + + return account.writeToBuffer(); + }); + + // Encrypt identitySecret with key + final identitySecretBytes = await encryptionKeyType.encryptSecretToBytes( + secret: identitySecret, + cryptoKind: superIdentity.currentInstance.recordKey.kind, + encryptionKey: encryptionKey, + ); + + // Create local account object + // Does not contain the account key or its secret + // as that is not to be persisted, and only pulled from the identity key + // and optionally decrypted with the unlock password + final localAccount = LocalAccount( + superIdentity: superIdentity, + identitySecretBytes: identitySecretBytes, + encryptionKeyType: encryptionKeyType, + biometricsEnabled: false, + hiddenAccount: false, + name: accountSpec.name, + ); + + // Add local account object to internal store + final newLocalAccounts = localAccounts.add(localAccount); + + await _localAccounts.set(newLocalAccounts); + _streamController.add(AccountRepositoryChange.localAccounts); + + // Return local account object + return localAccount; + } + + Future _decryptedLogin( + SuperIdentity superIdentity, SecretKey identitySecret) async { + // Verify identity secret works and return the valid cryptosystem + final cs = await superIdentity.currentInstance + .validateIdentitySecret(identitySecret); + + // Read the identity key to get the account keys + final accountRecordInfoList = await superIdentity.currentInstance + .readAccount( + superRecordKey: superIdentity.recordKey, + secretKey: identitySecret, + applicationId: veilidChatApplicationId); + if (accountRecordInfoList.length > 1) { + throw IdentityException.limitExceeded; + } else if (accountRecordInfoList.isEmpty) { + throw IdentityException.noAccount; + } + final accountRecordInfo = accountRecordInfoList.single; + + // Add to user logins and select it + final userLogins = await _userLogins.get(); + final now = Veilid.instance.now(); + final newUserLogins = userLogins.replaceFirstWhere( + (ul) => ul.superIdentityRecordKey == superIdentity.recordKey, + (ul) => ul != null + ? ul.copyWith(lastActive: now) + : UserLogin( + superIdentityRecordKey: superIdentity.recordKey, + identitySecret: + TypedSecret(kind: cs.kind(), value: identitySecret), + accountRecordInfo: accountRecordInfo, + lastActive: now), + addIfNotFound: true); + + await _userLogins.set(newUserLogins); + await _activeLocalAccount.set(superIdentity.recordKey); + + _streamController + ..add(AccountRepositoryChange.userLogins) + ..add(AccountRepositoryChange.activeLocalAccount); + + return true; + } + + Future login(TypedKey accountSuperRecordKey, + EncryptionKeyType encryptionKeyType, String encryptionKey) async { + final localAccounts = await _localAccounts.get(); + + // Get account, throws if not found + final localAccount = localAccounts.firstWhere( + (la) => la.superIdentity.recordKey == accountSuperRecordKey); + + // Log in with this local account + + // Derive key from password + if (localAccount.encryptionKeyType != encryptionKeyType) { + throw Exception('Wrong authentication type'); + } + + final identitySecret = + await localAccount.encryptionKeyType.decryptSecretFromBytes( + secretBytes: localAccount.identitySecretBytes, + cryptoKind: localAccount.superIdentity.currentInstance.recordKey.kind, + encryptionKey: encryptionKey, + ); + + // Validate this secret with the identity public key and log in + return _decryptedLogin(localAccount.superIdentity, identitySecret); + } + + Future logout(TypedKey? accountMasterRecordKey) async { + // Resolve which user to log out + final activeLocalAccount = await _activeLocalAccount.get(); + final logoutUser = accountMasterRecordKey ?? activeLocalAccount; + if (logoutUser == null) { + log.error('missing user in logout: $accountMasterRecordKey'); + return; + } + + if (logoutUser == activeLocalAccount) { + await switchToAccount( + _localAccounts.value.firstOrNull?.superIdentity.recordKey); + } + + final logoutUserLogin = fetchUserLogin(logoutUser); + if (logoutUserLogin == null) { + // Already logged out + return; + } + + // Remove user from active logins list + final newUserLogins = (await _userLogins.get()) + .removeWhere((ul) => ul.superIdentityRecordKey == logoutUser); + await _userLogins.set(newUserLogins); + _streamController.add(AccountRepositoryChange.userLogins); + } + + ////////////////////////////////////////////////////////////// + /// Fields + + static AccountRepository instance = AccountRepository._(); + + final TableDBValue> _localAccounts; + final TableDBValue> _userLogins; + final TableDBValue _activeLocalAccount; + final StreamController _streamController; +} diff --git a/lib/account_manager/repository/repository.dart b/lib/account_manager/repository/repository.dart new file mode 100644 index 0000000..74bf9f8 --- /dev/null +++ b/lib/account_manager/repository/repository.dart @@ -0,0 +1 @@ +export 'account_repository.dart'; diff --git a/lib/account_manager/views/edit_account_page.dart b/lib/account_manager/views/edit_account_page.dart new file mode 100644 index 0000000..5622c1f --- /dev/null +++ b/lib/account_manager/views/edit_account_page.dart @@ -0,0 +1,325 @@ +import 'dart:async'; + +import 'package:async_tools/async_tools.dart'; +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:go_router/go_router.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../layout/default_app_bar.dart'; +import '../../notifications/notifications.dart'; +import '../../theme/theme.dart'; +import '../../tools/tools.dart'; +import '../../veilid_processor/veilid_processor.dart'; +import '../account_manager.dart'; +import 'edit_profile_form.dart'; + +const _kDoBackArrow = 'doBackArrow'; + +class EditAccountPage extends StatefulWidget { + const EditAccountPage( + {required this.superIdentityRecordKey, + required this.initialValue, + required this.accountRecord, + super.key}); + + @override + State createState() => _EditAccountPageState(); + + final TypedKey superIdentityRecordKey; + final AccountSpec initialValue; + final OwnedDHTRecordPointer accountRecord; + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty( + 'superIdentityRecordKey', superIdentityRecordKey)) + ..add(DiagnosticsProperty('initialValue', initialValue)) + ..add(DiagnosticsProperty( + 'accountRecord', accountRecord)); + } +} + +class _EditAccountPageState extends WindowSetupState { + _EditAccountPageState() + : super( + titleBarStyle: TitleBarStyle.normal, + orientationCapability: OrientationCapability.portraitOnly); + + EditProfileForm _editAccountForm(BuildContext context) => EditProfileForm( + header: translate('edit_account_page.header'), + instructions: translate('edit_account_page.instructions'), + submitText: translate('button.update'), + submitDisabledText: translate('button.waiting_for_network'), + onSubmit: _onSubmit, + onModifiedState: _onModifiedState, + initialValue: widget.initialValue, + ); + + Future _onRemoveAccount() async { + // dismiss the keyboard by unfocusing the textfield + FocusScope.of(context).unfocus(); + await asyncSleep(const Duration(milliseconds: 250)); + if (!mounted) { + return; + } + + final confirmed = await StyledDialog.show( + context: context, + title: translate('edit_account_page.remove_account_confirm'), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Text(translate('edit_account_page.remove_account_confirm_message')) + .paddingLTRB(24.scaled(context), 24.scaled(context), + 24.scaled(context), 0), + Text(translate('confirmation.are_you_sure')) + .paddingAll(8.scaled(context)), + Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ + ElevatedButton( + onPressed: () { + Navigator.of(context).pop(false); + }, + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.cancel, size: 16.scaled(context)) + .paddingLTRB(0, 0, 4.scaled(context), 0), + Text(translate('button.no')).paddingLTRB(0, 0, 4, 0) + ])), + ElevatedButton( + onPressed: () { + Navigator.of(context).pop(true); + }, + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.check, size: 16.scaled(context)) + .paddingLTRB(0, 0, 4.scaled(context), 0), + Text(translate('button.yes')) + .paddingLTRB(0, 0, 4.scaled(context), 0) + ])) + ]).paddingAll(24.scaled(context)) + ])); + if (confirmed != null && confirmed) { + try { + setState(() { + _isInAsyncCall = true; + }); + try { + final success = await AccountRepository.instance.deleteLocalAccount( + widget.superIdentityRecordKey, widget.accountRecord); + if (mounted) { + if (success) { + context + .read() + .info(text: translate('edit_account_page.account_removed')); + GoRouterHelper(context).pop(); + } else { + context.read().error( + title: translate('edit_account_page.failed_to_remove_title'), + text: translate('edit_account_page.try_again_network')); + } + } + } finally { + setState(() { + _isInAsyncCall = false; + }); + } + } on Exception catch (e, st) { + if (mounted) { + await showErrorStacktraceModal( + context: context, error: e, stackTrace: st); + } + } + } + } + + Future _onDestroyAccount() async { + // dismiss the keyboard by unfocusing the textfield + FocusScope.of(context).unfocus(); + await asyncSleep(const Duration(milliseconds: 250)); + if (!mounted) { + return; + } + + final confirmed = await StyledDialog.show( + context: context, + title: translate('edit_account_page.destroy_account_confirm'), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Text(translate('edit_account_page.destroy_account_confirm_message')) + .paddingLTRB(24.scaled(context), 24.scaled(context), + 24.scaled(context), 0), + Text(translate( + 'edit_account_page.destroy_account_confirm_message_details')) + .paddingLTRB(24.scaled(context), 24.scaled(context), + 24.scaled(context), 0), + Text(translate('confirmation.are_you_sure')) + .paddingAll(24.scaled(context)), + Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ + ElevatedButton( + onPressed: () { + Navigator.of(context).pop(false); + }, + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.cancel, size: 16.scaled(context)) + .paddingLTRB(0, 0, 4.scaled(context), 0), + Text(translate('button.no')) + .paddingLTRB(0, 0, 4.scaled(context), 0) + ])), + ElevatedButton( + onPressed: () { + Navigator.of(context).pop(true); + }, + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.check, size: 16.scaled(context)) + .paddingLTRB(0, 0, 4.scaled(context), 0), + Text(translate('button.yes')) + .paddingLTRB(0, 0, 4.scaled(context), 0) + ])) + ]).paddingAll(24.scaled(context)) + ])); + if (confirmed != null && confirmed) { + try { + setState(() { + _isInAsyncCall = true; + }); + try { + final success = await AccountRepository.instance.destroyAccount( + widget.superIdentityRecordKey, widget.accountRecord); + if (mounted) { + if (success) { + context + .read() + .info(text: translate('edit_account_page.account_destroyed')); + GoRouterHelper(context).pop(); + } else { + context.read().error( + title: translate('edit_account_page.failed_to_destroy_title'), + text: translate('edit_account_page.try_again_network')); + } + } + } finally { + setState(() { + _isInAsyncCall = false; + }); + } + } on Exception catch (e, st) { + if (mounted) { + await showErrorStacktraceModal( + context: context, error: e, stackTrace: st); + } + } + } + } + + void _onModifiedState(bool isModified) { + setState(() { + _isModified = isModified; + }); + } + + Future _onSubmit(AccountSpec accountSpec) async { + try { + setState(() { + _isInAsyncCall = true; + }); + try { + // Look up account cubit for this specific account + final perAccountCollectionBlocMapCubit = + context.read(); + final accountRecordCubit = perAccountCollectionBlocMapCubit + .entry(widget.superIdentityRecordKey) + ?.accountRecordCubit; + if (accountRecordCubit == null) { + return false; + } + + // Update account profile DHT record + // This triggers ConversationCubits to update + accountRecordCubit.updateAccount(accountSpec, () async { + // Update local account profile + await AccountRepository.instance + .updateLocalAccount(widget.superIdentityRecordKey, accountSpec); + }); + + return true; + } finally { + setState(() { + _isInAsyncCall = false; + }); + } + } on Exception catch (e, st) { + if (mounted) { + await showErrorStacktraceModal( + context: context, error: e, stackTrace: st); + } + } + return false; + } + + @override + Widget build(BuildContext context) { + final displayModalHUD = _isInAsyncCall; + + return StyledScaffold( + appBar: DefaultAppBar( + context: context, + title: Text(translate('edit_account_page.titlebar')), + leading: Navigator.canPop(context) + ? IconButton( + icon: const Icon(Icons.arrow_back), + iconSize: 24.scaled(context), + onPressed: () { + singleFuture((this, _kDoBackArrow), () async { + if (_isModified) { + final ok = await showConfirmModal( + context: context, + title: + translate('confirmation.discard_changes'), + text: translate( + 'confirmation.are_you_sure_discard')); + if (!ok) { + return; + } + } + if (context.mounted) { + Navigator.pop(context); + } + }); + }) + : null, + actions: [ + const SignalStrengthMeterWidget(), + IconButton( + icon: const Icon(Icons.settings), + iconSize: 24.scaled(context), + tooltip: translate('menu.settings_tooltip'), + onPressed: () async { + await GoRouterHelper(context).push('/settings'); + }) + ]), + body: SingleChildScrollView( + child: Column(children: [ + _editAccountForm(context).paddingLTRB(0, 0, 0, 32), + StyledButtonBox( + instructions: + translate('edit_account_page.remove_account_description'), + buttonIcon: Icons.person_remove_alt_1, + buttonText: translate('edit_account_page.remove_account'), + onClick: _onRemoveAccount, + ), + StyledButtonBox( + instructions: + translate('edit_account_page.destroy_account_description'), + buttonIcon: Icons.person_off, + buttonText: translate('edit_account_page.destroy_account'), + onClick: _onDestroyAccount, + ) + ]).paddingSymmetric(horizontal: 24, vertical: 8))) + .withModalHUD(context, displayModalHUD); + } + + //////////////////////////////////////////////////////////////////////////// + + bool _isInAsyncCall = false; + bool _isModified = false; +} diff --git a/lib/account_manager/views/edit_profile_form.dart b/lib/account_manager/views/edit_profile_form.dart new file mode 100644 index 0000000..c4f84da --- /dev/null +++ b/lib/account_manager/views/edit_profile_form.dart @@ -0,0 +1,363 @@ +import 'package:async_tools/async_tools.dart'; +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:form_builder_validators/form_builder_validators.dart'; + +import '../../contacts/contacts.dart'; +import '../../proto/proto.dart' as proto; +import '../../theme/theme.dart'; +import '../../veilid_processor/veilid_processor.dart'; +import '../models/models.dart'; + +const _kDoSubmitEditProfile = 'doSubmitEditProfile'; + +class EditProfileForm extends StatefulWidget { + const EditProfileForm({ + required this.header, + required this.instructions, + required this.submitText, + required this.submitDisabledText, + required this.initialValue, + required this.onSubmit, + this.onModifiedState, + super.key, + }); + + @override + State createState() => _EditProfileFormState(); + + final String header; + final String instructions; + final Future Function(AccountSpec) onSubmit; + final void Function(bool)? onModifiedState; + final String submitText; + final String submitDisabledText; + final AccountSpec initialValue; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(StringProperty('header', header)) + ..add(StringProperty('instructions', instructions)) + ..add(StringProperty('submitText', submitText)) + ..add(StringProperty('submitDisabledText', submitDisabledText)) + ..add(ObjectFlagProperty Function(AccountSpec)>.has( + 'onSubmit', onSubmit)) + ..add(ObjectFlagProperty.has( + 'onModifiedState', onModifiedState)) + ..add(DiagnosticsProperty('initialValue', initialValue)); + } + + static const formFieldName = 'name'; + static const formFieldPronouns = 'pronouns'; + static const formFieldAbout = 'about'; + static const formFieldAvailability = 'availability'; + static const formFieldFreeMessage = 'free_message'; + static const formFieldAwayMessage = 'away_message'; + static const formFieldBusyMessage = 'busy_message'; + static const formFieldAvatar = 'avatar'; + static const formFieldAutoAway = 'auto_away'; + static const formFieldAutoAwayTimeout = 'auto_away_timeout'; +} + +class _EditProfileFormState extends State { + final _formKey = GlobalKey(); + + @override + void initState() { + _savedValue = widget.initialValue; + _currentValueName = widget.initialValue.name; + _currentValueAutoAway = widget.initialValue.autoAway; + + super.initState(); + } + + FormBuilderDropdown _availabilityDropDown( + BuildContext context) { + final theme = Theme.of(context); + final scale = theme.extension()!; + + final initialValue = + _savedValue.availability == proto.Availability.AVAILABILITY_UNSPECIFIED + ? proto.Availability.AVAILABILITY_FREE + : _savedValue.availability; + + final availabilities = [ + proto.Availability.AVAILABILITY_FREE, + proto.Availability.AVAILABILITY_AWAY, + proto.Availability.AVAILABILITY_BUSY, + proto.Availability.AVAILABILITY_OFFLINE, + ]; + + return FormBuilderDropdown( + name: EditProfileForm.formFieldAvailability, + initialValue: initialValue, + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(8).scaled(context), + floatingLabelBehavior: FloatingLabelBehavior.always, + labelText: translate('account.form_availability'), + hintText: translate('account.empty_busy_message')), + items: availabilities + .map((availability) => DropdownMenuItem( + value: availability, + child: Row(mainAxisSize: MainAxisSize.min, children: [ + AvailabilityWidget.availabilityIcon( + context, availability, scale.primaryScale.appText), + Text(availability == proto.Availability.AVAILABILITY_OFFLINE + ? translate('availability.always_show_offline') + : AvailabilityWidget.availabilityName(availability)) + .paddingLTRB(8.scaled(context), 0, 0, 0), + ]))) + .toList(), + ); + } + + AccountSpec _makeAccountSpec() { + final name = _formKey + .currentState!.fields[EditProfileForm.formFieldName]!.value as String; + final pronouns = _formKey.currentState! + .fields[EditProfileForm.formFieldPronouns]!.value as String; + final about = _formKey + .currentState!.fields[EditProfileForm.formFieldAbout]!.value as String; + final availability = _formKey + .currentState! + .fields[EditProfileForm.formFieldAvailability]! + .value as proto.Availability; + + final invisible = availability == proto.Availability.AVAILABILITY_OFFLINE; + final freeMessage = _formKey.currentState! + .fields[EditProfileForm.formFieldFreeMessage]!.value as String; + final awayMessage = _formKey.currentState! + .fields[EditProfileForm.formFieldAwayMessage]!.value as String; + final busyMessage = _formKey.currentState! + .fields[EditProfileForm.formFieldBusyMessage]!.value as String; + + const proto.DataReference? avatar = null; + // final avatar = _formKey.currentState! + // .fields[EditProfileForm.formFieldAvatar]!.value + //as proto.DataReference?; + + final autoAway = _formKey + .currentState!.fields[EditProfileForm.formFieldAutoAway]!.value as bool; + final autoAwayTimeoutString = _formKey.currentState! + .fields[EditProfileForm.formFieldAutoAwayTimeout]!.value as String; + final autoAwayTimeout = int.parse(autoAwayTimeoutString); + + return AccountSpec( + name: name, + pronouns: pronouns, + about: about, + availability: availability, + invisible: invisible, + freeMessage: freeMessage, + awayMessage: awayMessage, + busyMessage: busyMessage, + avatar: avatar, + autoAway: autoAway, + autoAwayTimeout: autoAwayTimeout); + } + + // Check if everything is the same and update state + void _onChanged() { + final currentValue = _makeAccountSpec(); + _isModified = currentValue != _savedValue; + final onModifiedState = widget.onModifiedState; + if (onModifiedState != null) { + onModifiedState(_isModified); + } + } + + Widget _editProfileForm( + BuildContext context, + ) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + + return FormBuilder( + key: _formKey, + autovalidateMode: AutovalidateMode.onUserInteraction, + onChanged: _onChanged, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + spacing: 8, + children: [ + Row(children: [ + const Spacer(), + StyledAvatar( + name: _currentValueName, + size: 128.scaled(context), + ).paddingLTRB(0, 0, 0, 16), + const Spacer() + ]), + FormBuilderTextField( + autofocus: true, + name: EditProfileForm.formFieldName, + initialValue: _savedValue.name, + onChanged: (x) { + setState(() { + _currentValueName = x ?? ''; + }); + }, + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(8).scaled(context), + floatingLabelBehavior: FloatingLabelBehavior.always, + labelText: translate('account.form_name'), + hintText: translate('account.empty_name')), + maxLength: 64, + // The validator receives the text that the user has entered. + validator: FormBuilderValidators.compose([ + FormBuilderValidators.required(), + ]), + textInputAction: TextInputAction.next, + ), + FormBuilderTextField( + name: EditProfileForm.formFieldPronouns, + initialValue: _savedValue.pronouns, + maxLength: 64, + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(8).scaled(context), + floatingLabelBehavior: FloatingLabelBehavior.always, + labelText: translate('account.form_pronouns'), + hintText: translate('account.empty_pronouns')), + textInputAction: TextInputAction.next, + ), + FormBuilderTextField( + name: EditProfileForm.formFieldAbout, + initialValue: _savedValue.about, + maxLength: 1024, + maxLines: 8, + minLines: 1, + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(8).scaled(context), + floatingLabelBehavior: FloatingLabelBehavior.always, + labelText: translate('account.form_about'), + hintText: translate('account.empty_about')), + textInputAction: TextInputAction.newline, + ), + _availabilityDropDown(context).paddingLTRB(0, 0, 0, 16), + FormBuilderTextField( + name: EditProfileForm.formFieldFreeMessage, + initialValue: _savedValue.freeMessage, + maxLength: 128, + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(8).scaled(context), + floatingLabelBehavior: FloatingLabelBehavior.always, + labelText: translate('account.form_free_message'), + hintText: translate('account.empty_free_message')), + textInputAction: TextInputAction.next, + ), + FormBuilderTextField( + name: EditProfileForm.formFieldAwayMessage, + initialValue: _savedValue.awayMessage, + maxLength: 128, + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(8).scaled(context), + floatingLabelBehavior: FloatingLabelBehavior.always, + labelText: translate('account.form_away_message'), + hintText: translate('account.empty_away_message')), + textInputAction: TextInputAction.next, + ), + FormBuilderTextField( + name: EditProfileForm.formFieldBusyMessage, + initialValue: _savedValue.busyMessage, + maxLength: 128, + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(8).scaled(context), + floatingLabelBehavior: FloatingLabelBehavior.always, + labelText: translate('account.form_busy_message'), + hintText: translate('account.empty_busy_message')), + textInputAction: TextInputAction.next, + ), + FormBuilderCheckbox( + name: EditProfileForm.formFieldAutoAway, + initialValue: _savedValue.autoAway, + title: Text(translate('account.form_auto_away'), + style: textTheme.labelMedium), + onChanged: (v) { + setState(() { + _currentValueAutoAway = v ?? false; + }); + }, + ).paddingLTRB(0, 0, 0, 16.scaled(context)), + FormBuilderTextField( + name: EditProfileForm.formFieldAutoAwayTimeout, + enabled: _currentValueAutoAway, + initialValue: _savedValue.autoAwayTimeout.toString(), + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(8).scaled(context), + labelText: translate('account.form_auto_away_timeout'), + ), + validator: FormBuilderValidators.positiveNumber(), + textInputAction: TextInputAction.next, + ), + Row(children: [ + const Spacer(), + Text(widget.instructions).toCenter().flexible(flex: 6), + const Spacer(), + ]).paddingSymmetric(vertical: 16.scaled(context)), + Row(children: [ + const Spacer(), + Builder(builder: (context) { + final networkReady = context + .watch() + .state + .asData + ?.value + .isPublicInternetReady ?? + false; + + return ElevatedButton( + onPressed: (networkReady && _isModified) ? _doSubmit : null, + child: Padding( + padding: EdgeInsetsGeometry.all(4.scaled(context)), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(networkReady ? Icons.check : Icons.hourglass_empty, + size: 16.scaled(context)) + .paddingLTRB(0, 0, 4.scaled(context), 0), + Text(networkReady + ? widget.submitText + : widget.submitDisabledText) + .paddingLTRB(0, 0, 4.scaled(context), 0) + ]), + )); + }), + const Spacer() + ]) + ], + ), + ); + } + + void _doSubmit() { + final onSubmit = widget.onSubmit; + if (_formKey.currentState?.saveAndValidate() ?? false) { + singleFuture((this, _kDoSubmitEditProfile), () async { + final updatedAccountSpec = _makeAccountSpec(); + final saved = await onSubmit(updatedAccountSpec); + if (saved) { + setState(() { + _savedValue = updatedAccountSpec; + }); + _onChanged(); + } + }); + } + } + + @override + Widget build(BuildContext context) => _editProfileForm( + context, + ); + + /////////////////////////////////////////////////////////////////////////// + late AccountSpec _savedValue; + late bool _currentValueAutoAway; + late String _currentValueName; + var _isModified = false; +} diff --git a/lib/account_manager/views/new_account_page.dart b/lib/account_manager/views/new_account_page.dart new file mode 100644 index 0000000..5012527 --- /dev/null +++ b/lib/account_manager/views/new_account_page.dart @@ -0,0 +1,132 @@ +import 'dart:async'; + +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:go_router/go_router.dart'; + +import '../../layout/default_app_bar.dart'; +import '../../notifications/cubits/notifications_cubit.dart'; +import '../../theme/theme.dart'; +import '../../tools/tools.dart'; +import '../../veilid_processor/veilid_processor.dart'; +import '../account_manager.dart'; +import 'edit_profile_form.dart'; + +class NewAccountPage extends StatefulWidget { + const NewAccountPage({super.key}); + + @override + State createState() => _NewAccountPageState(); +} + +class _NewAccountPageState extends WindowSetupState { + _NewAccountPageState() + : super( + titleBarStyle: TitleBarStyle.normal, + orientationCapability: OrientationCapability.portraitOnly); + + Widget _newAccountForm( + BuildContext context, + ) => + EditProfileForm( + header: translate('new_account_page.header'), + instructions: translate('new_account_page.instructions'), + submitText: translate('new_account_page.create'), + submitDisabledText: translate('button.waiting_for_network'), + initialValue: const AccountSpec.empty(), + onSubmit: _onSubmit); + + Future _onSubmit(AccountSpec accountSpec) async { + // dismiss the keyboard by unfocusing the textfield + FocusScope.of(context).unfocus(); + + try { + setState(() { + _isInAsyncCall = true; + }); + try { + final networkReady = context + .read() + .state + .asData + ?.value + .isPublicInternetReady ?? + false; + + final canSubmit = networkReady; + if (!canSubmit) { + context.read().error( + text: translate('new_account_page.network_is_offline'), + title: translate('new_account_page.error')); + return false; + } + + final isFirstAccount = + AccountRepository.instance.getLocalAccounts().isEmpty; + + final writableSuperIdentity = await AccountRepository.instance + .createWithNewSuperIdentity(accountSpec); + GoRouterHelper(context).pushReplacement('/new_account/recovery_key', + extra: [writableSuperIdentity, accountSpec.name, isFirstAccount]); + + return true; + } finally { + if (mounted) { + setState(() { + _isInAsyncCall = false; + }); + } + } + } on Exception catch (e, st) { + if (mounted) { + await showErrorStacktraceModal( + context: context, error: e, stackTrace: st); + } + } + return false; + } + + @override + Widget build(BuildContext context) { + final displayModalHUD = _isInAsyncCall; + + return StyledScaffold( + appBar: DefaultAppBar( + context: context, + title: Text(translate('new_account_page.titlebar')), + leading: GoRouterHelper(context).canPop() + ? IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + if (GoRouterHelper(context).canPop()) { + GoRouterHelper(context).pop(); + } else { + GoRouterHelper(context).go('/'); + } + }, + ) + : null, + actions: [ + const SignalStrengthMeterWidget(), + IconButton( + icon: const Icon(Icons.settings), + iconSize: 24.scaled(context), + tooltip: translate('menu.settings_tooltip'), + onPressed: () async { + await GoRouterHelper(context).push('/settings'); + }) + ]), + body: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + child: _newAccountForm( + context, + )).paddingAll(2), + ).withModalHUD(context, displayModalHUD); + } + + //////////////////////////////////////////////////////////////////////////// + + bool _isInAsyncCall = false; +} diff --git a/lib/account_manager/views/profile_widget.dart b/lib/account_manager/views/profile_widget.dart new file mode 100644 index 0000000..8217414 --- /dev/null +++ b/lib/account_manager/views/profile_widget.dart @@ -0,0 +1,73 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/material.dart'; + +import '../../proto/proto.dart' as proto; +import '../../theme/theme.dart'; + +class ProfileWidget extends StatelessWidget { + const ProfileWidget({ + required proto.Profile profile, + String? byline, + super.key, + }) : _profile = profile, + _byline = byline; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + + final textTheme = theme.textTheme; + + return DecoratedBox( + decoration: ShapeDecoration( + color: scaleConfig.preferBorders + ? scale.primaryScale.elementBackground + : scale.primaryScale.border, + shape: RoundedRectangleBorder( + side: !scaleConfig.useVisualIndicators + ? BorderSide.none + : BorderSide( + strokeAlign: BorderSide.strokeAlignCenter, + color: scaleConfig.preferBorders + ? scale.primaryScale.border + : scale.primaryScale.borderText, + width: 2), + borderRadius: BorderRadius.all( + Radius.circular(8 * scaleConfig.borderRadiusScale))), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 8.scaled(context), + children: [ + Text( + _profile.name, + style: textTheme.titleMedium!.copyWith( + color: scaleConfig.preferBorders + ? scale.primaryScale.border + : scale.primaryScale.borderText), + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + maxLines: 1, + ), + if (_byline != null) + Text( + _byline, + style: textTheme.bodySmall!.copyWith( + color: scaleConfig.preferBorders + ? scale.primaryScale.border + : scale.primaryScale.primary), + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + maxLines: 1, + ), + ]).paddingAll(8.scaled(context)), + ); + } + + //////////////////////////////////////////////////////////////////////////// + + final proto.Profile _profile; + final String? _byline; +} diff --git a/lib/account_manager/views/show_recovery_key_page.dart b/lib/account_manager/views/show_recovery_key_page.dart new file mode 100644 index 0000000..5423543 --- /dev/null +++ b/lib/account_manager/views/show_recovery_key_page.dart @@ -0,0 +1,270 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; + +import 'package:async_tools/async_tools.dart'; +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:file_saver/file_saver.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:go_router/go_router.dart'; +import 'package:pdf/widgets.dart' as pw; +import 'package:printing/printing.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:screenshot/screenshot.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../layout/default_app_bar.dart'; +import '../../theme/theme.dart'; +import '../../tools/tools.dart'; +import '../../veilid_processor/veilid_processor.dart'; + +class ShowRecoveryKeyPage extends StatefulWidget { + const ShowRecoveryKeyPage( + {required WritableSuperIdentity writableSuperIdentity, + required String name, + required bool isFirstAccount, + super.key}) + : _writableSuperIdentity = writableSuperIdentity, + _name = name, + _isFirstAccount = isFirstAccount; + + @override + State createState() => _ShowRecoveryKeyPageState(); + + final WritableSuperIdentity _writableSuperIdentity; + final String _name; + final bool _isFirstAccount; +} + +class _ShowRecoveryKeyPageState extends WindowSetupState { + _ShowRecoveryKeyPageState() + : super( + titleBarStyle: TitleBarStyle.normal, + orientationCapability: OrientationCapability.portraitOnly); + + Future _shareRecoveryKey( + BuildContext context, Uint8List recoveryKey, String name) async { + setState(() { + _isInAsyncCall = true; + }); + + final screenshotController = ScreenshotController(); + final bytes = await screenshotController.captureFromWidget( + Container( + color: Colors.white, + width: 400, + height: 400, + child: _recoveryKeyWidget(context, recoveryKey, name)), + ); + + setState(() { + _isInAsyncCall = false; + }); + + if (!kIsWeb && Platform.isLinux) { + // Share plus doesn't do Linux yet + await FileSaver.instance.saveFile(name: 'recovery_key.png', bytes: bytes); + } else { + final xfile = XFile.fromData( + bytes, + mimeType: 'image/png', + name: 'recovery_key.png', + ); + await Share.shareXFiles([xfile]); + } + } + + static Future _printRecoveryKey( + BuildContext context, Uint8List recoveryKey, String name) async { + final wrapped = await WidgetWrapper.fromWidget( + context: context, + widget: SizedBox( + width: 400, + height: 400, + child: _recoveryKeyWidget(context, recoveryKey, name)), + constraints: const BoxConstraints(maxWidth: 400, maxHeight: 400), + pixelRatio: 3); + + final doc = pw.Document() + ..addPage(pw.Page( + build: (context) => + pw.Center(child: pw.Image(wrapped, width: 400)) // Center + )); // Page + + await Printing.layoutPdf(onLayout: (format) async => doc.save()); + } + + static Widget _recoveryKeyWidget( + BuildContext context, Uint8List recoveryKey, String name) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + //final scaleConfig = theme.extension()!; + + return Column(mainAxisSize: MainAxisSize.min, children: [ + Text( + style: textTheme.headlineSmall!.copyWith( + color: Colors.black, + fontWeight: FontWeight.bold, + ), + translate('show_recovery_key_page.recovery_key')) + .paddingLTRB(16, 16, 16, 0), + FittedBox( + child: QrImageView.withQr( + size: 300, + qr: QrCode.fromUint8List( + data: recoveryKey, + errorCorrectLevel: QrErrorCorrectLevel.L))) + .paddingLTRB(16, 16, 16, 8) + .expanded(), + Text( + style: textTheme.labelMedium!.copyWith( + color: Colors.black, + fontWeight: FontWeight.bold, + ), + name) + .paddingLTRB(16, 8, 16, 24), + ]); + } + + static Widget _recoveryKeyDialog( + BuildContext context, Uint8List recoveryKey, String name) { + final theme = Theme.of(context); + //final textTheme = theme.textTheme; + final scaleConfig = theme.extension()!; + + final cardsize = + min(MediaQuery.of(context).size.shortestSide - 48.0, 400); + + return Dialog( + shape: RoundedRectangleBorder( + side: const BorderSide(width: 2), + borderRadius: + BorderRadius.circular(16 * scaleConfig.borderRadiusScale)), + backgroundColor: Colors.white, + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: cardsize, + maxWidth: cardsize, + minHeight: cardsize + 16, + maxHeight: cardsize + 16), + child: _recoveryKeyWidget(context, recoveryKey, name))); + } + + @override + // ignore: prefer_expression_function_bodies + Widget build(BuildContext context) { + final theme = Theme.of(context); + // final scale = theme.extension()!; + // final scaleConfig = theme.extension()!; + + final displayModalHUD = _isInAsyncCall; + + return StyledScaffold( + appBar: DefaultAppBar( + context: context, + title: Text(translate('show_recovery_key_page.titlebar')), + actions: [ + const SignalStrengthMeterWidget(), + IconButton( + icon: const Icon(Icons.settings), + tooltip: translate('menu.settings_tooltip'), + onPressed: () async { + await GoRouterHelper(context).push('/settings'); + }) + ]), + body: SingleChildScrollView( + child: Column(children: [ + Text( + style: theme.textTheme.headlineSmall, + textAlign: TextAlign.center, + translate('show_recovery_key_page.instructions')) + .paddingAll(24), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Text( + softWrap: true, + textAlign: TextAlign.center, + translate('show_recovery_key_page.instructions_details'))) + .toCenter() + .paddingLTRB(24, 0, 24, 24), + Text( + textAlign: TextAlign.center, + translate('show_recovery_key_page.instructions_options')) + .paddingLTRB(12, 0, 12, 24), + StyledButtonBox( + instructions: + translate('show_recovery_key_page.instructions_print'), + buttonIcon: Icons.print, + buttonText: translate('show_recovery_key_page.print'), + onClick: () { + // + singleFuture(this, () async { + await _printRecoveryKey(context, + widget._writableSuperIdentity.recoveryKey, widget._name); + }); + + setState(() { + _codeHandled = true; + }); + }), + StyledButtonBox( + instructions: + translate('show_recovery_key_page.instructions_view'), + buttonIcon: Icons.edit_document, + buttonText: translate('show_recovery_key_page.view'), + onClick: () { + // + singleFuture(this, () async { + await showDialog( + context: context, + builder: (context) => _recoveryKeyDialog( + context, + widget._writableSuperIdentity.recoveryKey, + widget._name)); + }); + + setState(() { + _codeHandled = true; + }); + }), + StyledButtonBox( + instructions: + translate('show_recovery_key_page.instructions_share'), + buttonIcon: Icons.ios_share, + buttonText: translate('show_recovery_key_page.share'), + onClick: () { + // + singleFuture(this, () async { + await _shareRecoveryKey(context, + widget._writableSuperIdentity.recoveryKey, widget._name); + }); + + setState(() { + _codeHandled = true; + }); + }), + Offstage( + offstage: !_codeHandled, + child: ElevatedButton( + onPressed: () { + if (context.mounted) { + if (widget._isFirstAccount) { + GoRouterHelper(context).go('/'); + } else { + GoRouterHelper(context).canPop() + ? GoRouterHelper(context).pop() + : GoRouterHelper(context).go('/'); + } + } + }, + child: Text(translate('button.finish')).paddingAll(8)) + .paddingAll(12)) + ]))).withModalHUD(context, displayModalHUD); + } + + bool _codeHandled = false; + bool _isInAsyncCall = false; +} diff --git a/lib/account_manager/views/views.dart b/lib/account_manager/views/views.dart new file mode 100644 index 0000000..f554e88 --- /dev/null +++ b/lib/account_manager/views/views.dart @@ -0,0 +1,4 @@ +export 'edit_account_page.dart'; +export 'new_account_page.dart'; +export 'profile_widget.dart'; +export 'show_recovery_key_page.dart'; diff --git a/lib/app.dart b/lib/app.dart index 957f46f..5f4d6dc 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,35 +1,111 @@ import 'package:animated_theme_switcher/animated_theme_switcher.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_translate/flutter_translate.dart'; import 'package:form_builder_validators/form_builder_validators.dart'; +import 'package:provider/provider.dart'; +import 'account_manager/account_manager.dart'; +import 'init.dart'; +import 'layout/splash.dart'; +import 'notifications/notifications.dart'; import 'router/router.dart'; +import 'settings/settings.dart'; +import 'theme/theme.dart'; import 'tick.dart'; +import 'veilid_processor/veilid_processor.dart'; -class VeilidChatApp extends ConsumerWidget { +class ScrollBehaviorModified extends ScrollBehavior { + const ScrollBehaviorModified(); + @override + ScrollPhysics getScrollPhysics(BuildContext context) => + const ClampingScrollPhysics(); +} + +class VeilidChatApp extends StatelessWidget { const VeilidChatApp({ - required this.theme, + required this.initialThemeData, super.key, }); - final ThemeData theme; + static const name = 'VeilidChat'; - @override - Widget build(BuildContext context, WidgetRef ref) { - final router = ref.watch(routerProvider); - final localizationDelegate = LocalizedApp.of(context).delegate; + final ThemeData initialThemeData; - return ThemeProvider( - initTheme: theme, - builder: (_, theme) => LocalizationProvider( - state: LocalizationProvider.of(context).state, - child: BackgroundTicker( - builder: (context) => MaterialApp.router( + Widget appBuilder( + BuildContext context, LocalizationDelegate localizationDelegate) => + ThemeProvider( + initTheme: initialThemeData, + builder: (context, theme) => LocalizationProvider( + state: LocalizationProvider.of(context).state, + child: MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => + PreferencesCubit(PreferencesRepository.instance), + ), + BlocProvider( + create: (context) => NotificationsCubit( + const NotificationsState(queue: IList.empty()))), + BlocProvider( + create: (context) => + ConnectionStateCubit(ProcessorRepository.instance)), + BlocProvider( + create: (context) => RouterCubit(AccountRepository.instance), + ), + BlocProvider( + create: (context) => + LocalAccountsCubit(AccountRepository.instance), + ), + BlocProvider( + create: (context) => + UserLoginsCubit(AccountRepository.instance), + ), + BlocProvider( + create: (context) => + ActiveLocalAccountCubit(AccountRepository.instance), + ), + BlocProvider( + create: (context) => PerAccountCollectionBlocMapCubit( + accountRepository: AccountRepository.instance, + locator: context.read)), + ], + child: BackgroundTicker(child: Builder(builder: (context) { + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + + final gradient = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: scaleConfig.preferBorders && + theme.brightness == Brightness.light + ? [ + scale.grayScale.hoverElementBackground, + scale.grayScale.subtleBackground, + ] + : [ + scale.primaryScale.hoverElementBackground, + scale.primaryScale.subtleBackground, + ]); + + final wallpaper = PreferencesRepository + .instance.value.themePreference + .wallpaper(); + + return Stack( + fit: StackFit.expand, + alignment: Alignment.center, + children: [ + wallpaper ?? + DecoratedBox( + decoration: BoxDecoration(gradient: gradient)), + MaterialApp.router( + scrollBehavior: const ScrollBehaviorModified(), debugShowCheckedModeBanner: false, - routerConfig: router, + routerConfig: context.read().router(), title: translate('app.title'), theme: theme, localizationsDelegates: [ @@ -40,13 +116,32 @@ class VeilidChatApp extends ConsumerWidget { ], supportedLocales: localizationDelegate.supportedLocales, locale: localizationDelegate.currentLocale, - )), - )); - } + ) + ]); + })), + )), + ); + + @override + Widget build(BuildContext context) => FutureProvider( + initialData: null, + create: (context) => VeilidChatGlobalInit.initialize(), + builder: (context, __) { + final globalInit = context.watch(); + if (globalInit == null) { + // Splash screen until we're done with init + return const Splash(); + } + // Once init is done, we proceed with the app + final localizationDelegate = LocalizedApp.of(context).delegate; + + return SafeArea(child: appBuilder(context, localizationDelegate)); + }); @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); - properties.add(DiagnosticsProperty('theme', theme)); + properties + .add(DiagnosticsProperty('themeData', initialThemeData)); } } diff --git a/lib/chat/chat.dart b/lib/chat/chat.dart new file mode 100644 index 0000000..08ae2e7 --- /dev/null +++ b/lib/chat/chat.dart @@ -0,0 +1,3 @@ +export 'cubits/cubits.dart'; +export 'models/models.dart'; +export 'views/views.dart'; diff --git a/lib/chat/cubits/active_chat_cubit.dart b/lib/chat/cubits/active_chat_cubit.dart new file mode 100644 index 0000000..2e72abc --- /dev/null +++ b/lib/chat/cubits/active_chat_cubit.dart @@ -0,0 +1,13 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:veilid_support/veilid_support.dart'; + +// XXX: if we ever want to have more than one chat 'open', we should put the +// operations and state for that here. + +class ActiveChatCubit extends Cubit { + ActiveChatCubit(super.initialState); + + void setActiveChat(TypedKey? activeChatLocalConversationRecordKey) { + emit(activeChatLocalConversationRecordKey); + } +} diff --git a/lib/chat/cubits/chat_component_cubit.dart b/lib/chat/cubits/chat_component_cubit.dart new file mode 100644 index 0000000..c0473be --- /dev/null +++ b/lib/chat/cubits/chat_component_cubit.dart @@ -0,0 +1,439 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:async_tools/async_tools.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart' as core; +import 'package:veilid_support/veilid_support.dart'; + +import '../../account_manager/account_manager.dart'; +import '../../contacts/contacts.dart'; +import '../../conversation/conversation.dart'; +import '../../proto/proto.dart' as proto; +import '../../tools/tools.dart'; +import '../models/chat_component_state.dart'; +import '../models/message_state.dart'; +import '../models/window_state.dart'; +import '../views/chat_component_widget.dart'; +import 'cubits.dart'; + +const metadataKeyIdentityPublicKey = 'identityPublicKey'; +const metadataKeyExpirationDuration = 'expiration'; +const metadataKeyViewLimit = 'view_limit'; +const metadataKeyAttachments = 'attachments'; +const _sfChangedContacts = 'changedContacts'; + +class ChatComponentCubit extends Cubit { + ChatComponentCubit._({ + required AccountInfo accountInfo, + required AccountRecordCubit accountRecordCubit, + required ContactListCubit contactListCubit, + required List conversationCubits, + required SingleContactMessagesCubit messagesCubit, + }) : _accountInfo = accountInfo, + _accountRecordCubit = accountRecordCubit, + _contactListCubit = contactListCubit, + _conversationCubits = conversationCubits, + _messagesCubit = messagesCubit, + super(const ChatComponentState( + localUser: null, + remoteUsers: IMap.empty(), + historicalRemoteUsers: IMap.empty(), + unknownUsers: IMap.empty(), + messageWindow: AsyncLoading(), + title: '', + )) { + // Immediate Init + _init(); + + // Async Init + _initWait.add(_initAsync); + } + + factory ChatComponentCubit.singleContact( + {required AccountInfo accountInfo, + required AccountRecordCubit accountRecordCubit, + required ContactListCubit contactListCubit, + required ActiveConversationCubit activeConversationCubit, + required SingleContactMessagesCubit messagesCubit}) => + ChatComponentCubit._( + accountInfo: accountInfo, + accountRecordCubit: accountRecordCubit, + contactListCubit: contactListCubit, + conversationCubits: [activeConversationCubit], + messagesCubit: messagesCubit, + ); + + void _init() { + // Get local user info and account record cubit + _localUserIdentityKey = _accountInfo.identityTypedPublicKey; + + // Subscribe to local user info + _accountRecordSubscription = + _accountRecordCubit.stream.listen(_onChangedAccountRecord); + _onChangedAccountRecord(_accountRecordCubit.state); + + // Subscribe to messages + _messagesSubscription = _messagesCubit.stream.listen(_onChangedMessages); + _onChangedMessages(_messagesCubit.state); + + // Subscribe to contact list changes + _contactListSubscription = + _contactListCubit.stream.listen(_onChangedContacts); + _onChangedContacts(_contactListCubit.state); + } + + Future _initAsync(Completer cancel) async { + // Subscribe to remote user info + await _updateConversationSubscriptions(); + } + + @override + Future close() async { + await _initWait(); + await _contactListSubscription.cancel(); + await _accountRecordSubscription.cancel(); + await _messagesSubscription.cancel(); + await _conversationSubscriptions.values.map((v) => v.cancel()).wait; + + await super.close(); + } + + //////////////////////////////////////////////////////////////////////////// + // Public Interface + + // Set the tail position of the log for pagination. + // If tail is 0, the end of the log is used. + // If tail is negative, the position is subtracted from the current log + // length. + // If tail is positive, the position is absolute from the head of the log + // If follow is enabled, the tail offset will update when the log changes + Future setWindow( + {int? tail, int? count, bool? follow, bool forceRefresh = false}) async { + //await _initWait(); + await _messagesCubit.setWindow( + tail: tail, count: count, follow: follow, forceRefresh: forceRefresh); + } + + // Send a message + void sendMessage( + {required String text, + String? replyToMessageId, + Timestamp? expiration, + int? viewLimit, + List? attachments}) { + final replyId = (replyToMessageId != null) + ? base64UrlNoPadDecode(replyToMessageId) + : null; + + _addTextMessage( + text: text, + replyId: replyId, + expiration: expiration, + viewLimit: viewLimit, + attachments: attachments ?? []); + } + + // Run a chat command + void runCommand(String command) { + _messagesCubit.runCommand(command); + } + + //////////////////////////////////////////////////////////////////////////// + // Private Implementation + + void _onChangedAccountRecord(AsyncValue avAccount) { + // Update local 'User' + final account = avAccount.asData?.value; + if (account == null) { + emit(state.copyWith(localUser: null)); + return; + } + final localUser = core.User( + id: _localUserIdentityKey.toString(), + name: account.profile.name, + metadata: {metadataKeyIdentityPublicKey: _localUserIdentityKey}); + emit(state.copyWith(localUser: localUser)); + } + + void _onChangedMessages( + AsyncValue> avMessagesState) { + emit(_convertMessages(state, avMessagesState)); + } + + void _onChangedContacts(DHTShortArrayCubitState bavContacts) { + // Rewrite users when contacts change + singleFuture((this, _sfChangedContacts), _updateConversationSubscriptions); + } + + void _onChangedConversation( + TypedKey remoteIdentityPublicKey, + AsyncValue avConversationState, + ) { + // Update remote 'User' + final activeConversationState = avConversationState.asData?.value; + if (activeConversationState == null) { + // Don't change user information on loading state + return; + } + + final remoteUser = + _convertRemoteUser(remoteIdentityPublicKey, activeConversationState); + + emit(_updateTitle(state.copyWith( + remoteUsers: state.remoteUsers.add(remoteUser.id, remoteUser)))); + } + + static ChatComponentState _updateTitle(ChatComponentState currentState) { + if (currentState.remoteUsers.length == 0) { + return currentState.copyWith(title: 'Empty Chat'); + } + if (currentState.remoteUsers.length == 1) { + final remoteUser = currentState.remoteUsers.values.first; + return currentState.copyWith(title: remoteUser.name ?? ''); + } + return currentState.copyWith( + title: ''); + } + + core.User _convertRemoteUser(TypedKey remoteIdentityPublicKey, + ActiveConversationState activeConversationState) { + // See if we have a contact for this remote user + final contacts = _contactListCubit.state.state.asData?.value; + if (contacts != null) { + final contactIdx = contacts.indexWhere((x) => + x.value.identityPublicKey.toVeilid() == remoteIdentityPublicKey); + if (contactIdx != -1) { + final contact = contacts[contactIdx].value; + return core.User( + id: remoteIdentityPublicKey.toString(), + name: contact.displayName, + metadata: {metadataKeyIdentityPublicKey: remoteIdentityPublicKey}); + } + } + + return core.User( + id: remoteIdentityPublicKey.toString(), + name: activeConversationState.remoteConversation?.profile.name ?? + '', + metadata: {metadataKeyIdentityPublicKey: remoteIdentityPublicKey}); + } + + core.User _convertUnknownUser(TypedKey remoteIdentityPublicKey) => core.User( + id: remoteIdentityPublicKey.toString(), + name: '<$remoteIdentityPublicKey>', + metadata: {metadataKeyIdentityPublicKey: remoteIdentityPublicKey}); + + Future _updateConversationSubscriptions() async { + // Get existing subscription keys and state + final existing = _conversationSubscriptions.keys.toList(); + var currentRemoteUsersState = state.remoteUsers; + + // Process cubit list + for (final cc in _conversationCubits) { + // Get the remote identity key + final remoteIdentityPublicKey = cc.input.remoteIdentityPublicKey; + + // If the cubit is already being listened to we have nothing to do + if (existing.remove(remoteIdentityPublicKey)) { + // If the cubit is not already being listened to we should do that + _conversationSubscriptions[remoteIdentityPublicKey] = cc.stream.listen( + (avConv) => + _onChangedConversation(remoteIdentityPublicKey, avConv)); + } + + final activeConversationState = cc.state.asData?.value; + if (activeConversationState != null) { + final remoteUser = _convertRemoteUser( + remoteIdentityPublicKey, activeConversationState); + currentRemoteUsersState = + currentRemoteUsersState.add(remoteUser.id, remoteUser); + } + } + // Purge remote users we didn't see in the cubit list any more + final cancels = >[]; + for (final deadUser in existing) { + currentRemoteUsersState = + currentRemoteUsersState.remove(deadUser.toString()); + cancels.add(_conversationSubscriptions.remove(deadUser)!.cancel()); + } + await cancels.wait; + + // Emit change to remote users state + emit(_updateTitle(state.copyWith(remoteUsers: currentRemoteUsersState))); + } + + (ChatComponentState, core.Message?) _messageStateToChatMessage( + ChatComponentState currentState, MessageState message) { + final authorIdentityPublicKey = message.content.author.toVeilid(); + final authorUserId = authorIdentityPublicKey.toString(); + + late final core.User author; + if (authorIdentityPublicKey == _localUserIdentityKey && + currentState.localUser != null) { + author = currentState.localUser!; + } else { + final remoteUser = currentState.remoteUsers[authorUserId]; + if (remoteUser != null) { + author = remoteUser; + } else { + final historicalRemoteUser = + currentState.historicalRemoteUsers[authorUserId]; + if (historicalRemoteUser != null) { + author = historicalRemoteUser; + } else { + final unknownRemoteUser = currentState.unknownUsers[authorUserId]; + if (unknownRemoteUser != null) { + author = unknownRemoteUser; + } else { + final unknownUser = _convertUnknownUser(authorIdentityPublicKey); + currentState = currentState.copyWith( + unknownUsers: + currentState.unknownUsers.add(authorUserId, unknownUser)); + author = unknownUser; + } + } + } + } + + // types.Status? status; + // if (message.sendState != null) { + // assert(author.id == _localUserIdentityKey.toString(), + // 'send state should only be on sent messages'); + // switch (message.sendState!) { + // case MessageSendState.sending: + // status = types.Status.sending; + // case MessageSendState.sent: + // status = types.Status.sent; + // case MessageSendState.delivered: + // status = types.Status.delivered; + // } + // } + + final reconciledAt = message.reconciledTimestamp == null + ? null + : DateTime.fromMicrosecondsSinceEpoch( + message.reconciledTimestamp!.value.toInt()); + + // print('message seqid: ${message.seqId}'); + + switch (message.content.whichKind()) { + case proto.Message_Kind.text: + final reconciledId = message.content.authorUniqueIdString; + final contentText = message.content.text; + final textMessage = core.TextMessage( + authorId: author.id, + createdAt: DateTime.fromMicrosecondsSinceEpoch( + message.sentTimestamp.value.toInt()), + sentAt: reconciledAt, + id: reconciledId, + //text: '${contentText.text} (${message.seqId})', + text: contentText.text, + metadata: { + kSeqId: message.seqId, + kSending: message.sendState == MessageSendState.sending, + if (core.isOnlyEmoji(contentText.text)) 'isOnlyEmoji': true, + }); + return (currentState, textMessage); + case proto.Message_Kind.secret: + case proto.Message_Kind.delete: + case proto.Message_Kind.erase: + case proto.Message_Kind.settings: + case proto.Message_Kind.permissions: + case proto.Message_Kind.membership: + case proto.Message_Kind.moderation: + case proto.Message_Kind.notSet: + case proto.Message_Kind.readReceipt: + return (currentState, null); + } + } + + ChatComponentState _convertMessages(ChatComponentState currentState, + AsyncValue> avMessagesState) { + // Clear out unknown users + currentState = state.copyWith(unknownUsers: const IMap.empty()); + + final asError = avMessagesState.asError; + if (asError != null) { + addError(asError.error, asError.stackTrace); + return currentState.copyWith( + unknownUsers: const IMap.empty(), + messageWindow: AsyncValue.error(asError.error, asError.stackTrace)); + } else if (avMessagesState.asLoading != null) { + return currentState.copyWith( + unknownUsers: const IMap.empty(), + messageWindow: const AsyncValue.loading()); + } + final messagesState = avMessagesState.asData!.value; + + // Convert protobuf messages to chat messages + final chatMessages = []; + final tsSet = {}; + for (final message in messagesState.window) { + final (newState, chatMessage) = + _messageStateToChatMessage(currentState, message); + currentState = newState; + if (chatMessage == null) { + continue; + } + if (!tsSet.add(chatMessage.id)) { + log.error('duplicate id found: ${chatMessage.id}' + // '\nMessages:\n${messagesState.window}' + // '\nChatMessages:\n$chatMessages' + ); + } else { + chatMessages.add(chatMessage); + } + } + return currentState.copyWith( + messageWindow: AsyncValue.data(WindowState( + window: chatMessages.toIList(), + length: messagesState.length, + windowTail: messagesState.windowTail, + windowCount: messagesState.windowCount, + follow: messagesState.follow))); + } + + void _addTextMessage( + {required String text, + String? topic, + Uint8List? replyId, + Timestamp? expiration, + int? viewLimit, + List attachments = const []}) { + final protoMessageText = proto.Message_Text()..text = text; + if (topic != null) { + protoMessageText.topic = topic; + } + if (replyId != null) { + protoMessageText.replyId = replyId; + } + protoMessageText + ..expiration = expiration?.toInt64() ?? Int64.ZERO + ..viewLimit = viewLimit ?? 0; + protoMessageText.attachments.addAll(attachments); + + _messagesCubit.sendTextMessage(messageText: protoMessageText); + } + + //////////////////////////////////////////////////////////////////////////// + + final _initWait = WaitSet(); + final AccountInfo _accountInfo; + final AccountRecordCubit _accountRecordCubit; + final ContactListCubit _contactListCubit; + final List _conversationCubits; + final SingleContactMessagesCubit _messagesCubit; + + late final TypedKey _localUserIdentityKey; + late final StreamSubscription> + _accountRecordSubscription; + final Map>> + _conversationSubscriptions = {}; + late StreamSubscription _messagesSubscription; + late StreamSubscription> + _contactListSubscription; + double scrollOffset = 0; +} diff --git a/lib/chat/cubits/cubits.dart b/lib/chat/cubits/cubits.dart new file mode 100644 index 0000000..ae6b95d --- /dev/null +++ b/lib/chat/cubits/cubits.dart @@ -0,0 +1,3 @@ +export 'active_chat_cubit.dart'; +export 'chat_component_cubit.dart'; +export 'single_contact_messages_cubit.dart'; diff --git a/lib/chat/cubits/reconciliation/author_input_queue.dart b/lib/chat/cubits/reconciliation/author_input_queue.dart new file mode 100644 index 0000000..f750e77 --- /dev/null +++ b/lib/chat/cubits/reconciliation/author_input_queue.dart @@ -0,0 +1,298 @@ +import 'dart:async'; +import 'dart:math'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../../proto/proto.dart' as proto; + +import '../../../tools/tools.dart'; +import 'author_input_source.dart'; +import 'message_integrity.dart'; + +class AuthorInputQueue { + AuthorInputQueue._({ + required TypedKey author, + required AuthorInputSource inputSource, + required int inputPosition, + required proto.Message? previousMessage, + required void Function(Object, StackTrace?) onError, + required MessageIntegrity messageIntegrity, + }) : _author = author, + _onError = onError, + _inputSource = inputSource, + _previousMessage = previousMessage, + _messageIntegrity = messageIntegrity, + _inputPosition = inputPosition; + + static Future create({ + required TypedKey author, + required AuthorInputSource inputSource, + required proto.Message? previousMessage, + required void Function(Object, StackTrace?) onError, + }) async { + // Get ending input position + final inputPosition = await inputSource.getTailPosition() - 1; + + // Create an input queue for the input source + final queue = AuthorInputQueue._( + author: author, + inputSource: inputSource, + inputPosition: inputPosition, + previousMessage: previousMessage, + onError: onError, + messageIntegrity: await MessageIntegrity.create(author: author)); + + // Rewind the queue's 'inputPosition' to the first unreconciled message + if (!await queue._rewindInputToAfterLastMessage()) { + return null; + } + + return queue; + } + + //////////////////////////////////////////////////////////////////////////// + // Public interface + + /// Get the input source for this queue + AuthorInputSource get inputSource => _inputSource; + + /// Get the author of this queue + TypedKey get author => _author; + + /// Get the current message that needs reconciliation + Future getCurrentMessage() async { + try { + // if we have a current message already, return it + if (_currentMessage != null) { + return _currentMessage; + } + + // Get the window + final currentWindow = await _updateWindow(clampInputPosition: false); + if (currentWindow == null) { + return null; + } + final currentElement = + currentWindow.elements[_inputPosition - currentWindow.firstPosition]; + return _currentMessage = currentElement.value; + // Catch everything so we can avoid ParallelWaitError + // ignore: avoid_catches_without_on_clauses + } catch (e, st) { + log.error('Exception getting current message: $e:\n$st\n'); + _currentMessage = null; + return null; + } + } + + /// Move the reconciliation cursor (_inputPosition) forward on the input + /// queue and tees up the next message for processing + /// Returns true if there is more work to do + /// Returns false if there are no more messages to reconcile in this queue + Future advance() async { + // Move current message to previous + _previousMessage = await getCurrentMessage(); + _currentMessage = null; + + while (true) { + // Advance to next position + _inputPosition++; + + // Get more window if we need to + final currentMessage = await getCurrentMessage(); + if (currentMessage == null) { + return false; + } + + if (_previousMessage != null) { + // Ensure the timestamp is not moving backward + if (currentMessage.timestamp < _previousMessage!.timestamp) { + log.warning('timestamp backward: ${currentMessage.timestamp}' + ' < ${_previousMessage!.timestamp}'); + continue; + } + } + + // Verify the id chain for the message + final matchId = + await _messageIntegrity.generateMessageId(_previousMessage); + if (matchId.compare(currentMessage.idBytes) != 0) { + log.warning('id chain invalid: $matchId != ${currentMessage.idBytes}'); + continue; + } + + // Verify the signature for the message + if (!await _messageIntegrity.verifyMessage(currentMessage)) { + log.warning('invalid message signature: $currentMessage'); + continue; + } + + break; + } + return true; + } + + //////////////////////////////////////////////////////////////////////////// + // Internal implementation + + /// Walk backward from the tail of the input queue to find the first + /// message newer than our last reconciled message from this author + /// Returns false if no work is needed + Future _rewindInputToAfterLastMessage() async { + // Iterate windows over the inputSource + InputWindow? currentWindow; + outer: + while (true) { + // Get more window if we need to + currentWindow = await _updateWindow(clampInputPosition: true); + if (currentWindow == null) { + // Window is not available or things are empty so this + // queue can't work right now + return false; + } + + // Iterate through current window backward + for (var i = currentWindow.elements.length - 1; + i >= 0 && _inputPosition >= 0; + i--, _inputPosition--) { + final elem = currentWindow.elements[i]; + + // If we've found an input element that is older or same time as our + // last reconciled message for this author, or we find the message + // itself then we stop + if (_previousMessage != null) { + if (elem.value.authorUniqueIdBytes + .compare(_previousMessage!.authorUniqueIdBytes) == + 0 || + elem.value.timestamp <= _previousMessage!.timestamp) { + break outer; + } + } + } + // If we're at the beginning of the inputSource then we stop + if (_inputPosition < 0) { + break; + } + } + + // _inputPosition points to either before the input source starts + // or the position of the previous element. We still need to set the + // _currentMessage to the previous element so advance() can compare + // against it if we can. + if (_inputPosition >= 0) { + _currentMessage = currentWindow + .elements[_inputPosition - currentWindow.firstPosition].value; + } + + // After this advance(), the _inputPosition and _currentMessage should + // be equal to the first message to process and the current window to + // process should not be empty if there is work to do + return advance(); + } + + /// Slide the window toward the current position and load the batch around it + Future _updateWindow({required bool clampInputPosition}) async { + final inputTailPosition = await _inputSource.getTailPosition(); + if (inputTailPosition == 0) { + return null; + } + + // Handle out-of-range input position + if (clampInputPosition) { + _inputPosition = min(max(_inputPosition, 0), inputTailPosition - 1); + } else if (_inputPosition < 0 || _inputPosition >= inputTailPosition) { + return null; + } + + // Check if we are still in the window + final currentWindow = _currentWindow; + + int firstPosition; + int lastPosition; + if (currentWindow != null) { + firstPosition = currentWindow.firstPosition; + lastPosition = currentWindow.lastPosition; + + // Slide the window if we need to + if (_inputPosition >= firstPosition && _inputPosition <= lastPosition) { + return currentWindow; + } else if (_inputPosition < firstPosition) { + // Slide it backward, current position is now last + firstPosition = max((_inputPosition - _maxWindowLength) + 1, 0); + lastPosition = _inputPosition; + } else if (_inputPosition > lastPosition) { + // Slide it forward, current position is now first + firstPosition = _inputPosition; + lastPosition = + min((_inputPosition + _maxWindowLength) - 1, inputTailPosition - 1); + } + } else { + // need a new window, start with the input position at the end + lastPosition = _inputPosition; + firstPosition = max((_inputPosition - _maxWindowLength) + 1, 0); + } + + // Get another input batch futher back + final avCurrentWindow = await _inputSource.getWindow( + firstPosition, lastPosition - firstPosition + 1); + + final asErr = avCurrentWindow.asError; + if (asErr != null) { + _onError(asErr.error, asErr.stackTrace); + _currentWindow = null; + return null; + } + final asLoading = avCurrentWindow.asLoading; + if (asLoading != null) { + _currentWindow = null; + return null; + } + + final nextWindow = avCurrentWindow.asData!.value; + if (nextWindow == null || nextWindow.length == 0) { + _currentWindow = null; + return null; + } + + // Handle out-of-range input position + // Doing this again because getWindow is allowed to return a smaller + // window than the one requested, possibly due to DHT consistency + // fluctuations and race conditions + if (clampInputPosition) { + _inputPosition = min(max(_inputPosition, nextWindow.firstPosition), + nextWindow.lastPosition); + } else if (_inputPosition < nextWindow.firstPosition || + _inputPosition > nextWindow.lastPosition) { + return null; + } + + return _currentWindow = nextWindow; + } + + //////////////////////////////////////////////////////////////////////////// + + /// The author of this messages in the input source + final TypedKey _author; + + /// The input source we're pulling messages from + final AuthorInputSource _inputSource; + + /// What to call if an error happens + final void Function(Object, StackTrace?) _onError; + + /// The message integrity validator + final MessageIntegrity _messageIntegrity; + + /// The last message we reconciled/output + proto.Message? _previousMessage; + + /// The current message we're looking at + proto.Message? _currentMessage; + + /// The current position in the input source that we are looking at + int _inputPosition; + + /// The current input window from the InputSource; + InputWindow? _currentWindow; + + /// Desired maximum window length + static const _maxWindowLength = 256; +} diff --git a/lib/chat/cubits/reconciliation/author_input_source.dart b/lib/chat/cubits/reconciliation/author_input_source.dart new file mode 100644 index 0000000..e7ba765 --- /dev/null +++ b/lib/chat/cubits/reconciliation/author_input_source.dart @@ -0,0 +1,76 @@ +import 'dart:math'; + +import 'package:async_tools/async_tools.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:meta/meta.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../../proto/proto.dart' as proto; + +@immutable +class InputWindow { + const InputWindow({required this.elements, required this.firstPosition}) + : lastPosition = firstPosition + elements.length - 1, + isEmpty = elements.length == 0, + length = elements.length; + + final IList> elements; + final int firstPosition; + final int lastPosition; + final bool isEmpty; + final int length; +} + +class AuthorInputSource { + AuthorInputSource.fromDHTLog(DHTLog dhtLog) : _dhtLog = dhtLog; + + //////////////////////////////////////////////////////////////////////////// + + Future getTailPosition() => + _dhtLog.operate((reader) async => reader.length); + + Future> getWindow( + int startPosition, int windowLength) => + _dhtLog.operate((reader) async { + // Don't allow negative length + if (windowLength <= 0) { + return const AsyncValue.data(null); + } + // Trim if we're beyond input source + var endPosition = startPosition + windowLength - 1; + startPosition = max(startPosition, 0); + endPosition = max(endPosition, 0); + + // Get another input batch futher back + try { + Set? offlinePositions; + if (_dhtLog.writer != null) { + offlinePositions = await reader.getOfflinePositions(); + } + + final messages = await reader.getRangeProtobuf( + proto.Message.fromBuffer, startPosition, + length: endPosition - startPosition + 1); + if (messages == null) { + return const AsyncValue.loading(); + } + + final elements = messages.indexed + .map((x) => OnlineElementState( + value: x.$2, + isOffline: offlinePositions?.contains(x.$1 + startPosition) ?? + false)) + .toIList(); + + final window = + InputWindow(elements: elements, firstPosition: startPosition); + + return AsyncValue.data(window); + } on Exception catch (e, st) { + return AsyncValue.error(e, st); + } + }); + + //////////////////////////////////////////////////////////////////////////// + final DHTLog _dhtLog; +} diff --git a/lib/chat/cubits/reconciliation/message_integrity.dart b/lib/chat/cubits/reconciliation/message_integrity.dart new file mode 100644 index 0000000..40b3b18 --- /dev/null +++ b/lib/chat/cubits/reconciliation/message_integrity.dart @@ -0,0 +1,79 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:protobuf/protobuf.dart'; +import 'package:veilid_support/veilid_support.dart'; +import '../../../proto/proto.dart' as proto; + +class MessageIntegrity { + MessageIntegrity._({ + required TypedKey author, + required VeilidCryptoSystem crypto, + }) : _author = author, + _crypto = crypto; + static Future create({required TypedKey author}) async { + final crypto = await Veilid.instance.getCryptoSystem(author.kind); + return MessageIntegrity._(author: author, crypto: crypto); + } + + //////////////////////////////////////////////////////////////////////////// + // Public interface + + Future generateMessageId(proto.Message? previous) { + if (previous == null) { + // If there's no last sent message, + // we start at a hash of the identity public key + return _generateInitialId(); + } else { + // If there is a last message, we generate the hash + // of the last message's signature and use it as our next id + return _hashSignature(previous.signature); + } + } + + Future signMessage( + proto.Message message, + SecretKey authorSecret, + ) async { + // Ensure this message is not already signed + assert(!message.hasSignature(), 'should not sign message twice'); + // Generate data to sign + final data = Uint8List.fromList(utf8.encode(message.writeToJson())); + + // Sign with our identity + final signature = await _crypto.sign(_author.value, authorSecret, data); + + // Add to the message + message.signature = signature.toProto(); + } + + // XXX: change bool to an enum to allow for reconciling deleted + // XXX: messages. if a message is deleted it will not verify, but its + // XXX: signature will still be in place for the message chain. + // XXX: it should be added to a list to check for a ControlDelete that + // XXX: appears later in the message log. + Future verifyMessage(proto.Message message) { + // Ensure the message is signed + assert(message.hasSignature(), 'should not verify unsigned message'); + final signature = message.signature.toVeilid(); + + // Generate data to sign + final messageNoSig = message.deepCopy()..clearSignature(); + final data = Uint8List.fromList(utf8.encode(messageNoSig.writeToJson())); + + // Verify signature + return _crypto.verify(_author.value, data, signature); + } + + //////////////////////////////////////////////////////////////////////////// + // Private implementation + + Future _generateInitialId() async => + (await _crypto.generateHash(_author.decode())).decode(); + + Future _hashSignature(proto.Signature signature) async => + (await _crypto.generateHash(signature.toVeilid().decode())).decode(); + //////////////////////////////////////////////////////////////////////////// + final TypedKey _author; + final VeilidCryptoSystem _crypto; +} diff --git a/lib/chat/cubits/reconciliation/message_reconciliation.dart b/lib/chat/cubits/reconciliation/message_reconciliation.dart new file mode 100644 index 0000000..f6d46c3 --- /dev/null +++ b/lib/chat/cubits/reconciliation/message_reconciliation.dart @@ -0,0 +1,276 @@ +import 'dart:async'; + +import 'package:async_tools/async_tools.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:sorted_list/sorted_list.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../../proto/proto.dart' as proto; +import '../../../tools/tools.dart'; +import 'author_input_queue.dart'; +import 'author_input_source.dart'; +import 'output_position.dart'; + +class MessageReconciliation { + MessageReconciliation( + {required TableDBArrayProtobufCubit output, + required void Function(Object, StackTrace?) onError}) + : _outputCubit = output, + _onError = onError; + + //////////////////////////////////////////////////////////////////////////// + + void addInputSourceFromDHTLog(TypedKey author, DHTLog inputMessagesDHTLog) { + _inputSources[author] = AuthorInputSource.fromDHTLog(inputMessagesDHTLog); + } + + void reconcileMessages(TypedKey? author) { + // xxx: can we use 'author' here to optimize _updateAuthorInputQueues? + + singleFuture(this, onError: _onError, () async { + // Update queues + final activeInputQueues = await _updateAuthorInputQueues(); + + // Process all input queues together + await _outputCubit.operate((reconciledArray) => _reconcileInputQueues( + reconciledArray: reconciledArray, + activeInputQueues: activeInputQueues, + )); + }); + } + + //////////////////////////////////////////////////////////////////////////// + + // Prepare author input queues by removing dead ones and adding new ones + // Queues that are empty are not added until they have something in them + // Active input queues with a current message are returned in a list + Future> _updateAuthorInputQueues() async { + // Remove any dead input queues + final deadQueues = []; + for (final author in _inputQueues.keys) { + if (!_inputSources.containsKey(author)) { + deadQueues.add(author); + } + } + for (final author in deadQueues) { + _inputQueues.remove(author); + _outputPositions.remove(author); + } + + await _outputCubit.operate((outputArray) async { + final dws = DelayedWaitSet(); + + for (final kv in _inputSources.entries) { + final author = kv.key; + final inputSource = kv.value; + + final iqExisting = _inputQueues[author]; + if (iqExisting == null || iqExisting.inputSource != inputSource) { + dws.add((_) async { + try { + await _enqueueAuthorInput( + author: author, + inputSource: inputSource, + outputArray: outputArray); + // Catch everything so we can avoid ParallelWaitError + // ignore: avoid_catches_without_on_clauses + } catch (e, st) { + log.error('Exception updating author input queue: $e:\n$st\n'); + _inputQueues.remove(author); + _outputPositions.remove(author); + } + }); + } + } + + await dws(); + }); + + // Get the active input queues + final activeInputQueues = await _inputQueues.entries + .map((entry) async { + if (await entry.value.getCurrentMessage() != null) { + return entry.value; + } else { + return null; + } + }) + .toList() + .wait + ..removeNulls(); + + return activeInputQueues.cast(); + } + + // Set up a single author's message reconciliation + Future _enqueueAuthorInput( + {required TypedKey author, + required AuthorInputSource inputSource, + required TableDBArrayProtobuf + outputArray}) async { + // Get the position of our most recent reconciled message from this author + final outputPosition = + await _findLastOutputPosition(author: author, outputArray: outputArray); + + // Find oldest message we have not yet reconciled + final inputQueue = await AuthorInputQueue.create( + author: author, + inputSource: inputSource, + previousMessage: outputPosition?.message.content, + onError: _onError, + ); + + if (inputQueue != null) { + _inputQueues[author] = inputQueue; + _outputPositions[author] = outputPosition; + } else { + _inputQueues.remove(author); + _outputPositions.remove(author); + } + } + + // Get the position of our most recent reconciled message from this author + // XXX: For a group chat, this should find when the author + // was added to the membership so we don't just go back in time forever + Future _findLastOutputPosition( + {required TypedKey author, + required TableDBArrayProtobuf + outputArray}) async { + var pos = outputArray.length - 1; + while (pos >= 0) { + final message = await outputArray.get(pos); + if (message.content.author.toVeilid() == author) { + return OutputPosition(message, pos); + } + pos--; + } + return null; + } + + // Process a list of author input queues and insert their messages + // into the output array, performing validation steps along the way + Future _reconcileInputQueues({ + required TableDBArrayProtobuf reconciledArray, + required List activeInputQueues, + }) async { + // Ensure we have active queues to process + if (activeInputQueues.isEmpty) { + return; + } + + // Sort queues from earliest to latest and then by author + // to ensure a deterministic insert order + activeInputQueues.sort((a, b) { + final aout = _outputPositions[a.author]; + final bout = _outputPositions[b.author]; + final acmp = aout?.pos ?? -1; + final bcmp = bout?.pos ?? -1; + if (acmp == bcmp) { + return a.author.toString().compareTo(b.author.toString()); + } + return acmp.compareTo(bcmp); + }); + + // Start at the earliest position we know about in all the queues + var currentOutputPosition = + _outputPositions[activeInputQueues.first.author]; + + final toInsert = + SortedList(proto.MessageExt.compareTimestamp); + + while (activeInputQueues.isNotEmpty) { + // Get up to '_maxReconcileChunk' of the items from the queues + // that we can insert at this location + + bool added; + do { + added = false; + + final emptyQueues = {}; + for (final inputQueue in activeInputQueues) { + final inputCurrent = await inputQueue.getCurrentMessage(); + if (inputCurrent == null) { + log.error('Active input queue did not have a current message: ' + '${inputQueue.author}'); + continue; + } + if (currentOutputPosition == null || + inputCurrent.timestamp < + currentOutputPosition.message.content.timestamp) { + toInsert.add(inputCurrent); + added = true; + + // Advance this queue + if (!await inputQueue.advance()) { + // Mark queue as empty for removal + emptyQueues.add(inputQueue); + } + } + } + // Remove finished queues now that we're done iterating + activeInputQueues.removeWhere(emptyQueues.contains); + + if (toInsert.length >= _maxReconcileChunk) { + break; + } + } while (added); + + // Perform insertions in bulk + if (toInsert.isNotEmpty) { + final reconciledTime = Veilid.instance.now().toInt64(); + + // Add reconciled timestamps + final reconciledInserts = toInsert + .map((message) => proto.ReconciledMessage() + ..reconciledTime = reconciledTime + ..content = message) + .toList(); + + // Figure out where to insert the reconciled messages + final insertPos = currentOutputPosition?.pos ?? reconciledArray.length; + + // Insert them all at once + await reconciledArray.insertAll(insertPos, reconciledInserts); + + // Update output positions for input queues + final updatePositions = _outputPositions.keys.toSet(); + var outputPos = insertPos + reconciledInserts.length; + for (final inserted in reconciledInserts.reversed) { + if (updatePositions.isEmpty) { + // Last seen positions already recorded for each active author + break; + } + outputPos--; + final author = inserted.content.author.toVeilid(); + if (updatePositions.contains(author)) { + _outputPositions[author] = OutputPosition(inserted, outputPos); + updatePositions.remove(author); + } + } + + toInsert.clear(); + } else { + // If there's nothing to insert at this position move to the next one + final nextOutputPos = (currentOutputPosition != null) + ? currentOutputPosition.pos + 1 + : reconciledArray.length; + if (nextOutputPos == reconciledArray.length) { + currentOutputPosition = null; + } else { + currentOutputPosition = OutputPosition( + await reconciledArray.get(nextOutputPos), nextOutputPos); + } + } + } + } + + //////////////////////////////////////////////////////////////////////////// + + final Map _inputSources = {}; + final Map _inputQueues = {}; + final Map _outputPositions = {}; + final TableDBArrayProtobufCubit _outputCubit; + final void Function(Object, StackTrace?) _onError; + + static const _maxReconcileChunk = 65536; +} diff --git a/lib/chat/cubits/reconciliation/output_position.dart b/lib/chat/cubits/reconciliation/output_position.dart new file mode 100644 index 0000000..d983c95 --- /dev/null +++ b/lib/chat/cubits/reconciliation/output_position.dart @@ -0,0 +1,13 @@ +import 'package:equatable/equatable.dart'; +import 'package:meta/meta.dart'; + +import '../../../proto/proto.dart' as proto; + +@immutable +class OutputPosition extends Equatable { + const OutputPosition(this.message, this.pos); + final proto.ReconciledMessage message; + final int pos; + @override + List get props => [message, pos]; +} diff --git a/lib/chat/cubits/reconciliation/reconciliation.dart b/lib/chat/cubits/reconciliation/reconciliation.dart new file mode 100644 index 0000000..a8187cf --- /dev/null +++ b/lib/chat/cubits/reconciliation/reconciliation.dart @@ -0,0 +1,2 @@ +export 'message_integrity.dart'; +export 'message_reconciliation.dart'; diff --git a/lib/chat/cubits/single_contact_messages_cubit.dart b/lib/chat/cubits/single_contact_messages_cubit.dart new file mode 100644 index 0000000..0ec1037 --- /dev/null +++ b/lib/chat/cubits/single_contact_messages_cubit.dart @@ -0,0 +1,537 @@ +import 'dart:async'; + +import 'package:async_tools/async_tools.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:uuid/uuid.dart'; +import 'package:uuid/v4.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../account_manager/account_manager.dart'; +import '../../proto/proto.dart' as proto; +import '../../tools/tools.dart'; +import '../models/models.dart'; +import 'reconciliation/reconciliation.dart'; + +const _sfSendMessageTag = 'sfSendMessageTag'; + +class RenderStateElement { + RenderStateElement( + {required this.seqId, + required this.message, + required this.isLocal, + this.reconciledTimestamp, + this.sent = false, + this.sentOffline = false}); + + MessageSendState? get sendState { + if (!isLocal) { + return null; + } + if (reconciledTimestamp != null) { + return MessageSendState.delivered; + } + if (sent) { + if (!sentOffline) { + return MessageSendState.sent; + } else { + return MessageSendState.sending; + } + } + return null; + } + + int seqId; + proto.Message message; + bool isLocal; + Timestamp? reconciledTimestamp; + bool sent; + bool sentOffline; +} + +typedef SingleContactMessagesState = AsyncValue>; + +// Cubit that processes single-contact chats +// Builds the reconciled chat record from the local and remote conversation keys +class SingleContactMessagesCubit extends Cubit { + SingleContactMessagesCubit({ + required AccountInfo accountInfo, + required TypedKey remoteIdentityPublicKey, + required TypedKey localConversationRecordKey, + required TypedKey localMessagesRecordKey, + required TypedKey remoteConversationRecordKey, + required TypedKey? remoteMessagesRecordKey, + }) : _accountInfo = accountInfo, + _remoteIdentityPublicKey = remoteIdentityPublicKey, + _localConversationRecordKey = localConversationRecordKey, + _localMessagesRecordKey = localMessagesRecordKey, + _remoteConversationRecordKey = remoteConversationRecordKey, + _remoteMessagesRecordKey = remoteMessagesRecordKey, + _commandController = StreamController(), + super(const AsyncValue.loading()) { + // Async Init + _initWait.add(_init); + } + + @override + Future close() async { + await _initWait(); + + await serialFutureClose((this, _sfSendMessageTag)); + + await _commandController.close(); + await _commandRunnerFut; + await _unsentMessagesQueue.close(); + await _sentSubscription?.cancel(); + await _rcvdSubscription?.cancel(); + await _reconciledSubscription?.cancel(); + await _sentMessagesDHTLog?.close(); + await _rcvdMessagesDHTLog?.close(); + await _reconciledMessagesCubit?.close(); + + // If the local conversation record is gone, then delete the reconciled + // messages table as well + final conversationDead = await DHTRecordPool.instance + .isDeletedRecordKey(_localConversationRecordKey); + if (conversationDead) { + await SingleContactMessagesCubit.cleanupAndDeleteMessages( + localConversationRecordKey: _localConversationRecordKey); + } + + await super.close(); + } + + // Initialize everything + Future _init(Completer _) async { + _unsentMessagesQueue = PersistentQueue( + table: 'SingleContactUnsentMessages', + key: _remoteConversationRecordKey.toString(), + fromBuffer: proto.Message.fromBuffer, + toBuffer: (x) => x.writeToBuffer(), + closure: _processUnsentMessages, + onError: (e, st) { + log.error('Exception while processing unsent messages: $e\n$st\n'); + }); + + // Make crypto + await _initCrypto(); + + // Reconciled messages key + await _initReconciledMessagesCubit(); + + // Local messages key + await _initSentMessagesDHTLog(); + + // Remote messages key + await _initRcvdMessagesDHTLog(); + + // Command execution background process + _commandRunnerFut = Future.delayed(Duration.zero, _commandRunner); + + // Run reconciliation once for all input queues + _reconciliation.reconcileMessages(null); + } + + // Make crypto + Future _initCrypto() async { + _conversationCrypto = + await _accountInfo.makeConversationCrypto(_remoteIdentityPublicKey); + _senderMessageIntegrity = await MessageIntegrity.create( + author: _accountInfo.identityTypedPublicKey); + } + + // Open local messages key + Future _initSentMessagesDHTLog() async { + final writer = _accountInfo.identityWriter; + + final sentMessagesDHTLog = + await DHTLog.openWrite(_localMessagesRecordKey, writer, + debugName: 'SingleContactMessagesCubit::_initSentMessagesCubit::' + 'SentMessages', + parent: _localConversationRecordKey, + crypto: _conversationCrypto); + _sentSubscription = await sentMessagesDHTLog.listen(_updateSentMessages); + + _sentMessagesDHTLog = sentMessagesDHTLog; + _reconciliation.addInputSourceFromDHTLog( + _accountInfo.identityTypedPublicKey, sentMessagesDHTLog); + } + + // Open remote messages key + Future _initRcvdMessagesDHTLog() async { + // Don't bother if we don't have a remote messages record key yet + if (_remoteMessagesRecordKey == null) { + return; + } + + // Open new cubit if one is desired + final rcvdMessagesDHTLog = await DHTLog.openRead(_remoteMessagesRecordKey!, + debugName: 'SingleContactMessagesCubit::_initRcvdMessagesCubit::' + 'RcvdMessages', + parent: _remoteConversationRecordKey, + crypto: _conversationCrypto); + _rcvdSubscription = await rcvdMessagesDHTLog.listen(_updateRcvdMessages); + + _rcvdMessagesDHTLog = rcvdMessagesDHTLog; + _reconciliation.addInputSourceFromDHTLog( + _remoteIdentityPublicKey, rcvdMessagesDHTLog); + } + + void updateRemoteMessagesRecordKey(TypedKey? remoteMessagesRecordKey) { + _sspRemoteConversationRecordKey.updateState(remoteMessagesRecordKey, + (remoteMessagesRecordKey) async { + await _initWait(); + + // Don't bother if nothing is changing + if (_remoteMessagesRecordKey == remoteMessagesRecordKey) { + return; + } + + // Close existing DHTLog if we have one + final rcvdMessagesDHTLog = _rcvdMessagesDHTLog; + _rcvdMessagesDHTLog = null; + _remoteMessagesRecordKey = null; + await _rcvdSubscription?.cancel(); + _rcvdSubscription = null; + await rcvdMessagesDHTLog?.close(); + + // Init the new DHTLog if we should + _remoteMessagesRecordKey = remoteMessagesRecordKey; + await _initRcvdMessagesDHTLog(); + + // Run reconciliation once for all input queues + _reconciliation.reconcileMessages(null); + }); + } + + Future _makeLocalMessagesCrypto() => + VeilidCryptoPrivate.fromTypedKey( + _accountInfo.userLogin!.identitySecret, 'tabledb'); + + // Open reconciled chat record key + Future _initReconciledMessagesCubit() async { + final tableName = + _reconciledMessagesTableDBName(_localConversationRecordKey); + + final crypto = await _makeLocalMessagesCrypto(); + + _reconciledMessagesCubit = TableDBArrayProtobufCubit( + open: () => TableDBArrayProtobuf.make( + table: tableName, + crypto: crypto, + fromBuffer: proto.ReconciledMessage.fromBuffer), + ); + + _reconciliation = MessageReconciliation( + output: _reconciledMessagesCubit!, + onError: (e, st) { + addError(e, st); + emit(AsyncValue.error(e, st)); + }); + + _reconciledSubscription = + _reconciledMessagesCubit!.stream.listen(_updateReconciledMessagesState); + _updateReconciledMessagesState(_reconciledMessagesCubit!.state); + } + + //////////////////////////////////////////////////////////////////////////// + // Public interface + + // Set the tail position of the log for pagination. + // If tail is 0, the end of the log is used. + // If tail is negative, the position is subtracted from the current log + // length. + // If tail is positive, the position is absolute from the head of the log + // If follow is enabled, the tail offset will update when the log changes + Future setWindow( + {int? tail, int? count, bool? follow, bool forceRefresh = false}) async { + await _initWait(); + + // print('setWindow: tail=$tail count=$count, follow=$follow'); + + await _reconciledMessagesCubit!.setWindow( + tail: tail, count: count, follow: follow, forceRefresh: forceRefresh); + } + + // Set a user-visible 'text' message with possible attachments + void sendTextMessage({required proto.Message_Text messageText}) { + final message = proto.Message()..text = messageText; + _sendMessage(message: message); + } + + // Run a chat command + void runCommand(String command) { + final (cmd, rest) = command.splitOnce(' '); + + if (kIsDebugMode) { + if (cmd == '/repeat' && rest != null) { + final (countStr, text) = rest.splitOnce(' '); + final count = int.tryParse(countStr); + if (count != null) { + runCommandRepeat(count, text ?? ''); + } + } + } + } + + // Run a repeat command + void runCommandRepeat(int count, String text) { + _commandController.sink.add(() async { + for (var i = 0; i < count; i++) { + final protoMessageText = proto.Message_Text() + ..text = text.replaceAll(RegExp(r'\$n\b'), i.toString()); + final message = proto.Message()..text = protoMessageText; + _sendMessage(message: message); + await Future.delayed(const Duration(milliseconds: 50)); + } + }); + } + + //////////////////////////////////////////////////////////////////////////// + // Internal implementation + + // Called when the sent messages DHTLog gets a change + // This will re-render when messages are sent from another machine + void _updateSentMessages(DHTLogUpdate upd) { + _reconciliation.reconcileMessages(_accountInfo.identityTypedPublicKey); + } + + // Called when the received messages DHTLog gets a change + void _updateRcvdMessages(DHTLogUpdate upd) { + _reconciliation.reconcileMessages(_remoteIdentityPublicKey); + } + + // Called when the reconciled messages window gets a change + void _updateReconciledMessagesState( + TableDBArrayProtobufBusyState avmessages) { + // Update the view + _renderState(); + } + + Future _processMessageToSend( + proto.Message message, proto.Message? previousMessage) async { + // It's possible we had a signature from a previous + // operateAppendEventual attempt, so clear it and make a new message id too + message + ..clearSignature() + ..id = await _senderMessageIntegrity.generateMessageId(previousMessage); + + // Now sign it + await _senderMessageIntegrity.signMessage( + message, _accountInfo.identitySecretKey); + } + + // Async process to send messages in the background + Future _processUnsentMessages(IList messages) async { + try { + await _sentMessagesDHTLog!.operateAppendEventual((writer) async { + // Get the previous message if we have one + var previousMessage = writer.length == 0 + ? null + : await writer.getProtobuf( + proto.Message.fromBuffer, writer.length - 1); + + // Sign all messages + final processedMessages = messages.toList(); + for (final message in processedMessages) { + try { + await _processMessageToSend(message, previousMessage); + previousMessage = message; + } on Exception catch (e, st) { + log.error('Exception processing unsent message: $e:\n$st\n'); + } + } + final byteMessages = messages.map((m) => m.writeToBuffer()).toList(); + + return writer.addAll(byteMessages); + }); + } on Exception catch (e, st) { + log.error('Exception appending unsent messages: $e:\n$st\n'); + } + } + + // Produce a state for this cubit from the input cubits and queues + void _renderState() { + // Get all reconciled messages in the cubit window + final reconciledMessages = + _reconciledMessagesCubit?.state.state.asData?.value; + + // Get all sent messages that are still offline + //final sentMessages = _sentMessagesDHTLog. + + // Get all items in the unsent queue + final unsentMessages = _unsentMessagesQueue.queue; + + // If we aren't ready to render a state, say we're loading + if (reconciledMessages == null) { + emit(const AsyncLoading()); + return; + } + + // Generate state for each message + // final reconciledMessagesMap = + // IMap.fromValues( + // keyMapper: (x) => x.content.authorUniqueIdString, + // values: reconciledMessages.windowElements, + // ); + // final sentMessagesMap = + // IMap>.fromValues( + // keyMapper: (x) => x.value.authorUniqueIdString, + // values: sentMessages.window, + // ); + // final unsentMessagesMap = IMap.fromValues( + // keyMapper: (x) => x.authorUniqueIdString, + // values: unsentMessages, + // ); + + // List of all rendered state elements that we will turn into + // message states + final renderedElements = []; + + // Keep track of the ids we have rendered + // because there can be an overlap between the 'unsent messages' + // and the reconciled messages as the async state catches up + final renderedIds = {}; + + var seqId = (reconciledMessages.windowTail == 0 + ? reconciledMessages.length + : reconciledMessages.windowTail) - + reconciledMessages.windowElements.length; + for (final m in reconciledMessages.windowElements) { + final isLocal = + m.content.author.toVeilid() == _accountInfo.identityTypedPublicKey; + final reconciledTimestamp = Timestamp.fromInt64(m.reconciledTime); + //final sm = + //isLocal ? sentMessagesMap[m.content.authorUniqueIdString] : null; + //final sent = isLocal && sm != null; + //final sentOffline = isLocal && sm != null && sm.isOffline; + final sent = isLocal; + final sentOffline = false; // + + if (renderedIds.contains(m.content.authorUniqueIdString)) { + seqId++; + continue; + } + renderedElements.add(RenderStateElement( + seqId: seqId, + message: m.content, + isLocal: isLocal, + reconciledTimestamp: reconciledTimestamp, + sent: sent, + sentOffline: sentOffline, + )); + renderedIds.add(m.content.authorUniqueIdString); + seqId++; + } + + // Render in-flight messages at the bottom + + for (final m in unsentMessages) { + if (renderedIds.contains(m.authorUniqueIdString)) { + seqId++; + continue; + } + renderedElements.add(RenderStateElement( + seqId: seqId, + message: m, + isLocal: true, + sent: true, + sentOffline: true, + )); + renderedIds.add(m.authorUniqueIdString); + seqId++; + } + + // Render the state + final messages = renderedElements + .map((x) => MessageState( + seqId: x.seqId, + content: x.message, + sentTimestamp: Timestamp.fromInt64(x.message.timestamp), + reconciledTimestamp: x.reconciledTimestamp, + sendState: x.sendState)) + .toIList(); + + // Emit the rendered state + emit(AsyncValue.data(WindowState( + window: messages, + length: reconciledMessages.length, + windowTail: reconciledMessages.windowTail, + windowCount: reconciledMessages.windowCount, + follow: reconciledMessages.follow))); + } + + void _sendMessage({required proto.Message message}) { + // Add common fields + // real id and signature will get set by _processMessageToSend + // temporary id set here is random and not 'valid' in the eyes + // of reconcilation, noting that reconciled timestamp is not yet set. + message + ..author = _accountInfo.identityTypedPublicKey.toProto() + ..timestamp = Veilid.instance.now().toInt64() + ..id = Uuid.parse(_uuidGen.generate()); + + if ((message.writeToBuffer().lengthInBytes + 256) > 4096) { + throw const FormatException('message is too long'); + } + + // Put in the queue + serialFuture((this, _sfSendMessageTag), () async { + // Add the message to the persistent queue + await _unsentMessagesQueue.add(message); + + // Update the view + _renderState(); + }); + } + + Future _commandRunner() async { + await for (final command in _commandController.stream) { + await command(); + } + } + + ///////////////////////////////////////////////////////////////////////// + // Static utility functions + + static Future cleanupAndDeleteMessages( + {required TypedKey localConversationRecordKey}) async { + final recmsgdbname = + _reconciledMessagesTableDBName(localConversationRecordKey); + await Veilid.instance.deleteTableDB(recmsgdbname); + } + + static String _reconciledMessagesTableDBName( + TypedKey localConversationRecordKey) => + 'msg_${localConversationRecordKey.toString().replaceAll(':', '_')}'; + + ///////////////////////////////////////////////////////////////////////// + + final WaitSet _initWait = WaitSet(); + late final AccountInfo _accountInfo; + final TypedKey _remoteIdentityPublicKey; + final TypedKey _localConversationRecordKey; + final TypedKey _localMessagesRecordKey; + final TypedKey _remoteConversationRecordKey; + TypedKey? _remoteMessagesRecordKey; + + late final VeilidCrypto _conversationCrypto; + late final MessageIntegrity _senderMessageIntegrity; + + DHTLog? _sentMessagesDHTLog; + DHTLog? _rcvdMessagesDHTLog; + TableDBArrayProtobufCubit? _reconciledMessagesCubit; + + late final MessageReconciliation _reconciliation; + + late final PersistentQueue _unsentMessagesQueue; + StreamSubscription? _sentSubscription; + StreamSubscription? _rcvdSubscription; + StreamSubscription>? + _reconciledSubscription; + final StreamController Function()> _commandController; + late final Future _commandRunnerFut; + + final _sspRemoteConversationRecordKey = SingleStateProcessor(); + final _uuidGen = const UuidV4(); +} diff --git a/lib/chat/models/chat_component_state.dart b/lib/chat/models/chat_component_state.dart new file mode 100644 index 0000000..1a52524 --- /dev/null +++ b/lib/chat/models/chat_component_state.dart @@ -0,0 +1,26 @@ +import 'package:async_tools/async_tools.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'window_state.dart'; + +part 'chat_component_state.freezed.dart'; + +@freezed +sealed class ChatComponentState with _$ChatComponentState { + const factory ChatComponentState( + { + // Local user + required User? localUser, + // Active remote users + required IMap remoteUsers, + // Historical remote users + required IMap historicalRemoteUsers, + // Unknown users + required IMap unknownUsers, + // Messages state + required AsyncValue> messageWindow, + // Title of the chat + required String title}) = _ChatComponentState; +} diff --git a/lib/chat/models/chat_component_state.freezed.dart b/lib/chat/models/chat_component_state.freezed.dart new file mode 100644 index 0000000..bbecc7a --- /dev/null +++ b/lib/chat/models/chat_component_state.freezed.dart @@ -0,0 +1,316 @@ +// dart format width=80 +// 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 'chat_component_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$ChatComponentState { +// Local user + User? get localUser; // Active remote users + IMap get remoteUsers; // Historical remote users + IMap get historicalRemoteUsers; // Unknown users + IMap get unknownUsers; // Messages state + AsyncValue> get messageWindow; // Title of the chat + String get title; + + /// Create a copy of ChatComponentState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $ChatComponentStateCopyWith get copyWith => + _$ChatComponentStateCopyWithImpl( + this as ChatComponentState, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is ChatComponentState && + (identical(other.localUser, localUser) || + other.localUser == localUser) && + (identical(other.remoteUsers, remoteUsers) || + other.remoteUsers == remoteUsers) && + (identical(other.historicalRemoteUsers, historicalRemoteUsers) || + other.historicalRemoteUsers == historicalRemoteUsers) && + (identical(other.unknownUsers, unknownUsers) || + other.unknownUsers == unknownUsers) && + (identical(other.messageWindow, messageWindow) || + other.messageWindow == messageWindow) && + (identical(other.title, title) || other.title == title)); + } + + @override + int get hashCode => Object.hash(runtimeType, localUser, remoteUsers, + historicalRemoteUsers, unknownUsers, messageWindow, title); + + @override + String toString() { + return 'ChatComponentState(localUser: $localUser, remoteUsers: $remoteUsers, historicalRemoteUsers: $historicalRemoteUsers, unknownUsers: $unknownUsers, messageWindow: $messageWindow, title: $title)'; + } +} + +/// @nodoc +abstract mixin class $ChatComponentStateCopyWith<$Res> { + factory $ChatComponentStateCopyWith( + ChatComponentState value, $Res Function(ChatComponentState) _then) = + _$ChatComponentStateCopyWithImpl; + @useResult + $Res call( + {User? localUser, + IMap remoteUsers, + IMap historicalRemoteUsers, + IMap unknownUsers, + AsyncValue> messageWindow, + String title}); + + $UserCopyWith<$Res>? get localUser; + $AsyncValueCopyWith, $Res> get messageWindow; +} + +/// @nodoc +class _$ChatComponentStateCopyWithImpl<$Res> + implements $ChatComponentStateCopyWith<$Res> { + _$ChatComponentStateCopyWithImpl(this._self, this._then); + + final ChatComponentState _self; + final $Res Function(ChatComponentState) _then; + + /// Create a copy of ChatComponentState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? localUser = freezed, + Object? remoteUsers = null, + Object? historicalRemoteUsers = null, + Object? unknownUsers = null, + Object? messageWindow = null, + Object? title = null, + }) { + return _then(_self.copyWith( + localUser: freezed == localUser + ? _self.localUser + : localUser // ignore: cast_nullable_to_non_nullable + as User?, + remoteUsers: null == remoteUsers + ? _self.remoteUsers + : remoteUsers // ignore: cast_nullable_to_non_nullable + as IMap, + historicalRemoteUsers: null == historicalRemoteUsers + ? _self.historicalRemoteUsers + : historicalRemoteUsers // ignore: cast_nullable_to_non_nullable + as IMap, + unknownUsers: null == unknownUsers + ? _self.unknownUsers + : unknownUsers // ignore: cast_nullable_to_non_nullable + as IMap, + messageWindow: null == messageWindow + ? _self.messageWindow + : messageWindow // ignore: cast_nullable_to_non_nullable + as AsyncValue>, + title: null == title + ? _self.title + : title // ignore: cast_nullable_to_non_nullable + as String, + )); + } + + /// Create a copy of ChatComponentState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $UserCopyWith<$Res>? get localUser { + if (_self.localUser == null) { + return null; + } + + return $UserCopyWith<$Res>(_self.localUser!, (value) { + return _then(_self.copyWith(localUser: value)); + }); + } + + /// Create a copy of ChatComponentState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $AsyncValueCopyWith, $Res> get messageWindow { + return $AsyncValueCopyWith, $Res>(_self.messageWindow, + (value) { + return _then(_self.copyWith(messageWindow: value)); + }); + } +} + +/// @nodoc + +class _ChatComponentState implements ChatComponentState { + const _ChatComponentState( + {required this.localUser, + required this.remoteUsers, + required this.historicalRemoteUsers, + required this.unknownUsers, + required this.messageWindow, + required this.title}); + +// Local user + @override + final User? localUser; +// Active remote users + @override + final IMap remoteUsers; +// Historical remote users + @override + final IMap historicalRemoteUsers; +// Unknown users + @override + final IMap unknownUsers; +// Messages state + @override + final AsyncValue> messageWindow; +// Title of the chat + @override + final String title; + + /// Create a copy of ChatComponentState + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$ChatComponentStateCopyWith<_ChatComponentState> get copyWith => + __$ChatComponentStateCopyWithImpl<_ChatComponentState>(this, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _ChatComponentState && + (identical(other.localUser, localUser) || + other.localUser == localUser) && + (identical(other.remoteUsers, remoteUsers) || + other.remoteUsers == remoteUsers) && + (identical(other.historicalRemoteUsers, historicalRemoteUsers) || + other.historicalRemoteUsers == historicalRemoteUsers) && + (identical(other.unknownUsers, unknownUsers) || + other.unknownUsers == unknownUsers) && + (identical(other.messageWindow, messageWindow) || + other.messageWindow == messageWindow) && + (identical(other.title, title) || other.title == title)); + } + + @override + int get hashCode => Object.hash(runtimeType, localUser, remoteUsers, + historicalRemoteUsers, unknownUsers, messageWindow, title); + + @override + String toString() { + return 'ChatComponentState(localUser: $localUser, remoteUsers: $remoteUsers, historicalRemoteUsers: $historicalRemoteUsers, unknownUsers: $unknownUsers, messageWindow: $messageWindow, title: $title)'; + } +} + +/// @nodoc +abstract mixin class _$ChatComponentStateCopyWith<$Res> + implements $ChatComponentStateCopyWith<$Res> { + factory _$ChatComponentStateCopyWith( + _ChatComponentState value, $Res Function(_ChatComponentState) _then) = + __$ChatComponentStateCopyWithImpl; + @override + @useResult + $Res call( + {User? localUser, + IMap remoteUsers, + IMap historicalRemoteUsers, + IMap unknownUsers, + AsyncValue> messageWindow, + String title}); + + @override + $UserCopyWith<$Res>? get localUser; + @override + $AsyncValueCopyWith, $Res> get messageWindow; +} + +/// @nodoc +class __$ChatComponentStateCopyWithImpl<$Res> + implements _$ChatComponentStateCopyWith<$Res> { + __$ChatComponentStateCopyWithImpl(this._self, this._then); + + final _ChatComponentState _self; + final $Res Function(_ChatComponentState) _then; + + /// Create a copy of ChatComponentState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? localUser = freezed, + Object? remoteUsers = null, + Object? historicalRemoteUsers = null, + Object? unknownUsers = null, + Object? messageWindow = null, + Object? title = null, + }) { + return _then(_ChatComponentState( + localUser: freezed == localUser + ? _self.localUser + : localUser // ignore: cast_nullable_to_non_nullable + as User?, + remoteUsers: null == remoteUsers + ? _self.remoteUsers + : remoteUsers // ignore: cast_nullable_to_non_nullable + as IMap, + historicalRemoteUsers: null == historicalRemoteUsers + ? _self.historicalRemoteUsers + : historicalRemoteUsers // ignore: cast_nullable_to_non_nullable + as IMap, + unknownUsers: null == unknownUsers + ? _self.unknownUsers + : unknownUsers // ignore: cast_nullable_to_non_nullable + as IMap, + messageWindow: null == messageWindow + ? _self.messageWindow + : messageWindow // ignore: cast_nullable_to_non_nullable + as AsyncValue>, + title: null == title + ? _self.title + : title // ignore: cast_nullable_to_non_nullable + as String, + )); + } + + /// Create a copy of ChatComponentState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $UserCopyWith<$Res>? get localUser { + if (_self.localUser == null) { + return null; + } + + return $UserCopyWith<$Res>(_self.localUser!, (value) { + return _then(_self.copyWith(localUser: value)); + }); + } + + /// Create a copy of ChatComponentState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $AsyncValueCopyWith, $Res> get messageWindow { + return $AsyncValueCopyWith, $Res>(_self.messageWindow, + (value) { + return _then(_self.copyWith(messageWindow: value)); + }); + } +} + +// dart format on diff --git a/lib/chat/models/message_state.dart b/lib/chat/models/message_state.dart new file mode 100644 index 0000000..80852e6 --- /dev/null +++ b/lib/chat/models/message_state.dart @@ -0,0 +1,45 @@ +import 'package:change_case/change_case.dart'; +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../proto/proto.dart' as proto; +import '../../proto/proto.dart' show messageFromJson, messageToJson; + +part 'message_state.freezed.dart'; +part 'message_state.g.dart'; + +// Whether or not a message has been fully sent +enum MessageSendState { + // message is still being sent + sending, + // message issued has not reached the network + sent, + // message was sent and has reached the network + delivered; + + factory MessageSendState.fromJson(dynamic j) => + MessageSendState.values.byName((j as String).toCamelCase()); + String toJson() => name.toPascalCase(); +} + +@freezed +sealed class MessageState with _$MessageState { + @JsonSerializable() + const factory MessageState({ + // Sequence number of the message for display purposes + required int seqId, + // Content of the message + @JsonKey(fromJson: messageFromJson, toJson: messageToJson) + required proto.Message content, + // Sent timestamp + required Timestamp sentTimestamp, + // Reconciled timestamp + required Timestamp? reconciledTimestamp, + // The state of the message + required MessageSendState? sendState, + }) = _MessageState; + + factory MessageState.fromJson(dynamic json) => + _$MessageStateFromJson(json as Map); +} diff --git a/lib/chat/models/message_state.freezed.dart b/lib/chat/models/message_state.freezed.dart new file mode 100644 index 0000000..342b305 --- /dev/null +++ b/lib/chat/models/message_state.freezed.dart @@ -0,0 +1,276 @@ +// dart format width=80 +// 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_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$MessageState implements DiagnosticableTreeMixin { +// Sequence number of the message for display purposes + int get seqId; // Content of the message + @JsonKey(fromJson: messageFromJson, toJson: messageToJson) + proto.Message get content; // Sent timestamp + Timestamp get sentTimestamp; // Reconciled timestamp + Timestamp? get reconciledTimestamp; // The state of the message + MessageSendState? get sendState; + + /// Create a copy of MessageState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $MessageStateCopyWith get copyWith => + _$MessageStateCopyWithImpl( + this as MessageState, _$identity); + + /// Serializes this MessageState to a JSON map. + Map toJson(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('type', 'MessageState')) + ..add(DiagnosticsProperty('seqId', seqId)) + ..add(DiagnosticsProperty('content', content)) + ..add(DiagnosticsProperty('sentTimestamp', sentTimestamp)) + ..add(DiagnosticsProperty('reconciledTimestamp', reconciledTimestamp)) + ..add(DiagnosticsProperty('sendState', sendState)); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is MessageState && + (identical(other.seqId, seqId) || other.seqId == seqId) && + (identical(other.content, content) || other.content == content) && + (identical(other.sentTimestamp, sentTimestamp) || + other.sentTimestamp == sentTimestamp) && + (identical(other.reconciledTimestamp, reconciledTimestamp) || + other.reconciledTimestamp == reconciledTimestamp) && + (identical(other.sendState, sendState) || + other.sendState == sendState)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, seqId, content, sentTimestamp, + reconciledTimestamp, sendState); + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + return 'MessageState(seqId: $seqId, content: $content, sentTimestamp: $sentTimestamp, reconciledTimestamp: $reconciledTimestamp, sendState: $sendState)'; + } +} + +/// @nodoc +abstract mixin class $MessageStateCopyWith<$Res> { + factory $MessageStateCopyWith( + MessageState value, $Res Function(MessageState) _then) = + _$MessageStateCopyWithImpl; + @useResult + $Res call( + {int seqId, + @JsonKey(fromJson: messageFromJson, toJson: messageToJson) + proto.Message content, + Timestamp sentTimestamp, + Timestamp? reconciledTimestamp, + MessageSendState? sendState}); +} + +/// @nodoc +class _$MessageStateCopyWithImpl<$Res> implements $MessageStateCopyWith<$Res> { + _$MessageStateCopyWithImpl(this._self, this._then); + + final MessageState _self; + final $Res Function(MessageState) _then; + + /// Create a copy of MessageState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? seqId = null, + Object? content = null, + Object? sentTimestamp = null, + Object? reconciledTimestamp = freezed, + Object? sendState = freezed, + }) { + return _then(_self.copyWith( + seqId: null == seqId + ? _self.seqId + : seqId // ignore: cast_nullable_to_non_nullable + as int, + content: null == content + ? _self.content + : content // ignore: cast_nullable_to_non_nullable + as proto.Message, + sentTimestamp: null == sentTimestamp + ? _self.sentTimestamp + : sentTimestamp // ignore: cast_nullable_to_non_nullable + as Timestamp, + reconciledTimestamp: freezed == reconciledTimestamp + ? _self.reconciledTimestamp + : reconciledTimestamp // ignore: cast_nullable_to_non_nullable + as Timestamp?, + sendState: freezed == sendState + ? _self.sendState + : sendState // ignore: cast_nullable_to_non_nullable + as MessageSendState?, + )); + } +} + +/// @nodoc + +@JsonSerializable() +class _MessageState with DiagnosticableTreeMixin implements MessageState { + const _MessageState( + {required this.seqId, + @JsonKey(fromJson: messageFromJson, toJson: messageToJson) + required this.content, + required this.sentTimestamp, + required this.reconciledTimestamp, + required this.sendState}); + factory _MessageState.fromJson(Map json) => + _$MessageStateFromJson(json); + +// Sequence number of the message for display purposes + @override + final int seqId; +// Content of the message + @override + @JsonKey(fromJson: messageFromJson, toJson: messageToJson) + final proto.Message content; +// Sent timestamp + @override + final Timestamp sentTimestamp; +// Reconciled timestamp + @override + final Timestamp? reconciledTimestamp; +// The state of the message + @override + final MessageSendState? sendState; + + /// Create a copy of MessageState + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$MessageStateCopyWith<_MessageState> get copyWith => + __$MessageStateCopyWithImpl<_MessageState>(this, _$identity); + + @override + Map toJson() { + return _$MessageStateToJson( + this, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('type', 'MessageState')) + ..add(DiagnosticsProperty('seqId', seqId)) + ..add(DiagnosticsProperty('content', content)) + ..add(DiagnosticsProperty('sentTimestamp', sentTimestamp)) + ..add(DiagnosticsProperty('reconciledTimestamp', reconciledTimestamp)) + ..add(DiagnosticsProperty('sendState', sendState)); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _MessageState && + (identical(other.seqId, seqId) || other.seqId == seqId) && + (identical(other.content, content) || other.content == content) && + (identical(other.sentTimestamp, sentTimestamp) || + other.sentTimestamp == sentTimestamp) && + (identical(other.reconciledTimestamp, reconciledTimestamp) || + other.reconciledTimestamp == reconciledTimestamp) && + (identical(other.sendState, sendState) || + other.sendState == sendState)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, seqId, content, sentTimestamp, + reconciledTimestamp, sendState); + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + return 'MessageState(seqId: $seqId, content: $content, sentTimestamp: $sentTimestamp, reconciledTimestamp: $reconciledTimestamp, sendState: $sendState)'; + } +} + +/// @nodoc +abstract mixin class _$MessageStateCopyWith<$Res> + implements $MessageStateCopyWith<$Res> { + factory _$MessageStateCopyWith( + _MessageState value, $Res Function(_MessageState) _then) = + __$MessageStateCopyWithImpl; + @override + @useResult + $Res call( + {int seqId, + @JsonKey(fromJson: messageFromJson, toJson: messageToJson) + proto.Message content, + Timestamp sentTimestamp, + Timestamp? reconciledTimestamp, + MessageSendState? sendState}); +} + +/// @nodoc +class __$MessageStateCopyWithImpl<$Res> + implements _$MessageStateCopyWith<$Res> { + __$MessageStateCopyWithImpl(this._self, this._then); + + final _MessageState _self; + final $Res Function(_MessageState) _then; + + /// Create a copy of MessageState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? seqId = null, + Object? content = null, + Object? sentTimestamp = null, + Object? reconciledTimestamp = freezed, + Object? sendState = freezed, + }) { + return _then(_MessageState( + seqId: null == seqId + ? _self.seqId + : seqId // ignore: cast_nullable_to_non_nullable + as int, + content: null == content + ? _self.content + : content // ignore: cast_nullable_to_non_nullable + as proto.Message, + sentTimestamp: null == sentTimestamp + ? _self.sentTimestamp + : sentTimestamp // ignore: cast_nullable_to_non_nullable + as Timestamp, + reconciledTimestamp: freezed == reconciledTimestamp + ? _self.reconciledTimestamp + : reconciledTimestamp // ignore: cast_nullable_to_non_nullable + as Timestamp?, + sendState: freezed == sendState + ? _self.sendState + : sendState // ignore: cast_nullable_to_non_nullable + as MessageSendState?, + )); + } +} + +// dart format on diff --git a/lib/chat/models/message_state.g.dart b/lib/chat/models/message_state.g.dart new file mode 100644 index 0000000..2eee78d --- /dev/null +++ b/lib/chat/models/message_state.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'message_state.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_MessageState _$MessageStateFromJson(Map json) => + _MessageState( + seqId: (json['seq_id'] as num).toInt(), + content: messageFromJson(json['content'] as Map), + sentTimestamp: Timestamp.fromJson(json['sent_timestamp']), + reconciledTimestamp: json['reconciled_timestamp'] == null + ? null + : Timestamp.fromJson(json['reconciled_timestamp']), + sendState: json['send_state'] == null + ? null + : MessageSendState.fromJson(json['send_state']), + ); + +Map _$MessageStateToJson(_MessageState instance) => + { + 'seq_id': instance.seqId, + 'content': messageToJson(instance.content), + 'sent_timestamp': instance.sentTimestamp.toJson(), + 'reconciled_timestamp': instance.reconciledTimestamp?.toJson(), + 'send_state': instance.sendState?.toJson(), + }; diff --git a/lib/chat/models/models.dart b/lib/chat/models/models.dart new file mode 100644 index 0000000..30698cd --- /dev/null +++ b/lib/chat/models/models.dart @@ -0,0 +1,3 @@ +export 'chat_component_state.dart'; +export 'message_state.dart'; +export 'window_state.dart'; diff --git a/lib/chat/models/window_state.dart b/lib/chat/models/window_state.dart new file mode 100644 index 0000000..14a94a5 --- /dev/null +++ b/lib/chat/models/window_state.dart @@ -0,0 +1,27 @@ +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'window_state.freezed.dart'; + +@freezed +sealed class WindowState with _$WindowState { + const factory WindowState({ + // List of objects in the window + required IList window, + // Total number of objects (windowTail max) + required int length, + // One past the end of the last element + required int windowTail, + // The total number of elements to try to keep in the window + required int windowCount, + // If we should have the tail following the array + required bool follow, + }) = _WindowState; +} + +extension WindowStateExt on WindowState { + int get windowEnd => (length == 0) ? -1 : (windowTail - 1) % length; + int get windowStart => + (length == 0) ? 0 : (windowTail - window.length) % length; +} diff --git a/lib/chat/models/window_state.freezed.dart b/lib/chat/models/window_state.freezed.dart new file mode 100644 index 0000000..38a2ec1 --- /dev/null +++ b/lib/chat/models/window_state.freezed.dart @@ -0,0 +1,265 @@ +// dart format width=80 +// 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 'window_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$WindowState implements DiagnosticableTreeMixin { +// List of objects in the window + IList get window; // Total number of objects (windowTail max) + int get length; // One past the end of the last element + int get windowTail; // The total number of elements to try to keep in the window + int get windowCount; // If we should have the tail following the array + bool get follow; + + /// Create a copy of WindowState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $WindowStateCopyWith> get copyWith => + _$WindowStateCopyWithImpl>( + this as WindowState, _$identity); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('type', 'WindowState<$T>')) + ..add(DiagnosticsProperty('window', window)) + ..add(DiagnosticsProperty('length', length)) + ..add(DiagnosticsProperty('windowTail', windowTail)) + ..add(DiagnosticsProperty('windowCount', windowCount)) + ..add(DiagnosticsProperty('follow', follow)); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is WindowState && + const DeepCollectionEquality().equals(other.window, window) && + (identical(other.length, length) || other.length == length) && + (identical(other.windowTail, windowTail) || + other.windowTail == windowTail) && + (identical(other.windowCount, windowCount) || + other.windowCount == windowCount) && + (identical(other.follow, follow) || other.follow == follow)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + const DeepCollectionEquality().hash(window), + length, + windowTail, + windowCount, + follow); + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + return 'WindowState<$T>(window: $window, length: $length, windowTail: $windowTail, windowCount: $windowCount, follow: $follow)'; + } +} + +/// @nodoc +abstract mixin class $WindowStateCopyWith { + factory $WindowStateCopyWith( + WindowState value, $Res Function(WindowState) _then) = + _$WindowStateCopyWithImpl; + @useResult + $Res call( + {IList window, + int length, + int windowTail, + int windowCount, + bool follow}); +} + +/// @nodoc +class _$WindowStateCopyWithImpl + implements $WindowStateCopyWith { + _$WindowStateCopyWithImpl(this._self, this._then); + + final WindowState _self; + final $Res Function(WindowState) _then; + + /// Create a copy of WindowState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? window = null, + Object? length = null, + Object? windowTail = null, + Object? windowCount = null, + Object? follow = null, + }) { + return _then(_self.copyWith( + window: null == window + ? _self.window + : window // ignore: cast_nullable_to_non_nullable + as IList, + length: null == length + ? _self.length + : length // ignore: cast_nullable_to_non_nullable + as int, + windowTail: null == windowTail + ? _self.windowTail + : windowTail // ignore: cast_nullable_to_non_nullable + as int, + windowCount: null == windowCount + ? _self.windowCount + : windowCount // ignore: cast_nullable_to_non_nullable + as int, + follow: null == follow + ? _self.follow + : follow // ignore: cast_nullable_to_non_nullable + as bool, + )); + } +} + +/// @nodoc + +class _WindowState with DiagnosticableTreeMixin implements WindowState { + const _WindowState( + {required this.window, + required this.length, + required this.windowTail, + required this.windowCount, + required this.follow}); + +// List of objects in the window + @override + final IList window; +// Total number of objects (windowTail max) + @override + final int length; +// One past the end of the last element + @override + final int windowTail; +// The total number of elements to try to keep in the window + @override + final int windowCount; +// If we should have the tail following the array + @override + final bool follow; + + /// Create a copy of WindowState + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$WindowStateCopyWith> get copyWith => + __$WindowStateCopyWithImpl>(this, _$identity); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('type', 'WindowState<$T>')) + ..add(DiagnosticsProperty('window', window)) + ..add(DiagnosticsProperty('length', length)) + ..add(DiagnosticsProperty('windowTail', windowTail)) + ..add(DiagnosticsProperty('windowCount', windowCount)) + ..add(DiagnosticsProperty('follow', follow)); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _WindowState && + const DeepCollectionEquality().equals(other.window, window) && + (identical(other.length, length) || other.length == length) && + (identical(other.windowTail, windowTail) || + other.windowTail == windowTail) && + (identical(other.windowCount, windowCount) || + other.windowCount == windowCount) && + (identical(other.follow, follow) || other.follow == follow)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + const DeepCollectionEquality().hash(window), + length, + windowTail, + windowCount, + follow); + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + return 'WindowState<$T>(window: $window, length: $length, windowTail: $windowTail, windowCount: $windowCount, follow: $follow)'; + } +} + +/// @nodoc +abstract mixin class _$WindowStateCopyWith + implements $WindowStateCopyWith { + factory _$WindowStateCopyWith( + _WindowState value, $Res Function(_WindowState) _then) = + __$WindowStateCopyWithImpl; + @override + @useResult + $Res call( + {IList window, + int length, + int windowTail, + int windowCount, + bool follow}); +} + +/// @nodoc +class __$WindowStateCopyWithImpl + implements _$WindowStateCopyWith { + __$WindowStateCopyWithImpl(this._self, this._then); + + final _WindowState _self; + final $Res Function(_WindowState) _then; + + /// Create a copy of WindowState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? window = null, + Object? length = null, + Object? windowTail = null, + Object? windowCount = null, + Object? follow = null, + }) { + return _then(_WindowState( + window: null == window + ? _self.window + : window // ignore: cast_nullable_to_non_nullable + as IList, + length: null == length + ? _self.length + : length // ignore: cast_nullable_to_non_nullable + as int, + windowTail: null == windowTail + ? _self.windowTail + : windowTail // ignore: cast_nullable_to_non_nullable + as int, + windowCount: null == windowCount + ? _self.windowCount + : windowCount // ignore: cast_nullable_to_non_nullable + as int, + follow: null == follow + ? _self.follow + : follow // ignore: cast_nullable_to_non_nullable + as bool, + )); + } +} + +// dart format on diff --git a/lib/chat/views/chat_builders/chat_builders.dart b/lib/chat/views/chat_builders/chat_builders.dart new file mode 100644 index 0000000..529341f --- /dev/null +++ b/lib/chat/views/chat_builders/chat_builders.dart @@ -0,0 +1,2 @@ +export 'vc_composer_widget.dart'; +export 'vc_text_message_widget.dart'; diff --git a/lib/chat/views/chat_builders/vc_composer_widget.dart b/lib/chat/views/chat_builders/vc_composer_widget.dart new file mode 100644 index 0000000..f470e9b --- /dev/null +++ b/lib/chat/views/chat_builders/vc_composer_widget.dart @@ -0,0 +1,432 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_chat_ui/flutter_chat_ui.dart'; +// Typedefs need to come out +// ignore: implementation_imports +import 'package:flutter_chat_ui/src/utils/typedefs.dart'; +import 'package:provider/provider.dart'; + +import '../../../theme/theme.dart'; +import '../../chat.dart'; + +enum ShiftEnterAction { newline, send } + +/// The message composer widget positioned at the bottom of the chat screen. +/// +/// Includes a text input field, an optional attachment button, +/// and a send button. +class VcComposerWidget extends StatefulWidget { + /// Creates a message composer widget. + const VcComposerWidget({ + super.key, + this.textEditingController, + this.left = 0, + this.right = 0, + this.top, + this.bottom = 0, + this.sigmaX = 20, + this.sigmaY = 20, + this.padding = const EdgeInsets.all(8), + this.attachmentIcon = const Icon(Icons.attachment), + this.sendIcon = const Icon(Icons.send), + this.gap = 8, + this.inputBorder, + this.filled, + this.topWidget, + this.handleSafeArea = true, + this.backgroundColor, + this.attachmentIconColor, + this.sendIconColor, + this.hintColor, + this.textColor, + this.inputFillColor, + this.hintText = 'Type a message', + this.keyboardAppearance, + this.autocorrect, + this.autofocus = false, + this.textCapitalization = TextCapitalization.sentences, + this.keyboardType, + this.textInputAction = TextInputAction.newline, + this.shiftEnterAction = ShiftEnterAction.send, + this.focusNode, + this.maxLength, + this.minLines = 1, + this.maxLines = 3, + }); + + /// Optional controller for the text input field. + final TextEditingController? textEditingController; + + /// Optional left position. + final double? left; + + /// Optional right position. + final double? right; + + /// Optional top position. + final double? top; + + /// Optional bottom position. + final double? bottom; + + /// Optional X blur value for the background (if using glassmorphism). + final double? sigmaX; + + /// Optional Y blur value for the background (if using glassmorphism). + final double? sigmaY; + + /// Padding around the composer content. + final EdgeInsetsGeometry? padding; + + /// Icon for the attachment button. Defaults to [Icons.attachment]. + final Widget? attachmentIcon; + + /// Icon for the send button. Defaults to [Icons.send]. + final Widget? sendIcon; + + /// Horizontal gap between elements (attachment icon, text field, send icon). + final double? gap; + + /// Border style for the text input field. + final InputBorder? inputBorder; + + /// Whether the text input field should be filled. + final bool? filled; + + /// Optional widget to display above the main composer row. + final Widget? topWidget; + + /// Whether to adjust padding for the bottom safe area. + final bool handleSafeArea; + + /// Background color of the composer container. + final Color? backgroundColor; + + /// Color of the attachment icon. + final Color? attachmentIconColor; + + /// Color of the send icon. + final Color? sendIconColor; + + /// Color of the hint text in the input field. + final Color? hintColor; + + /// Color of the text entered in the input field. + final Color? textColor; + + /// Fill color for the text input field when [filled] is true. + final Color? inputFillColor; + + /// Placeholder text for the input field. + final String? hintText; + + /// Appearance of the keyboard. + final Brightness? keyboardAppearance; + + /// Whether to enable autocorrect for the input field. + final bool? autocorrect; + + /// Whether the input field should autofocus. + final bool autofocus; + + /// Capitalization behavior for the input field. + final TextCapitalization textCapitalization; + + /// Type of keyboard to display. + final TextInputType? keyboardType; + + /// Action button type for the keyboard (e.g., newline, send). + final TextInputAction textInputAction; + + /// Action when shift-enter is pressed (e.g., newline, send). + final ShiftEnterAction shiftEnterAction; + + /// Focus node for the text input field. + final FocusNode? focusNode; + + /// Maximum character length for the input field. + final int? maxLength; + + /// Minimum number of lines for the input field. + final int? minLines; + + /// Maximum number of lines the input field can expand to. + final int? maxLines; + + @override + State createState() => _VcComposerState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty( + 'textEditingController', textEditingController)) + ..add(DoubleProperty('left', left)) + ..add(DoubleProperty('right', right)) + ..add(DoubleProperty('top', top)) + ..add(DoubleProperty('bottom', bottom)) + ..add(DoubleProperty('sigmaX', sigmaX)) + ..add(DoubleProperty('sigmaY', sigmaY)) + ..add(DiagnosticsProperty('padding', padding)) + ..add(DoubleProperty('gap', gap)) + ..add(DiagnosticsProperty('inputBorder', inputBorder)) + ..add(DiagnosticsProperty('filled', filled)) + ..add(DiagnosticsProperty('handleSafeArea', handleSafeArea)) + ..add(ColorProperty('backgroundColor', backgroundColor)) + ..add(ColorProperty('attachmentIconColor', attachmentIconColor)) + ..add(ColorProperty('sendIconColor', sendIconColor)) + ..add(ColorProperty('hintColor', hintColor)) + ..add(ColorProperty('textColor', textColor)) + ..add(ColorProperty('inputFillColor', inputFillColor)) + ..add(StringProperty('hintText', hintText)) + ..add(EnumProperty('keyboardAppearance', keyboardAppearance)) + ..add(DiagnosticsProperty('autocorrect', autocorrect)) + ..add(DiagnosticsProperty('autofocus', autofocus)) + ..add(EnumProperty( + 'textCapitalization', textCapitalization)) + ..add(DiagnosticsProperty('keyboardType', keyboardType)) + ..add(EnumProperty('textInputAction', textInputAction)) + ..add( + EnumProperty('shiftEnterAction', shiftEnterAction)) + ..add(DiagnosticsProperty('focusNode', focusNode)) + ..add(IntProperty('maxLength', maxLength)) + ..add(IntProperty('minLines', minLines)) + ..add(IntProperty('maxLines', maxLines)); + } +} + +class _VcComposerState extends State { + final _key = GlobalKey(); + late final TextEditingController _textController; + late final FocusNode _focusNode; + late String _suffixText; + + @override + void initState() { + super.initState(); + _textController = widget.textEditingController ?? TextEditingController(); + _focusNode = widget.focusNode ?? FocusNode(); + _focusNode.onKeyEvent = _handleKeyEvent; + _updateSuffixText(); + WidgetsBinding.instance.addPostFrameCallback((_) => _measure()); + } + + void _updateSuffixText() { + final utf8Length = utf8.encode(_textController.text).length; + _suffixText = '$utf8Length/${widget.maxLength}'; + } + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + // Check for Shift+Enter + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.enter && + HardwareKeyboard.instance.isShiftPressed) { + if (widget.shiftEnterAction == ShiftEnterAction.send) { + _handleSubmitted(_textController.text); + return KeyEventResult.handled; + } else if (widget.shiftEnterAction == ShiftEnterAction.newline) { + final val = _textController.value; + final insertOffset = val.selection.extent.offset; + final messageWithNewLine = + '${_textController.text.substring(0, insertOffset)}\n' + '${_textController.text.substring(insertOffset)}'; + _textController.value = TextEditingValue( + text: messageWithNewLine, + selection: TextSelection.fromPosition( + TextPosition(offset: insertOffset + 1), + ), + ); + return KeyEventResult.handled; + } + } + return KeyEventResult.ignored; + } + + @override + void didUpdateWidget(covariant VcComposerWidget oldWidget) { + super.didUpdateWidget(oldWidget); + WidgetsBinding.instance.addPostFrameCallback((_) => _measure()); + } + + @override + void dispose() { + // Only try to dispose text controller if it's not provided, let + // user handle disposing it how they want. + if (widget.textEditingController == null) { + _textController.dispose(); + } + if (widget.focusNode == null) { + _focusNode.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final bottomSafeArea = + widget.handleSafeArea ? MediaQuery.of(context).padding.bottom : 0.0; + final onAttachmentTap = context.read(); + final theme = Theme.of(context); + final scaleTheme = theme.extension()!; + final config = scaleTheme.config; + final scheme = scaleTheme.scheme; + final scale = scaleTheme.scheme.scale(ScaleKind.primary); + final textTheme = theme.textTheme; + final scaleChatTheme = scaleTheme.chatTheme(); + final chatTheme = scaleChatTheme.chatTheme; + + final suffixTextStyle = + textTheme.bodySmall!.copyWith(color: scale.subtleText); + + return Positioned( + left: widget.left, + right: widget.right, + top: widget.top, + bottom: widget.bottom, + child: ClipRect( + child: DecoratedBox( + key: _key, + decoration: BoxDecoration( + border: config.preferBorders + ? Border(top: BorderSide(color: scale.border, width: 2)) + : null, + color: config.preferBorders + ? scale.elementBackground + : scale.border), + child: Column( + children: [ + if (widget.topWidget != null) widget.topWidget!, + Padding( + padding: widget.handleSafeArea + ? (widget.padding?.add( + EdgeInsets.only(bottom: bottomSafeArea), + ) ?? + EdgeInsets.only(bottom: bottomSafeArea)) + : (widget.padding ?? EdgeInsets.zero), + child: Row( + children: [ + if (widget.attachmentIcon != null && + onAttachmentTap != null) + IconButton( + icon: widget.attachmentIcon!, + color: widget.attachmentIconColor ?? + chatTheme.colors.onSurface.withValues(alpha: 0.5), + onPressed: onAttachmentTap, + ) + else + const SizedBox.shrink(), + SizedBox(width: widget.gap), + Expanded( + child: TextField( + controller: _textController, + decoration: InputDecoration( + filled: widget.filled ?? !config.preferBorders, + fillColor: widget.inputFillColor ?? + scheme.primaryScale.subtleBackground, + isDense: true, + contentPadding: + const EdgeInsets.fromLTRB(8, 8, 8, 8), + disabledBorder: OutlineInputBorder( + borderSide: config.preferBorders + ? BorderSide( + color: scheme.grayScale.border, + width: 2) + : BorderSide.none, + borderRadius: BorderRadius.all(Radius.circular( + 8 * config.borderRadiusScale))), + enabledBorder: OutlineInputBorder( + borderSide: config.preferBorders + ? BorderSide( + color: scheme.primaryScale.border, + width: 2) + : BorderSide.none, + borderRadius: BorderRadius.all(Radius.circular( + 8 * config.borderRadiusScale))), + focusedBorder: OutlineInputBorder( + borderSide: config.preferBorders + ? BorderSide( + color: scheme.primaryScale.border, + width: 2) + : BorderSide.none, + borderRadius: BorderRadius.all(Radius.circular( + 8 * config.borderRadiusScale))), + hintText: widget.hintText, + hintMaxLines: 1, + hintStyle: chatTheme.typography.bodyMedium.copyWith( + color: widget.hintColor ?? + chatTheme.colors.onSurface + .withValues(alpha: 0.5), + ), + border: widget.inputBorder, + hoverColor: Colors.transparent, + suffix: Text(_suffixText, style: suffixTextStyle)), + onSubmitted: _handleSubmitted, + onChanged: (value) { + setState(_updateSuffixText); + }, + textInputAction: widget.textInputAction, + keyboardAppearance: widget.keyboardAppearance, + autocorrect: widget.autocorrect ?? true, + autofocus: widget.autofocus, + textCapitalization: widget.textCapitalization, + keyboardType: widget.keyboardType, + focusNode: _focusNode, + //maxLength: widget.maxLength, + minLines: widget.minLines, + maxLines: widget.maxLines, + maxLengthEnforcement: MaxLengthEnforcement.none, + inputFormatters: [ + Utf8LengthLimitingTextInputFormatter( + maxLength: widget.maxLength), + ], + ), + ), + SizedBox(width: widget.gap), + if ((widget.sendIcon ?? scaleChatTheme.sendButtonIcon) != + null) + IconButton( + icon: + (widget.sendIcon ?? scaleChatTheme.sendButtonIcon)!, + color: widget.sendIconColor, + onPressed: () => _handleSubmitted(_textController.text), + ) + else + const SizedBox.shrink(), + ], + ), + ), + ], + ), + ), + ), + ); + } + + void _measure() { + if (!mounted) { + return; + } + + final renderBox = _key.currentContext?.findRenderObject() as RenderBox?; + if (renderBox != null) { + final height = renderBox.size.height; + final bottomSafeArea = MediaQuery.of(context).padding.bottom; + + context.read().setHeight( + // only set real height of the composer, ignoring safe area + widget.handleSafeArea ? height - bottomSafeArea : height, + ); + } + } + + void _handleSubmitted(String text) { + if (text.isNotEmpty) { + context.read()?.call(text); + _textController.clear(); + } + } +} diff --git a/lib/chat/views/chat_builders/vc_text_message_widget.dart b/lib/chat/views/chat_builders/vc_text_message_widget.dart new file mode 100644 index 0000000..7ea2957 --- /dev/null +++ b/lib/chat/views/chat_builders/vc_text_message_widget.dart @@ -0,0 +1,221 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart'; +import 'package:provider/provider.dart'; + +import '../../../theme/theme.dart'; +import '../date_formatter.dart'; + +/// A widget that displays a text message. +class VcTextMessageWidget extends StatelessWidget { + /// Creates a widget to display a simple text message. + const VcTextMessageWidget({ + required this.message, + required this.index, + this.padding, + this.borderRadius, + this.onlyEmojiFontSize, + this.sentBackgroundColor, + this.receivedBackgroundColor, + this.sentTextStyle, + this.receivedTextStyle, + this.timeStyle, + this.showTime = true, + this.showStatus = true, + super.key, + }); + + /// The text message data model. + final TextMessage message; + + /// The index of the message in the list. + final int index; + + /// Padding around the message bubble content. + final EdgeInsetsGeometry? padding; + + /// Border radius of the message bubble. + final BorderRadiusGeometry? borderRadius; + + /// Font size for messages containing only emojis. + final double? onlyEmojiFontSize; + + /// Background color for messages sent by the current user. + final Color? sentBackgroundColor; + + /// Background color for messages received from other users. + final Color? receivedBackgroundColor; + + /// Text style for messages sent by the current user. + final TextStyle? sentTextStyle; + + /// Text style for messages received from other users. + final TextStyle? receivedTextStyle; + + /// Text style for the message timestamp and status. + final TextStyle? timeStyle; + + /// Whether to display the message timestamp. + final bool showTime; + + /// Whether to display the message status (sent, delivered, seen) + /// for sent messages. + final bool showStatus; + + bool get _isOnlyEmoji => message.metadata?['isOnlyEmoji'] == true; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scaleTheme = theme.extension()!; + final scaleChatTheme = scaleTheme.chatTheme(); + final chatTheme = scaleChatTheme.chatTheme; + + final isSentByMe = context.watch() == message.authorId; + final backgroundColor = _resolveBackgroundColor(isSentByMe, scaleChatTheme); + final textStyle = _resolveTextStyle(isSentByMe, scaleChatTheme); + final timeStyle = _resolveTimeStyle(isSentByMe, scaleChatTheme); + final emojiFontSize = onlyEmojiFontSize ?? scaleChatTheme.onlyEmojiFontSize; + + final timeAndStatus = showTime || (isSentByMe && showStatus) + ? TimeAndStatus( + time: message.time, + status: message.status, + showTime: showTime, + showStatus: isSentByMe && showStatus, + textStyle: timeStyle, + ) + : null; + + final textContent = Text( + message.text, + style: _isOnlyEmoji + ? textStyle.copyWith(fontSize: emojiFontSize) + : textStyle, + ); + + return Column( + crossAxisAlignment: + isSentByMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + Container( + padding: _isOnlyEmoji + ? EdgeInsets.symmetric( + horizontal: (padding?.horizontal ?? 0) / 2, + // vertical: 0, + ) + : padding, + decoration: _isOnlyEmoji + ? null + : BoxDecoration( + color: backgroundColor, + borderRadius: borderRadius ?? chatTheme.shape, + ), + child: textContent), + if (timeAndStatus != null) timeAndStatus, + ]); + } + + Color _resolveBackgroundColor(bool isSentByMe, ScaleChatTheme theme) { + if (isSentByMe) { + return sentBackgroundColor ?? theme.primaryColor; + } + return receivedBackgroundColor ?? theme.secondaryColor; + } + + TextStyle _resolveTextStyle(bool isSentByMe, ScaleChatTheme theme) { + if (isSentByMe) { + return sentTextStyle ?? theme.sentMessageBodyTextStyle; + } + return receivedTextStyle ?? theme.receivedMessageBodyTextStyle; + } + + TextStyle _resolveTimeStyle(bool isSentByMe, ScaleChatTheme theme) => + theme.timeStyle; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('message', message)) + ..add(IntProperty('index', index)) + ..add(DiagnosticsProperty('padding', padding)) + ..add(DiagnosticsProperty( + 'borderRadius', borderRadius)) + ..add(DoubleProperty('onlyEmojiFontSize', onlyEmojiFontSize)) + ..add(ColorProperty('sentBackgroundColor', sentBackgroundColor)) + ..add(ColorProperty('receivedBackgroundColor', receivedBackgroundColor)) + ..add(DiagnosticsProperty('sentTextStyle', sentTextStyle)) + ..add(DiagnosticsProperty( + 'receivedTextStyle', receivedTextStyle)) + ..add(DiagnosticsProperty('timeStyle', timeStyle)) + ..add(DiagnosticsProperty('showTime', showTime)) + ..add(DiagnosticsProperty('showStatus', showStatus)); + } +} + +/// A widget to display the message timestamp and status indicator. +class TimeAndStatus extends StatelessWidget { + /// Creates a widget for displaying time and status. + const TimeAndStatus({ + required this.time, + this.status, + this.showTime = true, + this.showStatus = true, + this.textStyle, + super.key, + }); + + /// The time the message was created. + final DateTime? time; + + /// The status of the message. + final MessageStatus? status; + + /// Whether to display the timestamp. + final bool showTime; + + /// Whether to display the status indicator. + final bool showStatus; + + /// The text style for the time and status. + final TextStyle? textStyle; + + @override + Widget build(BuildContext context) { + final dformat = DateFormatter(); + + return Row( + spacing: 2, + mainAxisSize: MainAxisSize.min, + children: [ + if (showTime && time != null) + Text(dformat.chatDateTimeFormat(time!.toLocal()), style: textStyle), + if (showStatus && status != null) + if (status == MessageStatus.sending) + SizedBox( + width: 6.scaled(context), + height: 6.scaled(context), + child: CircularProgressIndicator( + color: textStyle?.color, + strokeWidth: 2, + ), + ) + else + Icon(getIconForStatus(status!), + color: textStyle?.color, size: 12.scaled(context)), + ], + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('time', time)) + ..add(EnumProperty('status', status)) + ..add(DiagnosticsProperty('showTime', showTime)) + ..add(DiagnosticsProperty('showStatus', showStatus)) + ..add(DiagnosticsProperty('textStyle', textStyle)); + } +} diff --git a/lib/chat/views/chat_component_widget.dart b/lib/chat/views/chat_component_widget.dart new file mode 100644 index 0000000..aecf531 --- /dev/null +++ b/lib/chat/views/chat_component_widget.dart @@ -0,0 +1,513 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:async_tools/async_tools.dart'; +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart' as core; +import 'package:flutter_chat_ui/flutter_chat_ui.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../account_manager/account_manager.dart'; +import '../../contacts/contacts.dart'; +import '../../conversation/conversation.dart'; +import '../../notifications/notifications.dart'; +import '../../theme/theme.dart'; +import '../chat.dart'; +import 'chat_builders/chat_builders.dart'; + +const onEndReachedThreshold = 0.75; +const _kScrollTag = 'kScrollTag'; +const kSeqId = 'seqId'; +const kSending = 'sending'; +const maxMessageLength = 2048; + +class ChatComponentWidget extends StatefulWidget { + const ChatComponentWidget._({ + required super.key, + required TypedKey localConversationRecordKey, + required void Function() onCancel, + required void Function() onClose, + }) : _localConversationRecordKey = localConversationRecordKey, + _onCancel = onCancel, + _onClose = onClose; + + // Create a single-contact chat and its associated state + static Widget singleContact({ + required BuildContext context, + required TypedKey localConversationRecordKey, + required void Function() onCancel, + required void Function() onClose, + Key? key, + }) { + // Get the account info + final accountInfo = context.watch().state; + + // Get the account record cubit + final accountRecordCubit = context.read(); + + // Get the contact list cubit + final contactListCubit = context.watch(); + + // Get the active conversation cubit + final activeConversationCubit = context.select< + ActiveConversationsBlocMapCubit, + ActiveConversationCubit?>((x) => x.entry(localConversationRecordKey)); + if (activeConversationCubit == null) { + return waitingPage(onCancel: onCancel); + } + + // Get the messages cubit + final messagesCubit = context.select( + (x) => x.entry(localConversationRecordKey)); + if (messagesCubit == null) { + return waitingPage(onCancel: onCancel); + } + + // Make chat component state + return BlocProvider( + key: key, + create: (context) => ChatComponentCubit.singleContact( + accountInfo: accountInfo, + accountRecordCubit: accountRecordCubit, + contactListCubit: contactListCubit, + activeConversationCubit: activeConversationCubit, + messagesCubit: messagesCubit, + ), + child: ChatComponentWidget._( + key: ValueKey(localConversationRecordKey), + localConversationRecordKey: localConversationRecordKey, + onCancel: onCancel, + onClose: onClose)); + } + + @override + State createState() => _ChatComponentWidgetState(); + + //////////////////////////////////////////////////////////////////////////// + final TypedKey _localConversationRecordKey; + final void Function() _onCancel; + final void Function() _onClose; +} + +class _ChatComponentWidgetState extends State { + //////////////////////////////////////////////////////////////////// + + @override + void initState() { + _chatController = core.InMemoryChatController(); + _textEditingController = TextEditingController(); + _scrollController = ScrollController(); + _chatStateProcessor = SingleStateProcessor(); + _focusNode = FocusNode(); + + final chatComponentCubit = context.read(); + _chatStateProcessor.follow( + chatComponentCubit.stream, chatComponentCubit.state, _updateChatState); + + super.initState(); + } + + @override + void dispose() { + unawaited(_chatStateProcessor.close()); + + _focusNode.dispose(); + _chatController.dispose(); + _scrollController.dispose(); + _textEditingController.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scaleTheme = theme.extension()!; + final scale = scaleTheme.scheme.scale(ScaleKind.primary); + final textTheme = theme.textTheme; + final scaleChatTheme = scaleTheme.chatTheme(); + + // Get the enclosing chat component cubit that contains our state + // (created by ChatComponentWidget.singleContact()) + final chatComponentCubit = context.watch(); + final chatComponentState = chatComponentCubit.state; + + final localUser = chatComponentState.localUser; + if (localUser == null) { + return const EmptyChatWidget(); + } + + final messageWindow = chatComponentState.messageWindow.asData?.value; + if (messageWindow == null) { + return chatComponentState.messageWindow.buildNotData(); + } + final isLastPage = messageWindow.windowStart == 0; + final isFirstPage = messageWindow.windowEnd == messageWindow.length - 1; + final title = chatComponentState.title; + + if (chatComponentCubit.scrollOffset != 0) { + _scrollController.position.correctPixels( + _scrollController.position.pixels + chatComponentCubit.scrollOffset); + + chatComponentCubit.scrollOffset = 0; + } + + return Column( + children: [ + Container( + height: 40.scaledNoShrink(context), + decoration: BoxDecoration( + color: scale.border, + ), + child: Row(children: [ + Align( + alignment: AlignmentDirectional.centerStart, + child: Padding( + padding: const EdgeInsetsDirectional.fromSTEB(16, 0, 16, 0), + child: Text(title, + textAlign: TextAlign.start, + style: textTheme.titleMedium! + .copyWith(color: scale.borderText)), + )), + const Spacer(), + IconButton( + iconSize: 24.scaledNoShrink(context), + icon: Icon(Icons.close, color: scale.borderText), + onPressed: widget._onClose) + .paddingLTRB(0, 0, 8, 0) + ]), + ), + DecoratedBox( + decoration: const BoxDecoration(color: Colors.transparent), + child: NotificationListener( + onNotification: (notification) { + if (chatComponentCubit.scrollOffset != 0) { + return false; + } + + if (!isFirstPage && + notification.metrics.pixels <= + ((notification.metrics.maxScrollExtent - + notification.metrics.minScrollExtent) * + (1.0 - onEndReachedThreshold) + + notification.metrics.minScrollExtent)) { + // + final scrollOffset = (notification.metrics.maxScrollExtent - + notification.metrics.minScrollExtent) * + (1.0 - onEndReachedThreshold); + + chatComponentCubit.scrollOffset = scrollOffset; + + // + singleFuture((chatComponentCubit, _kScrollTag), () async { + await _handlePageForward( + chatComponentCubit, messageWindow, notification); + }); + } else if (!isLastPage && + notification.metrics.pixels >= + ((notification.metrics.maxScrollExtent - + notification.metrics.minScrollExtent) * + onEndReachedThreshold + + notification.metrics.minScrollExtent)) { + // + final scrollOffset = + -(notification.metrics.maxScrollExtent - + notification.metrics.minScrollExtent) * + (1.0 - onEndReachedThreshold); + + chatComponentCubit.scrollOffset = scrollOffset; + // + singleFuture((chatComponentCubit, _kScrollTag), () async { + await _handlePageBackward( + chatComponentCubit, messageWindow, notification); + }); + } + return false; + }, + child: ValueListenableBuilder( + valueListenable: _textEditingController, + builder: (context, textEditingValue, __) { + final messageIsValid = + _messageIsValid(textEditingValue.text); + var sendIconColor = scaleTheme.config.preferBorders + ? scale.border + : scale.borderText; + + if (!messageIsValid || + _textEditingController.text.isEmpty) { + sendIconColor = sendIconColor.withAlpha(128); + } + + return Chat( + currentUserId: localUser.id, + resolveUser: (id) async { + if (id == localUser.id) { + return localUser; + } + return chatComponentState.remoteUsers.get(id); + }, + chatController: _chatController, + onMessageSend: (text) => + _handleSendPressed(chatComponentCubit, text), + theme: scaleChatTheme.chatTheme, + builders: core.Builders( + // Chat list builder + chatAnimatedListBuilder: (context, itemBuilder) => + ChatAnimatedListReversed( + scrollController: _scrollController, + messageGroupingTimeoutInSeconds: 60, + itemBuilder: itemBuilder), + // Text message builder + textMessageBuilder: (context, message, index) { + var showTime = true; + if (_chatController.messages.length > 1 && + index < _chatController.messages.length - 1 && + message.time != null) { + final nextMessage = + _chatController.messages[index + 1]; + if (nextMessage.time != null) { + if (nextMessage.time! + .difference(message.time!) + .inSeconds < + 60 && + nextMessage.authorId == message.authorId) { + showTime = false; + } + } + } + return VcTextMessageWidget( + message: message, + index: index, + padding: const EdgeInsets.symmetric( + vertical: 12, horizontal: 16) + .scaled(context), + showTime: showTime, + showStatus: showTime, + ); + }, + // Composer builder + composerBuilder: (ctx) => VcComposerWidget( + autofocus: true, + padding: const EdgeInsets.all(4).scaled(context), + gap: 8.scaled(context), + focusNode: _focusNode, + textInputAction: isAnyMobile + ? TextInputAction.newline + : TextInputAction.send, + shiftEnterAction: isAnyMobile + ? ShiftEnterAction.send + : ShiftEnterAction.newline, + textEditingController: _textEditingController, + maxLength: maxMessageLength, + keyboardType: TextInputType.multiline, + sendIconColor: sendIconColor, + topWidget: messageIsValid + ? null + : Text(translate('chat.message_too_long'), + style: TextStyle( + color: scaleTheme + .scheme.errorScale.primary)) + .toCenter(), + ), + ), + timeFormat: core.DateFormat.jm(), + ); + }))).expanded(), + ], + ); + } + + ///////////////////////////////////////////////////////////////////// + + bool _messageIsValid(String text) => + utf8.encode(text).lengthInBytes < maxMessageLength; + + Future _updateChatState(ChatComponentState chatComponentState) async { + // Update message window state + final data = chatComponentState.messageWindow.asData; + if (data == null) { + await _chatController.setMessages([]); + return; + } + + final windowState = data.value; + + // await _chatController.setMessages(windowState.window.toList()); + + final newMessagesSet = windowState.window.toSet(); + final newMessagesById = + Map.fromEntries(newMessagesSet.map((m) => MapEntry(m.id, m))); + final newMessagesBySeqId = Map.fromEntries( + newMessagesSet.map((m) => MapEntry(m.metadata![kSeqId], m))); + final oldMessagesSet = _chatController.messages.toSet(); + + if (oldMessagesSet.isEmpty) { + await _chatController.setMessages(windowState.window.toList()); + return; + } + + // See how many messages differ by equality (not identity) + // If there are more than `replaceAllMessagesThreshold` differences + // just replace the whole list of messages + final diffs = newMessagesSet.diffAndIntersect(oldMessagesSet, + diffThisMinusOther: true, diffOtherMinusThis: true); + final addedMessages = diffs.diffThisMinusOther!; + final removedMessages = diffs.diffOtherMinusThis!; + + final replaceAllPaginationLimit = windowState.windowCount / 3; + + if ((addedMessages.length >= replaceAllPaginationLimit) || + removedMessages.length >= replaceAllPaginationLimit) { + await _chatController.setMessages(windowState.window.toList()); + return; + } + + // Remove messages that are gone, and replace the ones that have changed + for (final m in removedMessages) { + final newm = newMessagesById[m.id]; + if (newm != null) { + await _chatController.updateMessage(m, newm); + } else { + final newm = newMessagesBySeqId[m.metadata![kSeqId]]; + if (newm != null) { + await _chatController.updateMessage(m, newm); + addedMessages.remove(newm); + } else { + await _chatController.removeMessage(m); + } + } + } + + if (addedMessages.isNotEmpty) { + await _chatController.setMessages(windowState.window.toList()); + } + + // // // Check for append + // if (addedMessages.isNotEmpty) { + // if (_chatController.messages.isNotEmpty && + // (addedMessages.first.metadata![kSeqId] as int) > + // (_chatController.messages.reversed.last.metadata![kSeqId] + // as int)) { + // await _chatController.insertAllMessages(addedMessages.reversedView, + // index: 0); + // } + // // Check for prepend + // else if (_chatController.messages.isNotEmpty && + // (addedMessages.last.metadata![kSeqId] as int) < + // (_chatController.messages.reversed.first.metadata![kSeqId] + // as int)) { + // await _chatController.insertAllMessages( + // addedMessages.reversedView, + // ); + // } + // // Otherwise just replace + // // xxx could use a better algorithm here to merge added messages in + // else { + // await _chatController.setMessages(windowState.window.toList()); + // } + // } + } + + void _handleSendPressed(ChatComponentCubit chatComponentCubit, String text) { + _focusNode.requestFocus(); + + if (text.startsWith('/')) { + chatComponentCubit.runCommand(text); + return; + } + + if (!_messageIsValid(text)) { + context + .read() + .error(text: translate('chat.message_too_long')); + return; + } + + chatComponentCubit.sendMessage(text: text); + } + + // void _handleAttachmentPressed() async { + Future _handlePageForward( + ChatComponentCubit chatComponentCubit, + WindowState messageWindow, + ScrollNotification notification) async { + debugPrint( + '_handlePageForward: messagesState.length=${messageWindow.length} ' + 'messagesState.windowTail=${messageWindow.windowTail} ' + 'messagesState.windowCount=${messageWindow.windowCount} ' + 'ScrollNotification=$notification'); + + // Go forward a page + final tail = min(messageWindow.length, + messageWindow.windowTail + (messageWindow.windowCount ~/ 4)) % + messageWindow.length; + + // Set follow + final follow = messageWindow.length == 0 || + tail == 0; // xxx incorporate scroll position + + // final scrollOffset = (notification.metrics.maxScrollExtent - + // notification.metrics.minScrollExtent) * + // (1.0 - onEndReachedThreshold); + + // chatComponentCubit.scrollOffset = scrollOffset; + + await chatComponentCubit.setWindow( + tail: tail, count: messageWindow.windowCount, follow: follow); + + // chatComponentCubit.state.scrollController.position.jumpTo( + // chatComponentCubit.state.scrollController.offset + scrollOffset); + + //chatComponentCubit.scrollOffset = 0; + } + + Future _handlePageBackward( + ChatComponentCubit chatComponentCubit, + WindowState messageWindow, + ScrollNotification notification, + ) async { + debugPrint( + '_handlePageBackward: messagesState.length=${messageWindow.length} ' + 'messagesState.windowTail=${messageWindow.windowTail} ' + 'messagesState.windowCount=${messageWindow.windowCount} ' + 'ScrollNotification=$notification'); + + // Go back a page + final tail = max( + messageWindow.windowCount, + (messageWindow.windowTail - (messageWindow.windowCount ~/ 4)) % + messageWindow.length); + + // Set follow + final follow = messageWindow.length == 0 || + tail == 0; // xxx incorporate scroll position + + // final scrollOffset = -(notification.metrics.maxScrollExtent - + // notification.metrics.minScrollExtent) * + // (1.0 - onEndReachedThreshold); + + // chatComponentCubit.scrollOffset = scrollOffset; + + await chatComponentCubit.setWindow( + tail: tail, count: messageWindow.windowCount, follow: follow); + + // chatComponentCubit.scrollOffset = scrollOffset; + + // chatComponentCubit.state.scrollController.position.jumpTo( + // chatComponentCubit.state.scrollController.offset + scrollOffset); + + //chatComponentCubit.scrollOffset = 0; + } + + late final core.ChatController _chatController; + late final TextEditingController _textEditingController; + late final ScrollController _scrollController; + late final SingleStateProcessor _chatStateProcessor; + late final FocusNode _focusNode; +} diff --git a/lib/chat/views/date_formatter.dart b/lib/chat/views/date_formatter.dart new file mode 100644 index 0000000..2835b70 --- /dev/null +++ b/lib/chat/views/date_formatter.dart @@ -0,0 +1,41 @@ +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:intl/intl.dart'; + +class DateFormatter { + DateFormatter(); + + String chatDateTimeFormat(DateTime dateTime) { + final now = DateTime.now(); + + final justNow = now.subtract(const Duration(minutes: 1)); + + final localDateTime = dateTime.toLocal(); + + if (!localDateTime.difference(justNow).isNegative) { + return translate('date_formatter.just_now'); + } + + final roughTimeString = DateFormat.jm().format(dateTime); + + if (localDateTime.day == now.day && + localDateTime.month == now.month && + localDateTime.year == now.year) { + return roughTimeString; + } + + final yesterday = now.subtract(const Duration(days: 1)); + + if (localDateTime.day == yesterday.day && + localDateTime.month == now.month && + localDateTime.year == now.year) { + return translate('date_formatter.yesterday'); + } + + if (now.difference(localDateTime).inDays < 4) { + final weekday = DateFormat(DateFormat.WEEKDAY).format(localDateTime); + + return '$weekday, $roughTimeString'; + } + return '${DateFormat.yMd().format(dateTime)}, $roughTimeString'; + } +} diff --git a/lib/components/empty_chat_widget.dart b/lib/chat/views/empty_chat_widget.dart similarity index 52% rename from lib/components/empty_chat_widget.dart rename to lib/chat/views/empty_chat_widget.dart index dbe184d..946fc3f 100644 --- a/lib/components/empty_chat_widget.dart +++ b/lib/chat/views/empty_chat_widget.dart @@ -1,32 +1,36 @@ import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_translate/flutter_translate.dart'; -class EmptyChatWidget extends ConsumerWidget { +import '../../theme/theme.dart'; + +class EmptyChatWidget extends StatelessWidget { const EmptyChatWidget({super.key}); @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context, WidgetRef ref) { - // + Widget build( + BuildContext context, + ) { + final theme = Theme.of(context); + final scale = theme.extension()!; return Container( width: double.infinity, height: double.infinity, decoration: BoxDecoration( - color: Theme.of(context).scaffoldBackgroundColor, + color: scale.primaryScale.appBackground, ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.chat, - color: Theme.of(context).disabledColor, + color: scale.primaryScale.subtleBorder, size: 48, ), Text( - 'Say Something', + translate('chat.say_something'), style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).disabledColor, + color: scale.primaryScale.subtleBorder, ), ), ], diff --git a/lib/chat/views/no_conversation_widget.dart b/lib/chat/views/no_conversation_widget.dart new file mode 100644 index 0000000..3269bea --- /dev/null +++ b/lib/chat/views/no_conversation_widget.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_translate/flutter_translate.dart'; + +import '../../theme/theme.dart'; + +class NoConversationWidget extends StatelessWidget { + const NoConversationWidget({super.key}); + + @override + // ignore: prefer_expression_function_bodies + Widget build( + BuildContext context, + ) { + final theme = Theme.of(context); + final scaleScheme = theme.extension()!; + final scaleConfig = theme.extension()!; + final scale = scaleScheme.scale(ScaleKind.primary); + + return DecoratedBox( + decoration: BoxDecoration( + color: scale.appBackground.withAlpha(scaleConfig.wallpaperAlpha), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.diversity_3, + color: scale.appText.withAlpha(127), + size: 48, + ), + Text( + textAlign: TextAlign.center, + translate('chat.start_a_conversation'), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: scale.appText.withAlpha(127), + ), + ), + ], + )); + } +} diff --git a/lib/chat/views/utf8_length_limiting_text_input_formatter.dart b/lib/chat/views/utf8_length_limiting_text_input_formatter.dart new file mode 100644 index 0000000..f037ca8 --- /dev/null +++ b/lib/chat/views/utf8_length_limiting_text_input_formatter.dart @@ -0,0 +1,54 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +class Utf8LengthLimitingTextInputFormatter extends TextInputFormatter { + Utf8LengthLimitingTextInputFormatter({this.maxLength}) + : assert(maxLength != null || maxLength! >= 0, 'maxLength is invalid'); + + final int? maxLength; + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + if (maxLength != null && _bytesLength(newValue.text) > maxLength!) { + // If already at the maximum and tried to enter even more, + // keep the old value. + if (_bytesLength(oldValue.text) == maxLength) { + return oldValue; + } + return _truncate(newValue, maxLength!); + } + return newValue; + } + + static TextEditingValue _truncate(TextEditingValue value, int maxLength) { + var newValue = ''; + if (_bytesLength(value.text) > maxLength) { + var length = 0; + + value.text.characters.takeWhile((char) { + final nbBytes = _bytesLength(char); + if (length + nbBytes <= maxLength) { + newValue += char; + length += nbBytes; + return true; + } + return false; + }); + } + return TextEditingValue( + text: newValue, + selection: value.selection.copyWith( + baseOffset: min(value.selection.start, newValue.length), + extentOffset: min(value.selection.end, newValue.length), + ), + ); + } + + static int _bytesLength(String value) => utf8.encode(value).length; +} diff --git a/lib/chat/views/views.dart b/lib/chat/views/views.dart new file mode 100644 index 0000000..41b1936 --- /dev/null +++ b/lib/chat/views/views.dart @@ -0,0 +1,4 @@ +export 'chat_component_widget.dart'; +export 'empty_chat_widget.dart'; +export 'no_conversation_widget.dart'; +export 'utf8_length_limiting_text_input_formatter.dart'; diff --git a/lib/chat_list/chat_list.dart b/lib/chat_list/chat_list.dart new file mode 100644 index 0000000..6acdd43 --- /dev/null +++ b/lib/chat_list/chat_list.dart @@ -0,0 +1,2 @@ +export 'cubits/cubits.dart'; +export 'views/views.dart'; diff --git a/lib/chat_list/cubits/chat_list_cubit.dart b/lib/chat_list/cubits/chat_list_cubit.dart new file mode 100644 index 0000000..ae31f29 --- /dev/null +++ b/lib/chat_list/cubits/chat_list_cubit.dart @@ -0,0 +1,142 @@ +import 'dart:async'; + +import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../account_manager/account_manager.dart'; +import '../../chat/chat.dart'; +import '../../proto/proto.dart' as proto; + +////////////////////////////////////////////////// + +////////////////////////////////////////////////// +// Mutable state for per-account chat list +typedef ChatListCubitState = DHTShortArrayCubitState; + +class ChatListCubit extends DHTShortArrayCubit + with StateMapFollowable { + ChatListCubit({ + required AccountInfo accountInfo, + required OwnedDHTRecordPointer chatListRecordPointer, + required ActiveChatCubit activeChatCubit, + }) : _activeChatCubit = activeChatCubit, + super( + open: () => _open(accountInfo, chatListRecordPointer), + decodeElement: proto.Chat.fromBuffer); + + static Future _open(AccountInfo accountInfo, + OwnedDHTRecordPointer chatListRecordPointer) async { + final dhtRecord = await DHTShortArray.openOwned(chatListRecordPointer, + debugName: 'ChatListCubit::_open::ChatList', + parent: accountInfo.accountRecordKey); + + return dhtRecord; + } + + Future getDefaultChatSettings( + proto.Contact contact) async { + final pronouns = contact.profile.pronouns.isEmpty + ? '' + : ' [${contact.profile.pronouns}])'; + return proto.ChatSettings() + ..title = '${contact.displayName}$pronouns' + ..description = '' + ..defaultExpiration = Int64.ZERO; + } + + /// Create a new chat (singleton for single contact chats) + Future getOrCreateChatSingleContact({ + required proto.Contact contact, + }) async { + // Make local copy so we don't share the buffer + final localConversationRecordKey = + contact.localConversationRecordKey.toVeilid(); + final remoteIdentityPublicKey = contact.identityPublicKey.toVeilid(); + final remoteConversationRecordKey = + contact.remoteConversationRecordKey.toVeilid(); + + // Create 1:1 conversation type Chat + final chatMember = proto.ChatMember() + ..remoteIdentityPublicKey = remoteIdentityPublicKey.toProto() + ..remoteConversationRecordKey = remoteConversationRecordKey.toProto(); + + final directChat = proto.DirectChat() + ..settings = await getDefaultChatSettings(contact) + ..localConversationRecordKey = localConversationRecordKey.toProto() + ..remoteMember = chatMember; + + final chat = proto.Chat()..direct = directChat; + + // Add Chat to account's list + await operateWriteEventual((writer) async { + // See if we have added this chat already + for (var i = 0; i < writer.length; i++) { + final cbuf = await writer.get(i); + if (cbuf == null) { + throw Exception('Failed to get chat'); + } + final c = proto.Chat.fromBuffer(cbuf); + + switch (c.whichKind()) { + case proto.Chat_Kind.direct: + if (c.direct.localConversationRecordKey == + contact.localConversationRecordKey) { + // Nothing to do here + return; + } + case proto.Chat_Kind.group: + if (c.group.localConversationRecordKey == + contact.localConversationRecordKey) { + throw StateError('direct conversation record key should' + ' not be used for group chats!'); + } + case proto.Chat_Kind.notSet: + throw StateError('unknown chat kind'); + } + } + + // Add chat + await writer.add(chat.writeToBuffer()); + }); + } + + /// Delete a chat + Future deleteChat( + {required TypedKey localConversationRecordKey}) async { + // Remove Chat from account's list + await operateWriteEventual((writer) async { + if (_activeChatCubit.state == localConversationRecordKey) { + _activeChatCubit.setActiveChat(null); + } + for (var i = 0; i < writer.length; i++) { + final c = await writer.getProtobuf(proto.Chat.fromBuffer, i); + if (c == null) { + throw Exception('Failed to get chat'); + } + + if (c.localConversationRecordKey == localConversationRecordKey) { + await writer.remove(i); + return; + } + } + }); + } + + /// StateMapFollowable ///////////////////////// + @override + IMap getStateMap(ChatListCubitState state) { + final stateValue = state.state.asData?.value; + if (stateValue == null) { + return IMap(); + } + return IMap.fromIterable(stateValue, + keyMapper: (e) => e.value.localConversationRecordKey, + valueMapper: (e) => e.value); + } + + //////////////////////////////////////////////////////////////////////////// + + final ActiveChatCubit _activeChatCubit; +} diff --git a/lib/chat_list/cubits/cubits.dart b/lib/chat_list/cubits/cubits.dart new file mode 100644 index 0000000..cafafff --- /dev/null +++ b/lib/chat_list/cubits/cubits.dart @@ -0,0 +1 @@ +export 'chat_list_cubit.dart'; diff --git a/lib/chat_list/views/chat_list_widget.dart b/lib/chat_list/views/chat_list_widget.dart new file mode 100644 index 0000000..33ddaab --- /dev/null +++ b/lib/chat_list/views/chat_list_widget.dart @@ -0,0 +1,93 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:searchable_listview/searchable_listview.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../contacts/contacts.dart'; +import '../../proto/proto.dart' as proto; +import '../../theme/theme.dart'; +import '../chat_list.dart'; + +class ChatListWidget extends StatelessWidget { + const ChatListWidget({super.key}); + + Widget _itemBuilderDirect( + proto.DirectChat direct, IMap contactMap) { + final contact = contactMap[direct.localConversationRecordKey]; + return ChatSingleContactItemWidget( + localConversationRecordKey: + direct.localConversationRecordKey.toVeilid(), + contact: contact) + .paddingLTRB(0, 4, 0, 0); + } + + List _itemFilter(IMap contactMap, + IList> chatList, String filter) { + final lowerValue = filter.toLowerCase(); + return chatList.map((x) => x.value).where((c) { + switch (c.whichKind()) { + case proto.Chat_Kind.direct: + final contact = contactMap[c.direct.localConversationRecordKey]; + if (contact == null) { + return false; + } + return contact.nickname.toLowerCase().contains(lowerValue) || + contact.profile.name.toLowerCase().contains(lowerValue) || + contact.profile.pronouns.toLowerCase().contains(lowerValue); + case proto.Chat_Kind.group: + // xxx: how to filter group chats + return true; + case proto.Chat_Kind.notSet: + throw StateError('unknown chat kind'); + } + }).toList(); + } + + @override + Widget build(BuildContext context) { + final contactListV = context.watch().state; + + return contactListV.builder((context, contactList) { + final contactMap = IMap.fromIterable(contactList, + keyMapper: (c) => c.value.localConversationRecordKey, + valueMapper: (c) => c.value); + + final chatListV = context.watch().state; + return chatListV.builder((context, chatList) => SizedBox.expand( + child: styledContainer( + context: context, + child: (chatList.isEmpty) + ? const SizedBox.expand(child: EmptyChatListWidget()) + : TapRegion( + onTapOutside: (_) { + FocusScope.of(context).unfocus(); + }, + child: SearchableList( + initialList: chatList.map((x) => x.value).toList(), + itemBuilder: (c) { + switch (c.whichKind()) { + case proto.Chat_Kind.direct: + return _itemBuilderDirect( + c.direct, + contactMap, + ); + case proto.Chat_Kind.group: + return const Text('group chats not yet supported!'); + case proto.Chat_Kind.notSet: + throw StateError('unknown chat kind'); + } + }, + filter: (value) => + _itemFilter(contactMap, chatList, value), + searchFieldPadding: const EdgeInsets.fromLTRB(0, 0, 0, 4), + inputDecoration: InputDecoration( + labelText: translate('chat_list.search'), + ), + )).paddingAll(8), + ))); + }); + } +} diff --git a/lib/chat_list/views/chat_single_contact_item_widget.dart b/lib/chat_list/views/chat_single_contact_item_widget.dart new file mode 100644 index 0000000..d2594c5 --- /dev/null +++ b/lib/chat_list/views/chat_single_contact_item_widget.dart @@ -0,0 +1,84 @@ +import 'package:async_tools/async_tools.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:veilid_support/veilid_support.dart'; +import '../../chat/cubits/active_chat_cubit.dart'; +import '../../contacts/contacts.dart'; +import '../../proto/proto.dart' as proto; +import '../../theme/theme.dart'; +import '../chat_list.dart'; + +class ChatSingleContactItemWidget extends StatelessWidget { + const ChatSingleContactItemWidget({ + required TypedKey localConversationRecordKey, + required proto.Contact? contact, + bool disabled = false, + super.key, + }) : _localConversationRecordKey = localConversationRecordKey, + _contact = contact, + _disabled = disabled; + + final TypedKey _localConversationRecordKey; + final proto.Contact? _contact; + final bool _disabled; + + @override + Widget build( + BuildContext context, + ) { + final scaleTheme = Theme.of(context).extension()!; + + final activeChatCubit = context.watch(); + final selected = activeChatCubit.state == _localConversationRecordKey; + + final name = _contact == null ? '?' : _contact.nameOrNickname; + final title = _contact == null + ? translate('chat_list.deleted_contact') + : _contact.displayName; + final subtitle = _contact == null ? '' : _contact.profile.status; + final availability = _contact == null + ? proto.Availability.AVAILABILITY_UNSPECIFIED + : _contact.profile.availability; + + final scaleTileTheme = scaleTheme.tileTheme( + disabled: _disabled, + selected: selected, + ); + + final avatar = StyledAvatar( + name: name, + size: 32.scaled(context), + ); + + return StyledSlideTile( + key: ValueKey(_localConversationRecordKey), + disabled: _disabled, + selected: selected, + tileScale: ScaleKind.primary, + title: title, + subtitle: subtitle, + leading: avatar, + trailing: AvailabilityWidget( + availability: availability, + color: scaleTileTheme.textColor, + ).fit(fit: BoxFit.fill), + onTap: () { + singleFuture(activeChatCubit, () async { + activeChatCubit.setActiveChat(_localConversationRecordKey); + }); + }, + endActions: [ + SlideTileAction( + //icon: Icons.delete, + label: translate('button.delete'), + actionScale: ScaleKind.tertiary, + onPressed: (context) async { + final chatListCubit = context.read(); + await chatListCubit.deleteChat( + localConversationRecordKey: _localConversationRecordKey); + }) + ], + ); + } +} diff --git a/lib/components/empty_chat_list_widget.dart b/lib/chat_list/views/empty_chat_list_widget.dart similarity index 79% rename from lib/components/empty_chat_list_widget.dart rename to lib/chat_list/views/empty_chat_list_widget.dart index 3ef0f97..024cbf0 100644 --- a/lib/components/empty_chat_list_widget.dart +++ b/lib/chat_list/views/empty_chat_list_widget.dart @@ -1,15 +1,14 @@ import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_translate/flutter_translate.dart'; -import '../tools/tools.dart'; +import '../../theme/theme.dart'; -class EmptyChatListWidget extends ConsumerWidget { +class EmptyChatListWidget extends StatelessWidget { const EmptyChatListWidget({super.key}); @override // ignore: prefer_expression_function_bodies - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final theme = Theme.of(context); final textTheme = theme.textTheme; final scale = theme.extension()!; diff --git a/lib/chat_list/views/views.dart b/lib/chat_list/views/views.dart new file mode 100644 index 0000000..1420794 --- /dev/null +++ b/lib/chat_list/views/views.dart @@ -0,0 +1,3 @@ +export 'chat_list_widget.dart'; +export 'chat_single_contact_item_widget.dart'; +export 'empty_chat_list_widget.dart'; diff --git a/lib/components/account_bubble.dart b/lib/components/account_bubble.dart deleted file mode 100644 index 57424ae..0000000 --- a/lib/components/account_bubble.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:circular_profile_avatar/circular_profile_avatar.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:window_manager/window_manager.dart'; - -import '../entities/local_account.dart'; -import '../providers/logins.dart'; - -class AccountBubble extends ConsumerWidget { - const AccountBubble({required this.account, super.key}); - final LocalAccount account; - - @override - Widget build(BuildContext context, WidgetRef ref) { - windowManager.setTitleBarStyle(TitleBarStyle.normal); - final logins = ref.watch(loginsProvider); - - return ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 300), - child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ - Expanded( - flex: 4, - child: CircularProfileAvatar('', - child: Container(color: Theme.of(context).disabledColor))), - const Expanded(child: Text('Placeholder')) - ])); - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(DiagnosticsProperty('account', account)); - } -} - -class AddAccountBubble extends ConsumerWidget { - const AddAccountBubble({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - windowManager.setTitleBarStyle(TitleBarStyle.normal); - final logins = ref.watch(loginsProvider); - - return Column(mainAxisAlignment: MainAxisAlignment.center, children: [ - CircularProfileAvatar('', - borderWidth: 4, - borderColor: Theme.of(context).unselectedWidgetColor, - child: Container( - color: Colors.blue, child: const Icon(Icons.add, size: 50))), - const Text('Add Account').paddingLTRB(0, 4, 0, 0) - ]); - } -} diff --git a/lib/components/bottom_sheet_action_button.dart b/lib/components/bottom_sheet_action_button.dart deleted file mode 100644 index 4330d33..0000000 --- a/lib/components/bottom_sheet_action_button.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -class BottomSheetActionButton extends ConsumerStatefulWidget { - const BottomSheetActionButton( - {required this.bottomSheetBuilder, - required this.builder, - this.foregroundColor, - this.backgroundColor, - this.shape, - super.key}); - final Color? foregroundColor; - final Color? backgroundColor; - final ShapeBorder? shape; - final Widget Function(BuildContext) builder; - final Widget Function(BuildContext) bottomSheetBuilder; - - @override - BottomSheetActionButtonState createState() => BottomSheetActionButtonState(); - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(ObjectFlagProperty.has( - 'bottomSheetBuilder', bottomSheetBuilder)) - ..add(ColorProperty('foregroundColor', foregroundColor)) - ..add(ColorProperty('backgroundColor', backgroundColor)) - ..add(DiagnosticsProperty('shape', shape)) - ..add(ObjectFlagProperty.has( - 'builder', builder)); - } -} - -class BottomSheetActionButtonState - extends ConsumerState { - bool _showFab = true; - - @override - void initState() { - super.initState(); - } - - @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context) { - // - return _showFab - ? FloatingActionButton( - elevation: 0, - hoverElevation: 0, - shape: widget.shape, - foregroundColor: widget.foregroundColor, - backgroundColor: widget.backgroundColor, - child: widget.builder(context), - onPressed: () async { - await showModalBottomSheet( - context: context, builder: widget.bottomSheetBuilder); - }, - ) - : Container(); - } - - void showFloatingActionButton(bool value) { - setState(() { - _showFab = value; - }); - } -} diff --git a/lib/components/chat_component.dart b/lib/components/chat_component.dart deleted file mode 100644 index ff1b642..0000000 --- a/lib/components/chat_component.dart +++ /dev/null @@ -1,205 +0,0 @@ -import 'dart:async'; - -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_chat_types/flutter_chat_types.dart' as types; -import 'package:flutter_chat_ui/flutter_chat_ui.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../proto/proto.dart' as proto; -import '../providers/account.dart'; -import '../providers/chat.dart'; -import '../providers/conversation.dart'; -import '../tools/tools.dart'; -import '../veilid_init.dart'; -import '../veilid_support/veilid_support.dart'; - -class ChatComponent extends ConsumerStatefulWidget { - const ChatComponent( - {required this.activeAccountInfo, - required this.activeChat, - required this.activeChatContact, - super.key}); - - final ActiveAccountInfo activeAccountInfo; - final TypedKey activeChat; - final proto.Contact activeChatContact; - - @override - ChatComponentState createState() => ChatComponentState(); - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(DiagnosticsProperty( - 'activeAccountInfo', activeAccountInfo)) - ..add(DiagnosticsProperty('activeChat', activeChat)) - ..add(DiagnosticsProperty( - 'activeChatContact', activeChatContact)); - } -} - -class ChatComponentState extends ConsumerState { - final _unfocusNode = FocusNode(); - late final types.User _localUser; - late final types.User _remoteUser; - - @override - void initState() { - super.initState(); - - _localUser = types.User( - id: widget.activeAccountInfo.localAccount.identityMaster - .identityPublicTypedKey() - .toString(), - firstName: widget.activeAccountInfo.account.profile.name, - ); - _remoteUser = types.User( - id: proto.TypedKeyProto.fromProto( - widget.activeChatContact.identityPublicKey) - .toString(), - firstName: widget.activeChatContact.remoteProfile.name); - } - - @override - void dispose() { - _unfocusNode.dispose(); - super.dispose(); - } - - types.Message protoMessageToMessage(proto.Message message) { - final isLocal = message.author == - widget.activeAccountInfo.localAccount.identityMaster - .identityPublicTypedKey() - .toProto(); - - final textMessage = types.TextMessage( - author: isLocal ? _localUser : _remoteUser, - createdAt: (message.timestamp ~/ 1000).toInt(), - id: message.timestamp.toString(), - text: message.text, - ); - return textMessage; - } - - Future _addMessage(proto.Message protoMessage) async { - if (protoMessage.text.isEmpty) { - return; - } - - final message = protoMessageToMessage(protoMessage); - - // setState(() { - // _messages.insert(0, message); - // }); - - // Now add the message to the conversation messages - final localConversationRecordKey = proto.TypedKeyProto.fromProto( - widget.activeChatContact.localConversationRecordKey); - final remoteIdentityPublicKey = proto.TypedKeyProto.fromProto( - widget.activeChatContact.identityPublicKey); - - await addLocalConversationMessage( - activeAccountInfo: widget.activeAccountInfo, - localConversationRecordKey: localConversationRecordKey, - remoteIdentityPublicKey: remoteIdentityPublicKey, - message: protoMessage); - - ref.invalidate(activeConversationMessagesProvider); - } - - Future _handleSendPressed(types.PartialText message) async { - final protoMessage = proto.Message() - ..author = widget.activeAccountInfo.localAccount.identityMaster - .identityPublicTypedKey() - .toProto() - ..timestamp = (await eventualVeilid.future).now().toInt64() - ..text = message.text; - //..signature = signature; - - await _addMessage(protoMessage); - } - - void _handleAttachmentPressed() { - // - } - - @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context) { - final theme = Theme.of(context); - final scale = theme.extension()!; - final textTheme = Theme.of(context).textTheme; - final chatTheme = makeChatTheme(scale, textTheme); - final contactName = widget.activeChatContact.editedProfile.name; - - final protoMessages = - ref.watch(activeConversationMessagesProvider).asData?.value; - if (protoMessages == null) { - return waitingPage(context); - } - final messages = []; - for (final protoMessage in protoMessages) { - final message = protoMessageToMessage(protoMessage); - messages.insert(0, message); - } - - return DefaultTextStyle( - style: textTheme.bodySmall!, - child: Align( - alignment: AlignmentDirectional.centerEnd, - child: Stack( - children: [ - Column( - children: [ - Container( - height: 48, - decoration: BoxDecoration( - color: scale.primaryScale.subtleBorder, - ), - child: Row(children: [ - Align( - alignment: AlignmentDirectional.centerStart, - child: Padding( - padding: const EdgeInsetsDirectional.fromSTEB( - 16, 0, 16, 0), - child: Text(contactName, - textAlign: TextAlign.start, - style: textTheme.titleMedium), - )), - const Spacer(), - IconButton( - icon: const Icon(Icons.close), - onPressed: () async { - ref.read(activeChatStateProvider.notifier).state = - null; - }).paddingLTRB(16, 0, 16, 0) - ]), - ), - Expanded( - child: DecoratedBox( - decoration: const BoxDecoration(), - child: Chat( - theme: chatTheme, - messages: messages, - //onAttachmentPressed: _handleAttachmentPressed, - //onMessageTap: _handleMessageTap, - //onPreviewDataFetched: _handlePreviewDataFetched, - - onSendPressed: (message) { - unawaited(_handleSendPressed(message)); - }, - //showUserAvatars: false, - //showUserNames: true, - user: _localUser, - ), - ), - ), - ], - ), - ], - ), - )); - } -} diff --git a/lib/components/chat_single_contact_item_widget.dart b/lib/components/chat_single_contact_item_widget.dart deleted file mode 100644 index f9cc102..0000000 --- a/lib/components/chat_single_contact_item_widget.dart +++ /dev/null @@ -1,94 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_slidable/flutter_slidable.dart'; -import 'package:flutter_translate/flutter_translate.dart'; -import '../proto/proto.dart' as proto; -import '../providers/account.dart'; -import '../providers/chat.dart'; -import '../tools/theme_service.dart'; - -class ChatSingleContactItemWidget extends ConsumerWidget { - const ChatSingleContactItemWidget({required this.contact, super.key}); - - final proto.Contact contact; - - @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); - //final textTheme = theme.textTheme; - final scale = theme.extension()!; - - final activeChat = ref.watch(activeChatStateProvider); - final remoteConversationRecordKey = - proto.TypedKeyProto.fromProto(contact.remoteConversationRecordKey); - final selected = activeChat == remoteConversationRecordKey; - - return Container( - margin: const EdgeInsets.fromLTRB(0, 4, 0, 0), - clipBehavior: Clip.antiAlias, - decoration: ShapeDecoration( - color: scale.tertiaryScale.subtleBorder, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - )), - child: Slidable( - key: ObjectKey(contact), - endActionPane: ActionPane( - motion: const DrawerMotion(), - children: [ - SlidableAction( - onPressed: (context) async { - final activeAccountInfo = - await ref.read(fetchActiveAccountProvider.future); - if (activeAccountInfo != null) { - await deleteChat( - activeAccountInfo: activeAccountInfo, - remoteConversationRecordKey: - remoteConversationRecordKey); - ref.invalidate(fetchChatListProvider); - } - }, - backgroundColor: scale.tertiaryScale.background, - foregroundColor: scale.tertiaryScale.text, - icon: Icons.delete, - label: translate('button.delete'), - padding: const EdgeInsets.all(2)), - // SlidableAction( - // onPressed: (context) => (), - // backgroundColor: scale.secondaryScale.background, - // foregroundColor: scale.secondaryScale.text, - // icon: Icons.edit, - // label: 'Edit', - // ), - ], - ), - - // The child of the Slidable is what the user sees when the - // component is not dragged. - child: ListTile( - onTap: () async { - ref.read(activeChatStateProvider.notifier).state = - remoteConversationRecordKey; - ref.invalidate(fetchChatListProvider); - }, - title: Text(contact.editedProfile.name), - - /// xxx show last message here - subtitle: (contact.editedProfile.pronouns.isNotEmpty) - ? Text(contact.editedProfile.pronouns) - : null, - iconColor: scale.tertiaryScale.background, - textColor: scale.tertiaryScale.text, - selected: selected, - //Text(Timestamp.fromInt64(contactInvitationRecord.expiration) / ), - leading: const Icon(Icons.chat)))); - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(DiagnosticsProperty('contact', contact)); - } -} diff --git a/lib/components/chat_single_contact_list_widget.dart b/lib/components/chat_single_contact_list_widget.dart deleted file mode 100644 index 48db337..0000000 --- a/lib/components/chat_single_contact_list_widget.dart +++ /dev/null @@ -1,92 +0,0 @@ -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_translate/flutter_translate.dart'; -import 'package:searchable_listview/searchable_listview.dart'; - -import '../proto/proto.dart' as proto; -import '../tools/tools.dart'; -import 'chat_single_contact_item_widget.dart'; -import 'empty_chat_list_widget.dart'; - -class ChatSingleContactListWidget extends ConsumerWidget { - ChatSingleContactListWidget( - {required IList contactList, - required this.chatList, - super.key}) - : contactMap = IMap.fromIterable(contactList, - keyMapper: (c) => c.remoteConversationRecordKey, - valueMapper: (c) => c); - - final IMap contactMap; - final IList chatList; - - @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); - //final textTheme = theme.textTheme; - final scale = theme.extension()!; - - return SizedBox.expand( - child: styledTitleContainer( - context: context, - title: translate('chat_list.chats'), - child: SizedBox.expand( - child: (chatList.isEmpty) - ? const EmptyChatListWidget() - : SearchableList( - autoFocusOnSearch: false, - initialList: chatList.toList(), - builder: (l, i, c) { - final contact = - contactMap[c.remoteConversationKey]; - if (contact == null) { - return const Text('...'); - } - return ChatSingleContactItemWidget( - contact: contact); - }, - filter: (value) { - final lowerValue = value.toLowerCase(); - return chatList.where((c) { - final contact = - contactMap[c.remoteConversationKey]; - if (contact == null) { - return false; - } - return contact.editedProfile.name - .toLowerCase() - .contains(lowerValue) || - contact.editedProfile.pronouns - .toLowerCase() - .contains(lowerValue); - }).toList(); - }, - spaceBetweenSearchAndList: 4, - inputDecoration: InputDecoration( - labelText: translate('chat_list.search'), - contentPadding: const EdgeInsets.all(2), - fillColor: scale.primaryScale.text, - focusedBorder: OutlineInputBorder( - borderSide: BorderSide( - color: scale.primaryScale.hoverBorder, - ), - borderRadius: BorderRadius.circular(8), - ), - ), - ).paddingAll(8)))) - .paddingLTRB(8, 8, 8, 65); - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(DiagnosticsProperty>( - 'contactMap', contactMap)) - ..add(IterableProperty('chatList', chatList)); - } -} diff --git a/lib/components/contact_invitation_display.dart b/lib/components/contact_invitation_display.dart deleted file mode 100644 index 5f32ca8..0000000 --- a/lib/components/contact_invitation_display.dart +++ /dev/null @@ -1,152 +0,0 @@ -import 'dart:async'; -import 'dart:math'; - -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:basic_utils/basic_utils.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_translate/flutter_translate.dart'; -import 'package:qr_flutter/qr_flutter.dart'; - -import '../tools/tools.dart'; -import '../veilid_support/veilid_support.dart'; - -class ContactInvitationDisplayDialog extends ConsumerStatefulWidget { - const ContactInvitationDisplayDialog({ - required this.name, - required this.message, - required this.generator, - super.key, - }); - - final String name; - final String message; - final FutureOr generator; - - @override - ContactInvitationDisplayDialogState createState() => - ContactInvitationDisplayDialogState(); - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(StringProperty('name', name)) - ..add(StringProperty('message', message)) - ..add(DiagnosticsProperty?>('generator', generator)); - } -} - -class ContactInvitationDisplayDialogState - extends ConsumerState { - final focusNode = FocusNode(); - final formKey = GlobalKey(); - late final AutoDisposeFutureProvider _generateFutureProvider; - - @override - void initState() { - super.initState(); - - _generateFutureProvider = - AutoDisposeFutureProvider((ref) async => widget.generator); - } - - @override - void dispose() { - focusNode.dispose(); - super.dispose(); - } - - String makeTextInvite(String message, Uint8List data) { - final invite = StringUtils.addCharAtPosition( - base64UrlNoPadEncode(data), '\n', 40, - repeat: true); - final msg = message.isNotEmpty ? '$message\n' : ''; - return '$msg' - '--- BEGIN VEILIDCHAT CONTACT INVITE ----\n' - '$invite\n' - '---- END VEILIDCHAT CONTACT INVITE -----\n'; - } - - @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context) { - final theme = Theme.of(context); - //final scale = theme.extension()!; - final textTheme = theme.textTheme; - - final signedContactInvitationBytesV = ref.watch(_generateFutureProvider); - final cardsize = - min(MediaQuery.of(context).size.shortestSide - 48.0, 400); - - return Dialog( - backgroundColor: Colors.white, - child: ConstrainedBox( - constraints: BoxConstraints( - minWidth: cardsize, - maxWidth: cardsize, - minHeight: cardsize, - maxHeight: cardsize), - child: signedContactInvitationBytesV.when( - loading: () => buildProgressIndicator(context), - data: (data) { - if (data == null) { - Navigator.of(context).pop(); - return const Text(''); - } - return Form( - key: formKey, - child: Column(children: [ - FittedBox( - child: Text( - translate( - 'send_invite_dialog.contact_invitation'), - style: textTheme.headlineSmall! - .copyWith(color: Colors.black))) - .paddingAll(8), - FittedBox( - child: QrImageView.withQr( - size: 300, - qr: QrCode.fromUint8List( - data: data, - errorCorrectLevel: - QrErrorCorrectLevel.L))) - .expanded(), - Text(widget.message, - softWrap: true, - style: textTheme.labelLarge! - .copyWith(color: Colors.black)) - .paddingAll(8), - ElevatedButton.icon( - icon: const Icon(Icons.copy), - label: Text( - translate('send_invite_dialog.copy_invitation')), - onPressed: () async { - showInfoToast( - context, - translate( - 'send_invite_dialog.invitation_copied')); - await Clipboard.setData(ClipboardData( - text: makeTextInvite(widget.message, data))); - }, - ).paddingAll(16), - ])); - }, - error: (e, s) { - Navigator.of(context).pop(); - showErrorToast(context, - translate('send_invite_dialog.failed_to_generate')); - return const Text(''); - }))); - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(DiagnosticsProperty('focusNode', focusNode)) - ..add(DiagnosticsProperty>('formKey', formKey)); - } -} diff --git a/lib/components/contact_invitation_item_widget.dart b/lib/components/contact_invitation_item_widget.dart deleted file mode 100644 index 1a967ba..0000000 --- a/lib/components/contact_invitation_item_widget.dart +++ /dev/null @@ -1,127 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_slidable/flutter_slidable.dart'; -import 'package:flutter_translate/flutter_translate.dart'; -import '../proto/proto.dart' as proto; -import '../providers/account.dart'; -import '../providers/contact_invite.dart'; -import '../tools/tools.dart'; -import 'contact_invitation_display.dart'; - -class ContactInvitationItemWidget extends ConsumerWidget { - const ContactInvitationItemWidget( - {required this.contactInvitationRecord, super.key}); - - final proto.ContactInvitationRecord contactInvitationRecord; - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(DiagnosticsProperty( - 'contactInvitationRecord', contactInvitationRecord)); - } - - @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); - //final textTheme = theme.textTheme; - final scale = theme.extension()!; - - return Container( - clipBehavior: Clip.antiAlias, - decoration: ShapeDecoration( - color: scale.tertiaryScale.subtleBorder, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - )), - child: Slidable( - // Specify a key if the Slidable is dismissible. - key: ObjectKey(contactInvitationRecord), - endActionPane: ActionPane( - // A motion is a widget used to control how the pane animates. - motion: const DrawerMotion(), - - // A pane can dismiss the Slidable. - //dismissible: DismissiblePane(onDismissed: () {}), - - // All actions are defined in the children parameter. - children: [ - // A SlidableAction can have an icon and/or a label. - SlidableAction( - onPressed: (context) async { - final activeAccountInfo = - await ref.read(fetchActiveAccountProvider.future); - if (activeAccountInfo != null) { - await deleteContactInvitation( - accepted: false, - activeAccountInfo: activeAccountInfo, - contactInvitationRecord: contactInvitationRecord); - ref.invalidate(fetchContactInvitationRecordsProvider); - } - }, - backgroundColor: scale.tertiaryScale.background, - foregroundColor: scale.tertiaryScale.text, - icon: Icons.delete, - label: translate('button.delete'), - padding: const EdgeInsets.all(2)), - ], - ), - - // startActionPane: ActionPane( - // motion: const DrawerMotion(), - // children: [ - // SlidableAction( - // // An action can be bigger than the others. - // flex: 2, - // onPressed: (context) => (), - // backgroundColor: Color(0xFF7BC043), - // foregroundColor: Colors.white, - // icon: Icons.archive, - // label: 'Archive', - // ), - // SlidableAction( - // onPressed: (context) => (), - // backgroundColor: Color(0xFF0392CF), - // foregroundColor: Colors.white, - // icon: Icons.save, - // label: 'Save', - // ), - // ], - // ), - - // The child of the Slidable is what the user sees when the - // component is not dragged. - child: ListTile( - //title: Text(translate('contact_list.invitation')), - onTap: () async { - final activeAccountInfo = - await ref.read(fetchActiveAccountProvider.future); - if (activeAccountInfo != null) { - // ignore: use_build_context_synchronously - if (!context.mounted) { - return; - } - await showDialog( - context: context, - builder: (context) => ContactInvitationDisplayDialog( - name: activeAccountInfo.localAccount.name, - message: contactInvitationRecord.message, - generator: Uint8List.fromList( - contactInvitationRecord.invitation), - )); - } - }, - title: Text( - contactInvitationRecord.message.isEmpty - ? translate('contact_list.invitation') - : contactInvitationRecord.message, - softWrap: true, - ), - iconColor: scale.tertiaryScale.background, - textColor: scale.tertiaryScale.text, - //Text(Timestamp.fromInt64(contactInvitationRecord.expiration) / ), - leading: const Icon(Icons.person_add)))); - } -} diff --git a/lib/components/contact_invitation_list_widget.dart b/lib/components/contact_invitation_list_widget.dart deleted file mode 100644 index 372a1cc..0000000 --- a/lib/components/contact_invitation_list_widget.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../proto/proto.dart' as proto; -import '../tools/tools.dart'; -import 'contact_invitation_item_widget.dart'; - -class ContactInvitationListWidget extends ConsumerStatefulWidget { - const ContactInvitationListWidget({ - required this.contactInvitationRecordList, - super.key, - }); - - final IList contactInvitationRecordList; - - @override - ContactInvitationListWidgetState createState() => - ContactInvitationListWidgetState(); - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(IterableProperty( - 'contactInvitationRecordList', contactInvitationRecordList)); - } -} - -class ContactInvitationListWidgetState - extends ConsumerState { - final ScrollController _scrollController = ScrollController(); - - @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context) { - final theme = Theme.of(context); - //final textTheme = theme.textTheme; - final scale = theme.extension()!; - - return Container( - width: double.infinity, - margin: const EdgeInsets.fromLTRB(4, 0, 4, 4), - decoration: ShapeDecoration( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - )), - constraints: const BoxConstraints(maxHeight: 200), - child: Container( - width: double.infinity, - decoration: ShapeDecoration( - color: scale.primaryScale.subtleBackground, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - )), - child: ListView.builder( - controller: _scrollController, - itemCount: widget.contactInvitationRecordList.length, - itemBuilder: (context, index) { - if (index < 0 || - index >= widget.contactInvitationRecordList.length) { - return null; - } - return ContactInvitationItemWidget( - contactInvitationRecord: - widget.contactInvitationRecordList[index], - key: ObjectKey(widget.contactInvitationRecordList[index])) - .paddingLTRB(4, 2, 4, 2); - }, - findChildIndexCallback: (key) { - final index = widget.contactInvitationRecordList.indexOf( - (key as ObjectKey).value! as proto.ContactInvitationRecord); - if (index == -1) { - return null; - } - return index; - }, - ).paddingLTRB(4, 6, 4, 6)), - ); - } -} diff --git a/lib/components/contact_item_widget.dart b/lib/components/contact_item_widget.dart deleted file mode 100644 index 1a4cb46..0000000 --- a/lib/components/contact_item_widget.dart +++ /dev/null @@ -1,122 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_animate/flutter_animate.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_slidable/flutter_slidable.dart'; -import 'package:flutter_translate/flutter_translate.dart'; -import '../proto/proto.dart' as proto; -import '../pages/main_pager/main_pager.dart'; -import '../providers/account.dart'; -import '../providers/chat.dart'; -import '../providers/contact.dart'; -import '../tools/theme_service.dart'; - -class ContactItemWidget extends ConsumerWidget { - const ContactItemWidget({required this.contact, super.key}); - - final proto.Contact contact; - - @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); - //final textTheme = theme.textTheme; - final scale = theme.extension()!; - - final remoteConversationKey = - proto.TypedKeyProto.fromProto(contact.remoteConversationRecordKey); - - return Container( - margin: const EdgeInsets.fromLTRB(0, 4, 0, 0), - clipBehavior: Clip.antiAlias, - decoration: ShapeDecoration( - color: scale.tertiaryScale.subtleBorder, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - )), - child: Slidable( - key: ObjectKey(contact), - endActionPane: ActionPane( - motion: const DrawerMotion(), - children: [ - SlidableAction( - onPressed: (context) async { - final activeAccountInfo = - await ref.read(fetchActiveAccountProvider.future); - if (activeAccountInfo != null) { - await deleteContact( - activeAccountInfo: activeAccountInfo, - contact: contact); - ref - ..invalidate(fetchContactListProvider) - ..invalidate(fetchChatListProvider); - } - }, - backgroundColor: scale.tertiaryScale.background, - foregroundColor: scale.tertiaryScale.text, - icon: Icons.delete, - label: translate('button.delete'), - padding: const EdgeInsets.all(2)), - // SlidableAction( - // onPressed: (context) => (), - // backgroundColor: scale.secondaryScale.background, - // foregroundColor: scale.secondaryScale.text, - // icon: Icons.edit, - // label: 'Edit', - // ), - ], - ), - - // The child of the Slidable is what the user sees when the - // component is not dragged. - child: ListTile( - onTap: () async { - final activeAccountInfo = - await ref.read(fetchActiveAccountProvider.future); - if (activeAccountInfo != null) { - // Start a chat - await getOrCreateChatSingleContact( - activeAccountInfo: activeAccountInfo, - remoteConversationRecordKey: remoteConversationKey); - ref - ..invalidate(fetchContactListProvider) - ..invalidate(fetchChatListProvider); - // Click over to chats - if (context.mounted) { - await MainPager.of(context)?.pageController.animateToPage( - 1, - duration: 250.ms, - curve: Curves.easeInOut); - } - } - - // // ignore: use_build_context_synchronously - // if (!context.mounted) { - // return; - // } - // await showDialog( - // context: context, - // builder: (context) => ContactInvitationDisplayDialog( - // name: activeAccountInfo.localAccount.name, - // message: contactInvitationRecord.message, - // generator: Uint8List.fromList( - // contactInvitationRecord.invitation), - // )); - // } - }, - title: Text(contact.editedProfile.name), - subtitle: (contact.editedProfile.pronouns.isNotEmpty) - ? Text(contact.editedProfile.pronouns) - : null, - iconColor: scale.tertiaryScale.background, - textColor: scale.tertiaryScale.text, - //Text(Timestamp.fromInt64(contactInvitationRecord.expiration) / ), - leading: const Icon(Icons.person)))); - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(DiagnosticsProperty('contact', contact)); - } -} diff --git a/lib/components/contact_list_widget.dart b/lib/components/contact_list_widget.dart deleted file mode 100644 index 1a8c87c..0000000 --- a/lib/components/contact_list_widget.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_translate/flutter_translate.dart'; -import 'package:searchable_listview/searchable_listview.dart'; - -import '../proto/proto.dart' as proto; -import '../tools/tools.dart'; -import 'contact_item_widget.dart'; -import 'empty_contact_list_widget.dart'; - -class ContactListWidget extends ConsumerWidget { - const ContactListWidget({required this.contactList, super.key}); - final IList contactList; - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(IterableProperty('contactList', contactList)); - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); - //final textTheme = theme.textTheme; - final scale = theme.extension()!; - - return SizedBox.expand( - child: styledTitleContainer( - context: context, - title: translate('contact_list.title'), - child: SizedBox.expand( - child: (contactList.isEmpty) - ? const EmptyContactListWidget() - : SearchableList( - autoFocusOnSearch: false, - initialList: contactList.toList(), - builder: (l, i, c) => ContactItemWidget(contact: c), - filter: (value) { - final lowerValue = value.toLowerCase(); - return contactList - .where((element) => - element.editedProfile.name - .toLowerCase() - .contains(lowerValue) || - element.editedProfile.pronouns - .toLowerCase() - .contains(lowerValue)) - .toList(); - }, - spaceBetweenSearchAndList: 4, - inputDecoration: InputDecoration( - labelText: translate('contact_list.search'), - contentPadding: const EdgeInsets.all(2), - fillColor: scale.primaryScale.text, - focusedBorder: OutlineInputBorder( - borderSide: BorderSide( - color: scale.primaryScale.hoverBorder, - ), - borderRadius: BorderRadius.circular(8), - ), - ), - ).paddingAll(8), - ))).paddingLTRB(8, 0, 8, 8); - } -} diff --git a/lib/components/default_app_bar.dart b/lib/components/default_app_bar.dart deleted file mode 100644 index 41e3601..0000000 --- a/lib/components/default_app_bar.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_svg/flutter_svg.dart'; - -class DefaultAppBar extends AppBar { - DefaultAppBar( - {required super.title, super.key, Widget? leading, super.actions}) - : super( - leading: leading ?? - Container( - margin: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: Colors.black.withAlpha(32), - shape: BoxShape.circle), - child: - SvgPicture.asset('assets/images/vlogo.svg', height: 32) - .paddingAll(4))); -} diff --git a/lib/components/invite_dialog.dart b/lib/components/invite_dialog.dart deleted file mode 100644 index 870c6fe..0000000 --- a/lib/components/invite_dialog.dart +++ /dev/null @@ -1,343 +0,0 @@ -import 'dart:async'; - -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_translate/flutter_translate.dart'; - -import '../entities/local_account.dart'; -import '../providers/account.dart'; -import '../providers/contact.dart'; -import '../providers/contact_invite.dart'; -import '../tools/tools.dart'; -import 'enter_password.dart'; -import 'enter_pin.dart'; -import 'profile_widget.dart'; - -class InviteDialog extends ConsumerStatefulWidget { - const InviteDialog( - {required this.onValidationCancelled, - required this.onValidationSuccess, - required this.onValidationFailed, - required this.inviteControlIsValid, - required this.buildInviteControl, - super.key}); - - final void Function() onValidationCancelled; - final void Function() onValidationSuccess; - final void Function() onValidationFailed; - final bool Function() inviteControlIsValid; - final Widget Function( - BuildContext context, - InviteDialogState dialogState, - Future Function({required Uint8List inviteData}) - validateInviteData) buildInviteControl; - - @override - InviteDialogState createState() => InviteDialogState(); - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(ObjectFlagProperty.has( - 'onValidationCancelled', onValidationCancelled)) - ..add(ObjectFlagProperty.has( - 'onValidationSuccess', onValidationSuccess)) - ..add(ObjectFlagProperty.has( - 'onValidationFailed', onValidationFailed)) - ..add(ObjectFlagProperty.has( - 'inviteControlIsValid', inviteControlIsValid)) - ..add(ObjectFlagProperty< - Widget Function( - BuildContext context, - InviteDialogState dialogState, - Future Function({required Uint8List inviteData}) - validateInviteData)>.has( - 'buildInviteControl', buildInviteControl)); - } -} - -class InviteDialogState extends ConsumerState { - ValidContactInvitation? _validInvitation; - bool _isValidating = false; - bool _isAccepting = false; - - @override - void initState() { - super.initState(); - } - - bool get isValidating => _isValidating; - bool get isAccepting => _isAccepting; - - Future _onAccept() async { - final navigator = Navigator.of(context); - - setState(() { - _isAccepting = true; - }); - final activeAccountInfo = await ref.read(fetchActiveAccountProvider.future); - if (activeAccountInfo == null) { - setState(() { - _isAccepting = false; - }); - navigator.pop(); - return; - } - final validInvitation = _validInvitation; - if (validInvitation != null) { - final acceptedContact = - await acceptContactInvitation(activeAccountInfo, validInvitation); - if (acceptedContact != null) { - // initiator when accept is received will create - // contact in the case of a 'note to self' - final isSelf = - activeAccountInfo.localAccount.identityMaster.identityPublicKey == - acceptedContact.remoteIdentity.identityPublicKey; - if (!isSelf) { - await createContact( - activeAccountInfo: activeAccountInfo, - profile: acceptedContact.profile, - remoteIdentity: acceptedContact.remoteIdentity, - remoteConversationRecordKey: - acceptedContact.remoteConversationRecordKey, - localConversationRecordKey: - acceptedContact.localConversationRecordKey, - ); - } - ref - ..invalidate(fetchContactInvitationRecordsProvider) - ..invalidate(fetchContactListProvider); - } else { - if (context.mounted) { - showErrorToast(context, 'invite_dialog.failed_to_accept'); - } - } - } - setState(() { - _isAccepting = false; - }); - navigator.pop(); - } - - Future _onReject() async { - final navigator = Navigator.of(context); - - setState(() { - _isAccepting = true; - }); - final activeAccountInfo = await ref.read(fetchActiveAccountProvider.future); - if (activeAccountInfo == null) { - setState(() { - _isAccepting = false; - }); - navigator.pop(); - return; - } - final validInvitation = _validInvitation; - if (validInvitation != null) { - if (await rejectContactInvitation(activeAccountInfo, validInvitation)) { - // do nothing right now - } else { - if (context.mounted) { - showErrorToast(context, 'invite_dialog.failed_to_reject'); - } - } - } - setState(() { - _isAccepting = false; - }); - navigator.pop(); - } - - Future _validateInviteData({ - required Uint8List inviteData, - }) async { - try { - final activeAccountInfo = - await ref.read(fetchActiveAccountProvider.future); - if (activeAccountInfo == null) { - setState(() { - _isValidating = false; - _validInvitation = null; - }); - return; - } - final contactInvitationRecords = - await ref.read(fetchContactInvitationRecordsProvider.future); - - setState(() { - _isValidating = true; - _validInvitation = null; - }); - final validatedContactInvitation = await validateContactInvitation( - activeAccountInfo: activeAccountInfo, - contactInvitationRecords: contactInvitationRecords, - inviteData: inviteData, - getEncryptionKeyCallback: - (cs, encryptionKeyType, encryptedSecret) async { - String encryptionKey; - switch (encryptionKeyType) { - case EncryptionKeyType.none: - encryptionKey = ''; - case EncryptionKeyType.pin: - final description = - translate('invite_dialog.protected_with_pin'); - if (!context.mounted) { - return null; - } - final pin = await showDialog( - context: context, - builder: (context) => EnterPinDialog( - reenter: false, description: description)); - if (pin == null) { - return null; - } - encryptionKey = pin; - case EncryptionKeyType.password: - final description = - translate('invite_dialog.protected_with_password'); - if (!context.mounted) { - return null; - } - final password = await showDialog( - context: context, - builder: (context) => - EnterPasswordDialog(description: description)); - if (password == null) { - return null; - } - encryptionKey = password; - } - return decryptSecretFromBytes( - secretBytes: encryptedSecret, - cryptoKind: cs.kind(), - encryptionKeyType: encryptionKeyType, - encryptionKey: encryptionKey); - }); - - // Check if validation was cancelled - if (validatedContactInvitation == null) { - setState(() { - _isValidating = false; - _validInvitation = null; - widget.onValidationCancelled(); - }); - return; - } - - // Verify expiration - // xxx - - setState(() { - widget.onValidationSuccess(); - _isValidating = false; - _validInvitation = validatedContactInvitation; - }); - } on ContactInviteInvalidKeyException catch (e) { - String errorText; - switch (e.type) { - case EncryptionKeyType.none: - errorText = translate('invite_dialog.invalid_invitation'); - case EncryptionKeyType.pin: - errorText = translate('invite_dialog.invalid_pin'); - case EncryptionKeyType.password: - errorText = translate('invite_dialog.invalid_password'); - } - if (context.mounted) { - showErrorToast(context, errorText); - } - setState(() { - _isValidating = false; - _validInvitation = null; - widget.onValidationFailed(); - }); - } on Exception catch (e) { - log.debug('exception: $e', e); - setState(() { - _isValidating = false; - _validInvitation = null; - widget.onValidationFailed(); - }); - rethrow; - } - } - - @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context) { - // final theme = Theme.of(context); - // final scale = theme.extension()!; - // final textTheme = theme.textTheme; - // final height = MediaQuery.of(context).size.height; - - if (_isAccepting) { - return SizedBox( - height: 300, - width: 300, - child: buildProgressIndicator(context).toCenter()) - .paddingAll(16); - } - return ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 400, maxWidth: 400), - child: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - widget.buildInviteControl(context, this, _validateInviteData), - if (_isValidating) - Column(children: [ - Text(translate('invite_dialog.validating')) - .paddingLTRB(0, 0, 0, 16), - buildProgressIndicator(context).paddingAll(16), - ]).toCenter(), - if (_validInvitation == null && - !_isValidating && - widget.inviteControlIsValid()) - Column(children: [ - Text(translate('invite_dialog.invalid_invitation')), - const Icon(Icons.error) - ]).paddingAll(16).toCenter(), - if (_validInvitation != null && !_isValidating) - Column(children: [ - Container( - constraints: const BoxConstraints(maxHeight: 64), - width: double.infinity, - child: ProfileWidget( - name: _validInvitation! - .contactRequestPrivate.profile.name, - pronouns: _validInvitation! - .contactRequestPrivate.profile.pronouns, - )).paddingLTRB(0, 0, 0, 8), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - ElevatedButton.icon( - icon: const Icon(Icons.check_circle), - label: Text(translate('button.accept')), - onPressed: _onAccept, - ), - ElevatedButton.icon( - icon: const Icon(Icons.cancel), - label: Text(translate('button.reject')), - onPressed: _onReject, - ) - ], - ), - ]) - ]), - ), - ); - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(DiagnosticsProperty('isValidating', isValidating)) - ..add(DiagnosticsProperty('isAccepting', isAccepting)); - } -} diff --git a/lib/components/no_conversation_widget.dart b/lib/components/no_conversation_widget.dart deleted file mode 100644 index faf820f..0000000 --- a/lib/components/no_conversation_widget.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -class NoContactWidget extends ConsumerWidget { - const NoContactWidget({super.key}); - - @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context, WidgetRef ref) { - // - return Container( - width: double.infinity, - height: double.infinity, - decoration: BoxDecoration( - color: Theme.of(context).primaryColor, - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.emoji_people_outlined, - color: Theme.of(context).disabledColor, - size: 48, - ), - Text( - 'Choose A Conversation To Chat', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).disabledColor, - ), - ), - ], - ), - ); - } -} diff --git a/lib/components/profile_widget.dart b/lib/components/profile_widget.dart deleted file mode 100644 index a4a7090..0000000 --- a/lib/components/profile_widget.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../tools/tools.dart'; - -class ProfileWidget extends ConsumerWidget { - const ProfileWidget({ - required this.name, - this.pronouns, - super.key, - }); - - final String name; - final String? pronouns; - - @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); - final scale = theme.extension()!; - final textTheme = theme.textTheme; - - return DecoratedBox( - decoration: ShapeDecoration( - color: scale.primaryScale.border, - shape: - RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))), - child: Column(children: [ - Text( - name, - style: textTheme.headlineSmall, - textAlign: TextAlign.left, - ).paddingAll(4), - if (pronouns != null && pronouns!.isNotEmpty) - Text(pronouns!, style: textTheme.bodyMedium).paddingLTRB(4, 0, 4, 4), - ]), - ); - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(StringProperty('name', name)) - ..add(StringProperty('pronouns', pronouns)); - } -} diff --git a/lib/components/scan_invite_dialog.dart b/lib/components/scan_invite_dialog.dart deleted file mode 100644 index a506bcf..0000000 --- a/lib/components/scan_invite_dialog.dart +++ /dev/null @@ -1,399 +0,0 @@ -import 'dart:async'; -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_translate/flutter_translate.dart'; -import 'package:image/image.dart' as img; -import 'package:mobile_scanner/mobile_scanner.dart'; -import 'package:pasteboard/pasteboard.dart'; -import 'package:zxing2/qrcode.dart'; - -import '../tools/tools.dart'; -import 'invite_dialog.dart'; - -class BarcodeOverlay extends CustomPainter { - BarcodeOverlay({ - required this.barcode, - required this.arguments, - required this.boxFit, - required this.capture, - }); - - final BarcodeCapture capture; - final Barcode barcode; - final MobileScannerArguments arguments; - final BoxFit boxFit; - - @override - void paint(Canvas canvas, Size size) { - if (barcode.corners == null) { - return; - } - final adjustedSize = applyBoxFit(boxFit, arguments.size, size); - - var verticalPadding = size.height - adjustedSize.destination.height; - var horizontalPadding = size.width - adjustedSize.destination.width; - if (verticalPadding > 0) { - verticalPadding = verticalPadding / 2; - } else { - verticalPadding = 0; - } - - if (horizontalPadding > 0) { - horizontalPadding = horizontalPadding / 2; - } else { - horizontalPadding = 0; - } - - final ratioWidth = - (Platform.isIOS ? capture.width! : arguments.size.width) / - adjustedSize.destination.width; - final ratioHeight = - (Platform.isIOS ? capture.height! : arguments.size.height) / - adjustedSize.destination.height; - - final adjustedOffset = []; - for (final offset in barcode.corners!) { - adjustedOffset.add( - Offset( - offset.dx / ratioWidth + horizontalPadding, - offset.dy / ratioHeight + verticalPadding, - ), - ); - } - final cutoutPath = Path()..addPolygon(adjustedOffset, true); - - final backgroundPaint = Paint() - ..color = Colors.red.withOpacity(0.3) - ..style = PaintingStyle.fill - ..blendMode = BlendMode.dstOut; - - canvas.drawPath(cutoutPath, backgroundPaint); - } - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => false; -} - -class ScannerOverlay extends CustomPainter { - ScannerOverlay(this.scanWindow); - - final Rect scanWindow; - - @override - void paint(Canvas canvas, Size size) { - final backgroundPath = Path()..addRect(Rect.largest); - final cutoutPath = Path()..addRect(scanWindow); - - final backgroundPaint = Paint() - ..color = Colors.black.withOpacity(0.5) - ..style = PaintingStyle.fill - ..blendMode = BlendMode.dstOut; - - final backgroundWithCutout = Path.combine( - PathOperation.difference, - backgroundPath, - cutoutPath, - ); - canvas.drawPath(backgroundWithCutout, backgroundPaint); - } - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => false; -} - -class ScanInviteDialog extends ConsumerStatefulWidget { - const ScanInviteDialog({super.key}); - - @override - ScanInviteDialogState createState() => ScanInviteDialogState(); - - static Future show(BuildContext context) async { - await showStyledDialog( - context: context, - title: translate('scan_invite_dialog.title'), - child: const ScanInviteDialog()); - } -} - -class ScanInviteDialogState extends ConsumerState { - bool scanned = false; - - @override - void initState() { - super.initState(); - } - - void onValidationCancelled() { - setState(() { - scanned = false; - }); - } - - void onValidationSuccess() {} - void onValidationFailed() { - setState(() { - scanned = false; - }); - } - - bool inviteControlIsValid() => false; // _pasteTextController.text.isNotEmpty; - - Future scanQRImage(BuildContext context) async { - final theme = Theme.of(context); - //final textTheme = theme.textTheme; - final scale = theme.extension()!; - final windowSize = MediaQuery.of(context).size; - //final maxDialogWidth = min(windowSize.width - 64.0, 800.0 - 64.0); - //final maxDialogHeight = windowSize.height - 64.0; - - final scanWindow = Rect.fromCenter( - center: MediaQuery.of(context).size.center(Offset.zero), - width: 200, - height: 200, - ); - - final cameraController = MobileScannerController(); - try { - return showDialog( - context: context, - builder: (context) => Stack( - fit: StackFit.expand, - children: [ - MobileScanner( - fit: BoxFit.contain, - scanWindow: scanWindow, - controller: cameraController, - errorBuilder: (context, error, child) => - ScannerErrorWidget(error: error), - onDetect: (c) { - final barcode = c.barcodes.firstOrNull; - - final barcodeBytes = barcode?.rawBytes; - if (barcodeBytes != null) { - cameraController.dispose(); - Navigator.pop(context, barcodeBytes); - } - }), - CustomPaint( - painter: ScannerOverlay(scanWindow), - ), - Align( - alignment: Alignment.bottomCenter, - child: Container( - alignment: Alignment.bottomCenter, - height: 100, - color: Colors.black.withOpacity(0.4), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - IconButton( - color: Colors.white, - icon: ValueListenableBuilder( - valueListenable: cameraController.torchState, - builder: (context, state, child) { - switch (state) { - case TorchState.off: - return Icon(Icons.flash_off, - color: - scale.grayScale.subtleBackground); - case TorchState.on: - return Icon(Icons.flash_on, - color: scale.primaryScale.background); - } - }, - ), - iconSize: 32, - onPressed: cameraController.toggleTorch, - ), - SizedBox( - width: windowSize.width - 120, - height: 50, - child: FittedBox( - child: Text( - translate('scan_invite_dialog.instructions'), - overflow: TextOverflow.fade, - style: Theme.of(context) - .textTheme - .labelLarge! - .copyWith(color: Colors.white), - ), - ), - ), - IconButton( - color: Colors.white, - icon: ValueListenableBuilder( - valueListenable: - cameraController.cameraFacingState, - builder: (context, state, child) { - switch (state) { - case CameraFacing.front: - return const Icon(Icons.camera_front); - case CameraFacing.back: - return const Icon(Icons.camera_rear); - } - }, - ), - iconSize: 32, - onPressed: cameraController.switchCamera, - ), - ], - ), - ), - ), - Align( - alignment: Alignment.topRight, - child: IconButton( - color: Colors.white, - icon: Icon(Icons.close, - color: scale.grayScale.background), - iconSize: 32, - onPressed: () => { - SchedulerBinding.instance - .addPostFrameCallback((_) { - cameraController.dispose(); - Navigator.pop(context, null); - }) - })), - ], - )); - } on MobileScannerException catch (e) { - if (e.errorCode == MobileScannerErrorCode.permissionDenied) { - showErrorToast( - context, translate('scan_invite_dialog.permission_error')); - } else { - showErrorToast(context, translate('scan_invite_dialog.error')); - } - } on Exception catch (_) { - showErrorToast(context, translate('scan_invite_dialog.error')); - } - - return null; - } - - Future pasteQRImage(BuildContext context) async { - final imageBytes = await Pasteboard.image; - if (imageBytes == null) { - if (context.mounted) { - showErrorToast(context, translate('scan_invite_dialog.not_an_image')); - } - return null; - } - - final image = img.decodeImage(imageBytes); - if (image == null) { - if (context.mounted) { - showErrorToast( - context, translate('scan_invite_dialog.could_not_decode_image')); - } - return null; - } - - try { - final source = RGBLuminanceSource( - image.width, - image.height, - image - .convert(numChannels: 4) - .getBytes(order: img.ChannelOrder.abgr) - .buffer - .asInt32List()); - final bitmap = BinaryBitmap(HybridBinarizer(source)); - - final reader = QRCodeReader(); - final result = reader.decode(bitmap); - - final segs = result.resultMetadata[ResultMetadataType.byteSegments]! - as List; - return Uint8List.fromList(segs[0].toList()); - } on Exception catch (_) { - if (context.mounted) { - showErrorToast( - context, translate('scan_invite_dialog.not_a_valid_qr_code')); - } - return null; - } - } - - Widget buildInviteControl( - BuildContext context, - InviteDialogState dialogState, - Future Function({required Uint8List inviteData}) - validateInviteData) { - //final theme = Theme.of(context); - //final scale = theme.extension()!; - //final textTheme = theme.textTheme; - //final height = MediaQuery.of(context).size.height; - - if (isiOS || isAndroid) { - return Column(mainAxisSize: MainAxisSize.min, children: [ - if (!scanned) - Text( - translate('scan_invite_dialog.scan_qr_here'), - ).paddingLTRB(0, 0, 0, 8), - if (!scanned) - Container( - constraints: const BoxConstraints(maxHeight: 200), - child: ElevatedButton( - onPressed: dialogState.isValidating - ? null - : () async { - final inviteData = await scanQRImage(context); - if (inviteData != null) { - setState(() { - scanned = true; - }); - await validateInviteData(inviteData: inviteData); - } - }, - child: Text(translate('scan_invite_dialog.scan'))), - ).paddingLTRB(0, 0, 0, 8) - ]); - } - return Column(mainAxisSize: MainAxisSize.min, children: [ - if (!scanned) - Text( - translate('scan_invite_dialog.paste_qr_here'), - ).paddingLTRB(0, 0, 0, 8), - if (!scanned) - Container( - constraints: const BoxConstraints(maxHeight: 200), - child: ElevatedButton( - onPressed: dialogState.isValidating - ? null - : () async { - final inviteData = await pasteQRImage(context); - if (inviteData != null) { - await validateInviteData(inviteData: inviteData); - setState(() { - scanned = true; - }); - } - }, - child: Text(translate('scan_invite_dialog.paste'))), - ).paddingLTRB(0, 0, 0, 8) - ]); - } - - @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context) { - return InviteDialog( - onValidationCancelled: onValidationCancelled, - onValidationSuccess: onValidationSuccess, - onValidationFailed: onValidationFailed, - inviteControlIsValid: inviteControlIsValid, - buildInviteControl: buildInviteControl); - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(DiagnosticsProperty('scanned', scanned)); - } -} diff --git a/lib/components/send_invite_dialog.dart b/lib/components/send_invite_dialog.dart deleted file mode 100644 index 49adb68..0000000 --- a/lib/components/send_invite_dialog.dart +++ /dev/null @@ -1,248 +0,0 @@ -import 'dart:async'; -import 'dart:math'; - -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_translate/flutter_translate.dart'; - -import '../entities/local_account.dart'; -import '../providers/account.dart'; -import '../providers/contact_invite.dart'; -import '../tools/tools.dart'; -import '../veilid_support/veilid_support.dart'; -import 'contact_invitation_display.dart'; -import 'enter_password.dart'; -import 'enter_pin.dart'; - -class SendInviteDialog extends ConsumerStatefulWidget { - const SendInviteDialog({super.key}); - - @override - SendInviteDialogState createState() => SendInviteDialogState(); - - static Future show(BuildContext context) async { - await showStyledDialog( - context: context, - title: translate('send_invite_dialog.title'), - child: const SendInviteDialog()); - } -} - -class SendInviteDialogState extends ConsumerState { - final _messageTextController = TextEditingController( - text: translate('send_invite_dialog.connect_with_me')); - - EncryptionKeyType _encryptionKeyType = EncryptionKeyType.none; - String _encryptionKey = ''; - Timestamp? _expiration; - - @override - void initState() { - super.initState(); - } - - Future _onNoneEncryptionSelected(bool selected) async { - setState(() { - if (selected) { - _encryptionKeyType = EncryptionKeyType.none; - } - }); - } - - Future _onPinEncryptionSelected(bool selected) async { - final description = translate('send_invite_dialog.pin_description'); - final pin = await showDialog( - context: context, - builder: (context) => - EnterPinDialog(reenter: false, description: description)); - if (pin == null) { - return; - } - // ignore: use_build_context_synchronously - if (!context.mounted) { - return; - } - final matchpin = await showDialog( - context: context, - builder: (context) => EnterPinDialog( - reenter: true, - description: description, - )); - if (matchpin == null) { - return; - } else if (pin == matchpin) { - setState(() { - _encryptionKeyType = EncryptionKeyType.pin; - _encryptionKey = pin; - }); - } else { - // ignore: use_build_context_synchronously - if (!context.mounted) { - return; - } - showErrorToast( - context, translate('send_invite_dialog.pin_does_not_match')); - setState(() { - _encryptionKeyType = EncryptionKeyType.none; - _encryptionKey = ''; - }); - } - } - - Future _onPasswordEncryptionSelected(bool selected) async { - final description = translate('send_invite_dialog.password_description'); - final password = await showDialog( - context: context, - builder: (context) => EnterPasswordDialog(description: description)); - if (password == null) { - return; - } - // ignore: use_build_context_synchronously - if (!context.mounted) { - return; - } - final matchpass = await showDialog( - context: context, - builder: (context) => EnterPasswordDialog( - matchPass: password, - description: description, - )); - if (matchpass == null) { - return; - } else if (password == matchpass) { - setState(() { - _encryptionKeyType = EncryptionKeyType.password; - _encryptionKey = password; - }); - } else { - // ignore: use_build_context_synchronously - if (!context.mounted) { - return; - } - showErrorToast( - context, translate('send_invite_dialog.password_does_not_match')); - setState(() { - _encryptionKeyType = EncryptionKeyType.none; - _encryptionKey = ''; - }); - } - } - - Future _onGenerateButtonPressed() async { - final navigator = Navigator.of(context); - - // Start generation - final activeAccountInfo = await ref.read(fetchActiveAccountProvider.future); - if (activeAccountInfo == null) { - navigator.pop(); - return; - } - final generator = createContactInvitation( - activeAccountInfo: activeAccountInfo, - encryptionKeyType: _encryptionKeyType, - encryptionKey: _encryptionKey, - message: _messageTextController.text, - expiration: _expiration); - // ignore: use_build_context_synchronously - if (!context.mounted) { - return; - } - await showDialog( - context: context, - builder: (context) => ContactInvitationDisplayDialog( - name: activeAccountInfo.localAccount.name, - message: _messageTextController.text, - generator: generator, - )); - // if (ret == null) { - // return; - // } - ref.invalidate(fetchContactInvitationRecordsProvider); - navigator.pop(); - } - - @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context) { - final windowSize = MediaQuery.of(context).size; - final maxDialogWidth = min(windowSize.width - 64.0, 800.0 - 64.0); - final maxDialogHeight = windowSize.height - 64.0; - - final theme = Theme.of(context); - //final scale = theme.extension()!; - final textTheme = theme.textTheme; - return ConstrainedBox( - constraints: - BoxConstraints(maxHeight: maxDialogHeight, maxWidth: maxDialogWidth), - child: SingleChildScrollView( - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - translate('send_invite_dialog.message_to_contact'), - ).paddingAll(8), - TextField( - controller: _messageTextController, - inputFormatters: [ - LengthLimitingTextInputFormatter(128), - ], - decoration: InputDecoration( - border: const OutlineInputBorder(), - hintText: translate('send_invite_dialog.enter_message_hint'), - labelText: translate('send_invite_dialog.message')), - ).paddingAll(8), - const SizedBox(height: 10), - Text(translate('send_invite_dialog.protect_this_invitation'), - style: textTheme.labelLarge) - .paddingAll(8), - Wrap(spacing: 5, children: [ - ChoiceChip( - label: Text(translate('send_invite_dialog.unlocked')), - selected: _encryptionKeyType == EncryptionKeyType.none, - onSelected: _onNoneEncryptionSelected, - ), - ChoiceChip( - label: Text(translate('send_invite_dialog.pin')), - selected: _encryptionKeyType == EncryptionKeyType.pin, - onSelected: _onPinEncryptionSelected, - ), - ChoiceChip( - label: Text(translate('send_invite_dialog.password')), - selected: _encryptionKeyType == EncryptionKeyType.password, - onSelected: _onPasswordEncryptionSelected, - ) - ]).paddingAll(8), - Container( - width: double.infinity, - height: 60, - padding: const EdgeInsets.all(8), - child: ElevatedButton( - onPressed: _onGenerateButtonPressed, - child: Text( - translate('send_invite_dialog.generate'), - ), - ), - ), - Text(translate('send_invite_dialog.note')).paddingAll(8), - Text( - translate('send_invite_dialog.note_text'), - style: Theme.of(context).textTheme.bodySmall, - ).paddingAll(8), - ], - ), - ), - ); - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(DiagnosticsProperty( - 'messageTextController', _messageTextController)); - } -} diff --git a/lib/components/signal_strength_meter.dart b/lib/components/signal_strength_meter.dart deleted file mode 100644 index c093529..0000000 --- a/lib/components/signal_strength_meter.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:signal_strength_indicator/signal_strength_indicator.dart'; -import 'package:go_router/go_router.dart'; - -import '../providers/connection_state.dart'; -import '../tools/tools.dart'; -import '../veilid_support/veilid_support.dart'; - -class SignalStrengthMeterWidget extends ConsumerWidget { - const SignalStrengthMeterWidget({super.key}); - - @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); - final scale = theme.extension()!; - - const iconSize = 16.0; - final connState = ref.watch(connectionStateProvider); - - late final double value; - late final Color color; - late final Color inactiveColor; - switch (connState.attachment.state) { - case AttachmentState.detached: - return Icon(Icons.signal_cellular_nodata, - size: iconSize, color: scale.grayScale.text); - case AttachmentState.detaching: - return Icon(Icons.signal_cellular_off, - size: iconSize, color: scale.grayScale.text); - case AttachmentState.attaching: - value = 0; - color = scale.primaryScale.text; - case AttachmentState.attachedWeak: - value = 1; - color = scale.primaryScale.text; - case AttachmentState.attachedStrong: - value = 2; - color = scale.primaryScale.text; - case AttachmentState.attachedGood: - value = 3; - color = scale.primaryScale.text; - case AttachmentState.fullyAttached: - value = 4; - color = scale.primaryScale.text; - case AttachmentState.overAttached: - value = 4; - color = scale.secondaryScale.subtleText; - } - inactiveColor = scale.grayScale.subtleText; - - return GestureDetector( - onLongPress: () async { - await context.push('/developer'); - }, - child: SignalStrengthIndicator.bars( - value: value, - activeColor: color, - inactiveColor: inactiveColor, - size: iconSize, - barCount: 4, - spacing: 1, - )); - } -} diff --git a/lib/contact_invitation/contact_invitation.dart b/lib/contact_invitation/contact_invitation.dart new file mode 100644 index 0000000..08ae2e7 --- /dev/null +++ b/lib/contact_invitation/contact_invitation.dart @@ -0,0 +1,3 @@ +export 'cubits/cubits.dart'; +export 'models/models.dart'; +export 'views/views.dart'; diff --git a/lib/contact_invitation/cubits/contact_invitation_list_cubit.dart b/lib/contact_invitation/cubits/contact_invitation_list_cubit.dart new file mode 100644 index 0000000..4875263 --- /dev/null +++ b/lib/contact_invitation/cubits/contact_invitation_list_cubit.dart @@ -0,0 +1,352 @@ +import 'dart:async'; + +import 'package:async_tools/async_tools.dart'; +import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; +import 'package:convert/convert.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:flutter/foundation.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../account_manager/account_manager.dart'; +import '../../proto/proto.dart' as proto; +import '../../tools/tools.dart'; +import '../models/models.dart'; + +////////////////////////////////////////////////// + +class ContactInviteInvalidKeyException implements Exception { + const ContactInviteInvalidKeyException(this.type) : super(); + final EncryptionKeyType type; +} + +class ContactInviteInvalidIdentityException implements Exception { + const ContactInviteInvalidIdentityException( + this.contactSuperIdentityRecordKey) + : super(); + final TypedKey contactSuperIdentityRecordKey; +} + +typedef GetEncryptionKeyCallback = Future Function( + VeilidCryptoSystem cs, + EncryptionKeyType encryptionKeyType, + Uint8List encryptedSecret); + +////////////////////////////////////////////////// + +typedef ContactInvitiationListState + = DHTShortArrayCubitState; +////////////////////////////////////////////////// +// Mutable state for per-account contact invitations + +class ContactInvitationListCubit + extends DHTShortArrayCubit + with + StateMapFollowable { + ContactInvitationListCubit({ + required AccountInfo accountInfo, + required OwnedDHTRecordPointer contactInvitationListRecordPointer, + }) : _accountInfo = accountInfo, + super( + open: () => _open(accountInfo.accountRecordKey, + contactInvitationListRecordPointer), + decodeElement: proto.ContactInvitationRecord.fromBuffer); + + static Future _open(TypedKey accountRecordKey, + OwnedDHTRecordPointer contactInvitationListRecordPointer) async { + final dhtRecord = await DHTShortArray.openOwned( + contactInvitationListRecordPointer, + debugName: 'ContactInvitationListCubit::_open::ContactInvitationList', + parent: accountRecordKey); + + return dhtRecord; + } + + Future<(Uint8List, TypedKey)> createInvitation( + {required proto.Profile profile, + required EncryptionKeyType encryptionKeyType, + required String encryptionKey, + required String recipient, + required String message, + required Timestamp? expiration}) async { + final pool = DHTRecordPool.instance; + + // Generate writer keypair to share with new contact + final crcs = await pool.veilid.bestCryptoSystem(); + final contactRequestWriter = await crcs.generateKeyPair(); + + final idcs = await _accountInfo.identityCryptoSystem; + final identityWriter = _accountInfo.identityWriter; + + // Encrypt the writer secret with the encryption key + final encryptedSecret = await encryptionKeyType.encryptSecretToBytes( + secret: contactRequestWriter.secret, + cryptoKind: crcs.kind(), + encryptionKey: encryptionKey, + ); + + // Create local conversation DHT record with the account record key as its + // parent. + // Do not set the encryption of this key yet as it will not yet be written + // to and it will be eventually encrypted with the DH of the contact's + // identity key + late final Uint8List signedContactInvitationBytes; + late final TypedKey contactRequestInboxKey; + await (await pool.createRecord( + debugName: 'ContactInvitationListCubit::createInvitation::' + 'LocalConversation', + parent: _accountInfo.accountRecordKey, + schema: DHTSchema.smpl( + oCnt: 0, + members: [DHTSchemaMember(mKey: identityWriter.key, mCnt: 1)]))) + .deleteScope((localConversation) async { + // dont bother reopening localConversation with writer + // Make ContactRequestPrivate and encrypt with the writer secret + final crpriv = proto.ContactRequestPrivate() + ..writerKey = contactRequestWriter.key.toProto() + ..profile = profile + ..superIdentityRecordKey = _accountInfo.superIdentityRecordKey.toProto() + ..chatRecordKey = localConversation.key.toProto() + ..expiration = expiration?.toInt64() ?? Int64.ZERO; + final crprivbytes = crpriv.writeToBuffer(); + final encryptedContactRequestPrivate = await crcs.encryptAeadWithNonce( + crprivbytes, contactRequestWriter.secret); + + // Create ContactRequest and embed contactrequestprivate + final creq = proto.ContactRequest() + ..encryptionKeyType = encryptionKeyType.toProto() + ..private = encryptedContactRequestPrivate; + + // Create DHT unicast inbox for ContactRequest + // Subkey 0 is the ContactRequest from the initiator + // Subkey 1 will contain the invitation response accept/reject eventually + await (await pool.createRecord( + debugName: 'ContactInvitationListCubit::createInvitation::' + 'ContactRequestInbox', + parent: _accountInfo.accountRecordKey, + schema: DHTSchema.smpl(oCnt: 1, members: [ + DHTSchemaMember(mCnt: 1, mKey: contactRequestWriter.key) + ]), + crypto: const VeilidCryptoPublic())) + .deleteScope((contactRequestInbox) async { + // Keep the contact request inbox key + contactRequestInboxKey = contactRequestInbox.key; + + // Store ContactRequest in owner subkey + await contactRequestInbox.eventualWriteProtobuf(creq); + // Store an empty invitation response + await contactRequestInbox.eventualWriteBytes(Uint8List(0), + subkey: 1, + writer: contactRequestWriter, + crypto: await DHTRecordPool.privateCryptoFromTypedSecret(TypedKey( + kind: contactRequestInbox.key.kind, + value: contactRequestWriter.secret))); + + // Create ContactInvitation and SignedContactInvitation + final cinv = proto.ContactInvitation() + ..contactRequestInboxKey = contactRequestInbox.key.toProto() + ..writerSecret = encryptedSecret; + final cinvbytes = cinv.writeToBuffer(); + final scinv = proto.SignedContactInvitation() + ..contactInvitation = cinvbytes + ..identitySignature = + (await idcs.signWithKeyPair(identityWriter, cinvbytes)).toProto(); + signedContactInvitationBytes = scinv.writeToBuffer(); + + // Create ContactInvitationRecord + final cinvrec = proto.ContactInvitationRecord() + ..contactRequestInbox = + contactRequestInbox.ownedDHTRecordPointer.toProto() + ..writerKey = contactRequestWriter.key.toProto() + ..writerSecret = contactRequestWriter.secret.toProto() + ..localConversationRecordKey = localConversation.key.toProto() + ..expiration = expiration?.toInt64() ?? Int64.ZERO + ..invitation = signedContactInvitationBytes + ..message = message + ..recipient = recipient; + + // Add ContactInvitationRecord to account's list + await operateWriteEventual((writer) async { + await writer.add(cinvrec.writeToBuffer()); + }); + }); + }); + + log.debug('createInvitation:\n' + 'contactRequestInboxKey=$contactRequestInboxKey\n' + 'bytes=${signedContactInvitationBytes.lengthInBytes}\n' + '${hex.encode(signedContactInvitationBytes)}'); + + return (signedContactInvitationBytes, contactRequestInboxKey); + } + + Future deleteInvitation( + {required bool accepted, + required TypedKey contactRequestInboxRecordKey}) async { + final pool = DHTRecordPool.instance; + + // Remove ContactInvitationRecord from account's list + final deletedItem = await operateWrite((writer) async { + for (var i = 0; i < writer.length; i++) { + final item = await writer.getProtobuf( + proto.ContactInvitationRecord.fromBuffer, i); + if (item == null) { + throw Exception('Failed to get contact invitation record'); + } + if (item.contactRequestInbox.recordKey.toVeilid() == + contactRequestInboxRecordKey) { + await writer.remove(i); + return item; + } + } + return null; + }); + + if (deletedItem != null) { + // Delete the contact request inbox + final contactRequestInbox = deletedItem.contactRequestInbox.toVeilid(); + await (await pool.openRecordOwned(contactRequestInbox, + debugName: 'ContactInvitationListCubit::deleteInvitation::' + 'ContactRequestInbox', + parent: _accountInfo.accountRecordKey)) + .scope((contactRequestInbox) async { + // Wipe out old invitation so it shows up as invalid + await contactRequestInbox.tryWriteBytes(Uint8List(0)); + }); + try { + await pool.deleteRecord(contactRequestInbox.recordKey); + } on Exception catch (e) { + log.debug('error removing contact request inbox: $e', e); + } + if (!accepted) { + try { + await pool + .deleteRecord(deletedItem.localConversationRecordKey.toVeilid()); + } on Exception catch (e) { + log.debug('error removing local conversation record: $e', e); + } + } + } + } + + Future validateInvitation({ + required Uint8List inviteData, + required GetEncryptionKeyCallback getEncryptionKeyCallback, + required CancelRequest cancelRequest, + }) async { + log.debug('validateInvitation:\n' + 'bytes=${inviteData.lengthInBytes}\n' + '${hex.encode(inviteData)}'); + + final pool = DHTRecordPool.instance; + + // Get contact request inbox from invitation + final signedContactInvitation = + proto.SignedContactInvitation.fromBuffer(inviteData); + final contactInvitationBytes = + Uint8List.fromList(signedContactInvitation.contactInvitation); + final contactInvitation = + proto.ContactInvitation.fromBuffer(contactInvitationBytes); + final contactRequestInboxKey = + contactInvitation.contactRequestInboxKey.toVeilid(); + + ValidContactInvitation? out; + + // Compare the invitation's contact request + // inbox with our list of extant invitations + // If we're chatting to ourselves, + // we are validating an invitation we have created + final contactInvitationList = state.state.asData?.value; + if (contactInvitationList == null) { + return null; + } + + final isSelf = contactInvitationList.indexWhere((cir) => + cir.value.contactRequestInbox.recordKey.toVeilid() == + contactRequestInboxKey) != + -1; + + await (await pool + .openRecordRead(contactRequestInboxKey, + debugName: 'ContactInvitationListCubit::validateInvitation::' + 'ContactRequestInbox', + parent: await pool.getParentRecordKey(contactRequestInboxKey) ?? + _accountInfo.accountRecordKey) + .withCancel(cancelRequest)) + .maybeDeleteScope(!isSelf, (contactRequestInbox) async { + // + final contactRequest = await contactRequestInbox + .getProtobuf(proto.ContactRequest.fromBuffer) + .withCancel(cancelRequest); + + final cs = await pool.veilid.getCryptoSystem(contactRequestInboxKey.kind); + + // Decrypt contact request private + final encryptionKeyType = + EncryptionKeyType.fromProto(contactRequest!.encryptionKeyType); + late final SharedSecret? writerSecret; + try { + writerSecret = await getEncryptionKeyCallback(cs, encryptionKeyType, + Uint8List.fromList(contactInvitation.writerSecret)); + } on Exception catch (_) { + throw ContactInviteInvalidKeyException(encryptionKeyType); + } + if (writerSecret == null) { + return null; + } + + final contactRequestPrivateBytes = await cs.decryptAeadWithNonce( + Uint8List.fromList(contactRequest.private), writerSecret); + + final contactRequestPrivate = + proto.ContactRequestPrivate.fromBuffer(contactRequestPrivateBytes); + final contactSuperIdentityRecordKey = + contactRequestPrivate.superIdentityRecordKey.toVeilid(); + + // Fetch the account master + final contactSuperIdentity = await SuperIdentity.open( + superRecordKey: contactSuperIdentityRecordKey) + .withCancel(cancelRequest); + if (contactSuperIdentity == null) { + throw ContactInviteInvalidIdentityException( + contactSuperIdentityRecordKey); + } + + // Verify + final idcs = await contactSuperIdentity.currentInstance.cryptoSystem; + final signature = signedContactInvitation.identitySignature.toVeilid(); + await idcs.verify(contactSuperIdentity.currentInstance.publicKey, + contactInvitationBytes, signature); + + final writer = KeyPair( + key: contactRequestPrivate.writerKey.toVeilid(), + secret: writerSecret); + + out = ValidContactInvitation( + accountInfo: _accountInfo, + contactRequestInboxKey: contactRequestInboxKey, + contactRequestPrivate: contactRequestPrivate, + contactSuperIdentity: contactSuperIdentity, + writer: writer); + }); + + return out; + } + + /// StateMapFollowable ///////////////////////// + @override + IMap getStateMap( + ContactInvitiationListState state) { + final stateValue = state.state.asData?.value; + if (stateValue == null) { + return IMap(); + } + return IMap.fromIterable(stateValue, + keyMapper: (e) => e.value.contactRequestInbox.recordKey.toVeilid(), + valueMapper: (e) => e.value); + } + + // + final AccountInfo _accountInfo; +} diff --git a/lib/contact_invitation/cubits/contact_request_inbox_cubit.dart b/lib/contact_invitation/cubits/contact_request_inbox_cubit.dart new file mode 100644 index 0000000..198ae85 --- /dev/null +++ b/lib/contact_invitation/cubits/contact_request_inbox_cubit.dart @@ -0,0 +1,44 @@ +import 'package:async_tools/async_tools.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../account_manager/account_manager.dart'; +import '../../proto/proto.dart' as proto; + +// Watch subkey #1 of the ContactRequest record for accept/reject +typedef ContactRequestInboxState = AsyncValue; + +class ContactRequestInboxCubit + extends DefaultDHTRecordCubit { + ContactRequestInboxCubit( + {required AccountInfo accountInfo, required this.contactInvitationRecord}) + : super( + open: () => _open( + accountInfo: accountInfo, + contactInvitationRecord: contactInvitationRecord), + decodeState: (buf) => buf.isEmpty + ? null + : proto.SignedContactResponse.fromBuffer(buf)); + + static Future _open( + {required AccountInfo accountInfo, + required proto.ContactInvitationRecord contactInvitationRecord}) async { + final pool = DHTRecordPool.instance; + + final accountRecordKey = accountInfo.accountRecordKey; + + final writerSecret = contactInvitationRecord.writerSecret.toVeilid(); + final recordKey = + contactInvitationRecord.contactRequestInbox.recordKey.toVeilid(); + final writerTypedSecret = + TypedKey(kind: recordKey.kind, value: writerSecret); + return pool.openRecordRead(recordKey, + debugName: 'ContactRequestInboxCubit::_open::' + 'ContactRequestInbox', + crypto: + await DHTRecordPool.privateCryptoFromTypedSecret(writerTypedSecret), + parent: accountRecordKey, + defaultSubkey: 1); + } + + final proto.ContactInvitationRecord contactInvitationRecord; +} diff --git a/lib/contact_invitation/cubits/cubits.dart b/lib/contact_invitation/cubits/cubits.dart new file mode 100644 index 0000000..fd2833f --- /dev/null +++ b/lib/contact_invitation/cubits/cubits.dart @@ -0,0 +1,5 @@ +export 'contact_invitation_list_cubit.dart'; +export 'contact_request_inbox_cubit.dart'; +export 'invitation_generator_cubit.dart'; +export 'waiting_invitation_cubit.dart'; +export 'waiting_invitations_bloc_map_cubit.dart'; diff --git a/lib/contact_invitation/cubits/invitation_generator_cubit.dart b/lib/contact_invitation/cubits/invitation_generator_cubit.dart new file mode 100644 index 0000000..8d2226c --- /dev/null +++ b/lib/contact_invitation/cubits/invitation_generator_cubit.dart @@ -0,0 +1,9 @@ +import 'dart:typed_data'; + +import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; +import 'package:veilid_support/veilid_support.dart'; + +class InvitationGeneratorCubit extends FutureCubit<(Uint8List, TypedKey)> { + InvitationGeneratorCubit(super.fut); + InvitationGeneratorCubit.value(super.state) : super.value(); +} diff --git a/lib/contact_invitation/cubits/waiting_invitation_cubit.dart b/lib/contact_invitation/cubits/waiting_invitation_cubit.dart new file mode 100644 index 0000000..b712546 --- /dev/null +++ b/lib/contact_invitation/cubits/waiting_invitation_cubit.dart @@ -0,0 +1,233 @@ +import 'dart:typed_data'; + +import 'package:async_tools/async_tools.dart'; +import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; +import 'package:equatable/equatable.dart'; +import 'package:meta/meta.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../account_manager/account_manager.dart'; +import '../../conversation/conversation.dart'; +import '../../proto/proto.dart' as proto; +import '../models/accepted_contact.dart'; +import 'contact_request_inbox_cubit.dart'; + +/// State of WaitingInvitationCubit +sealed class WaitingInvitationState + implements StateMachineState { + WaitingInvitationState({required this.global}); + final WaitingInvitationStateGlobal global; +} + +class WaitingInvitationStateGlobal { + WaitingInvitationStateGlobal( + {required this.accountInfo, + required this.accountRecordCubit, + required this.contactInvitationRecord}); + final AccountInfo accountInfo; + final AccountRecordCubit accountRecordCubit; + final proto.ContactInvitationRecord contactInvitationRecord; +} + +/// State of WaitingInvitationCubit: +/// Signature was invalid +class WaitingInvitationStateInvalidSignature + with StateMachineEndState + implements WaitingInvitationState { + const WaitingInvitationStateInvalidSignature({required this.global}); + + @override + final WaitingInvitationStateGlobal global; +} + +/// State of WaitingInvitationCubit: +/// Failed to initialize +class WaitingInvitationStateInitFailed + with StateMachineEndState + implements WaitingInvitationState { + const WaitingInvitationStateInitFailed( + {required this.global, required this.exception}); + + @override + final WaitingInvitationStateGlobal global; + final Exception exception; +} + +/// State of WaitingInvitationCubit: +/// Finished normally with an invitation status +class WaitingInvitationStateInvitationStatus + with StateMachineEndState + implements WaitingInvitationState { + const WaitingInvitationStateInvitationStatus( + {required this.global, required this.status}); + @override + final WaitingInvitationStateGlobal global; + final InvitationStatus status; +} + +@immutable +class InvitationStatus extends Equatable { + const InvitationStatus({required this.acceptedContact}); + final AcceptedContact? acceptedContact; + + @override + List get props => [acceptedContact]; +} + +/// State of WaitingInvitationCubit: +/// Waiting for the invited contact to accept/reject the invitation +class WaitingInvitationStateWaitForContactResponse + extends AsyncCubitReactorState< + WaitingInvitationState, + ContactRequestInboxState, + ContactRequestInboxCubit> implements WaitingInvitationState { + WaitingInvitationStateWaitForContactResponse(super.create, + {required this.global}) + : super(onState: (ctx) async { + final signedContactResponse = ctx.state.asData?.value; + if (signedContactResponse == null) { + return null; + } + + final contactResponse = proto.ContactResponse.fromBuffer( + signedContactResponse.contactResponse); + final contactSuperRecordKey = + contactResponse.superIdentityRecordKey.toVeilid(); + + // Fetch the remote contact's account superidentity + return WaitingInvitationStateWaitForContactSuperIdentity( + () => SuperIdentityCubit(superRecordKey: contactSuperRecordKey), + global: global, + signedContactResponse: signedContactResponse); + }); + + @override + final WaitingInvitationStateGlobal global; +} + +/// State of WaitingInvitationCubit: +/// Once an accept/reject happens, get the SuperIdentity of the recipient +class WaitingInvitationStateWaitForContactSuperIdentity + extends AsyncCubitReactorState implements WaitingInvitationState { + WaitingInvitationStateWaitForContactSuperIdentity(super.create, + {required this.global, + required proto.SignedContactResponse signedContactResponse}) + : super(onState: (ctx) async { + final contactSuperIdentity = ctx.state.asData?.value; + if (contactSuperIdentity == null) { + return null; + } + + final contactResponseBytes = + Uint8List.fromList(signedContactResponse.contactResponse); + final contactResponse = + proto.ContactResponse.fromBuffer(contactResponseBytes); + + // Verify + final idcs = await contactSuperIdentity.currentInstance.cryptoSystem; + final signature = signedContactResponse.identitySignature.toVeilid(); + if (!await idcs.verify(contactSuperIdentity.currentInstance.publicKey, + contactResponseBytes, signature)) { + // Could not verify signature of contact response + return WaitingInvitationStateInvalidSignature( + global: global, + ); + } + + // Check for rejection + if (!contactResponse.accept) { + // Rejection + return WaitingInvitationStateInvitationStatus( + global: global, + status: const InvitationStatus(acceptedContact: null), + ); + } + + // Pull profile from remote conversation key + final remoteConversationRecordKey = + contactResponse.remoteConversationRecordKey.toVeilid(); + + return WaitingInvitationStateWaitForConversation( + () => ConversationCubit( + accountInfo: global.accountInfo, + remoteIdentityPublicKey: + contactSuperIdentity.currentInstance.typedPublicKey, + remoteConversationRecordKey: remoteConversationRecordKey), + global: global, + remoteConversationRecordKey: remoteConversationRecordKey, + contactSuperIdentity: contactSuperIdentity, + ); + }); + + @override + final WaitingInvitationStateGlobal global; +} + +/// State of WaitingInvitationCubit: +/// Wait for the conversation cubit to initialize so we can return the +/// accepted invitation +class WaitingInvitationStateWaitForConversation extends AsyncCubitReactorState< + WaitingInvitationState, + AsyncValue, + ConversationCubit> implements WaitingInvitationState { + WaitingInvitationStateWaitForConversation(super.create, + {required this.global, + required TypedKey remoteConversationRecordKey, + required SuperIdentity contactSuperIdentity}) + : super(onState: (ctx) async { + final remoteConversation = ctx.state.asData?.value.remoteConversation; + final localConversation = ctx.state.asData?.value.localConversation; + if (remoteConversation == null || localConversation != null) { + return null; + } + + // Stop reacting to the conversation cubit + ctx.stop(); + + // Complete the local conversation now that we have the remote profile + final remoteProfile = remoteConversation.profile; + final localConversationRecordKey = global + .contactInvitationRecord.localConversationRecordKey + .toVeilid(); + + try { + await ctx.cubit.initLocalConversation( + profile: global.accountRecordCubit.state.asData!.value.profile, + existingConversationRecordKey: localConversationRecordKey); + } on Exception catch (e) { + return WaitingInvitationStateInitFailed( + global: global, exception: e); + } + + return WaitingInvitationStateInvitationStatus( + global: global, + status: InvitationStatus( + acceptedContact: AcceptedContact( + remoteProfile: remoteProfile, + remoteIdentity: contactSuperIdentity, + remoteConversationRecordKey: remoteConversationRecordKey, + localConversationRecordKey: localConversationRecordKey))); + }); + + @override + final WaitingInvitationStateGlobal global; +} + +/// Invitation state processor for sent invitations +class WaitingInvitationCubit extends StateMachineCubit { + WaitingInvitationCubit({ + required ContactRequestInboxCubit Function() initialStateCreate, + required AccountInfo accountInfo, + required AccountRecordCubit accountRecordCubit, + required proto.ContactInvitationRecord contactInvitationRecord, + }) : super( + WaitingInvitationStateWaitForContactResponse( + initialStateCreate, + global: WaitingInvitationStateGlobal( + accountInfo: accountInfo, + accountRecordCubit: accountRecordCubit, + contactInvitationRecord: contactInvitationRecord), + ), + ); +} diff --git a/lib/contact_invitation/cubits/waiting_invitations_bloc_map_cubit.dart b/lib/contact_invitation/cubits/waiting_invitations_bloc_map_cubit.dart new file mode 100644 index 0000000..f125e71 --- /dev/null +++ b/lib/contact_invitation/cubits/waiting_invitations_bloc_map_cubit.dart @@ -0,0 +1,155 @@ +import 'package:async_tools/async_tools.dart'; +import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../account_manager/account_manager.dart'; +import '../../contacts/contacts.dart'; +import '../../notifications/notifications.dart'; +import '../../proto/proto.dart' as proto; +import 'cubits.dart'; + +typedef WaitingInvitationsBlocMapState + = BlocMapState; + +// Map of contactRequestInboxRecordKey to WaitingInvitationCubit +// Wraps a contact invitation cubit to watch for accept/reject +// Automatically follows the state of a ContactInvitationListCubit. +class WaitingInvitationsBlocMapCubit extends BlocMapCubit + with + StateMapFollower, + TypedKey, proto.ContactInvitationRecord> { + WaitingInvitationsBlocMapCubit( + {required AccountInfo accountInfo, + required AccountRecordCubit accountRecordCubit, + required ContactInvitationListCubit contactInvitationListCubit, + required ContactListCubit contactListCubit, + required NotificationsCubit notificationsCubit}) + : _accountInfo = accountInfo, + _accountRecordCubit = accountRecordCubit, + _contactInvitationListCubit = contactInvitationListCubit, + _contactListCubit = contactListCubit, + _notificationsCubit = notificationsCubit { + // React to invitation status changes + _singleInvitationStatusProcessor.follow( + stream, state, _invitationStatusListener); + + // Follow the contact invitation list cubit + follow(contactInvitationListCubit); + } + + @override + Future close() async { + await _singleInvitationStatusProcessor.close(); + await super.close(); + } + + void _addWaitingInvitation( + {required proto.ContactInvitationRecord contactInvitationRecord}) => + add( + contactInvitationRecord.contactRequestInbox.recordKey.toVeilid(), + () => WaitingInvitationCubit( + initialStateCreate: () => ContactRequestInboxCubit( + accountInfo: _accountInfo, + contactInvitationRecord: contactInvitationRecord), + accountInfo: _accountInfo, + accountRecordCubit: _accountRecordCubit, + contactInvitationRecord: contactInvitationRecord)); + + // Process all accepted or rejected invitations + Future _invitationStatusListener( + WaitingInvitationsBlocMapState newState) async { + for (final entry in newState.entries) { + final contactRequestInboxRecordKey = entry.key; + + switch (entry.value) { + case WaitingInvitationStateInvalidSignature(): + // Signature was invalid, display an error and treat like a rejection + await _contactInvitationListCubit.deleteInvitation( + accepted: false, + contactRequestInboxRecordKey: contactRequestInboxRecordKey); + + // Notify about error state + _notificationsCubit.error( + text: translate('waiting_invitation.invalid')); + case final WaitingInvitationStateInitFailed st: + // Initialization error, display an error and treat like a rejection + await _contactInvitationListCubit.deleteInvitation( + accepted: false, + contactRequestInboxRecordKey: contactRequestInboxRecordKey); + + // Notify about error state + _notificationsCubit.error( + text: '${translate('waiting_invitation.init_failed')}\n' + '${st.exception}'); + + case final WaitingInvitationStateInvitationStatus st: + final invStatus = st.status; + + // Delete invitation and process the accepted or rejected contact + final acceptedContact = invStatus.acceptedContact; + if (acceptedContact != null) { + await _contactInvitationListCubit.deleteInvitation( + accepted: true, + contactRequestInboxRecordKey: contactRequestInboxRecordKey); + + // Accept + await _contactListCubit.createContact( + profile: acceptedContact.remoteProfile, + remoteSuperIdentity: acceptedContact.remoteIdentity, + remoteConversationRecordKey: + acceptedContact.remoteConversationRecordKey, + localConversationRecordKey: + acceptedContact.localConversationRecordKey, + ); + + // Notify about acceptance + _notificationsCubit.info( + text: translate('waiting_invitation.accepted', + args: {'name': acceptedContact.remoteProfile.name})); + } else { + // Reject + await _contactInvitationListCubit.deleteInvitation( + accepted: false, + contactRequestInboxRecordKey: contactRequestInboxRecordKey); + + // Notify about rejection + _notificationsCubit.info( + text: translate( + 'waiting_invitation.rejected', + )); + } + case WaitingInvitationStateWaitForContactResponse(): + // Do nothing, still waiting for contact response + break; + case WaitingInvitationStateWaitForContactSuperIdentity(): + // Do nothing, still waiting for contact SuperIdentity + break; + case WaitingInvitationStateWaitForConversation(): + // Do nothing, still waiting for conversation + break; + } + } + } + + /// StateFollower ///////////////////////// + + @override + void removeFromState(TypedKey key) => remove(key); + + @override + void updateState(TypedKey key, proto.ContactInvitationRecord? oldValue, + proto.ContactInvitationRecord newValue) { + _addWaitingInvitation(contactInvitationRecord: newValue); + } + + //// + final AccountInfo _accountInfo; + final AccountRecordCubit _accountRecordCubit; + final ContactInvitationListCubit _contactInvitationListCubit; + final ContactListCubit _contactListCubit; + final NotificationsCubit _notificationsCubit; + final _singleInvitationStatusProcessor = + SingleStateProcessor(); +} diff --git a/lib/contact_invitation/models/accepted_contact.dart b/lib/contact_invitation/models/accepted_contact.dart new file mode 100644 index 0000000..3036258 --- /dev/null +++ b/lib/contact_invitation/models/accepted_contact.dart @@ -0,0 +1,28 @@ +import 'package:equatable/equatable.dart'; +import 'package:meta/meta.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../proto/proto.dart' as proto; + +@immutable +class AcceptedContact extends Equatable { + const AcceptedContact({ + required this.remoteProfile, + required this.remoteIdentity, + required this.remoteConversationRecordKey, + required this.localConversationRecordKey, + }); + + final proto.Profile remoteProfile; + final SuperIdentity remoteIdentity; + final TypedKey remoteConversationRecordKey; + final TypedKey localConversationRecordKey; + + @override + List get props => [ + remoteProfile, + remoteIdentity, + remoteConversationRecordKey, + localConversationRecordKey + ]; +} diff --git a/lib/contact_invitation/models/models.dart b/lib/contact_invitation/models/models.dart new file mode 100644 index 0000000..0936f63 --- /dev/null +++ b/lib/contact_invitation/models/models.dart @@ -0,0 +1,2 @@ +export 'accepted_contact.dart'; +export 'valid_contact_invitation.dart'; diff --git a/lib/contact_invitation/models/valid_contact_invitation.dart b/lib/contact_invitation/models/valid_contact_invitation.dart new file mode 100644 index 0000000..c39692c --- /dev/null +++ b/lib/contact_invitation/models/valid_contact_invitation.dart @@ -0,0 +1,125 @@ +import 'package:meta/meta.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../account_manager/account_manager.dart'; +import '../../conversation/conversation.dart'; +import '../../proto/proto.dart' as proto; +import '../../tools/tools.dart'; +import 'models.dart'; + +////////////////////////////////////////////////// +/// + +class ValidContactInvitation { + @internal + ValidContactInvitation( + {required AccountInfo accountInfo, + required TypedKey contactRequestInboxKey, + required proto.ContactRequestPrivate contactRequestPrivate, + required SuperIdentity contactSuperIdentity, + required KeyPair writer}) + : _accountInfo = accountInfo, + _contactRequestInboxKey = contactRequestInboxKey, + _contactRequestPrivate = contactRequestPrivate, + _contactSuperIdentity = contactSuperIdentity, + _writer = writer; + + proto.Profile get remoteProfile => _contactRequestPrivate.profile; + + Future accept(proto.Profile profile) async { + final pool = DHTRecordPool.instance; + try { + // Ensure we don't delete this if we're trying to chat to self + // The initiating side will delete the records in deleteInvitation() + final isSelf = _contactSuperIdentity.currentInstance.publicKey == + _accountInfo.identityPublicKey; + + return (await pool.openRecordWrite(_contactRequestInboxKey, _writer, + debugName: 'ValidContactInvitation::accept::' + 'ContactRequestInbox', + parent: await pool.getParentRecordKey(_contactRequestInboxKey) ?? + _accountInfo.accountRecordKey)) + // ignore: prefer_expression_function_bodies + .maybeDeleteScope(!isSelf, (contactRequestInbox) async { + // Create local conversation key for this + // contact and send via contact response + final conversation = ConversationCubit( + accountInfo: _accountInfo, + remoteIdentityPublicKey: + _contactSuperIdentity.currentInstance.typedPublicKey); + final localConversationRecordKey = + await conversation.initLocalConversation(profile: profile); + + final contactResponse = proto.ContactResponse() + ..accept = true + ..remoteConversationRecordKey = localConversationRecordKey.toProto() + ..superIdentityRecordKey = + _accountInfo.superIdentityRecordKey.toProto(); + final contactResponseBytes = contactResponse.writeToBuffer(); + + final cs = await _accountInfo.identityCryptoSystem; + final identitySignature = await cs.signWithKeyPair( + _accountInfo.identityWriter, contactResponseBytes); + + final signedContactResponse = proto.SignedContactResponse() + ..contactResponse = contactResponseBytes + ..identitySignature = identitySignature.toProto(); + + // Write the acceptance to the inbox + await contactRequestInbox.eventualWriteProtobuf(signedContactResponse, + subkey: 1); + + return AcceptedContact( + remoteProfile: _contactRequestPrivate.profile, + remoteIdentity: _contactSuperIdentity, + remoteConversationRecordKey: + _contactRequestPrivate.chatRecordKey.toVeilid(), + localConversationRecordKey: localConversationRecordKey, + ); + }); + } on Exception catch (e) { + log.debug('exception: $e', e); + return null; + } + } + + Future reject() async { + final pool = DHTRecordPool.instance; + + // Ensure we don't delete this if we're trying to chat to self + final isSelf = _contactSuperIdentity.currentInstance.publicKey == + _accountInfo.identityPublicKey; + + return (await pool.openRecordWrite(_contactRequestInboxKey, _writer, + debugName: 'ValidContactInvitation::reject::' + 'ContactRequestInbox', + parent: _accountInfo.accountRecordKey)) + .maybeDeleteScope(!isSelf, (contactRequestInbox) async { + final contactResponse = proto.ContactResponse() + ..accept = false + ..superIdentityRecordKey = + _accountInfo.superIdentityRecordKey.toProto(); + final contactResponseBytes = contactResponse.writeToBuffer(); + + final cs = await _accountInfo.identityCryptoSystem; + final identitySignature = await cs.signWithKeyPair( + _accountInfo.identityWriter, contactResponseBytes); + + final signedContactResponse = proto.SignedContactResponse() + ..contactResponse = contactResponseBytes + ..identitySignature = identitySignature.toProto(); + + // Write the rejection to the inbox + await contactRequestInbox.eventualWriteProtobuf(signedContactResponse, + subkey: 1); + return true; + }); + } + + // + final AccountInfo _accountInfo; + final TypedKey _contactRequestInboxKey; + final SuperIdentity _contactSuperIdentity; + final KeyPair _writer; + final proto.ContactRequestPrivate _contactRequestPrivate; +} diff --git a/lib/contact_invitation/views/camera_qr_scanner.dart b/lib/contact_invitation/views/camera_qr_scanner.dart new file mode 100644 index 0000000..11d8f77 --- /dev/null +++ b/lib/contact_invitation/views/camera_qr_scanner.dart @@ -0,0 +1,473 @@ +import 'dart:async'; + +import 'package:async_tools/async_tools.dart'; +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:camera/camera.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:zxing2/qrcode.dart'; + +import '../../theme/theme.dart'; + +enum _FrameState { + notFound, + formatError, + checksumError, +} + +class _ScannerOverlay extends CustomPainter { + _ScannerOverlay(this.scanWindow, this.frameColor); + + final Rect scanWindow; + final Color? frameColor; + + @override + void paint(Canvas canvas, Size size) { + final backgroundPath = Path()..addRect(Rect.largest); + final cutoutPath = Path()..addRect(scanWindow); + + final backgroundPaint = Paint() + ..color = (frameColor ?? Colors.black).withAlpha(127) + ..style = PaintingStyle.fill + ..blendMode = BlendMode.dstOut; + + final backgroundWithCutout = Path.combine( + PathOperation.difference, + backgroundPath, + cutoutPath, + ); + canvas.drawPath(backgroundWithCutout, backgroundPaint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +/// Camera QR scanner +class CameraQRScanner extends StatefulWidget { + const CameraQRScanner( + {required Widget Function(BuildContext) loadingBuilder, + required Widget Function( + BuildContext, Object error, StackTrace? stackTrace) + errorBuilder, + required Widget Function(BuildContext) bottomRowBuilder, + required void Function(String) showNotification, + required void Function(String, Object? error, StackTrace? stackTrace) + logError, + required void Function(T) onDone, + T? Function(Result)? onDetect, + T? Function(CameraImage)? onImageAvailable, + Size? scanSize, + Color? formatErrorFrameColor, + Color? checksumErrorFrameColor, + String? cameraErrorMessage, + String? deniedErrorMessage, + String? deniedWithoutPromptErrorMessage, + String? restrictedErrorMessage, + super.key}) + : _loadingBuilder = loadingBuilder, + _errorBuilder = errorBuilder, + _bottomRowBuilder = bottomRowBuilder, + _showNotification = showNotification, + _logError = logError, + _scanSize = scanSize, + _onDetect = onDetect, + _onDone = onDone, + _onImageAvailable = onImageAvailable, + _formatErrorFrameColor = formatErrorFrameColor, + _checksumErrorFrameColor = checksumErrorFrameColor, + _cameraErrorMessage = cameraErrorMessage, + _deniedErrorMessage = deniedErrorMessage, + _deniedWithoutPromptErrorMessage = deniedWithoutPromptErrorMessage, + _restrictedErrorMessage = restrictedErrorMessage; + @override + State> createState() => _CameraQRScannerState(); + + //////////////////////////////////////////////////////////////////////////// + + final Widget Function(BuildContext) _loadingBuilder; + final Widget Function(BuildContext, Object error, StackTrace? stackTrace) + _errorBuilder; + final Widget Function(BuildContext) _bottomRowBuilder; + final void Function(String) _showNotification; + final void Function(String, Object? error, StackTrace? stackTrace) _logError; + final T? Function(Result)? _onDetect; + final void Function(T) _onDone; + final T? Function(CameraImage)? _onImageAvailable; + + final Size? _scanSize; + final Color? _formatErrorFrameColor; + final Color? _checksumErrorFrameColor; + final String? _cameraErrorMessage; + final String? _deniedErrorMessage; + final String? _deniedWithoutPromptErrorMessage; + final String? _restrictedErrorMessage; +} + +class _CameraQRScannerState extends State> + with WidgetsBindingObserver, TickerProviderStateMixin { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + + // Async Init + _initWait.add(_init); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + unawaited(_controller?.dispose()); + super.dispose(); + } + + // #docregion AppLifecycle + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + final cameraController = _controller; + + // App state changed before we got the chance to initialize. + if (cameraController == null || !cameraController.value.isInitialized) { + return; + } + + if (state == AppLifecycleState.inactive) { + unawaited(cameraController.dispose()); + } else if (state == AppLifecycleState.resumed) { + unawaited(_initializeCameraController(cameraController.description)); + } + } + // #enddocregion AppLifecycle + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final activeColor = theme.colorScheme.primary; + final inactiveColor = theme.colorScheme.onPrimary; + + final scanSize = widget._scanSize; + final scanWindow = scanSize == null + ? null + : Rect.fromCenter( + center: Offset.zero, + width: scanSize.width, + height: scanSize.height, + ); + + return Scaffold( + body: FutureBuilder( + future: _initWait(), + builder: (context, av) => av.when( + error: (e, st) => widget._errorBuilder(context, e, st), + loading: () => widget._loadingBuilder(context), + data: (data, isComplete) => Column( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.all(1), + child: Center( + child: Stack( + alignment: AlignmentDirectional.center, + children: [ + _cameraPreviewWidget(context), + if (scanWindow != null) + IgnorePointer( + child: CustomPaint( + foregroundPainter: _ScannerOverlay( + scanWindow, + switch (_frameState) { + _FrameState.notFound => null, + _FrameState.formatError => + widget._formatErrorFrameColor, + _FrameState.checksumError => + widget._checksumErrorFrameColor + }), + )), + ]), + ), + ), + ), + widget._bottomRowBuilder(context), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _cameraToggleWidget(), + _torchToggleWidget(activeColor, inactiveColor) + ], + ), + ], + ), + ))); + } + + /// Display the preview from the camera + /// (or a message if the preview is not available). + Widget _cameraPreviewWidget(BuildContext context) { + final cameraController = _controller; + + if (cameraController == null || !cameraController.value.isInitialized) { + return widget._loadingBuilder(context); + } else { + return Listener( + onPointerDown: (_) => _pointers++, + onPointerUp: (_) => _pointers--, + child: CameraPreview( + cameraController, + child: LayoutBuilder( + builder: (context, constraints) => GestureDetector( + behavior: HitTestBehavior.opaque, + onScaleStart: _handleScaleStart, + onScaleUpdate: _handleScaleUpdate, + onTapDown: (details) => + _onViewFinderTap(details, constraints), + )), + ), + ); + } + } + + void _handleScaleStart(ScaleStartDetails details) { + _baseScale = _currentScale; + } + + Future _handleScaleUpdate(ScaleUpdateDetails details) async { + // When there are not exactly two fingers on screen don't scale + if (_controller == null || _pointers != 2) { + return; + } + + _currentScale = (_baseScale * details.scale) + .clamp(_minAvailableZoom, _maxAvailableZoom); + + await _controller!.setZoomLevel(_currentScale); + } + + Widget _torchToggleWidget(Color activeColor, Color inactiveColor) => + IconButton( + icon: const Icon(Icons.highlight), + color: _controller?.value.flashMode == FlashMode.torch + ? activeColor + : inactiveColor, + onPressed: _controller != null + ? () => _onSetFlashModeButtonPressed( + _controller?.value.flashMode == FlashMode.torch + ? FlashMode.off + : FlashMode.torch) + : null, + ); + + Widget _cameraToggleWidget() { + final currentCameraDescription = _controller?.description; + return IconButton( + icon: + Icon(isAndroid ? Icons.flip_camera_android : Icons.flip_camera_ios), + onPressed: (currentCameraDescription == null || _cameras.isEmpty) + ? null + : () { + final nextCameraIndex = + (_cameras.indexOf(currentCameraDescription) + 1) % + _cameras.length; + unawaited(_onNewCameraSelected(_cameras[nextCameraIndex])); + }); + } + + void _onViewFinderTap(TapDownDetails details, BoxConstraints constraints) { + final cameraController = _controller; + if (cameraController == null) { + return; + } + + final offset = Offset( + details.localPosition.dx / constraints.maxWidth, + details.localPosition.dy / constraints.maxHeight, + ); + unawaited(cameraController.setExposurePoint(offset)); + unawaited(cameraController.setFocusPoint(offset)); + } + + Future _onNewCameraSelected(CameraDescription cameraDescription) { + if (_controller != null) { + return _controller!.setDescription(cameraDescription); + } else { + return _initializeCameraController(cameraDescription); + } + } + + Future _initializeCameraController( + CameraDescription cameraDescription) async { + final cameraController = CameraController( + cameraDescription, + kIsWeb ? ResolutionPreset.max : ResolutionPreset.medium, + enableAudio: false, + imageFormatGroup: ImageFormatGroup.jpeg, + ); + + _controller = cameraController; + + // If the controller is updated then update the UI. + cameraController.addListener(() { + if (mounted) { + setState(() {}); + } + if (cameraController.value.hasError && + (cameraController.value.errorDescription?.isNotEmpty ?? false)) { + widget._showNotification( + '${widget._cameraErrorMessage ?? 'Camera error'}: ' + '${cameraController.value.errorDescription!}'); + } + }); + + try { + await cameraController.initialize(); + + try { + _maxAvailableZoom = await cameraController.getMaxZoomLevel(); + _minAvailableZoom = await cameraController.getMinZoomLevel(); + } on PlatformException { + _maxAvailableZoom = 1; + _minAvailableZoom = 1; + } + + await cameraController.startImageStream((cameraImage) { + final out = + (widget._onImageAvailable ?? _onImageAvailable)(cameraImage); + if (out != null) { + _controller = null; + unawaited(cameraController.dispose()); + widget._onDone(out); + } + }); + } on CameraException catch (e, st) { + switch (e.code) { + case 'CameraAccessDenied': + widget._showNotification( + widget._deniedErrorMessage ?? 'You have denied camera access.'); + case 'CameraAccessDeniedWithoutPrompt': + // iOS only + widget._showNotification(widget._deniedWithoutPromptErrorMessage ?? + 'Please go to Settings app to enable camera access.'); + case 'CameraAccessRestricted': + // iOS only + widget._showNotification( + widget._restrictedErrorMessage ?? 'Camera access is restricted.'); + default: + _showCameraException(e, st); + } + } + + if (mounted) { + setState(() {}); + } + } + + T? _onImageAvailable(CameraImage cameraImage) { + try { + final plane = cameraImage.planes.firstOrNull; + if (plane == null) { + return null; + } + + // final image = JpegDecoder().decode(plane.bytes); + // if (image == null) { + // return; + // } + + // final abgrImage = image + // .convert(numChannels: 4) + // .getBytes(order: ChannelOrder.abgr) + // .buffer + // .asInt32List(); + + final abgrImage = plane.bytes.buffer.asInt32List(); + + final source = + RGBLuminanceSource(cameraImage.width, cameraImage.height, abgrImage); + + final bitmap = BinaryBitmap(HybridBinarizer(source)); + + final reader = QRCodeReader(); + try { + final result = reader.decode(bitmap); + return widget._onDetect?.call(result); + } on NotFoundException { + _setFrameState(_FrameState.notFound); + } on FormatReaderException { + _setFrameState(_FrameState.formatError); + } on ChecksumException { + _setFrameState(_FrameState.checksumError); + } + + // Should also catch errors from QRCodeReader + // ignore: avoid_catches_without_on_clauses + } catch (e, st) { + widget._logError('Unexpected error: $e\n$st', e, st); + } + return null; + } + + void _setFrameState(_FrameState frameState) { + if (mounted) { + if (_frameState != frameState) { + setState(() { + _frameState = frameState; + }); + } + } + } + + void _onSetFlashModeButtonPressed(FlashMode mode) { + unawaited(_setFlashMode(mode).then((_) { + if (mounted) { + setState(() {}); + } + })); + } + + Future _setFlashMode(FlashMode mode) async { + if (_controller == null) { + return; + } + + try { + await _controller!.setFlashMode(mode); + } on CameraException catch (e, st) { + _showCameraException(e, st); + rethrow; + } + } + + void _showCameraException(CameraException e, StackTrace st) { + _logCameraException(e, st); + widget._showNotification('Error: ${e.code}\n${e.description}'); + } + + void _logCameraException(CameraException e, StackTrace st) { + final code = e.code; + final message = e.description; + widget._logError( + 'CameraException: $code${message == null ? '' : '\nMessage: $message'}', + e, + st); + } + + Future _init(Completer cancel) async { + _cameras = await availableCameras(); + if (_cameras.isNotEmpty) { + await _onNewCameraSelected(_cameras.first); + } + } + + //////////////////////////////////////////////////////////////////////////// + + CameraController? _controller; + final _initWait = WaitSet(); + late final List _cameras; + var _minAvailableZoom = 1.0; + var _maxAvailableZoom = 1.0; + var _currentScale = 1.0; + var _baseScale = 1.0; + var _pointers = 0; + _FrameState _frameState = _FrameState.notFound; +} diff --git a/lib/contact_invitation/views/contact_invitation_display.dart b/lib/contact_invitation/views/contact_invitation_display.dart new file mode 100644 index 0000000..4ab840d --- /dev/null +++ b/lib/contact_invitation/views/contact_invitation_display.dart @@ -0,0 +1,187 @@ +import 'dart:math'; + +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:basic_utils/basic_utils.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:provider/provider.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../account_manager/account_manager.dart'; +import '../../notifications/notifications.dart'; +import '../../proto/proto.dart' as proto; +import '../../theme/theme.dart'; +import '../contact_invitation.dart'; + +class ContactInvitationDisplayDialog extends StatelessWidget { + const ContactInvitationDisplayDialog._({ + required this.locator, + required this.recipient, + required this.message, + required this.fingerprint, + }); + + final Locator locator; + final String recipient; + final String message; + final String fingerprint; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(StringProperty('recipient', recipient)) + ..add(StringProperty('message', message)) + ..add(DiagnosticsProperty('locator', locator)) + ..add(StringProperty('fingerprint', fingerprint)); + } + + String makeTextInvite(String recipient, String message, Uint8List data) { + final invite = StringUtils.addCharAtPosition( + base64UrlNoPadEncode(data), '\n', 40, + repeat: true); + final to = recipient.isNotEmpty + ? '${translate('invitiation_dialog.to')}: $recipient\n' + : ''; + final msg = message.isNotEmpty ? '$message\n' : ''; + return '$to' + '$msg' + '--- BEGIN VEILIDCHAT CONTACT INVITE ----\n' + '$invite\n' + '---- END VEILIDCHAT CONTACT INVITE -----\n' + 'Fingerprint:\n$fingerprint\n'; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + final scaleConfig = theme.extension()!; + + final generatorOutputV = context.watch().state; + + final cardsize = + min(MediaQuery.of(context).size.shortestSide - 48.0, 400); + + final fingerprintText = + '${translate('create_invitation_dialog.fingerprint')}\n' + '$fingerprint'; + + return BlocListener( + bloc: locator(), + listener: (context, state) { + final listState = state.state.asData?.value; + final data = generatorOutputV.asData?.value; + + if (listState != null && data != null) { + final idx = listState.indexWhere((x) => + x.value.contactRequestInbox.recordKey.toVeilid() == data.$2); + if (idx == -1) { + // This invitation is gone, close it + Navigator.pop(context); + } + } + }, + child: PopControl( + dismissible: !generatorOutputV.isLoading, + child: Dialog( + shape: RoundedRectangleBorder( + side: const BorderSide(width: 2), + borderRadius: BorderRadius.circular( + 16 * scaleConfig.borderRadiusScale)), + backgroundColor: Colors.white, + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: cardsize, + maxWidth: cardsize, + minHeight: cardsize, + maxHeight: cardsize), + child: generatorOutputV.when( + loading: buildProgressIndicator, + data: (data) => Column(children: [ + FittedBox( + child: Text( + translate('create_invitation_dialog' + '.contact_invitation'), + style: textTheme.headlineSmall! + .copyWith(color: Colors.black))) + .paddingAll(8), + FittedBox( + child: QrImageView.withQr( + size: 300, + qr: QrCode.fromUint8List( + data: data.$1, + errorCorrectLevel: + QrErrorCorrectLevel.L)), + ).expanded(), + if (recipient.isNotEmpty) + AutoSizeText(recipient, + softWrap: true, + maxLines: 2, + style: textTheme.labelLarge! + .copyWith(color: Colors.black)) + .paddingAll(8), + if (message.isNotEmpty) + Text(message, + softWrap: true, + textAlign: TextAlign.center, + maxLines: 2, + style: textTheme.labelMedium! + .copyWith(color: Colors.black)) + .paddingAll(8), + Text(fingerprintText, + softWrap: true, + textAlign: TextAlign.center, + style: textTheme.labelSmall!.copyWith( + color: Colors.black, + fontFamily: 'Source Code Pro')) + .paddingAll(2), + ElevatedButton.icon( + icon: const Icon(Icons.copy), + style: ElevatedButton.styleFrom( + foregroundColor: Colors.black, + backgroundColor: Colors.white, + side: const BorderSide()), + label: Text(translate( + 'create_invitation_dialog.copy_invitation')), + onPressed: () async { + context.read().info( + text: translate('create_invitation_dialog' + '.invitation_copied')); + await Clipboard.setData(ClipboardData( + text: makeTextInvite( + recipient, message, data.$1))); + }, + ).paddingAll(16), + ]), + error: errorPage))))); + } + + static Future show({ + required BuildContext context, + required Locator locator, + required InvitationGeneratorCubit Function(BuildContext) create, + required String recipient, + required String message, + }) async { + final fingerprint = + locator().state.identityPublicKey.toString(); + + await showPopControlDialog( + context: context, + builder: (context) => BlocProvider( + create: create, + child: ContactInvitationDisplayDialog._( + locator: locator, + recipient: recipient, + message: message, + fingerprint: fingerprint, + ))); + } +} diff --git a/lib/contact_invitation/views/contact_invitation_item_widget.dart b/lib/contact_invitation/views/contact_invitation_item_widget.dart new file mode 100644 index 0000000..b86a833 --- /dev/null +++ b/lib/contact_invitation/views/contact_invitation_item_widget.dart @@ -0,0 +1,87 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import '../../proto/proto.dart' as proto; +import '../../theme/theme.dart'; +import '../contact_invitation.dart'; + +class ContactInvitationItemWidget extends StatelessWidget { + const ContactInvitationItemWidget( + {required this.contactInvitationRecord, + required this.disabled, + super.key}); + + final proto.ContactInvitationRecord contactInvitationRecord; + final bool disabled; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty( + 'contactInvitationRecord', contactInvitationRecord)) + ..add(DiagnosticsProperty('disabled', disabled)); + } + + @override + // ignore: prefer_expression_function_bodies + Widget build(BuildContext context) { + // final localConversationKey = + // contact.localConversationRecordKey.toVeilid(); + + const selected = + false; // xxx: eventually when we have selectable invitations: + // activeContactCubit.state == localConversationRecordKey; + + final tileDisabled = + disabled || context.watch().isBusy; + + var title = translate('contact_list.invitation'); + if (contactInvitationRecord.recipient.isNotEmpty) { + title = contactInvitationRecord.recipient; + } else if (contactInvitationRecord.message.isNotEmpty) { + title = contactInvitationRecord.message; + } + + return StyledSlideTile( + key: ObjectKey(contactInvitationRecord), + disabled: tileDisabled, + selected: selected, + tileScale: ScaleKind.secondary, + title: title, + leading: const Icon(Icons.person_add), + onTap: () async { + if (!context.mounted) { + return; + } + await ContactInvitationDisplayDialog.show( + context: context, + locator: context.read, + recipient: contactInvitationRecord.recipient, + message: contactInvitationRecord.message, + create: (context) => InvitationGeneratorCubit.value(( + Uint8List.fromList(contactInvitationRecord.invitation), + contactInvitationRecord.contactRequestInbox.recordKey + .toVeilid() + ))); + }, + endActions: [ + SlideTileAction( + // icon: Icons.delete, + label: translate('button.delete'), + actionScale: ScaleKind.tertiary, + onPressed: (context) async { + final contactInvitationListCubit = + context.read(); + await contactInvitationListCubit.deleteInvitation( + accepted: false, + contactRequestInboxRecordKey: contactInvitationRecord + .contactRequestInbox.recordKey + .toVeilid()); + }, + ) + ], + ); + } +} diff --git a/lib/contact_invitation/views/contact_invitation_list_widget.dart b/lib/contact_invitation/views/contact_invitation_list_widget.dart new file mode 100644 index 0000000..ff3bb8d --- /dev/null +++ b/lib/contact_invitation/views/contact_invitation_list_widget.dart @@ -0,0 +1,89 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_translate/flutter_translate.dart'; + +import '../../proto/proto.dart' as proto; +import '../../theme/theme.dart'; +import 'contact_invitation_item_widget.dart'; + +class ContactInvitationListWidget extends StatefulWidget { + const ContactInvitationListWidget({ + required this.contactInvitationRecordList, + required this.disabled, + super.key, + }); + + final IList contactInvitationRecordList; + final bool disabled; + + @override + ContactInvitationListWidgetState createState() => + ContactInvitationListWidgetState(); + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(IterableProperty( + 'contactInvitationRecordList', contactInvitationRecordList)) + ..add(DiagnosticsProperty('disabled', disabled)); + } +} + +class ContactInvitationListWidgetState + extends State + with SingleTickerProviderStateMixin { + late final _controller = AnimationController( + vsync: this, duration: const Duration(milliseconds: 250), value: 1); + late final _animation = + CurvedAnimation(parent: _controller, curve: Curves.easeInOut); + bool _expanded = true; + + @override + // ignore: prefer_expression_function_bodies + Widget build(BuildContext context) { + final theme = Theme.of(context); + // final textTheme = theme.textTheme; + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + + return styledExpandingSliver( + context: context, + animation: _animation, + expanded: _expanded, + backgroundColor: scaleConfig.preferBorders + ? scale.primaryScale.subtleBackground + : scale.primaryScale.subtleBorder, + onTap: () { + setState(() { + _expanded = !_expanded; + }); + _controller.animateTo(_expanded ? 1 : 0); + }, + title: translate('contacts_dialog.invitations'), + sliver: SliverList.builder( + itemCount: widget.contactInvitationRecordList.length, + itemBuilder: (context, index) { + if (index < 0 || + index >= widget.contactInvitationRecordList.length) { + return null; + } + return ContactInvitationItemWidget( + contactInvitationRecord: + widget.contactInvitationRecordList[index], + disabled: widget.disabled, + key: ObjectKey(widget.contactInvitationRecordList[index])) + .paddingLTRB(4, 2, 4, 2); + }, + findChildIndexCallback: (key) { + final index = widget.contactInvitationRecordList.indexOf( + (key as ObjectKey).value! as proto.ContactInvitationRecord); + if (index == -1) { + return null; + } + return index; + }, + )); + } +} diff --git a/lib/contact_invitation/views/create_invitation_dialog.dart b/lib/contact_invitation/views/create_invitation_dialog.dart new file mode 100644 index 0000000..41e6162 --- /dev/null +++ b/lib/contact_invitation/views/create_invitation_dialog.dart @@ -0,0 +1,268 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:provider/provider.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../account_manager/account_manager.dart'; +import '../../notifications/notifications.dart'; +import '../../theme/theme.dart'; +import '../contact_invitation.dart'; + +class CreateInvitationDialog extends StatefulWidget { + const CreateInvitationDialog._({required this.locator}); + + @override + State createState() => _CreateInvitationDialogState(); + + static Future show(BuildContext context) async { + await StyledDialog.show( + context: context, + title: translate('create_invitation_dialog.title'), + child: CreateInvitationDialog._(locator: context.read)); + } + + final Locator locator; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('locator', locator)); + } +} + +class _CreateInvitationDialogState extends State { + late final TextEditingController _messageTextController; + late final TextEditingController _recipientTextController; + + EncryptionKeyType _encryptionKeyType = EncryptionKeyType.none; + var _encryptionKey = ''; + Timestamp? _expiration; + + @override + void initState() { + final accountInfo = widget.locator().state; + final name = accountInfo.asData?.value.profile.name ?? + translate('create_invitation_dialog.me'); + _messageTextController = TextEditingController( + text: translate('create_invitation_dialog.connect_with_me', + args: {'name': name})); + _recipientTextController = TextEditingController(); + super.initState(); + } + + Future _onNoneEncryptionSelected(bool selected) async { + setState(() { + if (selected) { + _encryptionKeyType = EncryptionKeyType.none; + } + }); + } + + Future _onPinEncryptionSelected(bool selected) async { + final description = translate('create_invitation_dialog.pin_description'); + final pin = await showDialog( + context: context, + builder: (context) => + EnterPinDialog(reenter: false, description: description)); + if (pin == null) { + return; + } + if (!mounted) { + return; + } + final matchpin = await showDialog( + context: context, + builder: (context) => EnterPinDialog( + reenter: true, + description: description, + )); + if (matchpin == null) { + return; + } else if (pin == matchpin) { + setState(() { + _encryptionKeyType = EncryptionKeyType.pin; + _encryptionKey = pin; + }); + } else { + if (!mounted) { + return; + } + context.read().error( + text: translate('create_invitation_dialog.pin_does_not_match')); + setState(() { + _encryptionKeyType = EncryptionKeyType.none; + _encryptionKey = ''; + }); + } + } + + Future _onPasswordEncryptionSelected(bool selected) async { + final description = + translate('create_invitation_dialog.password_description'); + final password = await showDialog( + context: context, + builder: (context) => EnterPasswordDialog(description: description)); + if (password == null) { + return; + } + if (!mounted) { + return; + } + final matchpass = await showDialog( + context: context, + builder: (context) => EnterPasswordDialog( + matchPass: password, + description: description, + )); + if (matchpass == null) { + return; + } else if (password == matchpass) { + setState(() { + _encryptionKeyType = EncryptionKeyType.password; + _encryptionKey = password; + }); + } else { + if (!mounted) { + return; + } + context.read().error( + text: translate('create_invitation_dialog.password_does_not_match')); + setState(() { + _encryptionKeyType = EncryptionKeyType.none; + _encryptionKey = ''; + }); + } + } + + Future _onGenerateButtonPressed() async { + final navigator = Navigator.of(context); + + // Start generation + final contactInvitationListCubit = + widget.locator(); + final profile = + widget.locator().state.asData?.value.profile; + if (profile == null) { + return; + } + + final generator = contactInvitationListCubit.createInvitation( + profile: profile, + encryptionKeyType: _encryptionKeyType, + encryptionKey: _encryptionKey, + recipient: _recipientTextController.text, + message: _messageTextController.text, + expiration: _expiration); + + navigator.pop(); + + await ContactInvitationDisplayDialog.show( + context: context, + locator: widget.locator, + recipient: _recipientTextController.text, + message: _messageTextController.text, + create: (context) => InvitationGeneratorCubit(generator)); + } + + @override + Widget build(BuildContext context) { + final windowSize = MediaQuery.of(context).size; + final maxDialogWidth = min(windowSize.width - 64.0, 800.0 - 64.0); + final maxDialogHeight = windowSize.height - 64.0; + + final theme = Theme.of(context); + final textTheme = theme.textTheme; + + return ConstrainedBox( + constraints: + BoxConstraints(maxHeight: maxDialogHeight, maxWidth: maxDialogWidth), + child: SingleChildScrollView( + padding: const EdgeInsets.all(8).scaled(context), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + spacing: 16.scaled(context), + children: [ + TextField( + autofocus: true, + controller: _recipientTextController, + onChanged: (value) { + setState(() {}); + }, + inputFormatters: [ + LengthLimitingTextInputFormatter(128), + ], + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(8).scaled(context), + hintText: + translate('create_invitation_dialog.recipient_hint'), + labelText: + translate('create_invitation_dialog.recipient_name'), + helperText: + translate('create_invitation_dialog.recipient_helper')), + ), + TextField( + controller: _messageTextController, + inputFormatters: [ + LengthLimitingTextInputFormatter(128), + ], + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(8).scaled(context), + hintText: translate('create_invitation_dialog.message_hint'), + labelText: + translate('create_invitation_dialog.message_label'), + helperText: + translate('create_invitation_dialog.message_helper')), + ), + Text(translate('create_invitation_dialog.protect_this_invitation'), + style: textTheme.labelLarge), + Wrap( + alignment: WrapAlignment.center, + runAlignment: WrapAlignment.center, + runSpacing: 8, + spacing: 8, + children: [ + ChoiceChip( + label: Text(translate('create_invitation_dialog.unlocked')), + selected: _encryptionKeyType == EncryptionKeyType.none, + onSelected: _onNoneEncryptionSelected, + ), + ChoiceChip( + label: Text(translate('create_invitation_dialog.pin')), + selected: _encryptionKeyType == EncryptionKeyType.pin, + onSelected: _onPinEncryptionSelected, + ), + ChoiceChip( + label: Text(translate('create_invitation_dialog.password')), + selected: _encryptionKeyType == EncryptionKeyType.password, + onSelected: _onPasswordEncryptionSelected, + ) + ]).toCenter(), + Container( + padding: const EdgeInsets.all(8).scaled(context), + child: ElevatedButton( + onPressed: _recipientTextController.text.isNotEmpty + ? _onGenerateButtonPressed + : null, + child: Text( + translate('create_invitation_dialog.generate'), + ).paddingAll(16.scaled(context)), + ), + ).toCenter(), + Text(translate('create_invitation_dialog.note')), + Text( + translate('create_invitation_dialog.note_text'), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ); + } +} diff --git a/lib/contact_invitation/views/invitation_dialog.dart b/lib/contact_invitation/views/invitation_dialog.dart new file mode 100644 index 0000000..f4c7fcc --- /dev/null +++ b/lib/contact_invitation/views/invitation_dialog.dart @@ -0,0 +1,372 @@ +import 'dart:async'; + +import 'package:async_tools/async_tools.dart'; +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:provider/provider.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../account_manager/account_manager.dart'; +import '../../contacts/contacts.dart'; +import '../../notifications/notifications.dart'; +import '../../theme/theme.dart'; +import '../../tools/tools.dart'; +import '../contact_invitation.dart'; + +class InvitationDialog extends StatefulWidget { + const InvitationDialog( + {required Locator locator, + required this.onValidationCancelled, + required this.onValidationSuccess, + required this.onValidationFailed, + required this.inviteControlIsValid, + required this.buildInviteControl, + super.key}) + : _locator = locator; + + final void Function() onValidationCancelled; + final void Function() onValidationSuccess; + final void Function() onValidationFailed; + final bool Function() inviteControlIsValid; + final Widget Function( + BuildContext context, + InvitationDialogState dialogState, + Future Function({required Uint8List inviteData}) + validateInviteData) buildInviteControl; + final Locator _locator; + + @override + InvitationDialogState createState() => InvitationDialogState(); + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(ObjectFlagProperty.has( + 'onValidationCancelled', onValidationCancelled)) + ..add(ObjectFlagProperty.has( + 'onValidationSuccess', onValidationSuccess)) + ..add(ObjectFlagProperty.has( + 'onValidationFailed', onValidationFailed)) + ..add(ObjectFlagProperty.has( + 'inviteControlIsValid', inviteControlIsValid)) + ..add(ObjectFlagProperty< + Widget Function( + BuildContext context, + InvitationDialogState dialogState, + Future Function({required Uint8List inviteData}) + validateInviteData)>.has( + 'buildInviteControl', buildInviteControl)); + } +} + +class InvitationDialogState extends State { + @override + void initState() { + super.initState(); + } + + Future _onCancel() async { + final navigator = Navigator.of(context); + _cancelRequest.cancel(); + setState(() { + _isAccepting = false; + }); + navigator.pop(); + } + + Future _onAccept() async { + final navigator = Navigator.of(context); + final accountInfo = widget._locator().state; + final contactList = widget._locator(); + final profile = + widget._locator().state.asData!.value.profile; + + setState(() { + _isAccepting = true; + }); + final validInvitation = _validInvitation; + if (validInvitation != null) { + final acceptedContact = await validInvitation.accept(profile); + if (acceptedContact != null) { + // initiator when accept is received will create + // contact in the case of a 'note to self' + final isSelf = accountInfo.identityPublicKey == + acceptedContact.remoteIdentity.currentInstance.publicKey; + if (!isSelf) { + await contactList.createContact( + profile: acceptedContact.remoteProfile, + remoteSuperIdentity: acceptedContact.remoteIdentity, + remoteConversationRecordKey: + acceptedContact.remoteConversationRecordKey, + localConversationRecordKey: + acceptedContact.localConversationRecordKey, + ); + } + } else { + if (mounted) { + context + .read() + .error(text: 'invitation_dialog.failed_to_accept'); + } + } + } + setState(() { + _isAccepting = false; + }); + navigator.pop(); + } + + Future _onReject() async { + final navigator = Navigator.of(context); + + setState(() { + _isAccepting = true; + }); + final validInvitation = _validInvitation; + if (validInvitation != null) { + if (await validInvitation.reject()) { + // do nothing right now + } else { + if (mounted) { + context + .read() + .error(text: 'invitation_dialog.failed_to_reject'); + } + } + } + setState(() { + _isAccepting = false; + }); + navigator.pop(); + } + + Future _validateInviteData({ + required Uint8List inviteData, + }) async { + try { + final contactInvitationListCubit = + widget._locator(); + + setState(() { + _isValidating = true; + _validInvitation = null; + }); + final validatedContactInvitation = + await contactInvitationListCubit.validateInvitation( + inviteData: inviteData, + cancelRequest: _cancelRequest, + getEncryptionKeyCallback: + (cs, encryptionKeyType, encryptedSecret) async { + String encryptionKey; + switch (encryptionKeyType) { + case EncryptionKeyType.none: + encryptionKey = ''; + case EncryptionKeyType.pin: + final description = + translate('invitation_dialog.protected_with_pin'); + if (!mounted) { + return null; + } + final pin = await showDialog( + context: context, + builder: (context) => EnterPinDialog( + reenter: false, description: description)); + if (pin == null) { + return null; + } + encryptionKey = pin; + case EncryptionKeyType.password: + final description = + translate('invitation_dialog.protected_with_password'); + if (!mounted) { + return null; + } + final password = await showDialog( + context: context, + builder: (context) => + EnterPasswordDialog(description: description)); + if (password == null) { + return null; + } + encryptionKey = password; + } + return encryptionKeyType.decryptSecretFromBytes( + secretBytes: encryptedSecret, + cryptoKind: cs.kind(), + encryptionKey: encryptionKey); + }); + + // Check if validation was cancelled + if (validatedContactInvitation == null) { + setState(() { + _isValidating = false; + _validInvitation = null; + widget.onValidationCancelled(); + }); + return; + } + + // Verify expiration + // xxx + + setState(() { + widget.onValidationSuccess(); + _isValidating = false; + _validInvitation = validatedContactInvitation; + }); + } on ContactInviteInvalidIdentityException catch (_) { + if (mounted) { + context + .read() + .error(text: translate('invitation_dialog.invalid_identity')); + } + setState(() { + _isValidating = false; + _validInvitation = null; + widget.onValidationFailed(); + }); + } on ContactInviteInvalidKeyException catch (e) { + String errorText; + switch (e.type) { + case EncryptionKeyType.none: + errorText = translate('invitation_dialog.invalid_invitation'); + case EncryptionKeyType.pin: + errorText = translate('invitation_dialog.invalid_pin'); + case EncryptionKeyType.password: + errorText = translate('invitation_dialog.invalid_password'); + } + if (mounted) { + context.read().error(text: errorText); + } + setState(() { + _isValidating = false; + _validInvitation = null; + widget.onValidationFailed(); + }); + } on VeilidAPIException catch (e) { + late final String errorText; + if (e is VeilidAPIExceptionTryAgain) { + errorText = translate('invitation_dialog.try_again_online'); + } + if (e is VeilidAPIExceptionKeyNotFound) { + errorText = translate('invitation_dialog.key_not_found'); + } else { + errorText = translate('invitation_dialog.invalid_invitation'); + } + if (mounted) { + context.read().error(text: errorText); + } + setState(() { + _isValidating = false; + _validInvitation = null; + widget.onValidationFailed(); + }); + } on CancelException { + setState(() { + _isValidating = false; + _validInvitation = null; + widget.onValidationCancelled(); + }); + } on Exception catch (e) { + log.debug('exception: $e', e); + setState(() { + _isValidating = false; + _validInvitation = null; + widget.onValidationFailed(); + }); + rethrow; + } + } + + List _buildPreAccept() => [ + if (!_isValidating && _validInvitation == null) + widget.buildInviteControl(context, this, _validateInviteData), + if (_isValidating) + Column(children: [ + Text(translate('invitation_dialog.validating')) + .paddingLTRB(0, 0, 0, 16), + buildProgressIndicator().paddingAll(16), + ElevatedButton.icon( + icon: const Icon(Icons.cancel), + label: Text(translate('button.cancel')), + onPressed: _onCancel, + ).paddingAll(16), + ]).toCenter(), + if (_validInvitation == null && + !_isValidating && + widget.inviteControlIsValid()) + Column(children: [ + Text(translate('invitation_dialog.invalid_invitation')), + const Icon(Icons.error).paddingAll(16) + ]).toCenter(), + if (_validInvitation != null && !_isValidating) + Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + ProfileWidget( + profile: _validInvitation!.remoteProfile, + byline: _validInvitation!.remoteProfile.pronouns.isEmpty + ? null + : _validInvitation!.remoteProfile.pronouns, + ).paddingLTRB(0, 0, 0, 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton.icon( + icon: const Icon(Icons.check_circle), + label: Text(translate('button.accept')), + onPressed: _onAccept, + ).paddingLTRB(0, 0, 8, 0), + ElevatedButton.icon( + icon: const Icon(Icons.cancel), + label: Text(translate('button.reject')), + onPressed: _onReject, + ).paddingLTRB(8, 0, 0, 0) + ], + ), + ]) + ]; + + @override + // ignore: prefer_expression_function_bodies + Widget build(BuildContext context) { + // final theme = Theme.of(context); + // final scale = theme.extension()!; + // final textTheme = theme.textTheme; + // final height = MediaQuery.of(context).size.height; + final dismissible = !_isAccepting && !_isValidating; + + final dialog = ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 400, maxWidth: 400), + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: _isAccepting + ? [ + buildProgressIndicator().paddingAll(16), + ] + : _buildPreAccept()), + ), + ); + return PopControl(dismissible: dismissible, child: dialog); + } + + //////////////////////////////////////////////////////////////////////////// + + ValidContactInvitation? _validInvitation; + bool _isValidating = false; + bool _isAccepting = false; + final _cancelRequest = CancelRequest(); + + bool get isValidating => _isValidating; + bool get isAccepting => _isAccepting; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('isValidating', isValidating)) + ..add(DiagnosticsProperty('isAccepting', isAccepting)); + } +} diff --git a/lib/components/paste_invite_dialog.dart b/lib/contact_invitation/views/paste_invitation_dialog.dart similarity index 62% rename from lib/components/paste_invite_dialog.dart rename to lib/contact_invitation/views/paste_invitation_dialog.dart index b7e545c..b014fc2 100644 --- a/lib/components/paste_invite_dialog.dart +++ b/lib/contact_invitation/views/paste_invitation_dialog.dart @@ -1,30 +1,36 @@ import 'dart:async'; -import 'dart:typed_data'; import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_translate/flutter_translate.dart'; +import 'package:provider/provider.dart'; +import 'package:veilid_support/veilid_support.dart'; -import '../tools/tools.dart'; -import '../veilid_support/veilid_support.dart'; -import 'invite_dialog.dart'; +import '../../theme/theme.dart'; +import 'invitation_dialog.dart'; -class PasteInviteDialog extends ConsumerStatefulWidget { - const PasteInviteDialog({super.key}); +class PasteInvitationDialog extends StatefulWidget { + const PasteInvitationDialog({required Locator locator, super.key}) + : _locator = locator; @override - PasteInviteDialogState createState() => PasteInviteDialogState(); + PasteInvitationDialogState createState() => PasteInvitationDialogState(); static Future show(BuildContext context) async { - await showStyledDialog( + final locator = context.read; + + await showPopControlDialog( context: context, - title: translate('paste_invite_dialog.title'), - child: const PasteInviteDialog()); + builder: (context) => StyledDialog( + title: translate('paste_invitation_dialog.title'), + child: PasteInvitationDialog(locator: locator))); } + + final Locator _locator; } -class PasteInviteDialogState extends ConsumerState { +class PasteInvitationDialogState extends State { final _pasteTextController = TextEditingController(); @override @@ -58,8 +64,13 @@ class PasteInviteDialogState extends ConsumerState { .sublist(firstline, lastline) .join() .replaceAll(RegExp(r'[^A-Za-z0-9\-_]'), ''); - final inviteData = base64UrlNoPadDecode(inviteDataBase64); + var inviteData = Uint8List(0); + try { + inviteData = base64UrlNoPadDecode(inviteDataBase64); + } on Exception { + // + } await validateInviteData(inviteData: inviteData); } @@ -79,7 +90,7 @@ class PasteInviteDialogState extends ConsumerState { Widget buildInviteControl( BuildContext context, - InviteDialogState dialogState, + InvitationDialogState dialogState, Future Function({required Uint8List inviteData}) validateInviteData) { final theme = Theme.of(context); @@ -90,42 +101,39 @@ class PasteInviteDialogState extends ConsumerState { final monoStyle = TextStyle( fontFamily: 'Source Code Pro', fontSize: 11, - color: scale.primaryScale.text, + color: scale.primaryScale.appText, ); return Column(mainAxisSize: MainAxisSize.min, children: [ Text( - translate('paste_invite_dialog.paste_invite_here'), - ).paddingLTRB(0, 0, 0, 8), + translate('paste_invitation_dialog.paste_invite_here'), + ).paddingLTRB(0, 0, 0, 16), Container( constraints: const BoxConstraints(maxHeight: 200), child: TextField( enabled: !dialogState.isValidating, - onChanged: (text) async => - _onPasteChanged(text, validateInviteData), + autofocus: true, + onChanged: (text) => _onPasteChanged(text, validateInviteData), style: monoStyle, keyboardType: TextInputType.multiline, maxLines: null, controller: _pasteTextController, decoration: const InputDecoration( - border: OutlineInputBorder(), hintText: '--- BEGIN VEILIDCHAT CONTACT INVITE ----\n' 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX\n' '---- END VEILIDCHAT CONTACT INVITE -----\n', - //labelText: translate('paste_invite_dialog.paste') + //labelText: translate('paste_invitation_dialog.paste') ), )).paddingLTRB(0, 0, 0, 8) ]); } @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context) { - return InviteDialog( - onValidationCancelled: onValidationCancelled, - onValidationSuccess: onValidationSuccess, - onValidationFailed: onValidationFailed, - inviteControlIsValid: inviteControlIsValid, - buildInviteControl: buildInviteControl); - } + Widget build(BuildContext context) => InvitationDialog( + locator: widget._locator, + onValidationCancelled: onValidationCancelled, + onValidationSuccess: onValidationSuccess, + onValidationFailed: onValidationFailed, + inviteControlIsValid: inviteControlIsValid, + buildInviteControl: buildInviteControl); } diff --git a/lib/contact_invitation/views/scan_invitation_dialog.dart b/lib/contact_invitation/views/scan_invitation_dialog.dart new file mode 100644 index 0000000..058383d --- /dev/null +++ b/lib/contact_invitation/views/scan_invitation_dialog.dart @@ -0,0 +1,240 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:image/image.dart' as img; +import 'package:pasteboard/pasteboard.dart'; +import 'package:provider/provider.dart'; +import 'package:zxing2/qrcode.dart'; + +import '../../notifications/notifications.dart'; +import '../../theme/theme.dart'; +import '../../tools/tools.dart'; +import 'camera_qr_scanner.dart'; +import 'invitation_dialog.dart'; + +class ScanInvitationDialog extends StatefulWidget { + const ScanInvitationDialog({required Locator locator, super.key}) + : _locator = locator; + + @override + ScanInvitationDialogState createState() => ScanInvitationDialogState(); + + static Future show(BuildContext context) async { + final locator = context.read; + await showPopControlDialog( + context: context, + builder: (context) => StyledDialog( + title: translate('scan_invitation_dialog.title'), + child: ScanInvitationDialog(locator: locator))); + } + + final Locator _locator; +} + +class ScanInvitationDialogState extends State { + var _scanned = false; + + @override + void initState() { + super.initState(); + } + + void onValidationCancelled() { + setState(() { + _scanned = false; + }); + } + + void onValidationSuccess() {} + void onValidationFailed() { + setState(() { + _scanned = false; + }); + } + + bool inviteControlIsValid() => false; // _pasteTextController.text.isNotEmpty; + + Future scanQRImage(BuildContext context) async { + final theme = Theme.of(context); + final scale = theme.extension()!; + + try { + return showDialog( + context: context, + builder: (context) => Stack( + fit: StackFit.expand, + children: [ + CameraQRScanner( + scanSize: const Size(200, 200), + loadingBuilder: (context) => waitingPage(), + errorBuilder: (coRntext, e, st) => errorPage(e, st), + bottomRowBuilder: (context) => FittedBox( + fit: BoxFit.scaleDown, + child: Text( + translate( + 'scan_invitation_dialog.instructions'), + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelLarge), + ), + showNotification: (s) {}, + logError: log.error, + cameraErrorMessage: + translate('scan_invitation_dialog.camera_error'), + deniedErrorMessage: + translate('scan_invitation_dialog.permission_error'), + deniedWithoutPromptErrorMessage: + translate('scan_invitation_dialog.permission_error'), + restrictedErrorMessage: + translate('scan_invitation_dialog.permission_error'), + onDetect: (result) { + final byteSegments = result + .resultMetadata[ResultMetadataType.byteSegments]; + if (byteSegments != null) { + final segs = byteSegments as List; + + final byteData = Uint8List.fromList(segs[0].toList()); + return byteData; + } + return null; + }, + onDone: (result) { + Navigator.of(context).pop(result); + }), + Align( + alignment: Alignment.topRight, + child: IconButton( + color: Colors.white, + icon: Icon(Icons.close, + color: scale.primaryScale.appText), + iconSize: 32.scaled(context), + onPressed: () { + Navigator.of(context).pop(); + })), + ], + )); + } on Exception catch (_) { + context + .read() + .error(text: translate('scan_invitation_dialog.error')); + } + + return null; + } + + Future pasteQRImage(BuildContext context) async { + final imageBytes = await Pasteboard.image; + if (imageBytes == null) { + if (context.mounted) { + context + .read() + .error(text: translate('scan_invitation_dialog.not_an_image')); + } + return null; + } + + final image = img.decodeImage(imageBytes); + if (image == null) { + if (context.mounted) { + context.read().error( + text: translate('scan_invitation_dialog.could_not_decode_image')); + } + return null; + } + + try { + final source = RGBLuminanceSource( + image.width, + image.height, + image + .convert(numChannels: 4) + .getBytes(order: img.ChannelOrder.abgr) + .buffer + .asInt32List()); + final bitmap = BinaryBitmap(HybridBinarizer(source)); + + final reader = QRCodeReader(); + final result = reader.decode(bitmap); + + final segs = result.resultMetadata[ResultMetadataType.byteSegments]! + as List; + return Uint8List.fromList(segs[0].toList()); + } on Exception catch (_) { + if (context.mounted) { + context.read().error( + text: translate('scan_invitation_dialog.not_a_valid_qr_code')); + } + return null; + } + } + + Widget buildInviteControl( + BuildContext context, + InvitationDialogState dialogState, + Future Function({required Uint8List inviteData}) + validateInviteData) { + if (_scanned) { + return const SizedBox.shrink(); + } + + final children = []; + if (isiOS || isAndroid) { + children.addAll([ + Text( + translate('scan_invitation_dialog.scan_qr_here'), + ).paddingLTRB(0, 0, 0, 8), + Container( + constraints: const BoxConstraints(maxHeight: 200), + child: ElevatedButton( + onPressed: dialogState.isValidating + ? null + : () async { + final inviteData = await scanQRImage(context); + if (inviteData != null) { + setState(() { + _scanned = true; + }); + await validateInviteData(inviteData: inviteData); + } + }, + child: Text(translate('scan_invitation_dialog.scan'))), + ).paddingLTRB(0, 0, 0, 8) + ]); + } + + children.addAll([ + Text( + translate('scan_invitation_dialog.paste_qr_here'), + ).paddingLTRB(0, 0, 0, 8), + Container( + constraints: const BoxConstraints(maxHeight: 200), + child: ElevatedButton( + onPressed: dialogState.isValidating + ? null + : () async { + final inviteData = await pasteQRImage(context); + if (inviteData != null) { + await validateInviteData(inviteData: inviteData); + setState(() { + _scanned = true; + }); + } + }, + child: Text(translate('scan_invitation_dialog.paste'))), + ).paddingLTRB(0, 0, 0, 8) + ]); + + return Column(mainAxisSize: MainAxisSize.min, children: children); + } + + @override + Widget build(BuildContext context) => InvitationDialog( + locator: widget._locator, + onValidationCancelled: onValidationCancelled, + onValidationSuccess: onValidationSuccess, + onValidationFailed: onValidationFailed, + inviteControlIsValid: inviteControlIsValid, + buildInviteControl: buildInviteControl); +} diff --git a/lib/contact_invitation/views/views.dart b/lib/contact_invitation/views/views.dart new file mode 100644 index 0000000..319296b --- /dev/null +++ b/lib/contact_invitation/views/views.dart @@ -0,0 +1,8 @@ +export 'camera_qr_scanner.dart'; +export 'contact_invitation_display.dart'; +export 'contact_invitation_item_widget.dart'; +export 'contact_invitation_list_widget.dart'; +export 'create_invitation_dialog.dart'; +export 'invitation_dialog.dart'; +export 'paste_invitation_dialog.dart'; +export 'scan_invitation_dialog.dart'; diff --git a/lib/contacts/contacts.dart b/lib/contacts/contacts.dart new file mode 100644 index 0000000..08ae2e7 --- /dev/null +++ b/lib/contacts/contacts.dart @@ -0,0 +1,3 @@ +export 'cubits/cubits.dart'; +export 'models/models.dart'; +export 'views/views.dart'; diff --git a/lib/contacts/cubits/contact_list_cubit.dart b/lib/contacts/cubits/contact_list_cubit.dart new file mode 100644 index 0000000..a0591ad --- /dev/null +++ b/lib/contacts/cubits/contact_list_cubit.dart @@ -0,0 +1,166 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:async_tools/async_tools.dart'; +import 'package:protobuf/protobuf.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../account_manager/account_manager.dart'; +import '../../proto/proto.dart' as proto; +import '../../tools/tools.dart'; +import '../models/models.dart'; + +////////////////////////////////////////////////// +// Mutable state for per-account contacts + +class ContactListCubit extends DHTShortArrayCubit { + ContactListCubit({ + required AccountInfo accountInfo, + required OwnedDHTRecordPointer contactListRecordPointer, + }) : super( + open: () => + _open(accountInfo.accountRecordKey, contactListRecordPointer), + decodeElement: proto.Contact.fromBuffer); + + static Future _open(TypedKey accountRecordKey, + OwnedDHTRecordPointer contactListRecordPointer) async { + final dhtRecord = await DHTShortArray.openOwned(contactListRecordPointer, + debugName: 'ContactListCubit::_open::ContactList', + parent: accountRecordKey); + + return dhtRecord; + } + + @override + Future close() async { + await _contactProfileUpdateMap.close(); + await super.close(); + } + //////////////////////////////////////////////////////////////////////////// + // Public Interface + + void followContactProfileChanges(TypedKey localConversationRecordKey, + Stream profileStream, proto.Profile? profileState) { + _contactProfileUpdateMap + .follow(localConversationRecordKey, profileStream, profileState, + (remoteProfile) async { + if (remoteProfile == null) { + return; + } + return updateContactProfile( + localConversationRecordKey: localConversationRecordKey, + profile: remoteProfile); + }); + } + + Future updateContactProfile({ + required TypedKey localConversationRecordKey, + required proto.Profile profile, + }) async { + // Update contact's remoteProfile + await operateWriteEventual((writer) async { + for (var pos = 0; pos < writer.length; pos++) { + final c = await writer.getProtobuf(proto.Contact.fromBuffer, pos); + if (c != null && + c.localConversationRecordKey.toVeilid() == + localConversationRecordKey) { + if (c.profile == profile) { + // Unchanged + break; + } + final newContact = c.deepCopy()..profile = profile; + final updated = await writer.tryWriteItemProtobuf( + proto.Contact.fromBuffer, pos, newContact); + if (!updated) { + throw const DHTExceptionOutdated(); + } + break; + } + } + }); + } + + Future updateContactFields({ + required TypedKey localConversationRecordKey, + required ContactSpec updatedContactSpec, + }) async { + // Update contact's locally-modifiable fields + await operateWriteEventual((writer) async { + for (var pos = 0; pos < writer.length; pos++) { + final c = await writer.getProtobuf(proto.Contact.fromBuffer, pos); + if (c != null && + c.localConversationRecordKey.toVeilid() == + localConversationRecordKey) { + final newContact = await updatedContactSpec.updateProto(c); + + final updated = await writer.tryWriteItemProtobuf( + proto.Contact.fromBuffer, pos, newContact); + if (!updated) { + throw const DHTExceptionOutdated(); + } + break; + } + } + }); + } + + Future createContact({ + required proto.Profile profile, + required SuperIdentity remoteSuperIdentity, + required TypedKey localConversationRecordKey, + required TypedKey remoteConversationRecordKey, + }) async { + // Create Contact + final contact = proto.Contact() + ..profile = profile + ..superIdentityJson = jsonEncode(remoteSuperIdentity.toJson()) + ..identityPublicKey = + remoteSuperIdentity.currentInstance.typedPublicKey.toProto() + ..localConversationRecordKey = localConversationRecordKey.toProto() + ..remoteConversationRecordKey = remoteConversationRecordKey.toProto() + ..showAvailability = false; + + // Add Contact to account's list + await operateWriteEventual((writer) async { + await writer.add(contact.writeToBuffer()); + }); + } + + Future deleteContact( + {required TypedKey localConversationRecordKey}) async { + // Remove Contact from account's list + final deletedItem = await operateWriteEventual((writer) async { + for (var i = 0; i < writer.length; i++) { + final item = await writer.getProtobuf(proto.Contact.fromBuffer, i); + if (item == null) { + throw Exception('Failed to get contact'); + } + if (item.localConversationRecordKey.toVeilid() == + localConversationRecordKey) { + await writer.remove(i); + return item; + } + } + return null; + }); + + if (deletedItem != null) { + try { + // Mark the conversation records for deletion + await DHTRecordPool.instance + .deleteRecord(deletedItem.localConversationRecordKey.toVeilid()); + } on Exception catch (e) { + log.debug('error deleting local conversation record: $e', e); + } + try { + await DHTRecordPool.instance + .deleteRecord(deletedItem.remoteConversationRecordKey.toVeilid()); + } on Exception catch (e) { + log.debug('error deleting remote conversation record: $e', e); + } + } + } + + final _contactProfileUpdateMap = + SingleStateProcessorMap(); +} diff --git a/lib/contacts/cubits/cubits.dart b/lib/contacts/cubits/cubits.dart new file mode 100644 index 0000000..795d497 --- /dev/null +++ b/lib/contacts/cubits/cubits.dart @@ -0,0 +1 @@ +export 'contact_list_cubit.dart'; diff --git a/lib/contacts/models/contact_spec.dart b/lib/contacts/models/contact_spec.dart new file mode 100644 index 0000000..1596434 --- /dev/null +++ b/lib/contacts/models/contact_spec.dart @@ -0,0 +1,37 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; +import 'package:protobuf/protobuf.dart'; + +import '../../proto/proto.dart' as proto; + +@immutable +class ContactSpec extends Equatable { + const ContactSpec({ + required this.nickname, + required this.notes, + required this.showAvailability, + }); + + ContactSpec.fromProto(proto.Contact p) + : nickname = p.nickname, + notes = p.notes, + showAvailability = p.showAvailability; + + Future updateProto(proto.Contact old) async { + final newProto = old.deepCopy() + ..nickname = nickname + ..notes = notes + ..showAvailability = showAvailability; + + return newProto; + } + + //////////////////////////////////////////////////////////////////////////// + + final String nickname; + final String notes; + final bool showAvailability; + + @override + List get props => [nickname, notes, showAvailability]; +} diff --git a/lib/contacts/models/models.dart b/lib/contacts/models/models.dart new file mode 100644 index 0000000..d489632 --- /dev/null +++ b/lib/contacts/models/models.dart @@ -0,0 +1 @@ +export 'contact_spec.dart'; diff --git a/lib/contacts/views/availability_widget.dart b/lib/contacts/views/availability_widget.dart new file mode 100644 index 0000000..5ef6080 --- /dev/null +++ b/lib/contacts/views/availability_widget.dart @@ -0,0 +1,91 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:flutter_translate/flutter_translate.dart'; + +import '../../proto/proto.dart' as proto; +import '../../theme/theme.dart'; + +class AvailabilityWidget extends StatelessWidget { + const AvailabilityWidget( + {required this.availability, + required this.color, + this.vertical = true, + super.key}); + + static Widget availabilityIcon( + BuildContext context, + proto.Availability availability, + Color color, + ) { + late final Widget icon; + switch (availability) { + case proto.Availability.AVAILABILITY_AWAY: + icon = SvgPicture.asset('assets/images/toilet.svg', + width: 24.scaled(context), + height: 24.scaled(context), + colorFilter: ColorFilter.mode(color, BlendMode.srcATop)); + case proto.Availability.AVAILABILITY_BUSY: + icon = Icon(size: 24.scaled(context), Icons.event_busy); + case proto.Availability.AVAILABILITY_FREE: + icon = Icon(size: 24.scaled(context), Icons.event_available); + case proto.Availability.AVAILABILITY_OFFLINE: + icon = Icon(size: 24.scaled(context), Icons.cloud_off); + case proto.Availability.AVAILABILITY_UNSPECIFIED: + icon = Icon(size: 24.scaled(context), Icons.question_mark); + } + return icon; + } + + static String availabilityName(proto.Availability availability) { + late final String name; + switch (availability) { + case proto.Availability.AVAILABILITY_AWAY: + name = translate('availability.away'); + case proto.Availability.AVAILABILITY_BUSY: + name = translate('availability.busy'); + case proto.Availability.AVAILABILITY_FREE: + name = translate('availability.free'); + case proto.Availability.AVAILABILITY_OFFLINE: + name = translate('availability.offline'); + case proto.Availability.AVAILABILITY_UNSPECIFIED: + name = translate('availability.unspecified'); + } + return name; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + + final name = availabilityName(availability); + final icon = availabilityIcon(context, availability, color); + + return vertical + ? Column(mainAxisSize: MainAxisSize.min, children: [ + icon, + Text(name, style: textTheme.labelSmall!.copyWith(color: color)) + ]) + : Row(mainAxisSize: MainAxisSize.min, children: [ + icon, + Text(' $name', style: textTheme.labelLarge!.copyWith(color: color)) + ]); + } + + //////////////////////////////////////////////////////////////////////////// + + final proto.Availability availability; + final Color color; + final bool vertical; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add( + DiagnosticsProperty('availability', availability)) + ..add(DiagnosticsProperty('vertical', vertical)) + ..add(ColorProperty('color', color)); + } +} diff --git a/lib/contacts/views/contact_details_widget.dart b/lib/contacts/views/contact_details_widget.dart new file mode 100644 index 0000000..707dc1e --- /dev/null +++ b/lib/contacts/views/contact_details_widget.dart @@ -0,0 +1,51 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_translate/flutter_translate.dart'; + +import '../../proto/proto.dart' as proto; +import '../../tools/tools.dart'; +import '../contacts.dart'; + +class ContactDetailsWidget extends StatefulWidget { + const ContactDetailsWidget( + {required this.contact, this.onModifiedState, super.key}); + + @override + State createState() => _ContactDetailsWidgetState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('contact', contact)) + ..add(ObjectFlagProperty.has( + 'onModifiedState', onModifiedState)); + } + + final proto.Contact contact; + final void Function(bool)? onModifiedState; +} + +class _ContactDetailsWidgetState extends State { + @override + Widget build(BuildContext context) => SingleChildScrollView( + child: EditContactForm( + contact: widget.contact, + submitText: translate('button.update'), + submitDisabledText: translate('button.waiting_for_network'), + onModifiedState: widget.onModifiedState, + onSubmit: (updatedContactSpec) async { + final contactList = context.read(); + try { + await contactList.updateContactFields( + localConversationRecordKey: + widget.contact.localConversationRecordKey.toVeilid(), + updatedContactSpec: updatedContactSpec); + } on Exception catch (e) { + log.debug('error updating contact: $e', e); + return false; + } + return true; + })); +} diff --git a/lib/contacts/views/contact_item_widget.dart b/lib/contacts/views/contact_item_widget.dart new file mode 100644 index 0000000..9a76be5 --- /dev/null +++ b/lib/contacts/views/contact_item_widget.dart @@ -0,0 +1,101 @@ +import 'package:async_tools/async_tools.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import '../../proto/proto.dart' as proto; +import '../../theme/theme.dart'; + +const _kOnTap = 'onTap'; + +class ContactItemWidget extends StatelessWidget { + const ContactItemWidget( + {required proto.Contact contact, + required bool disabled, + required bool selected, + Future Function(proto.Contact)? onTap, + Future Function(proto.Contact)? onDoubleTap, + Future Function(proto.Contact)? onDelete, + super.key}) + : _disabled = disabled, + _selected = selected, + _contact = contact, + _onTap = onTap, + _onDoubleTap = onDoubleTap, + _onDelete = onDelete; + + @override + Widget build( + BuildContext context, + ) { + final name = _contact.nameOrNickname; + final title = _contact.displayName; + final subtitle = _contact.profile.status; + + final avatar = StyledAvatar( + name: name, + size: 34.scaled(context), + ); + + return StyledSlideTile( + key: ObjectKey(_contact), + disabled: _disabled, + selected: _selected, + tileScale: ScaleKind.primary, + title: title, + subtitle: subtitle, + leading: avatar, + onDoubleTap: _onDoubleTap == null + ? null + : () => singleFuture((this, _kOnTap), () async { + await _onDoubleTap(_contact); + }), + onTap: _onTap == null + ? null + : () => singleFuture((this, _kOnTap), () async { + await _onTap(_contact); + }), + startActions: [ + if (_onDoubleTap != null) + SlideTileAction( + //icon: Icons.edit, + label: translate('button.chat'), + actionScale: ScaleKind.secondary, + onPressed: (_context) => + singleFuture((this, _kOnTap), () async { + await _onDoubleTap(_contact); + }), + ), + ], + endActions: [ + if (_onTap != null) + SlideTileAction( + //icon: Icons.edit, + label: translate('button.edit'), + actionScale: ScaleKind.secondary, + onPressed: (_context) => + singleFuture((this, _kOnTap), () async { + await _onTap(_contact); + }), + ), + if (_onDelete != null) + SlideTileAction( + //icon: Icons.delete, + label: translate('button.delete'), + actionScale: ScaleKind.tertiary, + onPressed: (_context) => + singleFuture((this, _kOnTap), () async { + await _onDelete(_contact); + }), + ), + ], + ); + } + + //////////////////////////////////////////////////////////////////////////// + + final proto.Contact _contact; + final bool _disabled; + final bool _selected; + final Future Function(proto.Contact contact)? _onTap; + final Future Function(proto.Contact contact)? _onDoubleTap; + final Future Function(proto.Contact contact)? _onDelete; +} diff --git a/lib/contacts/views/contacts_browser.dart b/lib/contacts/views/contacts_browser.dart new file mode 100644 index 0000000..c04a4e4 --- /dev/null +++ b/lib/contacts/views/contacts_browser.dart @@ -0,0 +1,253 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:searchable_listview/searchable_listview.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../contact_invitation/contact_invitation.dart'; +import '../../proto/proto.dart' as proto; +import '../../theme/theme.dart'; +import '../cubits/cubits.dart'; +import 'contact_item_widget.dart'; +import 'empty_contact_list_widget.dart'; + +enum ContactsBrowserElementKind { + contact, + invitation, +} + +class ContactsBrowserElement { + ContactsBrowserElement.contact(proto.Contact c) + : kind = ContactsBrowserElementKind.contact, + invitation = null, + contact = c; + ContactsBrowserElement.invitation(proto.ContactInvitationRecord i) + : kind = ContactsBrowserElementKind.invitation, + contact = null, + invitation = i; + + String get sortKey => switch (kind) { + ContactsBrowserElementKind.contact => contact!.displayName, + ContactsBrowserElementKind.invitation => + invitation!.recipient + invitation!.message + }; + + final ContactsBrowserElementKind kind; + final proto.ContactInvitationRecord? invitation; + final proto.Contact? contact; +} + +class ContactsBrowser extends StatefulWidget { + const ContactsBrowser( + {required this.onContactSelected, + required this.onContactDeleted, + required this.onStartChat, + this.selectedContactRecordKey, + super.key}); + @override + State createState() => _ContactsBrowserState(); + + final Future Function(proto.Contact? contact) onContactSelected; + final Future Function(proto.Contact contact) onContactDeleted; + final Future Function(proto.Contact contact) onStartChat; + final TypedKey? selectedContactRecordKey; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty( + 'selectedContactRecordKey', selectedContactRecordKey)) + ..add( + ObjectFlagProperty Function(proto.Contact? contact)>.has( + 'onContactSelected', onContactSelected)) + ..add( + ObjectFlagProperty Function(proto.Contact contact)>.has( + 'onStartChat', onStartChat)) + ..add( + ObjectFlagProperty Function(proto.Contact contact)>.has( + 'onContactDeleted', onContactDeleted)); + } +} + +class _ContactsBrowserState extends State + with SingleTickerProviderStateMixin { + Widget buildInvitationButton(BuildContext context) { + final theme = Theme.of(context); + final scaleScheme = theme.extension()!; + final scaleConfig = theme.extension()!; + + final menuIconColor = scaleConfig.preferBorders + ? scaleScheme.primaryScale.hoverBorder + : scaleScheme.primaryScale.hoverBorder; + // final menuBackgroundColor = scaleConfig.preferBorders + // ? scaleScheme.primaryScale.activeElementBackground + // : scaleScheme.primaryScale.activeElementBackground; + + // final menuBorderColor = scaleScheme.primaryScale.hoverBorder; + + PopupMenuEntry makeMenuButton( + {required IconData iconData, + required String text, + void Function()? onTap}) => + PopupMenuItem( + onTap: onTap, + child: Row( + spacing: 8.scaled(context), + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Icon(iconData, size: 32.scaled(context)), + Text( + text, + textScaler: MediaQuery.of(context).textScaler, + maxLines: 2, + textAlign: TextAlign.center, + ) + ])); + final inviteMenuItems = [ + makeMenuButton( + iconData: Icons.contact_page, + text: translate('add_contact_sheet.create_invite'), + onTap: () async { + await CreateInvitationDialog.show(context); + }), + makeMenuButton( + iconData: Icons.qr_code_scanner, + text: translate('add_contact_sheet.scan_invite'), + onTap: () async { + await ScanInvitationDialog.show(context); + }), + makeMenuButton( + iconData: Icons.paste, + text: translate('add_contact_sheet.paste_invite'), + onTap: () async { + await PasteInvitationDialog.show(context); + }), + ]; + + return PopupMenuButton( + itemBuilder: (_) => inviteMenuItems, + menuPadding: const EdgeInsets.symmetric(vertical: 8).scaled(context), + tooltip: translate('add_contact_sheet.add_contact'), + child: Icon( + size: 32.scaled(context), Icons.person_add, color: menuIconColor)); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scale = theme.extension()!; + + final cilState = context.watch().state; + final contactInvitationRecordList = + cilState.state.asData?.value.map((x) => x.value).toIList() ?? + const IListConst([]); + + final ciState = context.watch().state; + final contactList = + ciState.state.asData?.value.map((x) => x.value).toIList(); + + final initialList = []; + if (contactList != null) { + initialList + .addAll(contactList.toList().map(ContactsBrowserElement.contact)); + } + if (contactInvitationRecordList.isNotEmpty) { + initialList.addAll(contactInvitationRecordList + .toList() + .map(ContactsBrowserElement.invitation)); + } + + initialList.sort((a, b) => a.sortKey.compareTo(b.sortKey)); + + return Column(children: [ + SearchableList( + initialList: initialList, + itemBuilder: (element) { + switch (element.kind) { + case ContactsBrowserElementKind.contact: + final contact = element.contact!; + return ContactItemWidget( + contact: contact, + selected: widget.selectedContactRecordKey == + contact.localConversationRecordKey.toVeilid(), + disabled: false, + onDoubleTap: _onStartChat, + onTap: onContactSelected, + onDelete: _onContactDeleted) + .paddingLTRB(0, 4.scaled(context), 0, 0); + case ContactsBrowserElementKind.invitation: + final invitation = element.invitation!; + return ContactInvitationItemWidget( + contactInvitationRecord: invitation, + disabled: false) + .paddingLTRB(0, 4.scaled(context), 0, 0); + } + }, + filter: (value) { + final lowerValue = value.toLowerCase(); + + final filtered = []; + for (final element in initialList) { + switch (element.kind) { + case ContactsBrowserElementKind.contact: + final contact = element.contact!; + if (contact.nickname.toLowerCase().contains(lowerValue) || + contact.profile.name + .toLowerCase() + .contains(lowerValue) || + contact.profile.pronouns + .toLowerCase() + .contains(lowerValue)) { + filtered.add(element); + } + case ContactsBrowserElementKind.invitation: + final invitation = element.invitation!; + if (invitation.message + .toLowerCase() + .contains(lowerValue) || + invitation.recipient + .toLowerCase() + .contains(lowerValue)) { + filtered.add(element); + } + } + } + return filtered; + }, + searchFieldHeight: 40.scaled(context), + listViewPadding: + const EdgeInsets.fromLTRB(4, 0, 4, 4).scaled(context), + searchFieldPadding: + const EdgeInsets.fromLTRB(4, 8, 4, 4).scaled(context), + emptyWidget: contactList == null + ? waitingPage( + text: translate('contact_list.loading_contacts')) + : const EmptyContactListWidget(), + defaultSuffixIconColor: scale.primaryScale.border, + searchFieldEnabled: contactList != null, + inputDecoration: + InputDecoration(labelText: translate('contact_list.search')), + secondaryWidget: buildInvitationButton(context) + .paddingLTRB(8.scaled(context), 0, 0, 0)) + .expanded() + ]); + } + + Future onContactSelected(proto.Contact contact) async { + await widget.onContactSelected(contact); + } + + Future _onStartChat(proto.Contact contact) async { + await widget.onStartChat(contact); + } + + Future _onContactDeleted(proto.Contact contact) async { + await widget.onContactDeleted(contact); + } + + //////////////////////////////////////////////////////////////////////////// +} diff --git a/lib/contacts/views/contacts_page.dart b/lib/contacts/views/contacts_page.dart new file mode 100644 index 0000000..c984b57 --- /dev/null +++ b/lib/contacts/views/contacts_page.dart @@ -0,0 +1,199 @@ +import 'package:async_tools/async_tools.dart'; +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:provider/provider.dart'; + +import '../../chat/chat.dart'; +import '../../chat_list/chat_list.dart'; +import '../../layout/layout.dart'; +import '../../proto/proto.dart' as proto; +import '../../theme/theme.dart'; +import '../contacts.dart'; + +const _kDoBackArrow = 'doBackArrow'; + +class ContactsPage extends StatefulWidget { + const ContactsPage({super.key}); + + @override + State createState() => _ContactsPageState(); +} + +class _ContactsPageState extends State { + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scaleTheme = theme.extension()!; + final appBarTheme = scaleTheme.appBarTheme(); + final scaleScheme = theme.extension()!; + final scale = scaleScheme.scale(ScaleKind.primary); + + final enableSplit = !isMobileSize(context); + final enableLeft = enableSplit || _selectedContact == null; + final enableRight = enableSplit || _selectedContact != null; + + return StyledScaffold( + appBar: DefaultAppBar( + context: context, + title: Text( + !enableSplit && enableRight + ? translate('contacts_dialog.edit_contact') + : translate('contacts_dialog.contacts'), + ), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + iconSize: 24.scaled(context), + onPressed: () { + singleFuture((this, _kDoBackArrow), () async { + final confirmed = await _onContactSelected(null); + if (!enableSplit && enableRight) { + } else { + if (confirmed) { + if (context.mounted) { + Navigator.pop(context); + } + } + } + }); + }, + ), + actions: [ + if (_selectedContact != null) + IconButton( + icon: const Icon(Icons.chat_bubble), + iconSize: 24.scaled(context), + color: appBarTheme.iconColor, + tooltip: translate('contacts_dialog.new_chat'), + onPressed: () async { + await _onChatStarted(_selectedContact!); + }), + if (enableSplit && _selectedContact != null) + IconButton( + icon: const Icon(Icons.close), + iconSize: 24.scaled(context), + color: appBarTheme.iconColor, + tooltip: translate('contacts_dialog.close_contact'), + onPressed: () async { + await _onContactSelected(null); + }), + ]), + body: LayoutBuilder(builder: (context, constraint) { + final maxWidth = constraint.maxWidth; + + return ColoredBox( + color: scale.appBackground, + child: + Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Offstage( + offstage: !enableLeft, + child: SizedBox( + width: enableLeft && !enableRight + ? maxWidth + : (maxWidth / 3).clamp(200, 500), + child: DecoratedBox( + decoration: + BoxDecoration(color: scale.subtleBackground), + child: ContactsBrowser( + selectedContactRecordKey: _selectedContact + ?.localConversationRecordKey + .toVeilid(), + onContactSelected: _onContactSelected, + onStartChat: _onChatStarted, + onContactDeleted: _onContactDeleted, + ).paddingLTRB(4, 0, 4, 8)))), + if (enableRight && enableLeft) + Container( + constraints: + const BoxConstraints(minWidth: 1, maxWidth: 1), + color: scale.subtleBorder), + if (enableRight) + if (_selectedContact == null) + const NoContactWidget().expanded() + else + ContactDetailsWidget( + contact: _selectedContact!, + onModifiedState: _onModifiedState) + .paddingLTRB(16, 16, 16, 16) + .expanded(), + ])); + })); + } + + void _onModifiedState(bool isModified) { + setState(() { + _isModified = isModified; + }); + } + + Future _onContactSelected(proto.Contact? contact) async { + if (contact != _selectedContact && _isModified) { + final ok = await showConfirmModal( + context: context, + title: translate('confirmation.discard_changes'), + text: translate('confirmation.are_you_sure_discard')); + if (!ok) { + return false; + } + } + setState(() { + _selectedContact = contact; + _isModified = false; + }); + return true; + } + + Future _onChatStarted(proto.Contact contact) async { + final chatListCubit = context.read(); + await chatListCubit.getOrCreateChatSingleContact(contact: contact); + + if (mounted) { + context + .read() + .setActiveChat(contact.localConversationRecordKey.toVeilid()); + Navigator.pop(context); + } + } + + Future _onContactDeleted(proto.Contact contact) async { + if (contact == _selectedContact && _isModified) { + final ok = await showConfirmModal( + context: context, + title: translate('confirmation.discard_changes'), + text: translate('confirmation.are_you_sure_discard')); + if (!ok) { + return false; + } + } + setState(() { + _selectedContact = null; + _isModified = false; + }); + + if (mounted) { + final localConversationRecordKey = + contact.localConversationRecordKey.toVeilid(); + + final contactListCubit = context.read(); + final chatListCubit = context.read(); + + // Delete the contact itself + await contactListCubit.deleteContact( + localConversationRecordKey: localConversationRecordKey); + + // Remove any chats for this contact + await chatListCubit.deleteChat( + localConversationRecordKey: localConversationRecordKey); + } + + return true; + } + + proto.Contact? _selectedContact; + var _isModified = false; +} diff --git a/lib/contacts/views/edit_contact_form.dart b/lib/contacts/views/edit_contact_form.dart new file mode 100644 index 0000000..7ab6019 --- /dev/null +++ b/lib/contacts/views/edit_contact_form.dart @@ -0,0 +1,233 @@ +import 'package:async_tools/async_tools.dart'; +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_translate/flutter_translate.dart'; + +import '../../proto/proto.dart' as proto; +import '../../theme/theme.dart'; +import '../models/contact_spec.dart'; +import 'availability_widget.dart'; + +const _kDoSubmitEditContact = 'doSubmitEditContact'; + +class EditContactForm extends StatefulWidget { + const EditContactForm({ + required this.contact, + required this.onSubmit, + required this.submitText, + required this.submitDisabledText, + this.onModifiedState, + super.key, + }); + + @override + State createState() => _EditContactFormState(); + + final proto.Contact contact; + final String submitText; + final String submitDisabledText; + final Future Function(ContactSpec) onSubmit; + final void Function(bool)? onModifiedState; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(ObjectFlagProperty Function(ContactSpec p1)>.has( + 'onSubmit', onSubmit)) + ..add(ObjectFlagProperty.has( + 'onModifiedState', onModifiedState)) + ..add(DiagnosticsProperty('contact', contact)) + ..add(StringProperty('submitText', submitText)) + ..add(StringProperty('submitDisabledText', submitDisabledText)); + } + + static const String formFieldNickname = 'nickname'; + static const String formFieldNotes = 'notes'; + static const String formFieldShowAvailability = 'show_availability'; +} + +class _EditContactFormState extends State { + final _formKey = GlobalKey(); + + @override + void initState() { + _savedValue = ContactSpec.fromProto(widget.contact); + _currentValueNickname = _savedValue.nickname; + + super.initState(); + } + + ContactSpec _makeContactSpec() { + final nickname = _formKey.currentState! + .fields[EditContactForm.formFieldNickname]!.value as String; + final notes = _formKey + .currentState!.fields[EditContactForm.formFieldNotes]!.value as String; + final showAvailability = _formKey.currentState! + .fields[EditContactForm.formFieldShowAvailability]!.value as bool; + + return ContactSpec( + nickname: nickname, notes: notes, showAvailability: showAvailability); + } + + // Check if everything is the same and update state + void _onChanged() { + final currentValue = _makeContactSpec(); + _isModified = currentValue != _savedValue; + final onModifiedState = widget.onModifiedState; + if (onModifiedState != null) { + onModifiedState(_isModified); + } + } + + Widget _availabilityWidget(proto.Availability availability, Color color) => + AvailabilityWidget( + availability: availability, + color: color, + vertical: false, + ); + + Widget _editContactForm(BuildContext context) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final textTheme = theme.textTheme; + + return FormBuilder( + key: _formKey, + autovalidateMode: AutovalidateMode.onUserInteraction, + onChanged: _onChanged, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + styledCard( + context: context, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row(children: [ + const Spacer(), + StyledAvatar( + name: _currentValueNickname.isNotEmpty + ? _currentValueNickname + : widget.contact.profile.name, + size: 128) + .paddingLTRB(0, 0, 0, 16), + const Spacer() + ]), + SelectableText(widget.contact.profile.name, + style: textTheme.bodyLarge) + .noEditDecoratorLabel( + context, + translate('contact_form.form_name'), + ) + .paddingSymmetric(vertical: 4), + SelectableText(widget.contact.profile.pronouns, + style: textTheme.bodyLarge) + .noEditDecoratorLabel( + context, + translate('contact_form.form_pronouns'), + ) + .paddingSymmetric(vertical: 4), + Row(mainAxisSize: MainAxisSize.min, children: [ + _availabilityWidget( + widget.contact.profile.availability, + scale.primaryScale.appText), + SelectableText(widget.contact.profile.status, + style: textTheme.bodyMedium) + .paddingSymmetric(horizontal: 8) + ]) + .noEditDecoratorLabel( + context, + translate('contact_form.form_status'), + ) + .paddingSymmetric(vertical: 4), + SelectableText(widget.contact.profile.about, + minLines: 1, + maxLines: 8, + style: textTheme.bodyMedium) + .noEditDecoratorLabel( + context, + translate('contact_form.form_about'), + ) + .paddingSymmetric(vertical: 4), + SelectableText( + widget.contact.identityPublicKey.value + .toVeilid() + .toString(), + style: textTheme.bodyMedium! + .copyWith(fontFamily: 'Source Code Pro')) + .noEditDecoratorLabel( + context, + translate('contact_form.form_fingerprint'), + ) + .paddingSymmetric(vertical: 4), + ]).paddingAll(16)) + .paddingLTRB(0, 0, 0, 16), + FormBuilderTextField( + name: EditContactForm.formFieldNickname, + initialValue: _currentValueNickname, + onChanged: (x) { + setState(() { + _currentValueNickname = x ?? ''; + }); + }, + decoration: InputDecoration( + labelText: translate('contact_form.form_nickname')), + maxLength: 64, + textInputAction: TextInputAction.next, + ), + FormBuilderCheckbox( + name: EditContactForm.formFieldShowAvailability, + initialValue: _savedValue.showAvailability, + title: Text(translate('contact_form.form_show_availability'), + style: textTheme.labelMedium), + ), + FormBuilderTextField( + name: EditContactForm.formFieldNotes, + initialValue: _savedValue.notes, + minLines: 1, + maxLines: 8, + maxLength: 1024, + decoration: InputDecoration( + labelText: translate('contact_form.form_notes')), + textInputAction: TextInputAction.newline, + ), + ElevatedButton( + onPressed: _isModified ? _doSubmit : null, + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.check, size: 24.scaled(context)) + .paddingLTRB(0, 0, 4, 0), + Text(widget.submitText).paddingLTRB(0, 0, 4.scaled(context), 0) + ]).paddingAll(4.scaled(context)), + ).paddingSymmetric(vertical: 4.scaled(context)).alignAtCenter(), + ], + ), + ); + } + + void _doSubmit() { + final onSubmit = widget.onSubmit; + if (_formKey.currentState?.saveAndValidate() ?? false) { + singleFuture((this, _kDoSubmitEditContact), () async { + final updatedContactSpec = _makeContactSpec(); + final saved = await onSubmit(updatedContactSpec); + if (saved) { + setState(() { + _savedValue = updatedContactSpec; + }); + _onChanged(); + } + }); + } + } + + @override + Widget build(BuildContext context) => _editContactForm(context); + + /////////////////////////////////////////////////////////////////////////// + late ContactSpec _savedValue; + late String _currentValueNickname; + bool _isModified = false; +} diff --git a/lib/components/empty_contact_list_widget.dart b/lib/contacts/views/empty_contact_list_widget.dart similarity index 62% rename from lib/components/empty_contact_list_widget.dart rename to lib/contacts/views/empty_contact_list_widget.dart index bcd832b..e6912fd 100644 --- a/lib/components/empty_contact_list_widget.dart +++ b/lib/contacts/views/empty_contact_list_widget.dart @@ -1,30 +1,34 @@ import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_translate/flutter_translate.dart'; -import '../tools/tools.dart'; +import '../../theme/theme.dart'; -class EmptyContactListWidget extends ConsumerWidget { +class EmptyContactListWidget extends StatelessWidget { const EmptyContactListWidget({super.key}); @override // ignore: prefer_expression_function_bodies - Widget build(BuildContext context, WidgetRef ref) { + Widget build( + BuildContext context, + ) { final theme = Theme.of(context); final textTheme = theme.textTheme; final scale = theme.extension()!; return Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - Icons.person_add_sharp, - color: scale.primaryScale.subtleBorder, - size: 48, - ), + // Icon( + // Icons.person_add_sharp, + // color: scale.primaryScale.subtleBorder, + // size: 48, + // ), Text( + textAlign: TextAlign.center, translate('contact_list.invite_people'), + //maxLines: 3, style: textTheme.bodyMedium?.copyWith( color: scale.primaryScale.subtleBorder, ), diff --git a/lib/contacts/views/no_contact_widget.dart b/lib/contacts/views/no_contact_widget.dart new file mode 100644 index 0000000..8edb5d5 --- /dev/null +++ b/lib/contacts/views/no_contact_widget.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_translate/flutter_translate.dart'; + +import '../../theme/models/scale_theme/scale_scheme.dart'; + +class NoContactWidget extends StatelessWidget { + const NoContactWidget({super.key}); + + @override + // ignore: prefer_expression_function_bodies + Widget build( + BuildContext context, + ) { + final theme = Theme.of(context); + final scale = theme.extension()!; + + return DecoratedBox( + decoration: BoxDecoration( + color: scale.primaryScale.appBackground, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.person, + color: scale.primaryScale.subtleBorder, + size: 48, + ), + Text( + textAlign: TextAlign.center, + translate('contacts_dialog.no_contact_selected'), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: scale.primaryScale.subtleBorder, + ), + ), + ], + ), + ); + } +} diff --git a/lib/contacts/views/views.dart b/lib/contacts/views/views.dart new file mode 100644 index 0000000..b74aff3 --- /dev/null +++ b/lib/contacts/views/views.dart @@ -0,0 +1,8 @@ +export 'availability_widget.dart'; +export 'contact_details_widget.dart'; +export 'contact_item_widget.dart'; +export 'contacts_browser.dart'; +export 'contacts_page.dart'; +export 'edit_contact_form.dart'; +export 'empty_contact_list_widget.dart'; +export 'no_contact_widget.dart'; diff --git a/lib/conversation/conversation.dart b/lib/conversation/conversation.dart new file mode 100644 index 0000000..d09042f --- /dev/null +++ b/lib/conversation/conversation.dart @@ -0,0 +1 @@ +export 'cubits/cubits.dart'; diff --git a/lib/conversation/cubits/active_conversations_bloc_map_cubit.dart b/lib/conversation/cubits/active_conversations_bloc_map_cubit.dart new file mode 100644 index 0000000..3c00eba --- /dev/null +++ b/lib/conversation/cubits/active_conversations_bloc_map_cubit.dart @@ -0,0 +1,178 @@ +import 'package:async_tools/async_tools.dart'; +import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; +import 'package:equatable/equatable.dart'; +import 'package:meta/meta.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../account_manager/account_manager.dart'; +import '../../chat_list/cubits/cubits.dart'; +import '../../contacts/contacts.dart'; +import '../../proto/proto.dart' as proto; +import '../conversation.dart'; + +@immutable +class ActiveConversationState extends Equatable { + const ActiveConversationState({ + required this.remoteIdentityPublicKey, + required this.localConversationRecordKey, + required this.remoteConversationRecordKey, + required this.localConversation, + required this.remoteConversation, + }); + + final TypedKey remoteIdentityPublicKey; + final TypedKey localConversationRecordKey; + final TypedKey remoteConversationRecordKey; + final proto.Conversation localConversation; + final proto.Conversation? remoteConversation; + + @override + List get props => [ + remoteIdentityPublicKey, + localConversationRecordKey, + remoteConversationRecordKey, + localConversation, + remoteConversation + ]; +} + +typedef ActiveConversationCubit = TransformerCubit< + AsyncValue, + AsyncValue, + ConversationCubit>; + +typedef ActiveConversationsBlocMapState + = BlocMapState>; + +// Map of localConversationRecordKey to ActiveConversationCubit +// Wraps a conversation cubit to only expose completely built conversations +// Automatically follows the state of a ChatListCubit. +// We currently only build the cubits for the chats that are active, not +// archived chats or contacts that are not actively in a chat. +// +// TODO(crioux): Polling contacts for new inactive chats is yet to be done +// +class ActiveConversationsBlocMapCubit extends BlocMapCubit, ActiveConversationCubit> + with StateMapFollower { + ActiveConversationsBlocMapCubit({ + required AccountInfo accountInfo, + required AccountRecordCubit accountRecordCubit, + required ChatListCubit chatListCubit, + required ContactListCubit contactListCubit, + }) : _accountInfo = accountInfo, + _accountRecordCubit = accountRecordCubit, + _contactListCubit = contactListCubit { + // Follow the chat list cubit + follow(chatListCubit); + } + + //////////////////////////////////////////////////////////////////////////// + // Public Interface + + //////////////////////////////////////////////////////////////////////////// + // Private Implementation + + // Add an active conversation to be tracked for changes + void _addDirectConversation( + {required TypedKey remoteIdentityPublicKey, + required TypedKey localConversationRecordKey, + required TypedKey remoteConversationRecordKey}) => + add(localConversationRecordKey, () { + // Conversation cubit the tracks the state between the local + // and remote halves of a contact's relationship with this account + final conversationCubit = ConversationCubit( + accountInfo: _accountInfo, + remoteIdentityPublicKey: remoteIdentityPublicKey, + localConversationRecordKey: localConversationRecordKey, + remoteConversationRecordKey: remoteConversationRecordKey, + ); + + // When remote conversation changes its profile, + // update our local contact + _contactListCubit.followContactProfileChanges( + localConversationRecordKey, + conversationCubit.stream.map((x) => x.map( + data: (d) => d.value.remoteConversation?.profile, + loading: (_) => null, + error: (_) => null)), + conversationCubit.state.asData?.value.remoteConversation?.profile); + + // When our local account profile changes, send it to the conversation + conversationCubit.watchAccountChanges( + _accountRecordCubit.stream, _accountRecordCubit.state); + + // Transformer that only passes through conversations where the local + // portion is not loading + // along with the contact that corresponds to the completed + // conversation + final transformedCubit = TransformerCubit< + AsyncValue, + AsyncValue, + ConversationCubit>(conversationCubit, + transform: (avstate) => avstate.when( + data: (data) => (data.localConversation == null) + ? const AsyncValue.loading() + : AsyncValue.data(ActiveConversationState( + localConversation: data.localConversation!, + remoteConversation: data.remoteConversation, + remoteIdentityPublicKey: remoteIdentityPublicKey, + localConversationRecordKey: localConversationRecordKey, + remoteConversationRecordKey: + remoteConversationRecordKey)), + loading: AsyncValue.loading, + error: AsyncValue.error)); + + return transformedCubit; + }); + + /// StateFollower ///////////////////////// + + @override + void removeFromState(TypedKey key) => remove(key); + + @override + void updateState(TypedKey key, proto.Chat? oldValue, proto.Chat newValue) { + switch (newValue.whichKind()) { + case proto.Chat_Kind.notSet: + throw StateError('unknown chat kind'); + case proto.Chat_Kind.direct: + final localConversationRecordKey = + newValue.direct.localConversationRecordKey.toVeilid(); + final remoteIdentityPublicKey = + newValue.direct.remoteMember.remoteIdentityPublicKey.toVeilid(); + final remoteConversationRecordKey = + newValue.direct.remoteMember.remoteConversationRecordKey.toVeilid(); + + if (oldValue != null) { + final oldLocalConversationRecordKey = + oldValue.direct.localConversationRecordKey.toVeilid(); + final oldRemoteIdentityPublicKey = + oldValue.direct.remoteMember.remoteIdentityPublicKey.toVeilid(); + final oldRemoteConversationRecordKey = oldValue + .direct.remoteMember.remoteConversationRecordKey + .toVeilid(); + + if (oldLocalConversationRecordKey == localConversationRecordKey && + oldRemoteIdentityPublicKey == remoteIdentityPublicKey && + oldRemoteConversationRecordKey == remoteConversationRecordKey) { + return; + } + } + + _addDirectConversation( + remoteIdentityPublicKey: remoteIdentityPublicKey, + localConversationRecordKey: localConversationRecordKey, + remoteConversationRecordKey: remoteConversationRecordKey); + + case proto.Chat_Kind.group: + break; + } + } + + //// + + final AccountInfo _accountInfo; + final AccountRecordCubit _accountRecordCubit; + final ContactListCubit _contactListCubit; +} diff --git a/lib/conversation/cubits/active_single_contact_chat_bloc_map_cubit.dart b/lib/conversation/cubits/active_single_contact_chat_bloc_map_cubit.dart new file mode 100644 index 0000000..68e1a58 --- /dev/null +++ b/lib/conversation/cubits/active_single_contact_chat_bloc_map_cubit.dart @@ -0,0 +1,113 @@ +import 'package:async_tools/async_tools.dart'; +import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; +import 'package:equatable/equatable.dart'; +import 'package:meta/meta.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../account_manager/account_manager.dart'; +import '../../chat/chat.dart'; +import '../../proto/proto.dart' as proto; +import '../conversation.dart'; +import 'active_conversations_bloc_map_cubit.dart'; + +@immutable +class _SingleContactChatState extends Equatable { + const _SingleContactChatState( + {required this.remoteIdentityPublicKey, + required this.localConversationRecordKey, + required this.remoteConversationRecordKey, + required this.localMessagesRecordKey, + required this.remoteMessagesRecordKey}); + + final TypedKey remoteIdentityPublicKey; + final TypedKey localConversationRecordKey; + final TypedKey remoteConversationRecordKey; + final TypedKey localMessagesRecordKey; + final TypedKey? remoteMessagesRecordKey; + + @override + List get props => [ + remoteIdentityPublicKey, + localConversationRecordKey, + remoteConversationRecordKey, + localMessagesRecordKey, + remoteMessagesRecordKey + ]; +} + +// Map of localConversationRecordKey to SingleContactMessagesCubit +// Wraps a SingleContactMessagesCubit to stream the latest messages to the state +// Automatically follows the state of a ActiveConversationsBlocMapCubit. +class ActiveSingleContactChatBlocMapCubit extends BlocMapCubit + with + StateMapFollower> { + ActiveSingleContactChatBlocMapCubit({ + required AccountInfo accountInfo, + required ActiveConversationsBlocMapCubit activeConversationsBlocMapCubit, + }) : _accountInfo = accountInfo { + // Follow the active conversations bloc map cubit + follow(activeConversationsBlocMapCubit); + } + + void _addConversationMessages(_SingleContactChatState state) { + update(state.localConversationRecordKey, + onUpdate: (cubit) => + cubit.updateRemoteMessagesRecordKey(state.remoteMessagesRecordKey), + onCreate: () => SingleContactMessagesCubit( + accountInfo: _accountInfo, + remoteIdentityPublicKey: state.remoteIdentityPublicKey, + localConversationRecordKey: state.localConversationRecordKey, + remoteConversationRecordKey: state.remoteConversationRecordKey, + localMessagesRecordKey: state.localMessagesRecordKey, + remoteMessagesRecordKey: state.remoteMessagesRecordKey, + )); + } + + _SingleContactChatState? _mapStateValue( + AsyncValue avInputState) { + final inputState = avInputState.asData?.value; + if (inputState == null) { + return null; + } + return _SingleContactChatState( + remoteIdentityPublicKey: inputState.remoteIdentityPublicKey, + localConversationRecordKey: inputState.localConversationRecordKey, + remoteConversationRecordKey: inputState.remoteConversationRecordKey, + localMessagesRecordKey: + inputState.localConversation.messages.toVeilid(), + remoteMessagesRecordKey: + inputState.remoteConversation?.messages.toVeilid()); + } + + /// StateFollower ///////////////////////// + + @override + void removeFromState(TypedKey key) => remove(key); + + @override + void updateState(TypedKey key, AsyncValue? oldValue, + AsyncValue newValue) { + final newState = _mapStateValue(newValue); + if (oldValue != null) { + final oldState = _mapStateValue(oldValue); + if (oldState == newState) { + return; + } + } + if (newState != null) { + _addConversationMessages(newState); + } else if (newValue.isLoading) { + addState(key, const AsyncValue.loading()); + } else { + final (error, stackTrace) = + (newValue.asError!.error, newValue.asError!.stackTrace); + addError(error, stackTrace); + addState(key, AsyncValue.error(error, stackTrace)); + } + } + + //// + final AccountInfo _accountInfo; +} diff --git a/lib/conversation/cubits/conversation_cubit.dart b/lib/conversation/cubits/conversation_cubit.dart new file mode 100644 index 0000000..329cddd --- /dev/null +++ b/lib/conversation/cubits/conversation_cubit.dart @@ -0,0 +1,316 @@ +// A Conversation is a type of Chat that is 1:1 between two Contacts only +// Each Contact in the ContactList has at most one Conversation between the +// remote contact and the local account + +import 'dart:async'; +import 'dart:convert'; + +import 'package:async_tools/async_tools.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:meta/meta.dart'; +import 'package:protobuf/protobuf.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../account_manager/account_manager.dart'; +import '../../proto/proto.dart' as proto; + +const _sfUpdateAccountChange = 'updateAccountChange'; + +@immutable +class ConversationState extends Equatable { + const ConversationState( + {required this.localConversation, required this.remoteConversation}); + + final proto.Conversation? localConversation; + final proto.Conversation? remoteConversation; + + @override + List get props => [localConversation, remoteConversation]; + + @override + String toString() => 'ConversationState(' + 'localConversation: ${DynamicDebug.toDebug(localConversation)}, ' + 'remoteConversation: ${DynamicDebug.toDebug(remoteConversation)}' + ')'; +} + +/// Represents the control channel between two contacts +/// Used to pass profile, identity and status changes, and the messages key for +/// 1-1 chats +class ConversationCubit extends Cubit> { + ConversationCubit( + {required AccountInfo accountInfo, + required TypedKey remoteIdentityPublicKey, + TypedKey? localConversationRecordKey, + TypedKey? remoteConversationRecordKey}) + : _accountInfo = accountInfo, + _remoteIdentityPublicKey = remoteIdentityPublicKey, + super(const AsyncValue.loading()) { + _identityWriter = _accountInfo.identityWriter; + + if (localConversationRecordKey != null) { + _initWait.add((_) async { + await _setLocalConversation(() async { + // Open local record key if it is specified + final pool = DHTRecordPool.instance; + final crypto = await _cachedConversationCrypto(); + final writer = _identityWriter; + + final record = await pool.openRecordWrite( + localConversationRecordKey, writer, + debugName: 'ConversationCubit::LocalConversation', + parent: accountInfo.accountRecordKey, + crypto: crypto); + + return record; + }); + }); + } + + if (remoteConversationRecordKey != null) { + _initWait.add((cancel) async { + await _setRemoteConversation(() async { + // Open remote record key if it is specified + final pool = DHTRecordPool.instance; + final crypto = await _cachedConversationCrypto(); + + final record = await pool.openRecordRead(remoteConversationRecordKey, + debugName: 'ConversationCubit::RemoteConversation', + parent: + await pool.getParentRecordKey(remoteConversationRecordKey) ?? + accountInfo.accountRecordKey, + crypto: crypto); + + return record; + }); + }); + } + } + + @override + Future close() async { + await _initWait(); + await _accountSubscription?.cancel(); + await _localSubscription?.cancel(); + await _remoteSubscription?.cancel(); + await _localConversationCubit?.close(); + await _remoteConversationCubit?.close(); + + await super.close(); + } + + //////////////////////////////////////////////////////////////////////////// + // Public Interface + + /// Initialize a local conversation + /// If we were the initiator of the conversation there may be an + /// incomplete 'existingConversationRecord' that we need to fill + /// in now that we have the remote identity key + /// The ConversationCubit must not already have a local conversation + /// Returns the local conversation record key that was initialized + Future initLocalConversation( + {required proto.Profile profile, + TypedKey? existingConversationRecordKey}) async { + assert(_localConversationCubit == null, + 'must not have a local conversation yet'); + + final pool = DHTRecordPool.instance; + + final crypto = await _cachedConversationCrypto(); + final accountRecordKey = _accountInfo.accountRecordKey; + final writer = _accountInfo.identityWriter; + + // Open with SMPL schema for identity writer + late final DHTRecord localConversationRecord; + if (existingConversationRecordKey != null) { + localConversationRecord = await pool.openRecordWrite( + existingConversationRecordKey, writer, + debugName: + 'ConversationCubit::initLocalConversation::LocalConversation', + parent: accountRecordKey, + crypto: crypto); + } else { + localConversationRecord = await pool.createRecord( + debugName: + 'ConversationCubit::initLocalConversation::LocalConversation', + parent: accountRecordKey, + crypto: crypto, + writer: writer, + schema: DHTSchema.smpl( + oCnt: 0, members: [DHTSchemaMember(mKey: writer.key, mCnt: 1)])); + } + await localConversationRecord.deleteScope((localConversation) async { + await _initLocalMessages( + localConversationKey: localConversation.key, + callback: (messages) async { + // Create initial local conversation key contents + final conversation = proto.Conversation() + ..profile = profile + ..superIdentityJson = + jsonEncode(_accountInfo.localAccount.superIdentity.toJson()) + ..messages = messages.recordKey.toProto(); + + // Write initial conversation to record + final update = await localConversation.tryWriteProtobuf( + proto.Conversation.fromBuffer, conversation); + if (update != null) { + throw Exception('Failed to write local conversation'); + } + + // If success, save the new local conversation + // record key in this object + localConversation.ref(); + await _setLocalConversation(() async => localConversation); + }); + }); + + return localConversationRecord.key; + } + + /// Force refresh of conversation keys + // Future refresh() async { + // await _initWait(); + + // final lcc = _localConversationCubit; + // final rcc = _remoteConversationCubit; + + // if (lcc != null) { + // await lcc.refreshDefault(); + // } + // if (rcc != null) { + // await rcc.refreshDefault(); + // } + // } + + /// Watch for account record changes and update the conversation + void watchAccountChanges(Stream> accountStream, + AsyncValue currentState) { + assert(_accountSubscription == null, 'only watch account once'); + _accountSubscription = accountStream.listen(_updateAccountChange); + _updateAccountChange(currentState); + } + + //////////////////////////////////////////////////////////////////////////// + // Private Implementation + + void _updateAccountChange(AsyncValue avaccount) { + final account = avaccount.asData?.value; + if (account == null) { + return; + } + final cubit = _localConversationCubit; + if (cubit == null) { + return; + } + serialFuture((this, _sfUpdateAccountChange), () async { + await cubit.record?.eventualUpdateProtobuf(proto.Conversation.fromBuffer, + (old) async { + if (old == null || old.profile == account.profile) { + return null; + } + return old.deepCopy()..profile = account.profile; + }); + }); + } + + void _updateLocalConversationState(AsyncValue avconv) { + final newState = avconv.when( + data: (conv) { + _incrementalState = ConversationState( + localConversation: conv, + remoteConversation: _incrementalState.remoteConversation); + return AsyncValue.data(_incrementalState); + }, + loading: AsyncValue.loading, + error: AsyncValue.error, + ); + emit(newState); + } + + void _updateRemoteConversationState(AsyncValue avconv) { + final newState = avconv.when( + data: (conv) { + _incrementalState = ConversationState( + localConversation: _incrementalState.localConversation, + remoteConversation: conv); + return AsyncValue.data(_incrementalState); + }, + loading: AsyncValue.loading, + error: AsyncValue.error, + ); + emit(newState); + } + + // Open local converation key + Future _setLocalConversation(Future Function() open) async { + assert(_localConversationCubit == null, + 'should not set local conversation twice'); + _localConversationCubit = DefaultDHTRecordCubit( + open: open, decodeState: proto.Conversation.fromBuffer); + + await _localConversationCubit!.ready(); + + _localSubscription = + _localConversationCubit!.stream.listen(_updateLocalConversationState); + _updateLocalConversationState(_localConversationCubit!.state); + } + + // Open remote converation key + Future _setRemoteConversation(Future Function() open) async { + assert(_remoteConversationCubit == null, + 'should not set remote conversation twice'); + _remoteConversationCubit = DefaultDHTRecordCubit( + open: open, decodeState: proto.Conversation.fromBuffer); + + await _remoteConversationCubit!.ready(); + + _remoteSubscription = + _remoteConversationCubit!.stream.listen(_updateRemoteConversationState); + _updateRemoteConversationState(_remoteConversationCubit!.state); + } + + // Initialize local messages + Future _initLocalMessages({ + required TypedKey localConversationKey, + required FutureOr Function(DHTLog) callback, + }) async { + final crypto = await _cachedConversationCrypto(); + final writer = _identityWriter; + + return (await DHTLog.create( + debugName: 'ConversationCubit::initLocalMessages::LocalMessages', + parent: localConversationKey, + crypto: crypto, + writer: writer)) + .deleteScope((messages) async => await callback(messages)); + } + + Future _cachedConversationCrypto() async { + var conversationCrypto = _conversationCrypto; + if (conversationCrypto != null) { + return conversationCrypto; + } + conversationCrypto = + await _accountInfo.makeConversationCrypto(_remoteIdentityPublicKey); + _conversationCrypto = conversationCrypto; + return conversationCrypto; + } + + //////////////////////////////////////////////////////////////////////////// + // Fields + TypedKey get remoteIdentityPublicKey => _remoteIdentityPublicKey; + + final AccountInfo _accountInfo; + late final KeyPair _identityWriter; + final TypedKey _remoteIdentityPublicKey; + DefaultDHTRecordCubit? _localConversationCubit; + DefaultDHTRecordCubit? _remoteConversationCubit; + StreamSubscription>? _localSubscription; + StreamSubscription>? _remoteSubscription; + StreamSubscription>? _accountSubscription; + var _incrementalState = const ConversationState( + localConversation: null, remoteConversation: null); + VeilidCrypto? _conversationCrypto; + final WaitSet _initWait = WaitSet(); +} diff --git a/lib/conversation/cubits/cubits.dart b/lib/conversation/cubits/cubits.dart new file mode 100644 index 0000000..029764f --- /dev/null +++ b/lib/conversation/cubits/cubits.dart @@ -0,0 +1,3 @@ +export 'active_conversations_bloc_map_cubit.dart'; +export 'active_single_contact_chat_bloc_map_cubit.dart'; +export 'conversation_cubit.dart'; diff --git a/lib/entities/entities.dart b/lib/entities/entities.dart deleted file mode 100644 index 8a24422..0000000 --- a/lib/entities/entities.dart +++ /dev/null @@ -1,3 +0,0 @@ -export 'local_account.dart'; -export 'preferences.dart'; -export 'user_login.dart'; diff --git a/lib/entities/local_account.dart b/lib/entities/local_account.dart deleted file mode 100644 index 68c5ca2..0000000 --- a/lib/entities/local_account.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'dart:typed_data'; - -import 'package:change_case/change_case.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; - -import '../proto/proto.dart' as proto; -import '../veilid_support/veilid_support.dart'; - -part 'local_account.freezed.dart'; -part 'local_account.g.dart'; - -// Local account identitySecretKey is potentially encrypted with a key -// using the following mechanisms -// * None : no key, bytes are unencrypted -// * Pin : Code is a numeric pin (4-256 numeric digits) hashed with Argon2 -// * Password: Code is a UTF-8 string that is hashed with Argon2 -enum EncryptionKeyType { - none, - pin, - password; - - factory EncryptionKeyType.fromJson(dynamic j) => - EncryptionKeyType.values.byName((j as String).toCamelCase()); - - factory EncryptionKeyType.fromProto(proto.EncryptionKeyType p) { - // ignore: exhaustive_cases - switch (p) { - case proto.EncryptionKeyType.ENCRYPTION_KEY_TYPE_NONE: - return EncryptionKeyType.none; - case proto.EncryptionKeyType.ENCRYPTION_KEY_TYPE_PIN: - return EncryptionKeyType.pin; - case proto.EncryptionKeyType.ENCRYPTION_KEY_TYPE_PASSWORD: - return EncryptionKeyType.password; - } - throw StateError('unknown EncryptionKeyType enum value'); - } - String toJson() => name.toPascalCase(); - proto.EncryptionKeyType toProto() => switch (this) { - EncryptionKeyType.none => - proto.EncryptionKeyType.ENCRYPTION_KEY_TYPE_NONE, - EncryptionKeyType.pin => - proto.EncryptionKeyType.ENCRYPTION_KEY_TYPE_PIN, - EncryptionKeyType.password => - proto.EncryptionKeyType.ENCRYPTION_KEY_TYPE_PASSWORD, - }; -} - -// Local Accounts are stored in a table locally and not backed by a DHT key -// and represents the accounts that have been added/imported -// on the current device. -// Stores a copy of the IdentityMaster associated with the account -// and the identitySecretKey optionally encrypted by an unlock code -// This is the root of the account information tree for VeilidChat -// -@freezed -class LocalAccount with _$LocalAccount { - const factory LocalAccount({ - // The master key record for the account, containing the identityPublicKey - required IdentityMaster identityMaster, - // The encrypted identity secret that goes with - // the identityPublicKey with appended salt - @Uint8ListJsonConverter() required Uint8List identitySecretBytes, - // The kind of encryption input used on the account - required EncryptionKeyType encryptionKeyType, - // If account is not hidden, password can be retrieved via - required bool biometricsEnabled, - // Keep account hidden unless account password is entered - // (tries all hidden accounts with auth method (no biometrics)) - required bool hiddenAccount, - // Display name for account until it is unlocked - required String name, - }) = _LocalAccount; - - factory LocalAccount.fromJson(dynamic json) => - _$LocalAccountFromJson(json as Map); -} diff --git a/lib/entities/local_account.freezed.dart b/lib/entities/local_account.freezed.dart deleted file mode 100644 index 19dcd76..0000000 --- a/lib/entities/local_account.freezed.dart +++ /dev/null @@ -1,301 +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 'local_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'); - -LocalAccount _$LocalAccountFromJson(Map json) { - return _LocalAccount.fromJson(json); -} - -/// @nodoc -mixin _$LocalAccount { -// The master key record for the account, containing the identityPublicKey - IdentityMaster get identityMaster => - throw _privateConstructorUsedError; // The encrypted identity secret that goes with -// the identityPublicKey with appended salt - @Uint8ListJsonConverter() - Uint8List get identitySecretBytes => - throw _privateConstructorUsedError; // The kind of encryption input used on the account - EncryptionKeyType get encryptionKeyType => - throw _privateConstructorUsedError; // If account is not hidden, password can be retrieved via - bool get biometricsEnabled => - throw _privateConstructorUsedError; // Keep account hidden unless account password is entered -// (tries all hidden accounts with auth method (no biometrics)) - bool get hiddenAccount => - throw _privateConstructorUsedError; // Display name for account until it is unlocked - String get name => throw _privateConstructorUsedError; - - Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) - $LocalAccountCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $LocalAccountCopyWith<$Res> { - factory $LocalAccountCopyWith( - LocalAccount value, $Res Function(LocalAccount) then) = - _$LocalAccountCopyWithImpl<$Res, LocalAccount>; - @useResult - $Res call( - {IdentityMaster identityMaster, - @Uint8ListJsonConverter() Uint8List identitySecretBytes, - EncryptionKeyType encryptionKeyType, - bool biometricsEnabled, - bool hiddenAccount, - String name}); - - $IdentityMasterCopyWith<$Res> get identityMaster; -} - -/// @nodoc -class _$LocalAccountCopyWithImpl<$Res, $Val extends LocalAccount> - implements $LocalAccountCopyWith<$Res> { - _$LocalAccountCopyWithImpl(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? identityMaster = null, - Object? identitySecretBytes = null, - Object? encryptionKeyType = null, - Object? biometricsEnabled = null, - Object? hiddenAccount = null, - Object? name = null, - }) { - return _then(_value.copyWith( - identityMaster: null == identityMaster - ? _value.identityMaster - : identityMaster // ignore: cast_nullable_to_non_nullable - as IdentityMaster, - identitySecretBytes: null == identitySecretBytes - ? _value.identitySecretBytes - : identitySecretBytes // ignore: cast_nullable_to_non_nullable - as Uint8List, - encryptionKeyType: null == encryptionKeyType - ? _value.encryptionKeyType - : encryptionKeyType // ignore: cast_nullable_to_non_nullable - as EncryptionKeyType, - biometricsEnabled: null == biometricsEnabled - ? _value.biometricsEnabled - : biometricsEnabled // ignore: cast_nullable_to_non_nullable - as bool, - hiddenAccount: null == hiddenAccount - ? _value.hiddenAccount - : hiddenAccount // ignore: cast_nullable_to_non_nullable - as bool, - name: null == name - ? _value.name - : name // ignore: cast_nullable_to_non_nullable - as String, - ) as $Val); - } - - @override - @pragma('vm:prefer-inline') - $IdentityMasterCopyWith<$Res> get identityMaster { - return $IdentityMasterCopyWith<$Res>(_value.identityMaster, (value) { - return _then(_value.copyWith(identityMaster: value) as $Val); - }); - } -} - -/// @nodoc -abstract class _$$LocalAccountImplCopyWith<$Res> - implements $LocalAccountCopyWith<$Res> { - factory _$$LocalAccountImplCopyWith( - _$LocalAccountImpl value, $Res Function(_$LocalAccountImpl) then) = - __$$LocalAccountImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {IdentityMaster identityMaster, - @Uint8ListJsonConverter() Uint8List identitySecretBytes, - EncryptionKeyType encryptionKeyType, - bool biometricsEnabled, - bool hiddenAccount, - String name}); - - @override - $IdentityMasterCopyWith<$Res> get identityMaster; -} - -/// @nodoc -class __$$LocalAccountImplCopyWithImpl<$Res> - extends _$LocalAccountCopyWithImpl<$Res, _$LocalAccountImpl> - implements _$$LocalAccountImplCopyWith<$Res> { - __$$LocalAccountImplCopyWithImpl( - _$LocalAccountImpl _value, $Res Function(_$LocalAccountImpl) _then) - : super(_value, _then); - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? identityMaster = null, - Object? identitySecretBytes = null, - Object? encryptionKeyType = null, - Object? biometricsEnabled = null, - Object? hiddenAccount = null, - Object? name = null, - }) { - return _then(_$LocalAccountImpl( - identityMaster: null == identityMaster - ? _value.identityMaster - : identityMaster // ignore: cast_nullable_to_non_nullable - as IdentityMaster, - identitySecretBytes: null == identitySecretBytes - ? _value.identitySecretBytes - : identitySecretBytes // ignore: cast_nullable_to_non_nullable - as Uint8List, - encryptionKeyType: null == encryptionKeyType - ? _value.encryptionKeyType - : encryptionKeyType // ignore: cast_nullable_to_non_nullable - as EncryptionKeyType, - biometricsEnabled: null == biometricsEnabled - ? _value.biometricsEnabled - : biometricsEnabled // ignore: cast_nullable_to_non_nullable - as bool, - hiddenAccount: null == hiddenAccount - ? _value.hiddenAccount - : hiddenAccount // ignore: cast_nullable_to_non_nullable - as bool, - name: null == name - ? _value.name - : name // ignore: cast_nullable_to_non_nullable - as String, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$LocalAccountImpl implements _LocalAccount { - const _$LocalAccountImpl( - {required this.identityMaster, - @Uint8ListJsonConverter() required this.identitySecretBytes, - required this.encryptionKeyType, - required this.biometricsEnabled, - required this.hiddenAccount, - required this.name}); - - factory _$LocalAccountImpl.fromJson(Map json) => - _$$LocalAccountImplFromJson(json); - -// The master key record for the account, containing the identityPublicKey - @override - final IdentityMaster identityMaster; -// The encrypted identity secret that goes with -// the identityPublicKey with appended salt - @override - @Uint8ListJsonConverter() - final Uint8List identitySecretBytes; -// The kind of encryption input used on the account - @override - final EncryptionKeyType encryptionKeyType; -// If account is not hidden, password can be retrieved via - @override - final bool biometricsEnabled; -// Keep account hidden unless account password is entered -// (tries all hidden accounts with auth method (no biometrics)) - @override - final bool hiddenAccount; -// Display name for account until it is unlocked - @override - final String name; - - @override - String toString() { - return 'LocalAccount(identityMaster: $identityMaster, identitySecretBytes: $identitySecretBytes, encryptionKeyType: $encryptionKeyType, biometricsEnabled: $biometricsEnabled, hiddenAccount: $hiddenAccount, name: $name)'; - } - - @override - bool operator ==(dynamic other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$LocalAccountImpl && - (identical(other.identityMaster, identityMaster) || - other.identityMaster == identityMaster) && - const DeepCollectionEquality() - .equals(other.identitySecretBytes, identitySecretBytes) && - (identical(other.encryptionKeyType, encryptionKeyType) || - other.encryptionKeyType == encryptionKeyType) && - (identical(other.biometricsEnabled, biometricsEnabled) || - other.biometricsEnabled == biometricsEnabled) && - (identical(other.hiddenAccount, hiddenAccount) || - other.hiddenAccount == hiddenAccount) && - (identical(other.name, name) || other.name == name)); - } - - @JsonKey(ignore: true) - @override - int get hashCode => Object.hash( - runtimeType, - identityMaster, - const DeepCollectionEquality().hash(identitySecretBytes), - encryptionKeyType, - biometricsEnabled, - hiddenAccount, - name); - - @JsonKey(ignore: true) - @override - @pragma('vm:prefer-inline') - _$$LocalAccountImplCopyWith<_$LocalAccountImpl> get copyWith => - __$$LocalAccountImplCopyWithImpl<_$LocalAccountImpl>(this, _$identity); - - @override - Map toJson() { - return _$$LocalAccountImplToJson( - this, - ); - } -} - -abstract class _LocalAccount implements LocalAccount { - const factory _LocalAccount( - {required final IdentityMaster identityMaster, - @Uint8ListJsonConverter() required final Uint8List identitySecretBytes, - required final EncryptionKeyType encryptionKeyType, - required final bool biometricsEnabled, - required final bool hiddenAccount, - required final String name}) = _$LocalAccountImpl; - - factory _LocalAccount.fromJson(Map json) = - _$LocalAccountImpl.fromJson; - - @override // The master key record for the account, containing the identityPublicKey - IdentityMaster get identityMaster; - @override // The encrypted identity secret that goes with -// the identityPublicKey with appended salt - @Uint8ListJsonConverter() - Uint8List get identitySecretBytes; - @override // The kind of encryption input used on the account - EncryptionKeyType get encryptionKeyType; - @override // If account is not hidden, password can be retrieved via - bool get biometricsEnabled; - @override // Keep account hidden unless account password is entered -// (tries all hidden accounts with auth method (no biometrics)) - bool get hiddenAccount; - @override // Display name for account until it is unlocked - String get name; - @override - @JsonKey(ignore: true) - _$$LocalAccountImplCopyWith<_$LocalAccountImpl> get copyWith => - throw _privateConstructorUsedError; -} diff --git a/lib/entities/preferences.dart b/lib/entities/preferences.dart deleted file mode 100644 index c7d7a4f..0000000 --- a/lib/entities/preferences.dart +++ /dev/null @@ -1,89 +0,0 @@ -import 'package:change_case/change_case.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; - -part 'preferences.freezed.dart'; -part 'preferences.g.dart'; - -// Theme supports light and dark mode, optionally selected by the -// operating system -enum BrightnessPreference { - system, - light, - dark; - - factory BrightnessPreference.fromJson(dynamic j) => - BrightnessPreference.values.byName((j as String).toCamelCase()); - - String toJson() => name.toPascalCase(); -} - -// Lock preference changes how frequently the messenger locks its -// interface and requires the identitySecretKey to be entered (pin/password/etc) -@freezed -class LockPreference with _$LockPreference { - const factory LockPreference({ - required int inactivityLockSecs, - required bool lockWhenSwitching, - required bool lockWithSystemLock, - }) = _LockPreference; - - factory LockPreference.fromJson(dynamic json) => - _$LockPreferenceFromJson(json as Map); -} - -// Theme supports multiple color variants based on 'Radix' -enum ColorPreference { - // Radix Colors - scarlet, - babydoll, - vapor, - gold, - garden, - forest, - arctic, - lapis, - eggplant, - lime, - grim, - // Accessible Colors - contrast; - - factory ColorPreference.fromJson(dynamic j) => - ColorPreference.values.byName((j as String).toCamelCase()); - String toJson() => name.toPascalCase(); -} - -// Theme supports multiple translations -enum LanguagePreference { - englishUS; - - factory LanguagePreference.fromJson(dynamic j) => - LanguagePreference.values.byName((j as String).toCamelCase()); - String toJson() => name.toPascalCase(); -} - -@freezed -class ThemePreferences with _$ThemePreferences { - const factory ThemePreferences({ - required BrightnessPreference brightnessPreference, - required ColorPreference colorPreference, - required double displayScale, - }) = _ThemePreferences; - - factory ThemePreferences.fromJson(dynamic json) => - _$ThemePreferencesFromJson(json as Map); -} - -// Preferences are stored in a table locally and globally affect all -// accounts imported/added and the app in general -@freezed -class Preferences with _$Preferences { - const factory Preferences({ - required ThemePreferences themePreferences, - required LanguagePreference language, - required LockPreference locking, - }) = _Preferences; - - factory Preferences.fromJson(dynamic json) => - _$PreferencesFromJson(json as Map); -} diff --git a/lib/entities/preferences.freezed.dart b/lib/entities/preferences.freezed.dart deleted file mode 100644 index 7020dcb..0000000 --- a/lib/entities/preferences.freezed.dart +++ /dev/null @@ -1,593 +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 'preferences.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'); - -LockPreference _$LockPreferenceFromJson(Map json) { - return _LockPreference.fromJson(json); -} - -/// @nodoc -mixin _$LockPreference { - int get inactivityLockSecs => throw _privateConstructorUsedError; - bool get lockWhenSwitching => throw _privateConstructorUsedError; - bool get lockWithSystemLock => throw _privateConstructorUsedError; - - Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) - $LockPreferenceCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $LockPreferenceCopyWith<$Res> { - factory $LockPreferenceCopyWith( - LockPreference value, $Res Function(LockPreference) then) = - _$LockPreferenceCopyWithImpl<$Res, LockPreference>; - @useResult - $Res call( - {int inactivityLockSecs, - bool lockWhenSwitching, - bool lockWithSystemLock}); -} - -/// @nodoc -class _$LockPreferenceCopyWithImpl<$Res, $Val extends LockPreference> - implements $LockPreferenceCopyWith<$Res> { - _$LockPreferenceCopyWithImpl(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? inactivityLockSecs = null, - Object? lockWhenSwitching = null, - Object? lockWithSystemLock = null, - }) { - return _then(_value.copyWith( - inactivityLockSecs: null == inactivityLockSecs - ? _value.inactivityLockSecs - : inactivityLockSecs // ignore: cast_nullable_to_non_nullable - as int, - lockWhenSwitching: null == lockWhenSwitching - ? _value.lockWhenSwitching - : lockWhenSwitching // ignore: cast_nullable_to_non_nullable - as bool, - lockWithSystemLock: null == lockWithSystemLock - ? _value.lockWithSystemLock - : lockWithSystemLock // ignore: cast_nullable_to_non_nullable - as bool, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$LockPreferenceImplCopyWith<$Res> - implements $LockPreferenceCopyWith<$Res> { - factory _$$LockPreferenceImplCopyWith(_$LockPreferenceImpl value, - $Res Function(_$LockPreferenceImpl) then) = - __$$LockPreferenceImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {int inactivityLockSecs, - bool lockWhenSwitching, - bool lockWithSystemLock}); -} - -/// @nodoc -class __$$LockPreferenceImplCopyWithImpl<$Res> - extends _$LockPreferenceCopyWithImpl<$Res, _$LockPreferenceImpl> - implements _$$LockPreferenceImplCopyWith<$Res> { - __$$LockPreferenceImplCopyWithImpl( - _$LockPreferenceImpl _value, $Res Function(_$LockPreferenceImpl) _then) - : super(_value, _then); - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? inactivityLockSecs = null, - Object? lockWhenSwitching = null, - Object? lockWithSystemLock = null, - }) { - return _then(_$LockPreferenceImpl( - inactivityLockSecs: null == inactivityLockSecs - ? _value.inactivityLockSecs - : inactivityLockSecs // ignore: cast_nullable_to_non_nullable - as int, - lockWhenSwitching: null == lockWhenSwitching - ? _value.lockWhenSwitching - : lockWhenSwitching // ignore: cast_nullable_to_non_nullable - as bool, - lockWithSystemLock: null == lockWithSystemLock - ? _value.lockWithSystemLock - : lockWithSystemLock // ignore: cast_nullable_to_non_nullable - as bool, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$LockPreferenceImpl implements _LockPreference { - const _$LockPreferenceImpl( - {required this.inactivityLockSecs, - required this.lockWhenSwitching, - required this.lockWithSystemLock}); - - factory _$LockPreferenceImpl.fromJson(Map json) => - _$$LockPreferenceImplFromJson(json); - - @override - final int inactivityLockSecs; - @override - final bool lockWhenSwitching; - @override - final bool lockWithSystemLock; - - @override - String toString() { - return 'LockPreference(inactivityLockSecs: $inactivityLockSecs, lockWhenSwitching: $lockWhenSwitching, lockWithSystemLock: $lockWithSystemLock)'; - } - - @override - bool operator ==(dynamic other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$LockPreferenceImpl && - (identical(other.inactivityLockSecs, inactivityLockSecs) || - other.inactivityLockSecs == inactivityLockSecs) && - (identical(other.lockWhenSwitching, lockWhenSwitching) || - other.lockWhenSwitching == lockWhenSwitching) && - (identical(other.lockWithSystemLock, lockWithSystemLock) || - other.lockWithSystemLock == lockWithSystemLock)); - } - - @JsonKey(ignore: true) - @override - int get hashCode => Object.hash( - runtimeType, inactivityLockSecs, lockWhenSwitching, lockWithSystemLock); - - @JsonKey(ignore: true) - @override - @pragma('vm:prefer-inline') - _$$LockPreferenceImplCopyWith<_$LockPreferenceImpl> get copyWith => - __$$LockPreferenceImplCopyWithImpl<_$LockPreferenceImpl>( - this, _$identity); - - @override - Map toJson() { - return _$$LockPreferenceImplToJson( - this, - ); - } -} - -abstract class _LockPreference implements LockPreference { - const factory _LockPreference( - {required final int inactivityLockSecs, - required final bool lockWhenSwitching, - required final bool lockWithSystemLock}) = _$LockPreferenceImpl; - - factory _LockPreference.fromJson(Map json) = - _$LockPreferenceImpl.fromJson; - - @override - int get inactivityLockSecs; - @override - bool get lockWhenSwitching; - @override - bool get lockWithSystemLock; - @override - @JsonKey(ignore: true) - _$$LockPreferenceImplCopyWith<_$LockPreferenceImpl> get copyWith => - throw _privateConstructorUsedError; -} - -ThemePreferences _$ThemePreferencesFromJson(Map json) { - return _ThemePreferences.fromJson(json); -} - -/// @nodoc -mixin _$ThemePreferences { - BrightnessPreference get brightnessPreference => - throw _privateConstructorUsedError; - ColorPreference get colorPreference => throw _privateConstructorUsedError; - double get displayScale => throw _privateConstructorUsedError; - - Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) - $ThemePreferencesCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $ThemePreferencesCopyWith<$Res> { - factory $ThemePreferencesCopyWith( - ThemePreferences value, $Res Function(ThemePreferences) then) = - _$ThemePreferencesCopyWithImpl<$Res, ThemePreferences>; - @useResult - $Res call( - {BrightnessPreference brightnessPreference, - ColorPreference colorPreference, - double displayScale}); -} - -/// @nodoc -class _$ThemePreferencesCopyWithImpl<$Res, $Val extends ThemePreferences> - implements $ThemePreferencesCopyWith<$Res> { - _$ThemePreferencesCopyWithImpl(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? brightnessPreference = null, - Object? colorPreference = null, - Object? displayScale = null, - }) { - return _then(_value.copyWith( - brightnessPreference: null == brightnessPreference - ? _value.brightnessPreference - : brightnessPreference // ignore: cast_nullable_to_non_nullable - as BrightnessPreference, - colorPreference: null == colorPreference - ? _value.colorPreference - : colorPreference // ignore: cast_nullable_to_non_nullable - as ColorPreference, - displayScale: null == displayScale - ? _value.displayScale - : displayScale // ignore: cast_nullable_to_non_nullable - as double, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$ThemePreferencesImplCopyWith<$Res> - implements $ThemePreferencesCopyWith<$Res> { - factory _$$ThemePreferencesImplCopyWith(_$ThemePreferencesImpl value, - $Res Function(_$ThemePreferencesImpl) then) = - __$$ThemePreferencesImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {BrightnessPreference brightnessPreference, - ColorPreference colorPreference, - double displayScale}); -} - -/// @nodoc -class __$$ThemePreferencesImplCopyWithImpl<$Res> - extends _$ThemePreferencesCopyWithImpl<$Res, _$ThemePreferencesImpl> - implements _$$ThemePreferencesImplCopyWith<$Res> { - __$$ThemePreferencesImplCopyWithImpl(_$ThemePreferencesImpl _value, - $Res Function(_$ThemePreferencesImpl) _then) - : super(_value, _then); - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? brightnessPreference = null, - Object? colorPreference = null, - Object? displayScale = null, - }) { - return _then(_$ThemePreferencesImpl( - brightnessPreference: null == brightnessPreference - ? _value.brightnessPreference - : brightnessPreference // ignore: cast_nullable_to_non_nullable - as BrightnessPreference, - colorPreference: null == colorPreference - ? _value.colorPreference - : colorPreference // ignore: cast_nullable_to_non_nullable - as ColorPreference, - displayScale: null == displayScale - ? _value.displayScale - : displayScale // ignore: cast_nullable_to_non_nullable - as double, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$ThemePreferencesImpl implements _ThemePreferences { - const _$ThemePreferencesImpl( - {required this.brightnessPreference, - required this.colorPreference, - required this.displayScale}); - - factory _$ThemePreferencesImpl.fromJson(Map json) => - _$$ThemePreferencesImplFromJson(json); - - @override - final BrightnessPreference brightnessPreference; - @override - final ColorPreference colorPreference; - @override - final double displayScale; - - @override - String toString() { - return 'ThemePreferences(brightnessPreference: $brightnessPreference, colorPreference: $colorPreference, displayScale: $displayScale)'; - } - - @override - bool operator ==(dynamic other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$ThemePreferencesImpl && - (identical(other.brightnessPreference, brightnessPreference) || - other.brightnessPreference == brightnessPreference) && - (identical(other.colorPreference, colorPreference) || - other.colorPreference == colorPreference) && - (identical(other.displayScale, displayScale) || - other.displayScale == displayScale)); - } - - @JsonKey(ignore: true) - @override - int get hashCode => Object.hash( - runtimeType, brightnessPreference, colorPreference, displayScale); - - @JsonKey(ignore: true) - @override - @pragma('vm:prefer-inline') - _$$ThemePreferencesImplCopyWith<_$ThemePreferencesImpl> get copyWith => - __$$ThemePreferencesImplCopyWithImpl<_$ThemePreferencesImpl>( - this, _$identity); - - @override - Map toJson() { - return _$$ThemePreferencesImplToJson( - this, - ); - } -} - -abstract class _ThemePreferences implements ThemePreferences { - const factory _ThemePreferences( - {required final BrightnessPreference brightnessPreference, - required final ColorPreference colorPreference, - required final double displayScale}) = _$ThemePreferencesImpl; - - factory _ThemePreferences.fromJson(Map json) = - _$ThemePreferencesImpl.fromJson; - - @override - BrightnessPreference get brightnessPreference; - @override - ColorPreference get colorPreference; - @override - double get displayScale; - @override - @JsonKey(ignore: true) - _$$ThemePreferencesImplCopyWith<_$ThemePreferencesImpl> get copyWith => - throw _privateConstructorUsedError; -} - -Preferences _$PreferencesFromJson(Map json) { - return _Preferences.fromJson(json); -} - -/// @nodoc -mixin _$Preferences { - ThemePreferences get themePreferences => throw _privateConstructorUsedError; - LanguagePreference get language => throw _privateConstructorUsedError; - LockPreference get locking => throw _privateConstructorUsedError; - - Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) - $PreferencesCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $PreferencesCopyWith<$Res> { - factory $PreferencesCopyWith( - Preferences value, $Res Function(Preferences) then) = - _$PreferencesCopyWithImpl<$Res, Preferences>; - @useResult - $Res call( - {ThemePreferences themePreferences, - LanguagePreference language, - LockPreference locking}); - - $ThemePreferencesCopyWith<$Res> get themePreferences; - $LockPreferenceCopyWith<$Res> get locking; -} - -/// @nodoc -class _$PreferencesCopyWithImpl<$Res, $Val extends Preferences> - implements $PreferencesCopyWith<$Res> { - _$PreferencesCopyWithImpl(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? themePreferences = null, - Object? language = null, - Object? locking = null, - }) { - return _then(_value.copyWith( - themePreferences: null == themePreferences - ? _value.themePreferences - : themePreferences // ignore: cast_nullable_to_non_nullable - as ThemePreferences, - language: null == language - ? _value.language - : language // ignore: cast_nullable_to_non_nullable - as LanguagePreference, - locking: null == locking - ? _value.locking - : locking // ignore: cast_nullable_to_non_nullable - as LockPreference, - ) as $Val); - } - - @override - @pragma('vm:prefer-inline') - $ThemePreferencesCopyWith<$Res> get themePreferences { - return $ThemePreferencesCopyWith<$Res>(_value.themePreferences, (value) { - return _then(_value.copyWith(themePreferences: value) as $Val); - }); - } - - @override - @pragma('vm:prefer-inline') - $LockPreferenceCopyWith<$Res> get locking { - return $LockPreferenceCopyWith<$Res>(_value.locking, (value) { - return _then(_value.copyWith(locking: value) as $Val); - }); - } -} - -/// @nodoc -abstract class _$$PreferencesImplCopyWith<$Res> - implements $PreferencesCopyWith<$Res> { - factory _$$PreferencesImplCopyWith( - _$PreferencesImpl value, $Res Function(_$PreferencesImpl) then) = - __$$PreferencesImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {ThemePreferences themePreferences, - LanguagePreference language, - LockPreference locking}); - - @override - $ThemePreferencesCopyWith<$Res> get themePreferences; - @override - $LockPreferenceCopyWith<$Res> get locking; -} - -/// @nodoc -class __$$PreferencesImplCopyWithImpl<$Res> - extends _$PreferencesCopyWithImpl<$Res, _$PreferencesImpl> - implements _$$PreferencesImplCopyWith<$Res> { - __$$PreferencesImplCopyWithImpl( - _$PreferencesImpl _value, $Res Function(_$PreferencesImpl) _then) - : super(_value, _then); - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? themePreferences = null, - Object? language = null, - Object? locking = null, - }) { - return _then(_$PreferencesImpl( - themePreferences: null == themePreferences - ? _value.themePreferences - : themePreferences // ignore: cast_nullable_to_non_nullable - as ThemePreferences, - language: null == language - ? _value.language - : language // ignore: cast_nullable_to_non_nullable - as LanguagePreference, - locking: null == locking - ? _value.locking - : locking // ignore: cast_nullable_to_non_nullable - as LockPreference, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$PreferencesImpl implements _Preferences { - const _$PreferencesImpl( - {required this.themePreferences, - required this.language, - required this.locking}); - - factory _$PreferencesImpl.fromJson(Map json) => - _$$PreferencesImplFromJson(json); - - @override - final ThemePreferences themePreferences; - @override - final LanguagePreference language; - @override - final LockPreference locking; - - @override - String toString() { - return 'Preferences(themePreferences: $themePreferences, language: $language, locking: $locking)'; - } - - @override - bool operator ==(dynamic other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$PreferencesImpl && - (identical(other.themePreferences, themePreferences) || - other.themePreferences == themePreferences) && - (identical(other.language, language) || - other.language == language) && - (identical(other.locking, locking) || other.locking == locking)); - } - - @JsonKey(ignore: true) - @override - int get hashCode => - Object.hash(runtimeType, themePreferences, language, locking); - - @JsonKey(ignore: true) - @override - @pragma('vm:prefer-inline') - _$$PreferencesImplCopyWith<_$PreferencesImpl> get copyWith => - __$$PreferencesImplCopyWithImpl<_$PreferencesImpl>(this, _$identity); - - @override - Map toJson() { - return _$$PreferencesImplToJson( - this, - ); - } -} - -abstract class _Preferences implements Preferences { - const factory _Preferences( - {required final ThemePreferences themePreferences, - required final LanguagePreference language, - required final LockPreference locking}) = _$PreferencesImpl; - - factory _Preferences.fromJson(Map json) = - _$PreferencesImpl.fromJson; - - @override - ThemePreferences get themePreferences; - @override - LanguagePreference get language; - @override - LockPreference get locking; - @override - @JsonKey(ignore: true) - _$$PreferencesImplCopyWith<_$PreferencesImpl> get copyWith => - throw _privateConstructorUsedError; -} diff --git a/lib/entities/preferences.g.dart b/lib/entities/preferences.g.dart deleted file mode 100644 index 0e6f96c..0000000 --- a/lib/entities/preferences.g.dart +++ /dev/null @@ -1,53 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'preferences.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -_$LockPreferenceImpl _$$LockPreferenceImplFromJson(Map json) => - _$LockPreferenceImpl( - inactivityLockSecs: json['inactivity_lock_secs'] as int, - lockWhenSwitching: json['lock_when_switching'] as bool, - lockWithSystemLock: json['lock_with_system_lock'] as bool, - ); - -Map _$$LockPreferenceImplToJson( - _$LockPreferenceImpl instance) => - { - 'inactivity_lock_secs': instance.inactivityLockSecs, - 'lock_when_switching': instance.lockWhenSwitching, - 'lock_with_system_lock': instance.lockWithSystemLock, - }; - -_$ThemePreferencesImpl _$$ThemePreferencesImplFromJson( - Map json) => - _$ThemePreferencesImpl( - brightnessPreference: - BrightnessPreference.fromJson(json['brightness_preference']), - colorPreference: ColorPreference.fromJson(json['color_preference']), - displayScale: (json['display_scale'] as num).toDouble(), - ); - -Map _$$ThemePreferencesImplToJson( - _$ThemePreferencesImpl instance) => - { - 'brightness_preference': instance.brightnessPreference.toJson(), - 'color_preference': instance.colorPreference.toJson(), - 'display_scale': instance.displayScale, - }; - -_$PreferencesImpl _$$PreferencesImplFromJson(Map json) => - _$PreferencesImpl( - themePreferences: ThemePreferences.fromJson(json['theme_preferences']), - language: LanguagePreference.fromJson(json['language']), - locking: LockPreference.fromJson(json['locking']), - ); - -Map _$$PreferencesImplToJson(_$PreferencesImpl instance) => - { - 'theme_preferences': instance.themePreferences.toJson(), - 'language': instance.language.toJson(), - 'locking': instance.locking.toJson(), - }; diff --git a/lib/entities/user_login.dart b/lib/entities/user_login.dart deleted file mode 100644 index 55a4fb2..0000000 --- a/lib/entities/user_login.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; - -import '../veilid_support/veilid_support.dart'; - -part 'user_login.freezed.dart'; -part 'user_login.g.dart'; - -// Represents a currently logged in account -// User logins are stored in the user_logins tablestore table -// indexed by the accountMasterKey -@freezed -class UserLogin with _$UserLogin { - const factory UserLogin({ - // Master record key for the user used to index the local accounts table - required TypedKey accountMasterRecordKey, - // The identity secret as unlocked from the local accounts table - required TypedSecret identitySecret, - // The account record key, owner key and secret pulled from the identity - required AccountRecordInfo accountRecordInfo, - - // The time this login was most recently used - required Timestamp lastActive, - }) = _UserLogin; - - factory UserLogin.fromJson(dynamic json) => - _$UserLoginFromJson(json as Map); -} - -// Represents a set of user logins -// and the currently selected account -@freezed -class ActiveLogins with _$ActiveLogins { - const factory ActiveLogins({ - // The list of current logged in accounts - required IList userLogins, - // The current selected account indexed by master record key - TypedKey? activeUserLogin, - }) = _ActiveLogins; - - factory ActiveLogins.empty() => - const ActiveLogins(userLogins: IListConst([])); - - factory ActiveLogins.fromJson(dynamic json) => - _$ActiveLoginsFromJson(json as Map); -} diff --git a/lib/entities/user_login.freezed.dart b/lib/entities/user_login.freezed.dart deleted file mode 100644 index aca29fc..0000000 --- a/lib/entities/user_login.freezed.dart +++ /dev/null @@ -1,406 +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 'user_login.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'); - -UserLogin _$UserLoginFromJson(Map json) { - return _UserLogin.fromJson(json); -} - -/// @nodoc -mixin _$UserLogin { -// Master record key for the user used to index the local accounts table - Typed get accountMasterRecordKey => - throw _privateConstructorUsedError; // The identity secret as unlocked from the local accounts table - Typed get identitySecret => - throw _privateConstructorUsedError; // The account record key, owner key and secret pulled from the identity - AccountRecordInfo get accountRecordInfo => - throw _privateConstructorUsedError; // The time this login was most recently used - Timestamp get lastActive => throw _privateConstructorUsedError; - - Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) - $UserLoginCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $UserLoginCopyWith<$Res> { - factory $UserLoginCopyWith(UserLogin value, $Res Function(UserLogin) then) = - _$UserLoginCopyWithImpl<$Res, UserLogin>; - @useResult - $Res call( - {Typed accountMasterRecordKey, - Typed identitySecret, - AccountRecordInfo accountRecordInfo, - Timestamp lastActive}); - - $AccountRecordInfoCopyWith<$Res> get accountRecordInfo; -} - -/// @nodoc -class _$UserLoginCopyWithImpl<$Res, $Val extends UserLogin> - implements $UserLoginCopyWith<$Res> { - _$UserLoginCopyWithImpl(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? accountMasterRecordKey = null, - Object? identitySecret = null, - Object? accountRecordInfo = null, - Object? lastActive = null, - }) { - return _then(_value.copyWith( - accountMasterRecordKey: null == accountMasterRecordKey - ? _value.accountMasterRecordKey - : accountMasterRecordKey // ignore: cast_nullable_to_non_nullable - as Typed, - identitySecret: null == identitySecret - ? _value.identitySecret - : identitySecret // ignore: cast_nullable_to_non_nullable - as Typed, - accountRecordInfo: null == accountRecordInfo - ? _value.accountRecordInfo - : accountRecordInfo // ignore: cast_nullable_to_non_nullable - as AccountRecordInfo, - lastActive: null == lastActive - ? _value.lastActive - : lastActive // ignore: cast_nullable_to_non_nullable - as Timestamp, - ) as $Val); - } - - @override - @pragma('vm:prefer-inline') - $AccountRecordInfoCopyWith<$Res> get accountRecordInfo { - return $AccountRecordInfoCopyWith<$Res>(_value.accountRecordInfo, (value) { - return _then(_value.copyWith(accountRecordInfo: value) as $Val); - }); - } -} - -/// @nodoc -abstract class _$$UserLoginImplCopyWith<$Res> - implements $UserLoginCopyWith<$Res> { - factory _$$UserLoginImplCopyWith( - _$UserLoginImpl value, $Res Function(_$UserLoginImpl) then) = - __$$UserLoginImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {Typed accountMasterRecordKey, - Typed identitySecret, - AccountRecordInfo accountRecordInfo, - Timestamp lastActive}); - - @override - $AccountRecordInfoCopyWith<$Res> get accountRecordInfo; -} - -/// @nodoc -class __$$UserLoginImplCopyWithImpl<$Res> - extends _$UserLoginCopyWithImpl<$Res, _$UserLoginImpl> - implements _$$UserLoginImplCopyWith<$Res> { - __$$UserLoginImplCopyWithImpl( - _$UserLoginImpl _value, $Res Function(_$UserLoginImpl) _then) - : super(_value, _then); - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? accountMasterRecordKey = null, - Object? identitySecret = null, - Object? accountRecordInfo = null, - Object? lastActive = null, - }) { - return _then(_$UserLoginImpl( - accountMasterRecordKey: null == accountMasterRecordKey - ? _value.accountMasterRecordKey - : accountMasterRecordKey // ignore: cast_nullable_to_non_nullable - as Typed, - identitySecret: null == identitySecret - ? _value.identitySecret - : identitySecret // ignore: cast_nullable_to_non_nullable - as Typed, - accountRecordInfo: null == accountRecordInfo - ? _value.accountRecordInfo - : accountRecordInfo // ignore: cast_nullable_to_non_nullable - as AccountRecordInfo, - lastActive: null == lastActive - ? _value.lastActive - : lastActive // ignore: cast_nullable_to_non_nullable - as Timestamp, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$UserLoginImpl implements _UserLogin { - const _$UserLoginImpl( - {required this.accountMasterRecordKey, - required this.identitySecret, - required this.accountRecordInfo, - required this.lastActive}); - - factory _$UserLoginImpl.fromJson(Map json) => - _$$UserLoginImplFromJson(json); - -// Master record key for the user used to index the local accounts table - @override - final Typed accountMasterRecordKey; -// The identity secret as unlocked from the local accounts table - @override - final Typed identitySecret; -// The account record key, owner key and secret pulled from the identity - @override - final AccountRecordInfo accountRecordInfo; -// The time this login was most recently used - @override - final Timestamp lastActive; - - @override - String toString() { - return 'UserLogin(accountMasterRecordKey: $accountMasterRecordKey, identitySecret: $identitySecret, accountRecordInfo: $accountRecordInfo, lastActive: $lastActive)'; - } - - @override - bool operator ==(dynamic other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$UserLoginImpl && - (identical(other.accountMasterRecordKey, accountMasterRecordKey) || - other.accountMasterRecordKey == accountMasterRecordKey) && - (identical(other.identitySecret, identitySecret) || - other.identitySecret == identitySecret) && - (identical(other.accountRecordInfo, accountRecordInfo) || - other.accountRecordInfo == accountRecordInfo) && - (identical(other.lastActive, lastActive) || - other.lastActive == lastActive)); - } - - @JsonKey(ignore: true) - @override - int get hashCode => Object.hash(runtimeType, accountMasterRecordKey, - identitySecret, accountRecordInfo, lastActive); - - @JsonKey(ignore: true) - @override - @pragma('vm:prefer-inline') - _$$UserLoginImplCopyWith<_$UserLoginImpl> get copyWith => - __$$UserLoginImplCopyWithImpl<_$UserLoginImpl>(this, _$identity); - - @override - Map toJson() { - return _$$UserLoginImplToJson( - this, - ); - } -} - -abstract class _UserLogin implements UserLogin { - const factory _UserLogin( - {required final Typed accountMasterRecordKey, - required final Typed identitySecret, - required final AccountRecordInfo accountRecordInfo, - required final Timestamp lastActive}) = _$UserLoginImpl; - - factory _UserLogin.fromJson(Map json) = - _$UserLoginImpl.fromJson; - - @override // Master record key for the user used to index the local accounts table - Typed get accountMasterRecordKey; - @override // The identity secret as unlocked from the local accounts table - Typed get identitySecret; - @override // The account record key, owner key and secret pulled from the identity - AccountRecordInfo get accountRecordInfo; - @override // The time this login was most recently used - Timestamp get lastActive; - @override - @JsonKey(ignore: true) - _$$UserLoginImplCopyWith<_$UserLoginImpl> get copyWith => - throw _privateConstructorUsedError; -} - -ActiveLogins _$ActiveLoginsFromJson(Map json) { - return _ActiveLogins.fromJson(json); -} - -/// @nodoc -mixin _$ActiveLogins { -// The list of current logged in accounts - IList get userLogins => - throw _privateConstructorUsedError; // The current selected account indexed by master record key - Typed? get activeUserLogin => - throw _privateConstructorUsedError; - - Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) - $ActiveLoginsCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $ActiveLoginsCopyWith<$Res> { - factory $ActiveLoginsCopyWith( - ActiveLogins value, $Res Function(ActiveLogins) then) = - _$ActiveLoginsCopyWithImpl<$Res, ActiveLogins>; - @useResult - $Res call( - {IList userLogins, - Typed? activeUserLogin}); -} - -/// @nodoc -class _$ActiveLoginsCopyWithImpl<$Res, $Val extends ActiveLogins> - implements $ActiveLoginsCopyWith<$Res> { - _$ActiveLoginsCopyWithImpl(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? userLogins = null, - Object? activeUserLogin = freezed, - }) { - return _then(_value.copyWith( - userLogins: null == userLogins - ? _value.userLogins - : userLogins // ignore: cast_nullable_to_non_nullable - as IList, - activeUserLogin: freezed == activeUserLogin - ? _value.activeUserLogin - : activeUserLogin // ignore: cast_nullable_to_non_nullable - as Typed?, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$ActiveLoginsImplCopyWith<$Res> - implements $ActiveLoginsCopyWith<$Res> { - factory _$$ActiveLoginsImplCopyWith( - _$ActiveLoginsImpl value, $Res Function(_$ActiveLoginsImpl) then) = - __$$ActiveLoginsImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {IList userLogins, - Typed? activeUserLogin}); -} - -/// @nodoc -class __$$ActiveLoginsImplCopyWithImpl<$Res> - extends _$ActiveLoginsCopyWithImpl<$Res, _$ActiveLoginsImpl> - implements _$$ActiveLoginsImplCopyWith<$Res> { - __$$ActiveLoginsImplCopyWithImpl( - _$ActiveLoginsImpl _value, $Res Function(_$ActiveLoginsImpl) _then) - : super(_value, _then); - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? userLogins = null, - Object? activeUserLogin = freezed, - }) { - return _then(_$ActiveLoginsImpl( - userLogins: null == userLogins - ? _value.userLogins - : userLogins // ignore: cast_nullable_to_non_nullable - as IList, - activeUserLogin: freezed == activeUserLogin - ? _value.activeUserLogin - : activeUserLogin // ignore: cast_nullable_to_non_nullable - as Typed?, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$ActiveLoginsImpl implements _ActiveLogins { - const _$ActiveLoginsImpl({required this.userLogins, this.activeUserLogin}); - - factory _$ActiveLoginsImpl.fromJson(Map json) => - _$$ActiveLoginsImplFromJson(json); - -// The list of current logged in accounts - @override - final IList userLogins; -// The current selected account indexed by master record key - @override - final Typed? activeUserLogin; - - @override - String toString() { - return 'ActiveLogins(userLogins: $userLogins, activeUserLogin: $activeUserLogin)'; - } - - @override - bool operator ==(dynamic other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$ActiveLoginsImpl && - const DeepCollectionEquality() - .equals(other.userLogins, userLogins) && - (identical(other.activeUserLogin, activeUserLogin) || - other.activeUserLogin == activeUserLogin)); - } - - @JsonKey(ignore: true) - @override - int get hashCode => Object.hash(runtimeType, - const DeepCollectionEquality().hash(userLogins), activeUserLogin); - - @JsonKey(ignore: true) - @override - @pragma('vm:prefer-inline') - _$$ActiveLoginsImplCopyWith<_$ActiveLoginsImpl> get copyWith => - __$$ActiveLoginsImplCopyWithImpl<_$ActiveLoginsImpl>(this, _$identity); - - @override - Map toJson() { - return _$$ActiveLoginsImplToJson( - this, - ); - } -} - -abstract class _ActiveLogins implements ActiveLogins { - const factory _ActiveLogins( - {required final IList userLogins, - final Typed? activeUserLogin}) = _$ActiveLoginsImpl; - - factory _ActiveLogins.fromJson(Map json) = - _$ActiveLoginsImpl.fromJson; - - @override // The list of current logged in accounts - IList get userLogins; - @override // The current selected account indexed by master record key - Typed? get activeUserLogin; - @override - @JsonKey(ignore: true) - _$$ActiveLoginsImplCopyWith<_$ActiveLoginsImpl> get copyWith => - throw _privateConstructorUsedError; -} diff --git a/lib/entities/user_login.g.dart b/lib/entities/user_login.g.dart deleted file mode 100644 index a2b2143..0000000 --- a/lib/entities/user_login.g.dart +++ /dev/null @@ -1,43 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'user_login.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -_$UserLoginImpl _$$UserLoginImplFromJson(Map json) => - _$UserLoginImpl( - accountMasterRecordKey: Typed.fromJson( - json['account_master_record_key']), - identitySecret: - Typed.fromJson(json['identity_secret']), - accountRecordInfo: - AccountRecordInfo.fromJson(json['account_record_info']), - lastActive: Timestamp.fromJson(json['last_active']), - ); - -Map _$$UserLoginImplToJson(_$UserLoginImpl instance) => - { - 'account_master_record_key': instance.accountMasterRecordKey.toJson(), - 'identity_secret': instance.identitySecret.toJson(), - 'account_record_info': instance.accountRecordInfo.toJson(), - 'last_active': instance.lastActive.toJson(), - }; - -_$ActiveLoginsImpl _$$ActiveLoginsImplFromJson(Map json) => - _$ActiveLoginsImpl( - userLogins: IList.fromJson( - json['user_logins'], (value) => UserLogin.fromJson(value)), - activeUserLogin: json['active_user_login'] == null - ? null - : Typed.fromJson(json['active_user_login']), - ); - -Map _$$ActiveLoginsImplToJson(_$ActiveLoginsImpl instance) => - { - 'user_logins': instance.userLogins.toJson( - (value) => value.toJson(), - ), - 'active_user_login': instance.activeUserLogin?.toJson(), - }; diff --git a/lib/init.dart b/lib/init.dart new file mode 100644 index 0000000..958cd08 --- /dev/null +++ b/lib/init.dart @@ -0,0 +1,61 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import 'account_manager/account_manager.dart'; +import 'app.dart'; +import 'tools/tools.dart'; +import 'veilid_processor/veilid_processor.dart'; + +List rootAssets = []; + +class VeilidChatGlobalInit { + VeilidChatGlobalInit._(); + + // Initialize Veilid + Future _initializeVeilid() async { + // Init Veilid + try { + Veilid.instance.initializeVeilidCore( + await getDefaultVeilidPlatformConfig(kIsWeb, VeilidChatApp.name)); + } on VeilidAPIExceptionAlreadyInitialized { + log.debug('Already initialized, not reinitializing veilid-core'); + } + + // Veilid logging + initVeilidLog(kIsDebugMode); + + // Startup Veilid + await ProcessorRepository.instance.startup(); + + // DHT Record Pool + await DHTRecordPool.init( + logger: (message) => log.debug('DHTRecordPool: $message')); + } + + // Initialize repositories + Future _initializeRepositories() async { + await AccountRepository.instance.init(); + } + + // Initialize asset manifest + static Future loadAssetManifest() async { + final assetManifest = await AssetManifest.loadFromAssetBundle(rootBundle); + rootAssets = assetManifest.listAssets(); + } + + static Future initialize() async { + final veilidChatGlobalInit = VeilidChatGlobalInit._(); + + await loadAssetManifest(); + + log.info('Initializing Veilid'); + await veilidChatGlobalInit._initializeVeilid(); + log.info('Initializing Repositories'); + await veilidChatGlobalInit._initializeRepositories(); + + return veilidChatGlobalInit; + } +} diff --git a/lib/keyboard_shortcuts.dart b/lib/keyboard_shortcuts.dart new file mode 100644 index 0000000..7b952c8 --- /dev/null +++ b/lib/keyboard_shortcuts.dart @@ -0,0 +1,271 @@ +import 'package:animated_theme_switcher/animated_theme_switcher.dart'; +import 'package:async_tools/async_tools.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import 'init.dart'; +import 'router/router.dart'; +import 'settings/settings.dart'; +import 'theme/theme.dart'; +import 'tools/tools.dart'; +import 'veilid_processor/veilid_processor.dart'; + +class ReloadThemeIntent extends Intent { + const ReloadThemeIntent(); +} + +class ChangeBrightnessIntent extends Intent { + const ChangeBrightnessIntent(); +} + +class ChangeColorIntent extends Intent { + const ChangeColorIntent(); +} + +class AttachDetachIntent extends Intent { + const AttachDetachIntent(); +} + +class DeveloperPageIntent extends Intent { + const DeveloperPageIntent(); +} + +class DisplayScaleUpIntent extends Intent { + const DisplayScaleUpIntent(); +} + +class DisplayScaleDownIntent extends Intent { + const DisplayScaleDownIntent(); +} + +class KeyboardShortcuts extends StatelessWidget { + const KeyboardShortcuts({required this.child, super.key}); + + void reloadTheme(BuildContext context) { + singleFuture(this, () async { + log.info('Reloading theme'); + + await VeilidChatGlobalInit.loadAssetManifest(); + + final theme = + PreferencesRepository.instance.value.themePreference.themeData(); + if (context.mounted) { + ThemeSwitcher.of(context).changeTheme(theme: theme); + + // Hack to reload translations + final localizationDelegate = LocalizedApp.of(context).delegate; + await LocalizationDelegate.create( + fallbackLocale: localizationDelegate.fallbackLocale.toString(), + supportedLocales: localizationDelegate.supportedLocales + .map((x) => x.toString()) + .toList()); + } + }); + } + + void _changeBrightness(BuildContext context) { + singleFuture(this, () async { + final prefs = PreferencesRepository.instance.value; + + final oldBrightness = prefs.themePreference.brightnessPreference; + final newBrightness = BrightnessPreference.values[ + (oldBrightness.index + 1) % BrightnessPreference.values.length]; + + log.info('Changing brightness to $newBrightness'); + + final newPrefs = prefs.copyWith( + themePreference: prefs.themePreference + .copyWith(brightnessPreference: newBrightness)); + await PreferencesRepository.instance.set(newPrefs); + + if (context.mounted) { + ThemeSwitcher.of(context) + .changeTheme(theme: newPrefs.themePreference.themeData()); + } + }); + } + + void _changeColor(BuildContext context) { + singleFuture(this, () async { + final prefs = PreferencesRepository.instance.value; + final oldColor = prefs.themePreference.colorPreference; + final newColor = ColorPreference + .values[(oldColor.index + 1) % ColorPreference.values.length]; + + log.info('Changing color to $newColor'); + + final newPrefs = prefs.copyWith( + themePreference: + prefs.themePreference.copyWith(colorPreference: newColor)); + await PreferencesRepository.instance.set(newPrefs); + + if (context.mounted) { + ThemeSwitcher.of(context) + .changeTheme(theme: newPrefs.themePreference.themeData()); + } + }); + } + + void _displayScaleUp(BuildContext context) { + singleFuture(this, () async { + final prefs = PreferencesRepository.instance.value; + final oldIndex = displayScaleToIndex(prefs.themePreference.displayScale); + if (oldIndex == maxDisplayScaleIndex) { + return; + } + final newIndex = oldIndex + 1; + final newDisplayScaleName = indexToDisplayScaleName(newIndex); + + log.info('Changing display scale to $newDisplayScaleName'); + + final newPrefs = prefs.copyWith( + themePreference: prefs.themePreference + .copyWith(displayScale: indexToDisplayScale(newIndex))); + await PreferencesRepository.instance.set(newPrefs); + + if (context.mounted) { + ThemeSwitcher.of(context) + .changeTheme(theme: newPrefs.themePreference.themeData()); + } + }); + } + + void _displayScaleDown(BuildContext context) { + singleFuture(this, () async { + final prefs = PreferencesRepository.instance.value; + final oldIndex = displayScaleToIndex(prefs.themePreference.displayScale); + if (oldIndex == 0) { + return; + } + final newIndex = oldIndex - 1; + final newDisplayScaleName = indexToDisplayScaleName(newIndex); + + log.info('Changing display scale to $newDisplayScaleName'); + + final newPrefs = prefs.copyWith( + themePreference: prefs.themePreference + .copyWith(displayScale: indexToDisplayScale(newIndex))); + await PreferencesRepository.instance.set(newPrefs); + + if (context.mounted) { + ThemeSwitcher.of(context) + .changeTheme(theme: newPrefs.themePreference.themeData()); + } + }); + } + + void _attachDetach(BuildContext context) { + singleFuture(this, () async { + if (ProcessorRepository.instance.processorConnectionState.isAttached) { + log.info('Detaching'); + await Veilid.instance.detach(); + } else if (ProcessorRepository + .instance.processorConnectionState.isDetached) { + log.info('Attaching'); + await Veilid.instance.attach(); + } + }); + } + + void _developerPage(BuildContext context) { + singleFuture(this, () async { + final path = GoRouter.of(context).location(); + if (path != '/developer') { + await GoRouterHelper(context).push('/developer'); + } + }); + } + + @override + Widget build(BuildContext context) => ThemeSwitcher( + builder: (context) => Shortcuts( + shortcuts: { + ////////////////////////// Reload Theme + const SingleActivator( + LogicalKeyboardKey.keyR, + control: true, + alt: true, + ): const ReloadThemeIntent(), + ////////////////////////// Switch Brightness + const SingleActivator( + LogicalKeyboardKey.keyB, + control: true, + alt: true, + ): const ChangeBrightnessIntent(), + ////////////////////////// Change Color + const SingleActivator( + LogicalKeyboardKey.keyC, + control: true, + alt: true, + ): const ChangeColorIntent(), + ////////////////////////// Attach/Detach + if (kIsDebugMode) + const SingleActivator( + LogicalKeyboardKey.keyA, + control: true, + alt: true, + ): const AttachDetachIntent(), + ////////////////////////// Show Developer Page + const SingleActivator( + LogicalKeyboardKey.keyD, + control: true, + alt: true, + ): const DeveloperPageIntent(), + ////////////////////////// Display Scale Up + SingleActivator( + LogicalKeyboardKey.equal, + meta: isMac || isiOS, + control: !(isMac || isiOS), + ): const DisplayScaleUpIntent(), + SingleActivator( + LogicalKeyboardKey.equal, + shift: true, + meta: isMac || isiOS, + control: !(isMac || isiOS), + ): const DisplayScaleUpIntent(), + SingleActivator( + LogicalKeyboardKey.add, + shift: true, + meta: isMac || isiOS, + control: !(isMac || isiOS), + ): const DisplayScaleUpIntent(), + SingleActivator( + LogicalKeyboardKey.numpadAdd, + meta: isMac || isiOS, + control: !(isMac || isiOS), + ): const DisplayScaleUpIntent(), + ////////////////////////// Display Scale Down + SingleActivator( + LogicalKeyboardKey.minus, + meta: isMac || isiOS, + control: !(isMac || isiOS), + ): const DisplayScaleDownIntent(), + SingleActivator( + LogicalKeyboardKey.numpadSubtract, + meta: isMac || isiOS, + control: !(isMac || isiOS), + ): const DisplayScaleDownIntent(), + }, + child: Actions(actions: >{ + ReloadThemeIntent: CallbackAction( + onInvoke: (intent) => reloadTheme(context)), + ChangeBrightnessIntent: CallbackAction( + onInvoke: (intent) => _changeBrightness(context)), + ChangeColorIntent: CallbackAction( + onInvoke: (intent) => _changeColor(context)), + AttachDetachIntent: CallbackAction( + onInvoke: (intent) => _attachDetach(context)), + DeveloperPageIntent: CallbackAction( + onInvoke: (intent) => _developerPage(context)), + DisplayScaleUpIntent: CallbackAction( + onInvoke: (intent) => _displayScaleUp(context)), + DisplayScaleDownIntent: CallbackAction( + onInvoke: (intent) => _displayScaleDown(context)), + }, child: Focus(autofocus: true, child: child)))); + + ///////////////////////////////////////////////////////// + + final Widget child; +} diff --git a/lib/layout/default_app_bar.dart b/lib/layout/default_app_bar.dart new file mode 100644 index 0000000..d13fec9 --- /dev/null +++ b/lib/layout/default_app_bar.dart @@ -0,0 +1,28 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import '../theme/theme.dart'; + +class DefaultAppBar extends AppBar { + DefaultAppBar( + {required BuildContext context, + super.title, + super.flexibleSpace, + super.key, + Widget? leading, + super.actions}) + : super( + toolbarHeight: 48.scaled(context), + leadingWidth: 40.scaled(context), + leading: leading ?? + Container( + margin: const EdgeInsets.all(4).scaled(context), + decoration: BoxDecoration( + color: Colors.black.withAlpha(32), + shape: BoxShape.circle), + child: SvgPicture.asset('assets/images/vlogo.svg', + width: 24.scaled(context), + height: 24.scaled(context)) + .paddingAll(4.scaled(context)))); +} diff --git a/lib/layout/home/drawer_menu/drawer_menu.dart b/lib/layout/home/drawer_menu/drawer_menu.dart new file mode 100644 index 0000000..33f2bc2 --- /dev/null +++ b/lib/layout/home/drawer_menu/drawer_menu.dart @@ -0,0 +1,422 @@ +import 'package:async_tools/async_tools.dart'; +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:go_router/go_router.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../../account_manager/account_manager.dart'; +import '../../../keyboard_shortcuts.dart'; +import '../../../theme/theme.dart'; +import '../../../tools/tools.dart'; +import '../../../veilid_processor/veilid_processor.dart'; +import 'menu_item_widget.dart'; + +const _scaleKind = ScaleKind.secondary; + +class DrawerMenu extends StatefulWidget { + const DrawerMenu({super.key}); + + @override + State createState() => _DrawerMenuState(); +} + +class _DrawerMenuState extends State { + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + void _doSwitchClick(TypedKey superIdentityRecordKey) { + singleFuture(this, () async { + await AccountRepository.instance.switchToAccount(superIdentityRecordKey); + }); + } + + void _doEditClick(TypedKey superIdentityRecordKey, + AccountSpec existingAccount, OwnedDHTRecordPointer accountRecord) { + singleFuture(this, () async { + await GoRouterHelper(context).push('/edit_account', + extra: [superIdentityRecordKey, existingAccount, accountRecord]); + }); + } + + Widget _wrapInBox( + {required Widget child, + required Color color, + required double borderRadius}) => + DecoratedBox( + decoration: ShapeDecoration( + color: color, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(borderRadius))), + child: child); + + Widget _makeAccountWidget( + {required String name, + required bool selected, + required ScaleColor scale, + required ScaleConfig scaleConfig, + required bool loggedIn, + required void Function()? callback, + required void Function()? footerCallback}) { + final theme = Theme.of(context); + + late final Color background; + late final Color hoverBackground; + late final Color activeBackground; + late final Color border; + late final Color hoverBorder; + late final Color activeBorder; + if (scaleConfig.useVisualIndicators && !scaleConfig.preferBorders) { + background = loggedIn ? scale.border : scale.subtleBorder; + hoverBackground = background; + activeBackground = background; + border = + selected ? scale.activeElementBackground : scale.elementBackground; + hoverBorder = border; + activeBorder = border; + } else { + background = selected + ? scale.elementBackground + : scale.elementBackground.withAlpha(128); + hoverBackground = scale.hoverElementBackground; + activeBackground = scale.activeElementBackground; + border = loggedIn ? scale.border : scale.subtleBorder; + hoverBorder = scale.hoverBorder; + activeBorder = scale.primary; + } + + final avatar = StyledAvatar( + name: name, + size: 34.scaled(context), + ); + + return AnimatedPadding( + padding: EdgeInsets.fromLTRB(selected ? 0 : 8, selected ? 0 : 2, + selected ? 0 : 8, selected ? 0 : 2) + .scaled(context), + duration: const Duration(milliseconds: 50), + child: MenuItemWidget( + title: name, + headerWidget: avatar, + titleStyle: theme.textTheme.titleSmall! + .copyWith(color: scaleConfig.useVisualIndicators ? border : null), + foregroundColor: scale.primary, + backgroundColor: background, + backgroundHoverColor: hoverBackground, + backgroundFocusColor: activeBackground, + borderColor: + (scaleConfig.preferBorders || scaleConfig.useVisualIndicators) + ? border + : null, + borderHoverColor: + (scaleConfig.preferBorders || scaleConfig.useVisualIndicators) + ? hoverBorder + : null, + borderFocusColor: + (scaleConfig.preferBorders || scaleConfig.useVisualIndicators) + ? activeBorder + : null, + borderRadius: 12 * scaleConfig.borderRadiusScale, + callback: callback, + footerButtonIcon: loggedIn ? Icons.edit_outlined : null, + footerCallback: footerCallback, + footerButtonIconColor: + scaleConfig.preferBorders ? scale.border : scale.borderText, + footerButtonIconHoverColor: + (scaleConfig.preferBorders || scaleConfig.useVisualIndicators) + ? null + : hoverBorder, + footerButtonIconFocusColor: + (scaleConfig.preferBorders || scaleConfig.useVisualIndicators) + ? null + : activeBorder, + minHeight: 48.scaled(context), + )); + } + + List _getAccountList( + {required IList localAccounts, + required TypedKey? activeLocalAccount, + required PerAccountCollectionBlocMapState + perAccountCollectionBlocMapState}) { + final theme = Theme.of(context); + final scaleScheme = theme.extension()!; + final scaleConfig = theme.extension()!; + final scale = scaleScheme.scale(_scaleKind); + + final loggedInAccounts = []; + final loggedOutAccounts = []; + + for (final la in localAccounts) { + final superIdentityRecordKey = la.superIdentity.recordKey; + + // See if this account is logged in + final perAccountState = + perAccountCollectionBlocMapState.get(superIdentityRecordKey); + final avAccountRecordState = perAccountState?.avAccountRecordState; + if (perAccountState != null && avAccountRecordState != null) { + // Account is logged in + final loggedInAccount = avAccountRecordState.when( + data: (value) => _makeAccountWidget( + name: value.profile.name, + scale: scale, + scaleConfig: scaleConfig, + selected: superIdentityRecordKey == activeLocalAccount, + loggedIn: true, + callback: () { + _doSwitchClick(superIdentityRecordKey); + }, + footerCallback: () { + _doEditClick( + superIdentityRecordKey, + AccountSpec.fromProto(value), + perAccountState.accountInfo.userLogin!.accountRecordInfo + .accountRecord); + }), + loading: () => _wrapInBox( + child: buildProgressIndicator(), + color: scaleScheme.grayScale.subtleBorder, + borderRadius: 12 * scaleConfig.borderRadiusScale), + error: (err, st) => _wrapInBox( + child: errorPage(err, st), + color: scaleScheme.errorScale.subtleBorder, + borderRadius: 12 * scaleConfig.borderRadiusScale), + ); + loggedInAccounts + .add(loggedInAccount.paddingLTRB(0, 0, 0, 8.scaled(context))); + } else { + // Account is not logged in + final scale = theme.extension()!.grayScale; + final loggedOutAccount = _makeAccountWidget( + name: la.name, + scale: scale, + scaleConfig: scaleConfig, + selected: superIdentityRecordKey == activeLocalAccount, + loggedIn: false, + callback: () => {_doSwitchClick(superIdentityRecordKey)}, + footerCallback: null, + ); + loggedOutAccounts.add(loggedOutAccount); + } + } + + // Assemble main menu + return [...loggedInAccounts, ...loggedOutAccounts]; + } + + Widget _getButton( + {required Icon icon, + required ScaleColor scale, + required ScaleConfig scaleConfig, + required String tooltip, + required void Function()? onPressed}) { + late final Color background; + late final Color hoverBackground; + late final Color activeBackground; + late final Color border; + late final Color hoverBorder; + late final Color activeBorder; + if (scaleConfig.useVisualIndicators && !scaleConfig.preferBorders) { + background = scale.border; + hoverBackground = scale.hoverBorder; + activeBackground = scale.primary; + border = scale.elementBackground; + hoverBorder = scale.hoverElementBackground; + activeBorder = scale.activeElementBackground; + } else { + background = scale.elementBackground; + hoverBackground = scale.hoverElementBackground; + activeBackground = scale.activeElementBackground; + border = scale.border; + hoverBorder = scale.hoverBorder; + activeBorder = scale.primary; + } + return IconButton( + icon: icon, + padding: const EdgeInsets.all(12), + color: border, + style: ButtonStyle( + backgroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.hovered)) { + return hoverBackground; + } + if (states.contains(WidgetState.focused)) { + return activeBackground; + } + return background; + }), shape: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.hovered)) { + return RoundedRectangleBorder( + side: BorderSide(color: hoverBorder, width: 2), + borderRadius: BorderRadius.all( + Radius.circular(12 * scaleConfig.borderRadiusScale))); + } + if (states.contains(WidgetState.focused)) { + return RoundedRectangleBorder( + side: BorderSide(color: activeBorder, width: 2), + borderRadius: BorderRadius.all( + Radius.circular(12 * scaleConfig.borderRadiusScale))); + } + return RoundedRectangleBorder( + side: BorderSide(color: border, width: 2), + borderRadius: BorderRadius.all( + Radius.circular(12 * scaleConfig.borderRadiusScale))); + })), + tooltip: tooltip, + onPressed: onPressed); + } + + Widget _getBottomButtons() { + final theme = Theme.of(context); + final scaleScheme = theme.extension()!; + final scaleConfig = theme.extension()!; + final scale = scaleScheme.scale(_scaleKind); + + final settingsButton = _getButton( + icon: const Icon( + Icons.settings, + applyTextScaling: true, + ), + tooltip: translate('menu.settings_tooltip'), + scale: scale, + scaleConfig: scaleConfig, + onPressed: () async { + await GoRouterHelper(context).push('/settings'); + }).paddingLTRB(0, 0, 16, 0); + + final addButton = _getButton( + icon: const Icon( + Icons.add, + applyTextScaling: true, + ), + tooltip: translate('menu.add_account_tooltip'), + scale: scale, + scaleConfig: scaleConfig, + onPressed: () async { + await GoRouterHelper(context).push('/new_account'); + }).paddingLTRB(0, 0, 16, 0); + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [settingsButton, addButton]).paddingLTRB(0, 16, 0, 16); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scaleScheme = theme.extension()!; + final scaleConfig = theme.extension()!; + final scale = scaleScheme.scale(_scaleKind); + //final textTheme = theme.textTheme; + final localAccounts = context.watch().state; + final perAccountCollectionBlocMapState = + context.watch().state; + final activeLocalAccount = context.watch().state; + final gradient = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + scale.border, + scale.subtleBorder, + ]); + + Widget menu = DecoratedBox( + decoration: ShapeDecoration( + shadows: themedShadow(scaleConfig, scale), + gradient: scaleConfig.useVisualIndicators ? null : gradient, + color: scaleConfig.useVisualIndicators + ? (scaleConfig.preferBorders + ? scale.appBackground + : scale.subtleBorder) + : null, + shape: RoundedRectangleBorder( + side: scaleConfig.preferBorders + ? BorderSide(color: scale.primary, width: 2) + : BorderSide.none, + borderRadius: BorderRadius.only( + topRight: Radius.circular(16 * scaleConfig.borderRadiusScale), + bottomRight: + Radius.circular(16 * scaleConfig.borderRadiusScale)))), + child: Column(children: [ + FittedBox( + fit: BoxFit.scaleDown, + child: ColorFiltered( + colorFilter: ColorFilter.mode( + theme.brightness == Brightness.light + ? scale.primary + : scale.border, + scaleConfig.preferBorders + ? BlendMode.modulate + : BlendMode.dst), + child: Row(children: [ + // SvgPicture.asset( + // height: 48, + // 'assets/images/icon.svg', + // colorFilter: scaleConfig.useVisualIndicators + // ? grayColorFilter + // : null) + // .paddingLTRB(0, 0, 16, 0), + GestureDetector( + onLongPress: () { + context + .findAncestorWidgetOfExactType()! + .reloadTheme(context); + }, + child: SvgPicture.asset( + height: 48, + 'assets/images/title.svg', + colorFilter: scaleConfig.useVisualIndicators + ? grayColorFilter + : src96StencilFilter)), + ]))), + Text(translate('menu.accounts'), + style: theme.textTheme.titleMedium!.copyWith( + color: scaleConfig.preferBorders + ? scale.border + : scale.borderText)) + .paddingLTRB(0, 16, 0, 16), + ListView( + shrinkWrap: true, + children: _getAccountList( + localAccounts: localAccounts, + activeLocalAccount: activeLocalAccount, + perAccountCollectionBlocMapState: + perAccountCollectionBlocMapState)) + .expanded(), + _getBottomButtons(), + Row(children: [ + Text('${translate('menu.version')} $packageInfoVersion', + style: theme.textTheme.labelMedium!.copyWith( + color: scaleConfig.preferBorders + ? scale.hoverBorder + : scale.subtleBackground)), + const Spacer(), + SignalStrengthMeterWidget( + color: scaleConfig.preferBorders + ? scale.hoverBorder + : scale.subtleBackground, + inactiveColor: scaleConfig.preferBorders + ? scale.border + : scale.elementBackground, + ), + ]) + ]).paddingAll(16), + ); + + if (scaleConfig.preferBorders || scaleConfig.useVisualIndicators) { + menu = menu.paddingLTRB(0, 2, 2, 2); + } + + return menu; + } +} diff --git a/lib/layout/home/drawer_menu/menu_item_widget.dart b/lib/layout/home/drawer_menu/menu_item_widget.dart new file mode 100644 index 0000000..80e466b --- /dev/null +++ b/lib/layout/home/drawer_menu/menu_item_widget.dart @@ -0,0 +1,141 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../../theme/views/preferences/preferences.dart'; + +class MenuItemWidget extends StatelessWidget { + const MenuItemWidget({ + required this.title, + required this.titleStyle, + required this.foregroundColor, + this.headerWidget, + this.widthBox, + this.callback, + this.backgroundColor, + this.backgroundHoverColor, + this.backgroundFocusColor, + this.borderColor, + this.borderHoverColor, + this.borderFocusColor, + this.borderRadius, + this.footerButtonIcon, + this.footerButtonIconColor, + this.footerButtonIconHoverColor, + this.footerButtonIconFocusColor, + this.footerCallback, + this.minHeight = 0, + super.key, + }); + + @override + Widget build(BuildContext context) => TextButton( + onPressed: callback, + style: TextButton.styleFrom(foregroundColor: foregroundColor).copyWith( + backgroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.hovered)) { + return backgroundHoverColor; + } + if (states.contains(WidgetState.focused)) { + return backgroundFocusColor; + } + return backgroundColor; + }), + overlayColor: + WidgetStateProperty.resolveWith((states) => backgroundHoverColor), + side: WidgetStateBorderSide.resolveWith((states) { + if (states.contains(WidgetState.hovered)) { + return borderColor != null + ? BorderSide(width: 2, color: borderHoverColor!) + : null; + } + if (states.contains(WidgetState.focused)) { + return borderColor != null + ? BorderSide(width: 2, color: borderFocusColor!) + : null; + } + return borderColor != null + ? BorderSide(width: 2, color: borderColor!) + : null; + }), + shape: WidgetStateProperty.all(RoundedRectangleBorder( + borderRadius: BorderRadius.circular(borderRadius ?? 0)))), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: minHeight), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (headerWidget != null) headerWidget!, + if (widthBox != null) widthBox!, + Expanded( + child: FittedBox( + alignment: Alignment.centerLeft, + fit: BoxFit.scaleDown, + child: Text( + title, + style: titleStyle, + ).paddingAll(8)), + ), + if (footerButtonIcon != null) + IconButton( + color: footerButtonIconColor, + focusColor: footerButtonIconFocusColor, + hoverColor: footerButtonIconHoverColor, + icon: Icon( + footerButtonIcon, + size: 24.scaled(context), + ), + onPressed: footerCallback), + ], + ).paddingAll(2), + )); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('textStyle', titleStyle)) + ..add(ObjectFlagProperty.has('callback', callback)) + ..add(DiagnosticsProperty('foregroundColor', foregroundColor)) + ..add(StringProperty('title', title)) + ..add( + DiagnosticsProperty('footerButtonIcon', footerButtonIcon)) + ..add(ObjectFlagProperty.has( + 'footerCallback', footerCallback)) + ..add(ColorProperty('footerButtonIconColor', footerButtonIconColor)) + ..add(ColorProperty( + 'footerButtonIconHoverColor', footerButtonIconHoverColor)) + ..add(ColorProperty( + 'footerButtonIconFocusColor', footerButtonIconFocusColor)) + ..add(ColorProperty('backgroundColor', backgroundColor)) + ..add(ColorProperty('backgroundHoverColor', backgroundHoverColor)) + ..add(ColorProperty('backgroundFocusColor', backgroundFocusColor)) + ..add(ColorProperty('borderColor', borderColor)) + ..add(DoubleProperty('borderRadius', borderRadius)) + ..add(ColorProperty('borderHoverColor', borderHoverColor)) + ..add(ColorProperty('borderFocusColor', borderFocusColor)) + ..add(DoubleProperty('minHeight', minHeight)); + } + + //////////////////////////////////////////////////////////////////////////// + + final String title; + final Widget? headerWidget; + final Widget? widthBox; + final TextStyle titleStyle; + final Color foregroundColor; + final void Function()? callback; + final IconData? footerButtonIcon; + final void Function()? footerCallback; + final Color? backgroundColor; + final Color? backgroundHoverColor; + final Color? backgroundFocusColor; + final Color? borderColor; + final double? borderRadius; + final Color? borderHoverColor; + final Color? borderFocusColor; + final Color? footerButtonIconColor; + final Color? footerButtonIconHoverColor; + final Color? footerButtonIconFocusColor; + final double minHeight; +} diff --git a/lib/layout/home/home.dart b/lib/layout/home/home.dart new file mode 100644 index 0000000..741483b --- /dev/null +++ b/lib/layout/home/home.dart @@ -0,0 +1,7 @@ +export 'drawer_menu/drawer_menu.dart'; +export 'home_account_invalid.dart'; +export 'home_account_locked.dart'; +export 'home_account_missing.dart'; +export 'home_account_ready.dart'; +export 'home_no_active.dart'; +export 'home_screen.dart'; diff --git a/lib/layout/home/home_account_invalid.dart b/lib/layout/home/home_account_invalid.dart new file mode 100644 index 0000000..bf11735 --- /dev/null +++ b/lib/layout/home/home_account_invalid.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +class HomeAccountInvalid extends StatefulWidget { + const HomeAccountInvalid({super.key}); + + @override + HomeAccountInvalidState createState() => HomeAccountInvalidState(); +} + +class HomeAccountInvalidState extends State { + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) => const Text('Account invalid'); +} +// xxx: delete invalid account + // Future.delayed(0.ms, () async { + // await showErrorModal(context, translate('home.invalid_account_title'), + // translate('home.invalid_account_text')); + // // Delete account + // await AccountRepository.instance.deleteLocalAccount(activeUserLogin); + // // Switch to no active user login + // await AccountRepository.instance.switchToAccount(null); + // }); diff --git a/lib/layout/home/home_account_locked.dart b/lib/layout/home/home_account_locked.dart new file mode 100644 index 0000000..0b8a4f7 --- /dev/null +++ b/lib/layout/home/home_account_locked.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +class HomeAccountLocked extends StatefulWidget { + const HomeAccountLocked({super.key}); + + @override + HomeAccountLockedState createState() => HomeAccountLockedState(); +} + +class HomeAccountLockedState extends State { + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) => const Text('Account locked'); +} diff --git a/lib/layout/home/home_account_missing.dart b/lib/layout/home/home_account_missing.dart new file mode 100644 index 0000000..a2e4db4 --- /dev/null +++ b/lib/layout/home/home_account_missing.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +class HomeAccountMissing extends StatefulWidget { + const HomeAccountMissing({super.key}); + + @override + HomeAccountMissingState createState() => HomeAccountMissingState(); +} + +class HomeAccountMissingState extends State { + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) => const Text('Account missing'); +} diff --git a/lib/layout/home/home_account_ready.dart b/lib/layout/home/home_account_ready.dart new file mode 100644 index 0000000..a829248 --- /dev/null +++ b/lib/layout/home/home_account_ready.dart @@ -0,0 +1,225 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:flutter_zoom_drawer/flutter_zoom_drawer.dart'; + +import '../../account_manager/account_manager.dart'; +import '../../chat/chat.dart'; +import '../../chat_list/chat_list.dart'; +import '../../contacts/contacts.dart'; +import '../../proto/proto.dart' as proto; +import '../../theme/theme.dart'; + +class HomeAccountReady extends StatefulWidget { + const HomeAccountReady({super.key}); + + @override + State createState() => _HomeAccountReadyState(); +} + +class _HomeAccountReadyState extends State { + @override + void initState() { + super.initState(); + } + + Widget buildMenuButton() => Builder(builder: (context) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + return AspectRatio( + aspectRatio: 1, + child: IconButton( + icon: Icon( + size: 32.scaled(context), + Icons.menu, + ), + color: scaleConfig.preferBorders + ? scale.primaryScale.border + : scale.primaryScale.borderText, + style: ButtonStyle( + backgroundColor: WidgetStateProperty.all( + scaleConfig.preferBorders + ? scale.primaryScale.hoverElementBackground + : scale.primaryScale.hoverBorder), + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + side: !scaleConfig.useVisualIndicators + ? BorderSide.none + : BorderSide( + strokeAlign: BorderSide.strokeAlignCenter, + color: scaleConfig.preferBorders + ? scale.primaryScale.border + : scale.primaryScale.borderText, + width: 2), + borderRadius: BorderRadius.all(Radius.circular( + 8 * scaleConfig.borderRadiusScale))), + )), + tooltip: translate('menu.accounts_menu_tooltip'), + onPressed: () async { + final ctrl = context.read(); + await ctrl.toggle?.call(); + })); + }); + + Widget buildContactsButton() => Builder(builder: (context) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + return AspectRatio( + aspectRatio: 1, + child: IconButton( + icon: Icon( + size: 32.scaled(context), + Icons.contacts, + ), + color: scaleConfig.preferBorders + ? scale.primaryScale.border + : scale.primaryScale.borderText, + style: ButtonStyle( + backgroundColor: WidgetStateProperty.all( + scaleConfig.preferBorders + ? scale.primaryScale.hoverElementBackground + : scale.primaryScale.hoverBorder), + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + side: !scaleConfig.useVisualIndicators + ? BorderSide.none + : BorderSide( + strokeAlign: BorderSide.strokeAlignCenter, + color: scaleConfig.preferBorders + ? scale.primaryScale.border + : scale.primaryScale.borderText, + width: 2), + borderRadius: BorderRadius.all(Radius.circular( + 8 * scaleConfig.borderRadiusScale))), + )), + tooltip: translate('menu.contacts_tooltip'), + onPressed: () async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const ContactsPage(), + ), + ); + })); + }); + + Widget buildLeftPane(BuildContext context) => Builder( + builder: (context) => Material( + color: Colors.transparent, + child: Builder(builder: (context) { + final profile = context.select( + (c) => c.state.asData!.value.profile); + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + + return ColoredBox( + color: scaleConfig.preferBorders + ? scale.primaryScale.subtleBackground + : scale.primaryScale.subtleBorder, + child: Column(children: [ + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + buildMenuButton().paddingLTRB(0, 0, 8, 0), + ProfileWidget( + profile: profile, + ).expanded(), + buildContactsButton().paddingLTRB(8, 0, 0, 0), + ])).paddingAll(8), + const ChatListWidget().expanded() + ])); + }))); + + Widget buildRightPane(BuildContext context) { + final activeChatCubit = context.watch(); + final activeChatLocalConversationKey = activeChatCubit.state; + if (activeChatLocalConversationKey == null) { + return const NoConversationWidget(); + } + return Material( + color: Colors.transparent, + child: Builder( + builder: (context) => ChatComponentWidget.singleContact( + context: context, + localConversationRecordKey: activeChatLocalConversationKey, + onCancel: () { + activeChatCubit.setActiveChat(null); + }, + onClose: () { + activeChatCubit.setActiveChat(null); + }, + key: ValueKey(activeChatLocalConversationKey)))); + } + + @override + Widget build(BuildContext context) { + final isSmallScreen = isMobileSize(context); + + final theme = Theme.of(context); + final scaleScheme = theme.extension()!; + final scaleConfig = theme.extension()!; + final scale = scaleScheme.scale(ScaleKind.primary); + + final activeChat = context.watch().state; + final hasActiveChat = activeChat != null; + + return LayoutBuilder(builder: (context, constraints) { + const leftColumnSize = 320.0; + + late final bool visibleLeft; + late final bool visibleRight; + late final double leftWidth; + late final double rightWidth; + if (isSmallScreen) { + if (hasActiveChat) { + visibleLeft = false; + visibleRight = true; + leftWidth = leftColumnSize; + rightWidth = constraints.maxWidth; + } else { + visibleLeft = true; + visibleRight = false; + leftWidth = constraints.maxWidth; + rightWidth = 400; // whatever + } + } else { + visibleLeft = true; + visibleRight = true; + leftWidth = leftColumnSize; + rightWidth = constraints.maxWidth - + leftColumnSize - + (scaleConfig.useVisualIndicators ? 2 : 0); + } + + return Row(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + Offstage( + offstage: !visibleLeft, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: leftWidth), + child: buildLeftPane(context))) + .withThemedShadow(scaleConfig, scale), + if (scaleConfig.useVisualIndicators) + Offstage( + offstage: !(visibleLeft && visibleRight), + child: SizedBox( + width: 2, + height: double.infinity, + child: ColoredBox( + color: scaleConfig.preferBorders + ? scale.subtleBorder + : scale.subtleBackground))), + Offstage( + offstage: !visibleRight, + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: constraints.maxHeight, maxWidth: rightWidth), + child: buildRightPane(context), + )), + ]); + }); + } +} diff --git a/lib/layout/home/home_no_active.dart b/lib/layout/home/home_no_active.dart new file mode 100644 index 0000000..b2671dc --- /dev/null +++ b/lib/layout/home/home_no_active.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; + +import '../../theme/theme.dart'; + +class HomeNoActive extends StatefulWidget { + const HomeNoActive({super.key}); + + @override + HomeNoActiveState createState() => HomeNoActiveState(); +} + +class HomeNoActiveState extends State { + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) => waitingPage(); +} diff --git a/lib/layout/home/home_screen.dart b/lib/layout/home/home_screen.dart new file mode 100644 index 0000000..2aefcd3 --- /dev/null +++ b/lib/layout/home/home_screen.dart @@ -0,0 +1,244 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:flutter_zoom_drawer/flutter_zoom_drawer.dart'; +import 'package:provider/provider.dart'; +import 'package:transitioned_indexed_stack/transitioned_indexed_stack.dart'; +import 'package:url_launcher/url_launcher_string.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../account_manager/account_manager.dart'; +import '../../settings/settings.dart'; +import '../../theme/theme.dart'; +import 'drawer_menu/drawer_menu.dart'; +import 'home_account_invalid.dart'; +import 'home_account_locked.dart'; +import 'home_account_missing.dart'; +import 'home_account_ready.dart'; +import 'home_no_active.dart'; + +class HomeScreen extends StatefulWidget { + const HomeScreen({super.key}); + + @override + HomeScreenState createState() => HomeScreenState(); + + static HomeScreenState? of(BuildContext context) => + context.findAncestorStateOfType(); +} + +class HomeScreenState extends State + with SingleTickerProviderStateMixin { + @override + void initState() { + WidgetsBinding.instance.addPostFrameCallback((_) async { + final localAccounts = context.read().state; + final activeLocalAccount = context.read().state; + final activeIndex = localAccounts + .indexWhere((x) => x.superIdentity.recordKey == activeLocalAccount); + final canClose = activeIndex != -1; + + final displayBetaWarning = context + .read() + .state + .asData + ?.value + .notificationsPreference + .displayBetaWarning ?? + true; + if (displayBetaWarning) { + await _doBetaDialog(context); + } + + if (!canClose) { + await _zoomDrawerController.open!(); + } + }); + super.initState(); + } + + Future _doBetaDialog(BuildContext context) async { + var displayBetaWarning = true; + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + + await showAlertWidgetModal( + context: context, + title: translate('splash.beta_title'), + child: Builder( + builder: (context) => + Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon(Icons.warning, size: 64.scaled(context)), + RichText( + textScaler: MediaQuery.of(context).textScaler, + textAlign: TextAlign.center, + text: TextSpan( + children: [ + TextSpan( + text: translate('splash.beta_text'), + style: theme.textTheme.bodyMedium! + .copyWith(color: scale.primaryScale.appText), + ), + TextSpan( + text: 'https://veilid.com/chat/knownissues', + style: theme.textTheme.bodyMedium!.copyWith( + color: scaleConfig.useVisualIndicators + ? scale.secondaryScale.primaryText + : scale.secondaryScale.primary, + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer() + ..onTap = () => launchUrlString( + 'https://veilid.com/chat/knownissues'), + ), + ], + ), + ), + Row(mainAxisSize: MainAxisSize.min, children: [ + StatefulBuilder( + builder: (context, setState) => Checkbox( + value: displayBetaWarning, + onChanged: (value) { + setState(() { + displayBetaWarning = value ?? true; + }); + }, + )), + Text( + translate('settings_page.display_beta_warning'), + style: theme.textTheme.bodyMedium! + .copyWith(color: scale.primaryScale.appText), + ), + ]), + ]), + )); + + final preferencesInstance = PreferencesRepository.instance; + await preferencesInstance.set(preferencesInstance.value.copyWith( + notificationsPreference: preferencesInstance + .value.notificationsPreference + .copyWith(displayBetaWarning: displayBetaWarning))); + } + + Widget _buildAccountPage( + BuildContext context, + TypedKey superIdentityRecordKey, + PerAccountCollectionState perAccountCollectionState) { + switch (perAccountCollectionState.accountInfo.status) { + case AccountInfoStatus.accountInvalid: + return const HomeAccountInvalid(); + case AccountInfoStatus.accountLocked: + return const HomeAccountLocked(); + case AccountInfoStatus.accountUnlocked: + // Are we ready to render? + if (!perAccountCollectionState.isReady) { + return waitingPage(); + } + + // Re-export all ready blocs to the account display subtree + final pages = >[ + const MaterialPage(child: HomeAccountReady()) + ]; + return perAccountCollectionState.provideReady( + child: Navigator(onDidRemovePage: pages.remove, pages: pages)); + } + } + + Widget _applyPageBorder(Widget child) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + + return ValueListenableBuilder( + valueListenable: _zoomDrawerController.stateNotifier!, + child: child, + builder: (context, drawerState, staticChild) => clipBorder( + clipEnabled: drawerState != DrawerState.closed, + borderEnabled: + scaleConfig.preferBorders && scaleConfig.useVisualIndicators, + borderRadius: 16 * scaleConfig.borderRadiusScale, + borderColor: scale.primaryScale.border, + child: staticChild!)); + } + + Widget _buildAccountPageView(BuildContext context) { + final localAccounts = context.watch().state; + final activeLocalAccount = context.watch().state; + final perAccountCollectionBlocMapState = + context.watch().state; + + final activeIndex = localAccounts + .indexWhere((x) => x.superIdentity.recordKey == activeLocalAccount); + if (activeIndex == -1) { + return _applyPageBorder(const HomeNoActive()); + } + + final accountPages = []; + + for (var i = 0; i < localAccounts.length; i++) { + final superIdentityRecordKey = localAccounts[i].superIdentity.recordKey; + final perAccountCollectionState = + perAccountCollectionBlocMapState.get(superIdentityRecordKey); + if (perAccountCollectionState == null) { + return HomeAccountMissing(key: ValueKey(superIdentityRecordKey)); + } + final accountPage = _buildAccountPage( + context, superIdentityRecordKey, perAccountCollectionState); + accountPages.add(_applyPageBorder(accountPage)); + } + + return SlideIndexedStack( + index: activeIndex, + beginSlideOffset: const Offset(1, 0), + children: accountPages, + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + final localAccounts = context.watch().state; + final activeLocalAccount = context.watch().state; + final activeIndex = localAccounts + .indexWhere((x) => x.superIdentity.recordKey == activeLocalAccount); + final canClose = activeIndex != -1; + + final drawer = Scaffold( + backgroundColor: Colors.transparent, + body: ZoomDrawer( + controller: _zoomDrawerController, + menuScreen: Builder(builder: (context) { + final zoomDrawer = ZoomDrawer.of(context); + zoomDrawer!.stateNotifier.addListener(() { + if (zoomDrawer.isOpen()) { + FocusManager.instance.primaryFocus?.unfocus(); + } + }); + return const DrawerMenu(); + }), + mainScreen: Provider.value( + value: _zoomDrawerController, + child: Builder(builder: _buildAccountPageView)), + borderRadius: 0, + angle: 0, + openCurve: Curves.fastEaseInToSlowEaseOut, + closeCurve: Curves.fastEaseInToSlowEaseOut, + menuScreenTapClose: canClose, + mainScreenTapClose: canClose, + disableDragGesture: !canClose, + mainScreenScale: .25, + slideWidth: min(360, MediaQuery.of(context).size.width * 0.9), + )); + + return DefaultTextStyle(style: theme.textTheme.bodySmall!, child: drawer); + } + + //////////////////////////////////////////////////////////////////////////// + + final _zoomDrawerController = ZoomDrawerController(); +} diff --git a/lib/layout/layout.dart b/lib/layout/layout.dart new file mode 100644 index 0000000..a744264 --- /dev/null +++ b/lib/layout/layout.dart @@ -0,0 +1,3 @@ +export 'default_app_bar.dart'; +export 'home/home.dart'; +export 'splash.dart'; diff --git a/lib/layout/splash.dart b/lib/layout/splash.dart new file mode 100644 index 0000000..c3af797 --- /dev/null +++ b/lib/layout/splash.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:radix_colors/radix_colors.dart'; + +import '../tools/tools.dart'; + +class Splash extends StatefulWidget { + const Splash({super.key}); + + @override + State createState() => _SplashState(); +} + +class _SplashState extends WindowSetupState { + _SplashState() + : super( + titleBarStyle: TitleBarStyle.hidden, + orientationCapability: OrientationCapability.portraitOnly); + + @override + Widget build(BuildContext context) => PopScope( + canPop: false, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + RadixColors.dark.plum.step4, + RadixColors.dark.plum.step2, + ])), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 300), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Splash Screen + Expanded( + flex: 2, + child: SvgPicture.asset( + 'assets/images/icon.svg', + )), + Expanded( + child: SvgPicture.asset( + 'assets/images/title.svg', + )) + ]))), + )); +} diff --git a/lib/main.dart b/lib/main.dart index 3644eab..7193e06 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,14 +4,15 @@ import 'dart:io'; import 'package:ansicolor/ansicolor.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_translate/flutter_translate.dart'; import 'package:intl/date_symbol_data_local.dart'; +import 'package:stack_trace/stack_trace.dart'; + import 'app.dart'; -import 'providers/window_control.dart'; +import 'settings/preferences_repository.dart'; +import 'theme/theme.dart'; import 'tools/tools.dart'; -import 'veilid_init.dart'; void main() async { // Disable all debugprints in release mode @@ -27,33 +28,41 @@ void main() async { // Ansi colors ansiColorDisabled = false; - // Catch errors - await runZonedGuarded(() async { + Future mainFunc() async { // Logs initLoggy(); - // Prepare theme + // Prepare preferences from SharedPreferences and theme WidgetsFlutterBinding.ensureInitialized(); - final themeService = await ThemeService.instance; - final initTheme = themeService.initial; + await PreferencesRepository.instance.init(); + final initialThemeData = + PreferencesRepository.instance.value.themePreference.themeData(); // Manage window on desktop platforms - await WindowControl.initialize(); + await initializeWindowControl(); // Make localization delegate - final delegate = await LocalizationDelegate.create( + final localizationDelegate = await LocalizationDelegate.create( fallbackLocale: 'en_US', supportedLocales: ['en_US']); await initializeDateFormatting(); - // Start up Veilid and Veilid processor in the background - unawaited(initializeVeilid()); + // Get package info + await initPackageInfo(); // Run the app // Hot reloads will only restart this part, not Veilid - runApp(ProviderScope( - observers: const [StateLogger()], - child: LocalizedApp(delegate, VeilidChatApp(theme: initTheme)))); - }, (error, stackTrace) { - log.error('Dart Runtime: {$error}\n{$stackTrace}'); - }); + runApp(LocalizedApp(localizationDelegate, + VeilidChatApp(initialThemeData: initialThemeData))); + } + + if (kDebugMode) { + // In debug mode, run the app without catching exceptions for debugging + // but do a much deeper async stack trace capture + await Chain.capture(mainFunc); + } else { + // Catch errors in production without killing the app + await runZonedGuarded(mainFunc, (error, stackTrace) { + log.error('Dart Runtime: {$error}\n{$stackTrace}'); + }); + } } diff --git a/lib/notifications/cubits/notifications_cubit.dart b/lib/notifications/cubits/notifications_cubit.dart new file mode 100644 index 0000000..769e64f --- /dev/null +++ b/lib/notifications/cubits/notifications_cubit.dart @@ -0,0 +1,26 @@ +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../notifications.dart'; + +class NotificationsCubit extends Cubit { + NotificationsCubit(super.initialState); + + void info({required String text, String? title}) { + emit(state.copyWith( + queue: state.queue.add(NotificationItem( + type: NotificationType.info, text: text, title: title)))); + } + + void error({required String text, String? title}) { + emit(state.copyWith( + queue: state.queue.add(NotificationItem( + type: NotificationType.info, text: text, title: title)))); + } + + IList popAll() { + final out = state.queue; + emit(state.copyWith(queue: state.queue.clear())); + return out; + } +} diff --git a/lib/notifications/models/models.dart b/lib/notifications/models/models.dart new file mode 100644 index 0000000..52f3c2b --- /dev/null +++ b/lib/notifications/models/models.dart @@ -0,0 +1,2 @@ +export 'notifications_preference.dart'; +export 'notifications_state.dart'; diff --git a/lib/notifications/models/notifications_preference.dart b/lib/notifications/models/notifications_preference.dart new file mode 100644 index 0000000..913cb25 --- /dev/null +++ b/lib/notifications/models/notifications_preference.dart @@ -0,0 +1,69 @@ +import 'package:change_case/change_case.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'notifications_preference.freezed.dart'; +part 'notifications_preference.g.dart'; + +@freezed +sealed class NotificationsPreference with _$NotificationsPreference { + const factory NotificationsPreference({ + @Default(true) bool displayBetaWarning, + @Default(true) bool enableBadge, + @Default(true) bool enableNotifications, + @Default(MessageNotificationContent.nameAndContent) + MessageNotificationContent messageNotificationContent, + @Default(NotificationMode.inAppOrPush) + NotificationMode onInvitationAcceptedMode, + @Default(SoundEffect.beepBaDeep) SoundEffect onInvitationAcceptedSound, + @Default(NotificationMode.inAppOrPush) + NotificationMode onMessageReceivedMode, + @Default(SoundEffect.boop) SoundEffect onMessageReceivedSound, + @Default(SoundEffect.bonk) SoundEffect onMessageSentSound, + }) = _NotificationsPreference; + + factory NotificationsPreference.fromJson(dynamic json) => + _$NotificationsPreferenceFromJson(json as Map); + + static const NotificationsPreference defaults = NotificationsPreference(); +} + +enum NotificationMode { + none, + inApp, + push, + inAppOrPush; + + factory NotificationMode.fromJson(dynamic j) => + NotificationMode.values.byName((j as String).toCamelCase()); + String toJson() => name.toPascalCase(); + + static const NotificationMode defaults = NotificationMode.none; +} + +enum MessageNotificationContent { + nothing, + nameOnly, + nameAndContent; + + factory MessageNotificationContent.fromJson(dynamic j) => + MessageNotificationContent.values.byName((j as String).toCamelCase()); + String toJson() => name.toPascalCase(); + + static const MessageNotificationContent defaults = + MessageNotificationContent.nothing; +} + +enum SoundEffect { + none, + bonk, + boop, + baDeep, + beepBaDeep, + custom; + + factory SoundEffect.fromJson(dynamic j) => + SoundEffect.values.byName((j as String).toCamelCase()); + String toJson() => name.toPascalCase(); + + static const SoundEffect defaults = SoundEffect.none; +} diff --git a/lib/notifications/models/notifications_preference.freezed.dart b/lib/notifications/models/notifications_preference.freezed.dart new file mode 100644 index 0000000..55f700a --- /dev/null +++ b/lib/notifications/models/notifications_preference.freezed.dart @@ -0,0 +1,364 @@ +// dart format width=80 +// 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 'notifications_preference.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$NotificationsPreference { + bool get displayBetaWarning; + bool get enableBadge; + bool get enableNotifications; + MessageNotificationContent get messageNotificationContent; + NotificationMode get onInvitationAcceptedMode; + SoundEffect get onInvitationAcceptedSound; + NotificationMode get onMessageReceivedMode; + SoundEffect get onMessageReceivedSound; + SoundEffect get onMessageSentSound; + + /// Create a copy of NotificationsPreference + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $NotificationsPreferenceCopyWith get copyWith => + _$NotificationsPreferenceCopyWithImpl( + this as NotificationsPreference, _$identity); + + /// Serializes this NotificationsPreference to a JSON map. + Map toJson(); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is NotificationsPreference && + (identical(other.displayBetaWarning, displayBetaWarning) || + other.displayBetaWarning == displayBetaWarning) && + (identical(other.enableBadge, enableBadge) || + other.enableBadge == enableBadge) && + (identical(other.enableNotifications, enableNotifications) || + other.enableNotifications == enableNotifications) && + (identical(other.messageNotificationContent, + messageNotificationContent) || + other.messageNotificationContent == + messageNotificationContent) && + (identical( + other.onInvitationAcceptedMode, onInvitationAcceptedMode) || + other.onInvitationAcceptedMode == onInvitationAcceptedMode) && + (identical(other.onInvitationAcceptedSound, + onInvitationAcceptedSound) || + other.onInvitationAcceptedSound == onInvitationAcceptedSound) && + (identical(other.onMessageReceivedMode, onMessageReceivedMode) || + other.onMessageReceivedMode == onMessageReceivedMode) && + (identical(other.onMessageReceivedSound, onMessageReceivedSound) || + other.onMessageReceivedSound == onMessageReceivedSound) && + (identical(other.onMessageSentSound, onMessageSentSound) || + other.onMessageSentSound == onMessageSentSound)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + displayBetaWarning, + enableBadge, + enableNotifications, + messageNotificationContent, + onInvitationAcceptedMode, + onInvitationAcceptedSound, + onMessageReceivedMode, + onMessageReceivedSound, + onMessageSentSound); + + @override + String toString() { + return 'NotificationsPreference(displayBetaWarning: $displayBetaWarning, enableBadge: $enableBadge, enableNotifications: $enableNotifications, messageNotificationContent: $messageNotificationContent, onInvitationAcceptedMode: $onInvitationAcceptedMode, onInvitationAcceptedSound: $onInvitationAcceptedSound, onMessageReceivedMode: $onMessageReceivedMode, onMessageReceivedSound: $onMessageReceivedSound, onMessageSentSound: $onMessageSentSound)'; + } +} + +/// @nodoc +abstract mixin class $NotificationsPreferenceCopyWith<$Res> { + factory $NotificationsPreferenceCopyWith(NotificationsPreference value, + $Res Function(NotificationsPreference) _then) = + _$NotificationsPreferenceCopyWithImpl; + @useResult + $Res call( + {bool displayBetaWarning, + bool enableBadge, + bool enableNotifications, + MessageNotificationContent messageNotificationContent, + NotificationMode onInvitationAcceptedMode, + SoundEffect onInvitationAcceptedSound, + NotificationMode onMessageReceivedMode, + SoundEffect onMessageReceivedSound, + SoundEffect onMessageSentSound}); +} + +/// @nodoc +class _$NotificationsPreferenceCopyWithImpl<$Res> + implements $NotificationsPreferenceCopyWith<$Res> { + _$NotificationsPreferenceCopyWithImpl(this._self, this._then); + + final NotificationsPreference _self; + final $Res Function(NotificationsPreference) _then; + + /// Create a copy of NotificationsPreference + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? displayBetaWarning = null, + Object? enableBadge = null, + Object? enableNotifications = null, + Object? messageNotificationContent = null, + Object? onInvitationAcceptedMode = null, + Object? onInvitationAcceptedSound = null, + Object? onMessageReceivedMode = null, + Object? onMessageReceivedSound = null, + Object? onMessageSentSound = null, + }) { + return _then(_self.copyWith( + displayBetaWarning: null == displayBetaWarning + ? _self.displayBetaWarning + : displayBetaWarning // ignore: cast_nullable_to_non_nullable + as bool, + enableBadge: null == enableBadge + ? _self.enableBadge + : enableBadge // ignore: cast_nullable_to_non_nullable + as bool, + enableNotifications: null == enableNotifications + ? _self.enableNotifications + : enableNotifications // ignore: cast_nullable_to_non_nullable + as bool, + messageNotificationContent: null == messageNotificationContent + ? _self.messageNotificationContent + : messageNotificationContent // ignore: cast_nullable_to_non_nullable + as MessageNotificationContent, + onInvitationAcceptedMode: null == onInvitationAcceptedMode + ? _self.onInvitationAcceptedMode + : onInvitationAcceptedMode // ignore: cast_nullable_to_non_nullable + as NotificationMode, + onInvitationAcceptedSound: null == onInvitationAcceptedSound + ? _self.onInvitationAcceptedSound + : onInvitationAcceptedSound // ignore: cast_nullable_to_non_nullable + as SoundEffect, + onMessageReceivedMode: null == onMessageReceivedMode + ? _self.onMessageReceivedMode + : onMessageReceivedMode // ignore: cast_nullable_to_non_nullable + as NotificationMode, + onMessageReceivedSound: null == onMessageReceivedSound + ? _self.onMessageReceivedSound + : onMessageReceivedSound // ignore: cast_nullable_to_non_nullable + as SoundEffect, + onMessageSentSound: null == onMessageSentSound + ? _self.onMessageSentSound + : onMessageSentSound // ignore: cast_nullable_to_non_nullable + as SoundEffect, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _NotificationsPreference implements NotificationsPreference { + const _NotificationsPreference( + {this.displayBetaWarning = true, + this.enableBadge = true, + this.enableNotifications = true, + this.messageNotificationContent = + MessageNotificationContent.nameAndContent, + this.onInvitationAcceptedMode = NotificationMode.inAppOrPush, + this.onInvitationAcceptedSound = SoundEffect.beepBaDeep, + this.onMessageReceivedMode = NotificationMode.inAppOrPush, + this.onMessageReceivedSound = SoundEffect.boop, + this.onMessageSentSound = SoundEffect.bonk}); + factory _NotificationsPreference.fromJson(Map json) => + _$NotificationsPreferenceFromJson(json); + + @override + @JsonKey() + final bool displayBetaWarning; + @override + @JsonKey() + final bool enableBadge; + @override + @JsonKey() + final bool enableNotifications; + @override + @JsonKey() + final MessageNotificationContent messageNotificationContent; + @override + @JsonKey() + final NotificationMode onInvitationAcceptedMode; + @override + @JsonKey() + final SoundEffect onInvitationAcceptedSound; + @override + @JsonKey() + final NotificationMode onMessageReceivedMode; + @override + @JsonKey() + final SoundEffect onMessageReceivedSound; + @override + @JsonKey() + final SoundEffect onMessageSentSound; + + /// Create a copy of NotificationsPreference + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$NotificationsPreferenceCopyWith<_NotificationsPreference> get copyWith => + __$NotificationsPreferenceCopyWithImpl<_NotificationsPreference>( + this, _$identity); + + @override + Map toJson() { + return _$NotificationsPreferenceToJson( + this, + ); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _NotificationsPreference && + (identical(other.displayBetaWarning, displayBetaWarning) || + other.displayBetaWarning == displayBetaWarning) && + (identical(other.enableBadge, enableBadge) || + other.enableBadge == enableBadge) && + (identical(other.enableNotifications, enableNotifications) || + other.enableNotifications == enableNotifications) && + (identical(other.messageNotificationContent, + messageNotificationContent) || + other.messageNotificationContent == + messageNotificationContent) && + (identical( + other.onInvitationAcceptedMode, onInvitationAcceptedMode) || + other.onInvitationAcceptedMode == onInvitationAcceptedMode) && + (identical(other.onInvitationAcceptedSound, + onInvitationAcceptedSound) || + other.onInvitationAcceptedSound == onInvitationAcceptedSound) && + (identical(other.onMessageReceivedMode, onMessageReceivedMode) || + other.onMessageReceivedMode == onMessageReceivedMode) && + (identical(other.onMessageReceivedSound, onMessageReceivedSound) || + other.onMessageReceivedSound == onMessageReceivedSound) && + (identical(other.onMessageSentSound, onMessageSentSound) || + other.onMessageSentSound == onMessageSentSound)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + displayBetaWarning, + enableBadge, + enableNotifications, + messageNotificationContent, + onInvitationAcceptedMode, + onInvitationAcceptedSound, + onMessageReceivedMode, + onMessageReceivedSound, + onMessageSentSound); + + @override + String toString() { + return 'NotificationsPreference(displayBetaWarning: $displayBetaWarning, enableBadge: $enableBadge, enableNotifications: $enableNotifications, messageNotificationContent: $messageNotificationContent, onInvitationAcceptedMode: $onInvitationAcceptedMode, onInvitationAcceptedSound: $onInvitationAcceptedSound, onMessageReceivedMode: $onMessageReceivedMode, onMessageReceivedSound: $onMessageReceivedSound, onMessageSentSound: $onMessageSentSound)'; + } +} + +/// @nodoc +abstract mixin class _$NotificationsPreferenceCopyWith<$Res> + implements $NotificationsPreferenceCopyWith<$Res> { + factory _$NotificationsPreferenceCopyWith(_NotificationsPreference value, + $Res Function(_NotificationsPreference) _then) = + __$NotificationsPreferenceCopyWithImpl; + @override + @useResult + $Res call( + {bool displayBetaWarning, + bool enableBadge, + bool enableNotifications, + MessageNotificationContent messageNotificationContent, + NotificationMode onInvitationAcceptedMode, + SoundEffect onInvitationAcceptedSound, + NotificationMode onMessageReceivedMode, + SoundEffect onMessageReceivedSound, + SoundEffect onMessageSentSound}); +} + +/// @nodoc +class __$NotificationsPreferenceCopyWithImpl<$Res> + implements _$NotificationsPreferenceCopyWith<$Res> { + __$NotificationsPreferenceCopyWithImpl(this._self, this._then); + + final _NotificationsPreference _self; + final $Res Function(_NotificationsPreference) _then; + + /// Create a copy of NotificationsPreference + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? displayBetaWarning = null, + Object? enableBadge = null, + Object? enableNotifications = null, + Object? messageNotificationContent = null, + Object? onInvitationAcceptedMode = null, + Object? onInvitationAcceptedSound = null, + Object? onMessageReceivedMode = null, + Object? onMessageReceivedSound = null, + Object? onMessageSentSound = null, + }) { + return _then(_NotificationsPreference( + displayBetaWarning: null == displayBetaWarning + ? _self.displayBetaWarning + : displayBetaWarning // ignore: cast_nullable_to_non_nullable + as bool, + enableBadge: null == enableBadge + ? _self.enableBadge + : enableBadge // ignore: cast_nullable_to_non_nullable + as bool, + enableNotifications: null == enableNotifications + ? _self.enableNotifications + : enableNotifications // ignore: cast_nullable_to_non_nullable + as bool, + messageNotificationContent: null == messageNotificationContent + ? _self.messageNotificationContent + : messageNotificationContent // ignore: cast_nullable_to_non_nullable + as MessageNotificationContent, + onInvitationAcceptedMode: null == onInvitationAcceptedMode + ? _self.onInvitationAcceptedMode + : onInvitationAcceptedMode // ignore: cast_nullable_to_non_nullable + as NotificationMode, + onInvitationAcceptedSound: null == onInvitationAcceptedSound + ? _self.onInvitationAcceptedSound + : onInvitationAcceptedSound // ignore: cast_nullable_to_non_nullable + as SoundEffect, + onMessageReceivedMode: null == onMessageReceivedMode + ? _self.onMessageReceivedMode + : onMessageReceivedMode // ignore: cast_nullable_to_non_nullable + as NotificationMode, + onMessageReceivedSound: null == onMessageReceivedSound + ? _self.onMessageReceivedSound + : onMessageReceivedSound // ignore: cast_nullable_to_non_nullable + as SoundEffect, + onMessageSentSound: null == onMessageSentSound + ? _self.onMessageSentSound + : onMessageSentSound // ignore: cast_nullable_to_non_nullable + as SoundEffect, + )); + } +} + +// dart format on diff --git a/lib/notifications/models/notifications_preference.g.dart b/lib/notifications/models/notifications_preference.g.dart new file mode 100644 index 0000000..d1d5e89 --- /dev/null +++ b/lib/notifications/models/notifications_preference.g.dart @@ -0,0 +1,50 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'notifications_preference.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_NotificationsPreference _$NotificationsPreferenceFromJson( + Map json) => + _NotificationsPreference( + displayBetaWarning: json['display_beta_warning'] as bool? ?? true, + enableBadge: json['enable_badge'] as bool? ?? true, + enableNotifications: json['enable_notifications'] as bool? ?? true, + messageNotificationContent: json['message_notification_content'] == null + ? MessageNotificationContent.nameAndContent + : MessageNotificationContent.fromJson( + json['message_notification_content']), + onInvitationAcceptedMode: json['on_invitation_accepted_mode'] == null + ? NotificationMode.inAppOrPush + : NotificationMode.fromJson(json['on_invitation_accepted_mode']), + onInvitationAcceptedSound: json['on_invitation_accepted_sound'] == null + ? SoundEffect.beepBaDeep + : SoundEffect.fromJson(json['on_invitation_accepted_sound']), + onMessageReceivedMode: json['on_message_received_mode'] == null + ? NotificationMode.inAppOrPush + : NotificationMode.fromJson(json['on_message_received_mode']), + onMessageReceivedSound: json['on_message_received_sound'] == null + ? SoundEffect.boop + : SoundEffect.fromJson(json['on_message_received_sound']), + onMessageSentSound: json['on_message_sent_sound'] == null + ? SoundEffect.bonk + : SoundEffect.fromJson(json['on_message_sent_sound']), + ); + +Map _$NotificationsPreferenceToJson( + _NotificationsPreference instance) => + { + 'display_beta_warning': instance.displayBetaWarning, + 'enable_badge': instance.enableBadge, + 'enable_notifications': instance.enableNotifications, + 'message_notification_content': + instance.messageNotificationContent.toJson(), + 'on_invitation_accepted_mode': instance.onInvitationAcceptedMode.toJson(), + 'on_invitation_accepted_sound': + instance.onInvitationAcceptedSound.toJson(), + 'on_message_received_mode': instance.onMessageReceivedMode.toJson(), + 'on_message_received_sound': instance.onMessageReceivedSound.toJson(), + 'on_message_sent_sound': instance.onMessageSentSound.toJson(), + }; diff --git a/lib/notifications/models/notifications_state.dart b/lib/notifications/models/notifications_state.dart new file mode 100644 index 0000000..6248bba --- /dev/null +++ b/lib/notifications/models/notifications_state.dart @@ -0,0 +1,23 @@ +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'notifications_state.freezed.dart'; + +enum NotificationType { + info, + error, +} + +@freezed +sealed class NotificationItem with _$NotificationItem { + const factory NotificationItem( + {required NotificationType type, + required String text, + String? title}) = _NotificationItem; +} + +@freezed +sealed class NotificationsState with _$NotificationsState { + const factory NotificationsState({required IList queue}) = + _NotificationsState; +} diff --git a/lib/notifications/models/notifications_state.freezed.dart b/lib/notifications/models/notifications_state.freezed.dart new file mode 100644 index 0000000..8633702 --- /dev/null +++ b/lib/notifications/models/notifications_state.freezed.dart @@ -0,0 +1,308 @@ +// dart format width=80 +// 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 'notifications_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$NotificationItem { + NotificationType get type; + String get text; + String? get title; + + /// Create a copy of NotificationItem + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $NotificationItemCopyWith get copyWith => + _$NotificationItemCopyWithImpl( + this as NotificationItem, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is NotificationItem && + (identical(other.type, type) || other.type == type) && + (identical(other.text, text) || other.text == text) && + (identical(other.title, title) || other.title == title)); + } + + @override + int get hashCode => Object.hash(runtimeType, type, text, title); + + @override + String toString() { + return 'NotificationItem(type: $type, text: $text, title: $title)'; + } +} + +/// @nodoc +abstract mixin class $NotificationItemCopyWith<$Res> { + factory $NotificationItemCopyWith( + NotificationItem value, $Res Function(NotificationItem) _then) = + _$NotificationItemCopyWithImpl; + @useResult + $Res call({NotificationType type, String text, String? title}); +} + +/// @nodoc +class _$NotificationItemCopyWithImpl<$Res> + implements $NotificationItemCopyWith<$Res> { + _$NotificationItemCopyWithImpl(this._self, this._then); + + final NotificationItem _self; + final $Res Function(NotificationItem) _then; + + /// Create a copy of NotificationItem + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? type = null, + Object? text = null, + Object? title = freezed, + }) { + return _then(_self.copyWith( + type: null == type + ? _self.type + : type // ignore: cast_nullable_to_non_nullable + as NotificationType, + text: null == text + ? _self.text + : text // ignore: cast_nullable_to_non_nullable + as String, + title: freezed == title + ? _self.title + : title // ignore: cast_nullable_to_non_nullable + as String?, + )); + } +} + +/// @nodoc + +class _NotificationItem implements NotificationItem { + const _NotificationItem({required this.type, required this.text, this.title}); + + @override + final NotificationType type; + @override + final String text; + @override + final String? title; + + /// Create a copy of NotificationItem + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$NotificationItemCopyWith<_NotificationItem> get copyWith => + __$NotificationItemCopyWithImpl<_NotificationItem>(this, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _NotificationItem && + (identical(other.type, type) || other.type == type) && + (identical(other.text, text) || other.text == text) && + (identical(other.title, title) || other.title == title)); + } + + @override + int get hashCode => Object.hash(runtimeType, type, text, title); + + @override + String toString() { + return 'NotificationItem(type: $type, text: $text, title: $title)'; + } +} + +/// @nodoc +abstract mixin class _$NotificationItemCopyWith<$Res> + implements $NotificationItemCopyWith<$Res> { + factory _$NotificationItemCopyWith( + _NotificationItem value, $Res Function(_NotificationItem) _then) = + __$NotificationItemCopyWithImpl; + @override + @useResult + $Res call({NotificationType type, String text, String? title}); +} + +/// @nodoc +class __$NotificationItemCopyWithImpl<$Res> + implements _$NotificationItemCopyWith<$Res> { + __$NotificationItemCopyWithImpl(this._self, this._then); + + final _NotificationItem _self; + final $Res Function(_NotificationItem) _then; + + /// Create a copy of NotificationItem + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? type = null, + Object? text = null, + Object? title = freezed, + }) { + return _then(_NotificationItem( + type: null == type + ? _self.type + : type // ignore: cast_nullable_to_non_nullable + as NotificationType, + text: null == text + ? _self.text + : text // ignore: cast_nullable_to_non_nullable + as String, + title: freezed == title + ? _self.title + : title // ignore: cast_nullable_to_non_nullable + as String?, + )); + } +} + +/// @nodoc +mixin _$NotificationsState { + IList get queue; + + /// Create a copy of NotificationsState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $NotificationsStateCopyWith get copyWith => + _$NotificationsStateCopyWithImpl( + this as NotificationsState, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is NotificationsState && + const DeepCollectionEquality().equals(other.queue, queue)); + } + + @override + int get hashCode => + Object.hash(runtimeType, const DeepCollectionEquality().hash(queue)); + + @override + String toString() { + return 'NotificationsState(queue: $queue)'; + } +} + +/// @nodoc +abstract mixin class $NotificationsStateCopyWith<$Res> { + factory $NotificationsStateCopyWith( + NotificationsState value, $Res Function(NotificationsState) _then) = + _$NotificationsStateCopyWithImpl; + @useResult + $Res call({IList queue}); +} + +/// @nodoc +class _$NotificationsStateCopyWithImpl<$Res> + implements $NotificationsStateCopyWith<$Res> { + _$NotificationsStateCopyWithImpl(this._self, this._then); + + final NotificationsState _self; + final $Res Function(NotificationsState) _then; + + /// Create a copy of NotificationsState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? queue = null, + }) { + return _then(_self.copyWith( + queue: null == queue + ? _self.queue + : queue // ignore: cast_nullable_to_non_nullable + as IList, + )); + } +} + +/// @nodoc + +class _NotificationsState implements NotificationsState { + const _NotificationsState({required this.queue}); + + @override + final IList queue; + + /// Create a copy of NotificationsState + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$NotificationsStateCopyWith<_NotificationsState> get copyWith => + __$NotificationsStateCopyWithImpl<_NotificationsState>(this, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _NotificationsState && + const DeepCollectionEquality().equals(other.queue, queue)); + } + + @override + int get hashCode => + Object.hash(runtimeType, const DeepCollectionEquality().hash(queue)); + + @override + String toString() { + return 'NotificationsState(queue: $queue)'; + } +} + +/// @nodoc +abstract mixin class _$NotificationsStateCopyWith<$Res> + implements $NotificationsStateCopyWith<$Res> { + factory _$NotificationsStateCopyWith( + _NotificationsState value, $Res Function(_NotificationsState) _then) = + __$NotificationsStateCopyWithImpl; + @override + @useResult + $Res call({IList queue}); +} + +/// @nodoc +class __$NotificationsStateCopyWithImpl<$Res> + implements _$NotificationsStateCopyWith<$Res> { + __$NotificationsStateCopyWithImpl(this._self, this._then); + + final _NotificationsState _self; + final $Res Function(_NotificationsState) _then; + + /// Create a copy of NotificationsState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? queue = null, + }) { + return _then(_NotificationsState( + queue: null == queue + ? _self.queue + : queue // ignore: cast_nullable_to_non_nullable + as IList, + )); + } +} + +// dart format on diff --git a/lib/notifications/notifications.dart b/lib/notifications/notifications.dart new file mode 100644 index 0000000..0426651 --- /dev/null +++ b/lib/notifications/notifications.dart @@ -0,0 +1,3 @@ +export 'cubits/notifications_cubit.dart'; +export 'models/models.dart'; +export 'views/views.dart'; diff --git a/lib/notifications/views/notifications_preferences.dart b/lib/notifications/views/notifications_preferences.dart new file mode 100644 index 0000000..ba06699 --- /dev/null +++ b/lib/notifications/views/notifications_preferences.dart @@ -0,0 +1,254 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_translate/flutter_translate.dart'; + +import '../../settings/settings.dart'; +import '../../theme/theme.dart'; +import '../notifications.dart'; + +Widget buildSettingsPageNotificationPreferences( + {required BuildContext context}) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + final textTheme = theme.textTheme; + + final preferencesRepository = PreferencesRepository.instance; + final notificationsPreference = + preferencesRepository.value.notificationsPreference; + + Future updatePreferences( + NotificationsPreference newNotificationsPreference) async { + final newPrefs = preferencesRepository.value + .copyWith(notificationsPreference: newNotificationsPreference); + await preferencesRepository.set(newPrefs); + } + + List> notificationModeItems() { + final out = >[]; + final items = [ + (NotificationMode.none, true, translate('settings_page.none')), + (NotificationMode.inApp, true, translate('settings_page.in_app')), + (NotificationMode.push, false, translate('settings_page.push')), + ( + NotificationMode.inAppOrPush, + true, + translate('settings_page.in_app_or_push') + ), + ]; + for (final x in items) { + out.add(DropdownMenuItem( + value: x.$1, + enabled: x.$2, + child: Text( + x.$3, + softWrap: false, + style: textTheme.labelMedium, + textAlign: TextAlign.center, + ).fit(fit: BoxFit.scaleDown))); + } + return out; + } + + List> soundEffectItems() { + final out = >[]; + final items = [ + (SoundEffect.none, true, translate('settings_page.none')), + (SoundEffect.bonk, true, translate('settings_page.bonk')), + (SoundEffect.boop, true, translate('settings_page.boop')), + (SoundEffect.baDeep, true, translate('settings_page.badeep')), + (SoundEffect.beepBaDeep, true, translate('settings_page.beep_badeep')), + (SoundEffect.custom, false, translate('settings_page.custom')), + ]; + for (final x in items) { + out.add(DropdownMenuItem( + value: x.$1, + enabled: x.$2, + child: Text( + x.$3, + softWrap: false, + style: textTheme.labelMedium, + textAlign: TextAlign.center, + ).fit(fit: BoxFit.scaleDown))); + } + return out; + } + + List> + messageNotificationContentItems() { + final out = >[]; + final items = [ + ( + MessageNotificationContent.nameAndContent, + true, + translate('settings_page.name_and_content') + ), + ( + MessageNotificationContent.nameOnly, + true, + translate('settings_page.name_only') + ), + ( + MessageNotificationContent.nothing, + true, + translate('settings_page.nothing') + ), + ]; + for (final x in items) { + out.add(DropdownMenuItem( + value: x.$1, + enabled: x.$2, + child: Text( + x.$3, + softWrap: false, + style: textTheme.labelMedium, + textAlign: TextAlign.center, + ))); + } + return out; + } + + // Invitation accepted + Widget notificationSettingsItem( + {required String title, + required bool notificationsEnabled, + NotificationMode? deliveryValue, + SoundEffect? soundValue, + Future Function(NotificationMode)? onNotificationModeChanged, + Future Function(SoundEffect)? onSoundChanged}) => + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8.scaled(context), + children: [ + Text('$title:', style: textTheme.titleMedium), + Wrap( + spacing: 8.scaled(context), // gap between adjacent chips + runSpacing: 8.scaled(context), // gap between lines + children: [ + if (deliveryValue != null) + IntrinsicWidth( + child: StyledDropdown( + decoratorLabel: translate('settings_page.delivery'), + items: notificationModeItems(), + value: deliveryValue, + onChanged: !notificationsEnabled + ? null + : onNotificationModeChanged, + )), + if (soundValue != null) + IntrinsicWidth( + child: StyledDropdown( + decoratorLabel: translate('settings_page.sound'), + items: soundEffectItems(), + value: soundValue, + onChanged: !notificationsEnabled ? null : onSoundChanged, + )) + ]) + ]).paddingAll(4.scaled(context)); + + return InputDecorator( + decoration: InputDecoration( + labelText: translate('settings_page.notifications'), + border: OutlineInputBorder( + borderRadius: + BorderRadius.circular(8 * scaleConfig.borderRadiusScale), + borderSide: BorderSide(width: 2, color: scale.primaryScale.border), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8.scaled(context), + children: [ + // Display Beta Warning + StyledCheckbox( + label: translate('settings_page.display_beta_warning'), + value: notificationsPreference.displayBetaWarning, + onChanged: (value) async { + final newNotificationsPreference = notificationsPreference + .copyWith(displayBetaWarning: value); + + await updatePreferences(newNotificationsPreference); + }), + // Enable Badge + StyledCheckbox( + label: translate('settings_page.enable_badge'), + value: notificationsPreference.enableBadge, + onChanged: (value) async { + final newNotificationsPreference = + notificationsPreference.copyWith(enableBadge: value); + await updatePreferences(newNotificationsPreference); + }), + // Enable Notifications + StyledCheckbox( + label: translate('settings_page.enable_notifications'), + value: notificationsPreference.enableNotifications, + onChanged: (value) async { + final newNotificationsPreference = notificationsPreference + .copyWith(enableNotifications: value); + await updatePreferences(newNotificationsPreference); + }), + StyledDropdown( + items: messageNotificationContentItems(), + value: notificationsPreference.messageNotificationContent, + decoratorLabel: + translate('settings_page.message_notification_content'), + onChanged: !notificationsPreference.enableNotifications + ? null + : (value) async { + final newNotificationsPreference = notificationsPreference + .copyWith(messageNotificationContent: value); + await updatePreferences(newNotificationsPreference); + }, + ).paddingAll(4.scaled(context)), + + // Notifications + + // Invitation accepted + notificationSettingsItem( + title: translate('settings_page.invitation_accepted'), + notificationsEnabled: + notificationsPreference.enableNotifications, + deliveryValue: notificationsPreference.onInvitationAcceptedMode, + soundValue: notificationsPreference.onInvitationAcceptedSound, + onNotificationModeChanged: (value) async { + final newNotificationsPreference = notificationsPreference + .copyWith(onInvitationAcceptedMode: value); + await updatePreferences(newNotificationsPreference); + }, + onSoundChanged: (value) async { + final newNotificationsPreference = notificationsPreference + .copyWith(onInvitationAcceptedSound: value); + await updatePreferences(newNotificationsPreference); + }), + + // Message received + notificationSettingsItem( + title: translate('settings_page.message_received'), + notificationsEnabled: + notificationsPreference.enableNotifications, + deliveryValue: notificationsPreference.onMessageReceivedMode, + soundValue: notificationsPreference.onMessageReceivedSound, + onNotificationModeChanged: (value) async { + final newNotificationsPreference = notificationsPreference + .copyWith(onMessageReceivedMode: value); + await updatePreferences(newNotificationsPreference); + }, + onSoundChanged: (value) async { + final newNotificationsPreference = notificationsPreference + .copyWith(onMessageReceivedSound: value); + await updatePreferences(newNotificationsPreference); + }), + + // Message sent + notificationSettingsItem( + title: translate('settings_page.message_sent'), + notificationsEnabled: + notificationsPreference.enableNotifications, + soundValue: notificationsPreference.onMessageSentSound, + onSoundChanged: (value) async { + final newNotificationsPreference = notificationsPreference + .copyWith(onMessageSentSound: value); + await updatePreferences(newNotificationsPreference); + }), + ]).paddingAll(4.scaled(context))); +} diff --git a/lib/notifications/views/notifications_widget.dart b/lib/notifications/views/notifications_widget.dart new file mode 100644 index 0000000..73fac5f --- /dev/null +++ b/lib/notifications/views/notifications_widget.dart @@ -0,0 +1,93 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:toastification/toastification.dart'; + +import '../../theme/theme.dart'; +import '../notifications.dart'; + +class NotificationsWidget extends StatelessWidget { + const NotificationsWidget({required Widget child, super.key}) + : _child = child; + + //////////////////////////////////////////////////////////////////////////// + // Public API + + @override + Widget build(BuildContext context) { + final notificationsCubit = context.read(); + + return BlocListener( + bloc: notificationsCubit, + listener: (context, state) { + if (state.queue.isNotEmpty) { + final queue = notificationsCubit.popAll(); + for (final notificationItem in queue) { + switch (notificationItem.type) { + case NotificationType.info: + _info( + context: context, + text: notificationItem.text, + title: notificationItem.title); + case NotificationType.error: + _error( + context: context, + text: notificationItem.text, + title: notificationItem.title); + } + } + } + }, + child: _child); + } + + //////////////////////////////////////////////////////////////////////////// + // Private Implementation + + void _toast( + {required BuildContext context, + required String text, + required ScaleToastTheme toastTheme, + String? title}) { + toastification.show( + context: context, + title: title != null + ? Text(title) + .copyWith(style: toastTheme.titleTextStyle) + .paddingLTRB(0, 0, 0, 8) + : null, + description: Text(text).copyWith(style: toastTheme.descriptionTextStyle), + icon: toastTheme.icon, + primaryColor: toastTheme.primaryColor, + backgroundColor: toastTheme.backgroundColor, + foregroundColor: toastTheme.foregroundColor, + padding: toastTheme.padding, + borderRadius: toastTheme.borderRadius, + borderSide: toastTheme.borderSide, + autoCloseDuration: const Duration(seconds: 2), + animationDuration: const Duration(milliseconds: 500), + ); + } + + void _info( + {required BuildContext context, required String text, String? title}) { + final theme = Theme.of(context); + final toastTheme = + theme.extension()!.toastTheme(ScaleToastKind.info); + + _toast(context: context, text: text, toastTheme: toastTheme, title: title); + } + + void _error( + {required BuildContext context, required String text, String? title}) { + final theme = Theme.of(context); + final toastTheme = + theme.extension()!.toastTheme(ScaleToastKind.error); + + _toast(context: context, text: text, toastTheme: toastTheme, title: title); + } + + //////////////////////////////////////////////////////////////////////////// + + final Widget _child; +} diff --git a/lib/notifications/views/views.dart b/lib/notifications/views/views.dart new file mode 100644 index 0000000..48a03b0 --- /dev/null +++ b/lib/notifications/views/views.dart @@ -0,0 +1,2 @@ +export 'notifications_preferences.dart'; +export 'notifications_widget.dart'; diff --git a/lib/pages/chat_only.dart b/lib/pages/chat_only.dart deleted file mode 100644 index 2dc57d2..0000000 --- a/lib/pages/chat_only.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../providers/window_control.dart'; -import 'home.dart'; - -class ChatOnlyPage extends ConsumerStatefulWidget { - const ChatOnlyPage({super.key}); - - @override - ChatOnlyPageState createState() => ChatOnlyPageState(); -} - -class ChatOnlyPageState extends ConsumerState - with TickerProviderStateMixin { - final _unfocusNode = FocusNode(); - - @override - void initState() { - super.initState(); - - WidgetsBinding.instance.addPostFrameCallback((_) async { - setState(() {}); - await ref.read(windowControlProvider.notifier).changeWindowSetup( - TitleBarStyle.normal, OrientationCapability.normal); - }); - } - - @override - void dispose() { - _unfocusNode.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - ref.watch(windowControlProvider); - - return SafeArea( - child: GestureDetector( - onTap: () => FocusScope.of(context).requestFocus(_unfocusNode), - child: HomePage.buildChatComponent(context, ref), - )); - } -} diff --git a/lib/pages/developer.dart b/lib/pages/developer.dart deleted file mode 100644 index da78c9c..0000000 --- a/lib/pages/developer.dart +++ /dev/null @@ -1,256 +0,0 @@ -import 'package:ansicolor/ansicolor.dart'; -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:cool_dropdown/cool_dropdown.dart'; -import 'package:cool_dropdown/models/cool_dropdown_item.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_animate/flutter_animate.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_translate/flutter_translate.dart'; -import 'package:go_router/go_router.dart'; -import 'package:loggy/loggy.dart'; -import 'package:quickalert/quickalert.dart'; -import 'package:xterm/xterm.dart'; - -import '../tools/tools.dart'; -import '../veilid_support/veilid_support.dart'; - -final globalDebugTerminal = Terminal( - maxLines: 50000, -); - -const kDefaultTerminalStyle = TerminalStyle( - fontSize: 11, - // height: 1.2, - fontFamily: 'Source Code Pro'); - -class DeveloperPage extends ConsumerStatefulWidget { - const DeveloperPage({super.key}); - - @override - DeveloperPageState createState() => DeveloperPageState(); -} - -class DeveloperPageState extends ConsumerState { - final _terminalController = TerminalController(); - final _debugCommandController = TextEditingController(); - final _logLevelController = DropdownController(duration: 250.ms); - final List> _logLevelDropdownItems = []; - var _logLevelDropDown = log.level.logLevel; - var _showEllet = false; - - @override - void initState() { - super.initState(); - _terminalController.addListener(() { - setState(() {}); - }); - - for (var i = 0; i < logLevels.length; i++) { - _logLevelDropdownItems.add(CoolDropdownItem( - label: logLevelName(logLevels[i]), - icon: Text(logLevelEmoji(logLevels[i])), - value: logLevels[i])); - } - } - - void _debugOut(String out) { - final pen = AnsiPen()..cyan(bold: true); - final colorOut = pen(out); - debugPrint(colorOut); - globalDebugTerminal.write(colorOut.replaceAll('\n', '\r\n')); - } - - Future _sendDebugCommand(String debugCommand) async { - if (debugCommand == 'ellet') { - setState(() { - _showEllet = !_showEllet; - }); - return; - } - _debugOut('DEBUG >>>\n$debugCommand\n'); - try { - final out = await Veilid.instance.debug(debugCommand); - _debugOut('<<< DEBUG\n$out\n'); - } on Exception catch (e, st) { - _debugOut('<<< ERROR\n$e\n<<< STACK\n$st'); - } - } - - Future clear(BuildContext context) async { - globalDebugTerminal.buffer.clear(); - if (context.mounted) { - showInfoToast(context, translate('developer.cleared')); - } - } - - Future copySelection(BuildContext context) async { - final selection = _terminalController.selection; - if (selection != null) { - final text = globalDebugTerminal.buffer.getText(selection); - _terminalController.clearSelection(); - await Clipboard.setData(ClipboardData(text: text)); - if (context.mounted) { - showInfoToast(context, translate('developer.copied')); - } - } - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final textTheme = theme.textTheme; - final scale = theme.extension()!; - - // WidgetsBinding.instance.addPostFrameCallback((_) { - // if (!_isScrolling && _wantsBottom) { - // _scrollToBottom(); - // } - // }); - - return Scaffold( - appBar: AppBar( - leading: IconButton( - icon: Icon(Icons.arrow_back, color: scale.primaryScale.text), - onPressed: () => GoRouterHelper(context).pop(), - ), - actions: [ - IconButton( - icon: const Icon(Icons.copy), - color: scale.primaryScale.text, - disabledColor: scale.grayScale.subtleText, - onPressed: _terminalController.selection == null - ? null - : () async { - await copySelection(context); - }), - IconButton( - icon: const Icon(Icons.clear_all), - color: scale.primaryScale.text, - disabledColor: scale.grayScale.subtleText, - onPressed: () async { - await QuickAlert.show( - context: context, - type: QuickAlertType.confirm, - title: translate('developer.are_you_sure_clear'), - textColor: scale.primaryScale.text, - confirmBtnColor: scale.primaryScale.elementBackground, - backgroundColor: scale.primaryScale.subtleBackground, - headerBackgroundColor: scale.primaryScale.background, - confirmBtnText: translate('button.ok'), - cancelBtnText: translate('button.cancel'), - onConfirmBtnTap: () async { - Navigator.pop(context); - if (context.mounted) { - await clear(context); - } - }); - }), - CoolDropdown( - controller: _logLevelController, - defaultItem: _logLevelDropdownItems - .singleWhere((x) => x.value == _logLevelDropDown), - onChange: (value) { - setState(() { - _logLevelDropDown = value; - Loggy('').level = getLogOptions(value); - setVeilidLogLevel(value); - _logLevelController.close(); - }); - }, - resultOptions: ResultOptions( - width: 64, - height: 40, - render: ResultRender.icon, - textStyle: textTheme.labelMedium! - .copyWith(color: scale.primaryScale.text), - padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), - openBoxDecoration: BoxDecoration( - color: scale.primaryScale.activeElementBackground), - boxDecoration: - BoxDecoration(color: scale.primaryScale.elementBackground), - ), - dropdownOptions: DropdownOptions( - width: 160, - align: DropdownAlign.right, - duration: 150.ms, - color: scale.primaryScale.elementBackground, - borderSide: BorderSide(color: scale.primaryScale.border), - borderRadius: BorderRadius.circular(8), - padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), - ), - dropdownTriangleOptions: const DropdownTriangleOptions( - align: DropdownTriangleAlign.right), - dropdownItemOptions: DropdownItemOptions( - selectedTextStyle: textTheme.labelMedium! - .copyWith(color: scale.primaryScale.text), - textStyle: textTheme.labelMedium! - .copyWith(color: scale.primaryScale.text), - selectedBoxDecoration: BoxDecoration( - color: scale.primaryScale.activeElementBackground), - mainAxisAlignment: MainAxisAlignment.spaceBetween, - padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), - selectedPadding: const EdgeInsets.fromLTRB(8, 4, 8, 4)), - dropdownList: _logLevelDropdownItems, - ) - ], - title: Text(translate('developer.title'), - style: - textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.bold)), - centerTitle: true, - ), - body: SafeArea( - child: Column(children: [ - Stack(alignment: AlignmentDirectional.center, children: [ - Image.asset('assets/images/ellet.png'), - TerminalView(globalDebugTerminal, - textStyle: kDefaultTerminalStyle, - controller: _terminalController, - //autofocus: true, - backgroundOpacity: _showEllet ? 0.75 : 1.0, - onSecondaryTapDown: (details, offset) async { - await copySelection(context); - }) - ]).expanded(), - TextField( - controller: _debugCommandController, - decoration: InputDecoration( - filled: true, - contentPadding: const EdgeInsets.fromLTRB(8, 2, 8, 2), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: scale.primaryScale.border)), - fillColor: scale.primaryScale.subtleBackground, - hintText: translate('developer.command'), - suffixIcon: IconButton( - icon: const Icon(Icons.send), - onPressed: _debugCommandController.text.isEmpty - ? null - : () async { - final debugCommand = _debugCommandController.text; - _debugCommandController.clear(); - await _sendDebugCommand(debugCommand); - }, - )), - onChanged: (_) { - setState(() => {}); - }, - onSubmitted: (debugCommand) async { - _debugCommandController.clear(); - await _sendDebugCommand(debugCommand); - }, - ).paddingAll(4) - ]))); - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(DiagnosticsProperty( - 'terminalController', _terminalController)) - ..add( - DiagnosticsProperty('logLevelDropDown', _logLevelDropDown)); - } -} diff --git a/lib/pages/edit_contact.dart b/lib/pages/edit_contact.dart deleted file mode 100644 index 169874f..0000000 --- a/lib/pages/edit_contact.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -class ContactsPage extends ConsumerWidget { - const ContactsPage({super.key}); - static const path = '/contacts'; - - @override - Widget build(BuildContext context, WidgetRef ref) => const Scaffold( - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('Contacts Page'), - // ElevatedButton( - // onPressed: () async { - // ref.watch(authNotifierProvider.notifier).login( - // "myEmail", - // "myPassword", - // ); - // }, - // child: const Text("Login"), - // ), - ], - ), - ), - ); -} diff --git a/lib/pages/home.dart b/lib/pages/home.dart deleted file mode 100644 index 408aa86..0000000 --- a/lib/pages/home.dart +++ /dev/null @@ -1,269 +0,0 @@ -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_animate/flutter_animate.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_translate/flutter_translate.dart'; -import 'package:go_router/go_router.dart'; - -import '../proto/proto.dart' as proto; -import '../components/chat_component.dart'; -import '../components/empty_chat_widget.dart'; -import '../components/profile_widget.dart'; -import '../entities/local_account.dart'; -import '../providers/account.dart'; -import '../providers/chat.dart'; -import '../providers/contact.dart'; -import '../providers/local_accounts.dart'; -import '../providers/logins.dart'; -import '../providers/window_control.dart'; -import '../tools/tools.dart'; -import '../veilid_support/veilid_support.dart'; -import 'main_pager/main_pager.dart'; - -class HomePage extends ConsumerStatefulWidget { - const HomePage({super.key}); - - @override - HomePageState createState() => HomePageState(); - - static Widget buildChatComponent(BuildContext context, WidgetRef ref) { - final contactList = ref.watch(fetchContactListProvider).asData?.value ?? - const IListConst([]); - - final activeChat = ref.watch(activeChatStateProvider); - if (activeChat == null) { - return const EmptyChatWidget(); - } - - final activeAccountInfo = - ref.watch(fetchActiveAccountProvider).asData?.value; - if (activeAccountInfo == null) { - return const EmptyChatWidget(); - } - - final activeChatContactIdx = contactList.indexWhere( - (c) => - proto.TypedKeyProto.fromProto(c.remoteConversationRecordKey) == - activeChat, - ); - if (activeChatContactIdx == -1) { - ref.read(activeChatStateProvider.notifier).state = null; - return const EmptyChatWidget(); - } - final activeChatContact = contactList[activeChatContactIdx]; - - return ChatComponent( - activeAccountInfo: activeAccountInfo, - activeChat: activeChat, - activeChatContact: activeChatContact); - } -} - -class HomePageState extends ConsumerState - with TickerProviderStateMixin { - final _unfocusNode = FocusNode(); - - @override - void initState() { - super.initState(); - - WidgetsBinding.instance.addPostFrameCallback((_) async { - setState(() {}); - await ref.read(windowControlProvider.notifier).changeWindowSetup( - TitleBarStyle.normal, OrientationCapability.normal); - }); - } - - @override - void dispose() { - _unfocusNode.dispose(); - super.dispose(); - } - - // ignore: prefer_expression_function_bodies - Widget buildAccountList() { - return const Column(children: [ - Center(child: Text('Small Profile')), - Center(child: Text('Contact invitations')), - Center(child: Text('Contacts')) - ]); - } - - Widget buildUnlockAccount( - BuildContext context, - IList localAccounts, - // ignore: prefer_expression_function_bodies - ) { - return const Center(child: Text('unlock account')); - } - - /// We have an active, unlocked, user login - Widget buildReadyAccount( - BuildContext context, - IList localAccounts, - TypedKey activeUserLogin, - proto.Account account) { - final theme = Theme.of(context); - final scale = theme.extension()!; - - return Column(children: [ - Row(children: [ - IconButton( - icon: const Icon(Icons.settings), - color: scale.secondaryScale.text, - constraints: const BoxConstraints.expand(height: 64, width: 64), - style: ButtonStyle( - backgroundColor: - MaterialStateProperty.all(scale.secondaryScale.border), - shape: MaterialStateProperty.all(const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(16))))), - tooltip: translate('app_bar.settings_tooltip'), - onPressed: () async { - context.go('/home/settings'); - }).paddingLTRB(0, 0, 8, 0), - ProfileWidget( - name: account.profile.name, - pronouns: account.profile.pronouns, - ).expanded(), - ]).paddingAll(8), - MainPager( - localAccounts: localAccounts, - activeUserLogin: activeUserLogin, - account: account) - .expanded() - ]); - } - - Widget buildUserPanel() { - final localAccountsV = ref.watch(localAccountsProvider); - final loginsV = ref.watch(loginsProvider); - - if (!localAccountsV.hasValue || !loginsV.hasValue) { - return waitingPage(context); - } - final localAccounts = localAccountsV.requireValue; - final logins = loginsV.requireValue; - - final activeUserLogin = logins.activeUserLogin; - if (activeUserLogin == null) { - // If no logged in user is active, show the list of account - return buildAccountList(); - } - final accountV = ref - .watch(fetchAccountProvider(accountMasterRecordKey: activeUserLogin)); - if (!accountV.hasValue) { - return waitingPage(context); - } - final account = accountV.requireValue; - switch (account.status) { - case AccountInfoStatus.noAccount: - Future.delayed(0.ms, () async { - await showErrorModal(context, translate('home.missing_account_title'), - translate('home.missing_account_text')); - // Delete account - await ref - .read(localAccountsProvider.notifier) - .deleteLocalAccount(activeUserLogin); - // Switch to no active user login - await ref.read(loginsProvider.notifier).switchToAccount(null); - }); - return waitingPage(context); - case AccountInfoStatus.accountInvalid: - Future.delayed(0.ms, () async { - await showErrorModal(context, translate('home.invalid_account_title'), - translate('home.invalid_account_text')); - // Delete account - await ref - .read(localAccountsProvider.notifier) - .deleteLocalAccount(activeUserLogin); - // Switch to no active user login - await ref.read(loginsProvider.notifier).switchToAccount(null); - }); - return waitingPage(context); - case AccountInfoStatus.accountLocked: - // Show unlock widget - return buildUnlockAccount(context, localAccounts); - case AccountInfoStatus.accountReady: - return buildReadyAccount( - context, - localAccounts, - activeUserLogin, - account.account!, - ); - } - } - - // ignore: prefer_expression_function_bodies - Widget buildPhone(BuildContext context) { - return Material(color: Colors.transparent, child: buildUserPanel()); - } - - // ignore: prefer_expression_function_bodies - Widget buildTabletLeftPane(BuildContext context) { - // - return Material(color: Colors.transparent, child: buildUserPanel()); - } - - // ignore: prefer_expression_function_bodies - Widget buildTabletRightPane(BuildContext context) { - // - return HomePage.buildChatComponent(context, ref); - } - - // ignore: prefer_expression_function_bodies - Widget buildTablet(BuildContext context) { - final w = MediaQuery.of(context).size.width; - final theme = Theme.of(context); - final scale = theme.extension()!; - - final children = [ - ConstrainedBox( - constraints: const BoxConstraints(minWidth: 300, maxWidth: 300), - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: w / 2), - child: buildTabletLeftPane(context))), - SizedBox( - width: 2, - height: double.infinity, - child: ColoredBox(color: scale.primaryScale.hoverBorder)), - Expanded(child: buildTabletRightPane(context)), - ]; - - return Row( - children: children, - ); - - // final theme = MultiSplitViewTheme( - // data: isDesktop - // ? MultiSplitViewThemeData( - // dividerThickness: 1, - // dividerPainter: DividerPainters.grooved2(thickness: 1)) - // : MultiSplitViewThemeData( - // dividerThickness: 3, - // dividerPainter: DividerPainters.grooved2(thickness: 1)), - // child: multiSplitView); - } - - @override - Widget build(BuildContext context) { - ref.watch(windowControlProvider); - - final theme = Theme.of(context); - final scale = theme.extension()!; - - return SafeArea( - child: GestureDetector( - onTap: () => FocusScope.of(context).requestFocus(_unfocusNode), - child: DecoratedBox( - decoration: BoxDecoration( - color: scale.primaryScale.activeElementBackground), - child: responsiveVisibility( - context: context, - phone: false, - ) - ? buildTablet(context) - : buildPhone(context), - ))); - } -} diff --git a/lib/pages/index.dart b/lib/pages/index.dart deleted file mode 100644 index 8a53316..0000000 --- a/lib/pages/index.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_svg/flutter_svg.dart'; -import 'package:radix_colors/radix_colors.dart'; - -import '../providers/window_control.dart'; - -class IndexPage extends ConsumerWidget { - const IndexPage({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - ref.watch(windowControlProvider); - - final theme = Theme.of(context); - final textTheme = theme.textTheme; - final monoTextStyle = textTheme.labelSmall! - .copyWith(fontFamily: 'Source Code Pro', fontSize: 11); - final emojiTextStyle = textTheme.labelSmall! - .copyWith(fontFamily: 'Noto Color Emoji', fontSize: 11); - - return Scaffold( - body: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - RadixColors.dark.plum.step4, - RadixColors.dark.plum.step2, - ])), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 300), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // Hack to preload fonts - Offstage(child: Text('🧱', style: emojiTextStyle)), - // Hack to preload fonts - Offstage(child: Text('A', style: monoTextStyle)), - // Splash Screen - Expanded( - flex: 2, - child: SvgPicture.asset( - 'assets/images/icon.svg', - )), - Expanded( - child: SvgPicture.asset( - 'assets/images/title.svg', - )) - ]))), - )); - } -} diff --git a/lib/pages/main_pager/account.dart b/lib/pages/main_pager/account.dart deleted file mode 100644 index 4c3e56d..0000000 --- a/lib/pages/main_pager/account.dart +++ /dev/null @@ -1,99 +0,0 @@ -// ignore_for_file: prefer_const_constructors - -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_translate/flutter_translate.dart'; - -import '../../components/contact_invitation_list_widget.dart'; -import '../../components/contact_list_widget.dart'; -import '../../entities/local_account.dart'; -import '../../proto/proto.dart' as proto; -import '../../providers/contact.dart'; -import '../../providers/contact_invite.dart'; -import '../../tools/theme_service.dart'; -import '../../tools/tools.dart'; -import '../../veilid_support/veilid_support.dart'; - -class AccountPage extends ConsumerStatefulWidget { - const AccountPage({ - required this.localAccounts, - required this.activeUserLogin, - required this.account, - super.key, - }); - - final IList localAccounts; - final TypedKey activeUserLogin; - final proto.Account account; - - @override - AccountPageState createState() => AccountPageState(); - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(IterableProperty('localAccounts', localAccounts)) - ..add(DiagnosticsProperty('activeUserLogin', activeUserLogin)) - ..add(DiagnosticsProperty('account', account)); - } -} - -class AccountPageState extends ConsumerState { - final _unfocusNode = FocusNode(); - - @override - void initState() { - super.initState(); - } - - @override - void dispose() { - _unfocusNode.dispose(); - super.dispose(); - } - - @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context) { - final theme = Theme.of(context); - final textTheme = theme.textTheme; - final scale = theme.extension()!; - - final contactInvitationRecordList = - ref.watch(fetchContactInvitationRecordsProvider).asData?.value ?? - const IListConst([]); - final contactList = ref.watch(fetchContactListProvider).asData?.value ?? - const IListConst([]); - - return SizedBox( - child: Column(children: [ - if (contactInvitationRecordList.isNotEmpty) - ExpansionTile( - tilePadding: EdgeInsets.fromLTRB(8, 0, 8, 0), - backgroundColor: scale.primaryScale.border, - collapsedBackgroundColor: scale.primaryScale.border, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - collapsedShape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - title: Text( - translate('account_page.contact_invitations'), - textAlign: TextAlign.center, - style: textTheme.titleMedium! - .copyWith(color: scale.primaryScale.subtleText), - ), - initiallyExpanded: true, - children: [ - ContactInvitationListWidget( - contactInvitationRecordList: contactInvitationRecordList) - ], - ).paddingLTRB(8, 0, 8, 8), - ContactListWidget(contactList: contactList).expanded(), - ])); - } -} diff --git a/lib/pages/main_pager/chats.dart b/lib/pages/main_pager/chats.dart deleted file mode 100644 index e823dfd..0000000 --- a/lib/pages/main_pager/chats.dart +++ /dev/null @@ -1,100 +0,0 @@ -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../components/chat_single_contact_list_widget.dart'; -import '../../components/empty_chat_list_widget.dart'; -import '../../entities/local_account.dart'; -import '../../proto/proto.dart' as proto; -import '../../providers/account.dart'; -import '../../providers/chat.dart'; -import '../../providers/contact.dart'; -import '../../providers/local_accounts.dart'; -import '../../providers/logins.dart'; -import '../../tools/tools.dart'; -import '../../veilid_support/veilid_support.dart'; - -class ChatsPage extends ConsumerStatefulWidget { - const ChatsPage({super.key}); - - @override - ChatsPageState createState() => ChatsPageState(); -} - -class ChatsPageState extends ConsumerState { - final _unfocusNode = FocusNode(); - - @override - void initState() { - super.initState(); - } - - @override - void dispose() { - _unfocusNode.dispose(); - super.dispose(); - } - - /// We have an active, unlocked, user login - Widget buildChatList( - BuildContext context, - IList localAccounts, - TypedKey activeUserLogin, - proto.Account account, - // ignore: prefer_expression_function_bodies - ) { - final contactList = ref.watch(fetchContactListProvider).asData?.value ?? - const IListConst([]); - final chatList = - ref.watch(fetchChatListProvider).asData?.value ?? const IListConst([]); - - return Column(children: [ - if (chatList.isNotEmpty) - ChatSingleContactListWidget( - contactList: contactList, chatList: chatList) - .expanded(), - if (chatList.isEmpty) const EmptyChatListWidget().expanded(), - ]); - } - - @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context) { - final localAccountsV = ref.watch(localAccountsProvider); - final loginsV = ref.watch(loginsProvider); - - if (!localAccountsV.hasValue || !loginsV.hasValue) { - return waitingPage(context); - } - final localAccounts = localAccountsV.requireValue; - final logins = loginsV.requireValue; - - final activeUserLogin = logins.activeUserLogin; - if (activeUserLogin == null) { - // If no logged in user is active show a placeholder - return waitingPage(context); - } - final accountV = ref - .watch(fetchAccountProvider(accountMasterRecordKey: activeUserLogin)); - if (!accountV.hasValue) { - return waitingPage(context); - } - final account = accountV.requireValue; - switch (account.status) { - case AccountInfoStatus.noAccount: - return waitingPage(context); - case AccountInfoStatus.accountInvalid: - return waitingPage(context); - case AccountInfoStatus.accountLocked: - return waitingPage(context); - case AccountInfoStatus.accountReady: - return buildChatList( - context, - localAccounts, - activeUserLogin, - account.account!, - ); - } - } -} diff --git a/lib/pages/main_pager/main_pager.dart b/lib/pages/main_pager/main_pager.dart deleted file mode 100644 index 7265285..0000000 --- a/lib/pages/main_pager/main_pager.dart +++ /dev/null @@ -1,315 +0,0 @@ -import 'dart:async'; - -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_animate/flutter_animate.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_translate/flutter_translate.dart'; -import 'package:preload_page_view/preload_page_view.dart'; -import 'package:stylish_bottom_bar/model/bar_items.dart'; -import 'package:stylish_bottom_bar/stylish_bottom_bar.dart'; - -import '../../components/bottom_sheet_action_button.dart'; -import '../../components/paste_invite_dialog.dart'; -import '../../components/scan_invite_dialog.dart'; -import '../../components/send_invite_dialog.dart'; -import '../../entities/local_account.dart'; -import '../../proto/proto.dart' as proto; -import '../../tools/tools.dart'; -import '../../veilid_support/veilid_support.dart'; -import 'account.dart'; -import 'chats.dart'; - -class MainPager extends ConsumerStatefulWidget { - const MainPager( - {required this.localAccounts, - required this.activeUserLogin, - required this.account, - super.key}); - - final IList localAccounts; - final TypedKey activeUserLogin; - final proto.Account account; - - @override - MainPagerState createState() => MainPagerState(); - - static MainPagerState? of(BuildContext context) => - context.findAncestorStateOfType(); - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(IterableProperty('localAccounts', localAccounts)) - ..add(DiagnosticsProperty('activeUserLogin', activeUserLogin)) - ..add(DiagnosticsProperty('account', account)); - } -} - -class MainPagerState extends ConsumerState - with TickerProviderStateMixin { - ////////////////////////////////////////////////////////////////// - - final _unfocusNode = FocusNode(); - - var _currentPage = 0; - final pageController = PreloadPageController(); - - final _selectedIconList = [Icons.person, Icons.chat]; - // final _unselectedIconList = [ - // Icons.chat_outlined, - // Icons.person_outlined - // ]; - final _fabIconList = [ - Icons.person_add_sharp, - Icons.add_comment_sharp, - ]; - final _bottomLabelList = [ - translate('pager.account'), - translate('pager.chats'), - ]; - - ////////////////////////////////////////////////////////////////// - - @override - void initState() { - super.initState(); - } - - @override - void dispose() { - _unfocusNode.dispose(); - pageController.dispose(); - super.dispose(); - } - - bool onScrollNotification(ScrollNotification notification) { - if (notification is UserScrollNotification && - notification.metrics.axis == Axis.vertical) { - switch (notification.direction) { - case ScrollDirection.forward: - // _hideBottomBarAnimationController.reverse(); - // _fabAnimationController.forward(from: 0); - break; - case ScrollDirection.reverse: - // _hideBottomBarAnimationController.forward(); - // _fabAnimationController.reverse(from: 1); - break; - case ScrollDirection.idle: - break; - } - } - return false; - } - - BottomBarItem buildBottomBarItem(int index) { - final theme = Theme.of(context); - final scale = theme.extension()!; - return BottomBarItem( - title: Text(_bottomLabelList[index]), - icon: Icon(_selectedIconList[index], color: scale.primaryScale.text), - selectedIcon: - Icon(_selectedIconList[index], color: scale.primaryScale.text), - backgroundColor: scale.primaryScale.text, - //unSelectedColor: theme.colorScheme.primaryContainer, - //selectedColor: theme.colorScheme.primary, - //badge: const Text('9+'), - //showBadge: true, - ); - } - - List _buildBottomBarItems() { - final bottomBarItems = List.empty(growable: true); - for (var index = 0; index < _bottomLabelList.length; index++) { - final item = buildBottomBarItem(index); - bottomBarItems.add(item); - } - return bottomBarItems; - } - - Future scanContactInvitationDialog(BuildContext context) async { - await showDialog( - context: context, - // ignore: prefer_expression_function_bodies - builder: (context) { - return const AlertDialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(20)), - ), - contentPadding: EdgeInsets.only( - top: 10, - ), - title: Text( - 'Scan Contact Invite', - style: TextStyle(fontSize: 24), - ), - content: ScanInviteDialog()); - }); - } - - Widget _newContactInvitationBottomSheetBuilder( - // ignore: prefer_expression_function_bodies - BuildContext context) { - final theme = Theme.of(context); - final textTheme = theme.textTheme; - final scale = theme.extension()!; - - return KeyboardListener( - focusNode: FocusNode(), - onKeyEvent: (ke) { - if (ke.logicalKey == LogicalKeyboardKey.escape) { - Navigator.pop(context); - } - }, - child: SizedBox( - height: 200, - child: Column(children: [ - Text(translate('accounts_menu.invite_contact'), - style: textTheme.titleMedium) - .paddingAll(8), - Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - Column(children: [ - IconButton( - onPressed: () async { - Navigator.pop(context); - await SendInviteDialog.show(context); - }, - iconSize: 64, - icon: const Icon(Icons.contact_page), - color: scale.primaryScale.background), - Text(translate('accounts_menu.create_invite')) - ]), - Column(children: [ - IconButton( - onPressed: () async { - Navigator.pop(context); - await ScanInviteDialog.show(context); - }, - iconSize: 64, - icon: const Icon(Icons.qr_code_scanner), - color: scale.primaryScale.background), - Text(translate('accounts_menu.scan_invite')) - ]), - Column(children: [ - IconButton( - onPressed: () async { - Navigator.pop(context); - await PasteInviteDialog.show(context); - }, - iconSize: 64, - icon: const Icon(Icons.paste), - color: scale.primaryScale.background), - Text(translate('accounts_menu.paste_invite')) - ]) - ]).expanded() - ]))); - } - - // ignore: prefer_expression_function_bodies - Widget _onNewChatBottomSheetBuilder(BuildContext context) { - return const SizedBox( - height: 200, - child: Center( - child: Text( - 'Group and custom chat functionality is not available yet'))); - } - - Widget _bottomSheetBuilder(BuildContext context) { - if (_currentPage == 0) { - // New contact invitation - return _newContactInvitationBottomSheetBuilder(context); - } else if (_currentPage == 1) { - // New chat - return _onNewChatBottomSheetBuilder(context); - } else { - // Unknown error - return waitingPage(context); - } - } - - @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context) { - final theme = Theme.of(context); - final scale = theme.extension()!; - - return Scaffold( - //extendBody: true, - backgroundColor: Colors.transparent, - body: NotificationListener( - onNotification: onScrollNotification, - child: PreloadPageView( - controller: pageController, - preloadPagesCount: 2, - onPageChanged: (index) { - setState(() { - _currentPage = index; - }); - }, - children: [ - AccountPage( - localAccounts: widget.localAccounts, - activeUserLogin: widget.activeUserLogin, - account: widget.account), - const ChatsPage(), - ])), - // appBar: AppBar( - // toolbarHeight: 24, - // title: Text( - // 'C', - // style: Theme.of(context).textTheme.headlineSmall, - // ), - // ), - bottomNavigationBar: StylishBottomBar( - backgroundColor: scale.primaryScale.hoverBorder, - // gradient: LinearGradient( - // begin: Alignment.topCenter, - // end: Alignment.bottomCenter, - // colors: [ - // theme.colorScheme.primary, - // theme.colorScheme.primaryContainer, - // ]), - //borderRadius: BorderRadius.all(Radius.circular(16)), - option: AnimatedBarOptions( - // iconSize: 32, - //barAnimation: BarAnimation.fade, - iconStyle: IconStyle.animated, - inkEffect: true, - inkColor: scale.primaryScale.hoverBackground, - //opacity: 0.3, - ), - items: _buildBottomBarItems(), - hasNotch: true, - fabLocation: StylishBarFabLocation.end, - currentIndex: _currentPage, - onTap: (index) async { - await pageController.animateToPage(index, - duration: 250.ms, curve: Curves.easeInOut); - }, - ), - - floatingActionButton: BottomSheetActionButton( - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(14))), - foregroundColor: scale.secondaryScale.text, - backgroundColor: scale.secondaryScale.hoverBorder, - builder: (context) => Icon( - _fabIconList[_currentPage], - color: scale.secondaryScale.text, - ), - bottomSheetBuilder: _bottomSheetBuilder), - floatingActionButtonLocation: FloatingActionButtonLocation.endDocked, - ); - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(DiagnosticsProperty( - 'pageController', pageController)); - } -} diff --git a/lib/pages/new_account.dart b/lib/pages/new_account.dart deleted file mode 100644 index 3ef1b6d..0000000 --- a/lib/pages/new_account.dart +++ /dev/null @@ -1,175 +0,0 @@ -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_form_builder/flutter_form_builder.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_translate/flutter_translate.dart'; -import 'package:form_builder_validators/form_builder_validators.dart'; -import 'package:go_router/go_router.dart'; - -import '../components/default_app_bar.dart'; -import '../components/signal_strength_meter.dart'; -import '../entities/entities.dart'; -import '../providers/local_accounts.dart'; -import '../providers/logins.dart'; -import '../providers/window_control.dart'; -import '../tools/tools.dart'; -import '../veilid_support/veilid_support.dart'; - -class NewAccountPage extends ConsumerStatefulWidget { - const NewAccountPage({super.key}); - - @override - NewAccountPageState createState() => NewAccountPageState(); -} - -class NewAccountPageState extends ConsumerState { - final _formKey = GlobalKey(); - late bool isInAsyncCall = false; - static const String formFieldName = 'name'; - static const String formFieldPronouns = 'pronouns'; - - @override - void initState() { - super.initState(); - - WidgetsBinding.instance.addPostFrameCallback((_) async { - setState(() {}); - await ref.read(windowControlProvider.notifier).changeWindowSetup( - TitleBarStyle.normal, OrientationCapability.portraitOnly); - }); - } - - /// Creates a new master identity, an account associated with the master - /// identity, stores the account in the identity key and then logs into - /// that account with no password set at this time - Future createAccount() async { - final localAccounts = ref.read(localAccountsProvider.notifier); - final logins = ref.read(loginsProvider.notifier); - - final name = _formKey.currentState!.fields[formFieldName]!.value as String; - final pronouns = - _formKey.currentState!.fields[formFieldPronouns]!.value as String? ?? - ''; - - final imws = await IdentityMasterWithSecrets.create(); - try { - final localAccount = await localAccounts.newLocalAccount( - identityMaster: imws.identityMaster, - identitySecret: imws.identitySecret, - name: name, - pronouns: pronouns); - - // Log in the new account by default with no pin - final ok = await logins.login(localAccount.identityMaster.masterRecordKey, - EncryptionKeyType.none, ''); - assert(ok, 'login with none should never fail'); - } on Exception catch (_) { - await imws.delete(); - rethrow; - } - } - - Widget _newAccountForm(BuildContext context, - {required Future Function(GlobalKey) - onSubmit}) => - FormBuilder( - key: _formKey, - child: ListView( - children: [ - Text(translate('new_account_page.header')) - .textStyle(context.headlineSmall) - .paddingSymmetric(vertical: 16), - FormBuilderTextField( - autofocus: true, - name: formFieldName, - decoration: - InputDecoration(labelText: translate('account.form_name')), - maxLength: 64, - // The validator receives the text that the user has entered. - validator: FormBuilderValidators.compose([ - FormBuilderValidators.required(), - ]), - ), - FormBuilderTextField( - name: formFieldPronouns, - maxLength: 64, - decoration: InputDecoration( - labelText: translate('account.form_pronouns')), - ), - Row(children: [ - const Spacer(), - Text(translate('new_account_page.instructions')) - .toCenter() - .flexible(flex: 6), - const Spacer(), - ]).paddingSymmetric(vertical: 4), - ElevatedButton( - onPressed: () async { - if (_formKey.currentState?.saveAndValidate() ?? false) { - setState(() { - isInAsyncCall = true; - }); - try { - await onSubmit(_formKey); - } finally { - if (mounted) { - setState(() { - isInAsyncCall = false; - }); - } - } - } - }, - child: Text(translate('new_account_page.create')), - ).paddingSymmetric(vertical: 4).alignAtCenterRight(), - ], - ), - ); - - @override - Widget build(BuildContext context) { - ref.watch(windowControlProvider); - - final localAccounts = ref.watch(localAccountsProvider); - final logins = ref.watch(loginsProvider); - - final displayModalHUD = - isInAsyncCall || !localAccounts.hasValue || !logins.hasValue; - - return Scaffold( - // resizeToAvoidBottomInset: false, - appBar: DefaultAppBar( - title: Text(translate('new_account_page.titlebar')), - actions: [ - const SignalStrengthMeterWidget(), - IconButton( - icon: const Icon(Icons.settings), - tooltip: translate('app_bar.settings_tooltip'), - onPressed: () async { - context.go('/new_account/settings'); - }) - ]), - body: _newAccountForm( - context, - onSubmit: (formKey) async { - FocusScope.of(context).unfocus(); - try { - await createAccount(); - } on Exception catch (e) { - if (context.mounted) { - await showErrorModal(context, translate('new_account_page.error'), - 'Exception: $e'); - } - } - }, - ).paddingSymmetric(horizontal: 24, vertical: 8), - ).withModalHUD(context, displayModalHUD); - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(DiagnosticsProperty('isInAsyncCall', isInAsyncCall)); - } -} diff --git a/lib/pages/settings.dart b/lib/pages/settings.dart deleted file mode 100644 index dfbb816..0000000 --- a/lib/pages/settings.dart +++ /dev/null @@ -1,139 +0,0 @@ -import 'package:animated_theme_switcher/animated_theme_switcher.dart'; -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_form_builder/flutter_form_builder.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_translate/flutter_translate.dart'; - -import '../components/default_app_bar.dart'; -import '../components/signal_strength_meter.dart'; -import '../entities/preferences.dart'; -import '../providers/window_control.dart'; -import '../tools/tools.dart'; - -class SettingsPage extends ConsumerStatefulWidget { - const SettingsPage({super.key}); - - @override - SettingsPageState createState() => SettingsPageState(); -} - -class SettingsPageState extends ConsumerState { - final _formKey = GlobalKey(); - late bool isInAsyncCall = false; -// ThemePreferences? themePreferences; - static const String formFieldTheme = 'theme'; - static const String formFieldBrightness = 'brightness'; - // static const String formFieldTitle = 'title'; - - @override - void initState() { - super.initState(); - } - - List> _getThemeDropdownItems() { - const colorPrefs = ColorPreference.values; - final colorNames = { - ColorPreference.scarlet: translate('themes.scarlet'), - ColorPreference.vapor: translate('themes.vapor'), - ColorPreference.babydoll: translate('themes.babydoll'), - ColorPreference.gold: translate('themes.gold'), - ColorPreference.garden: translate('themes.garden'), - ColorPreference.forest: translate('themes.forest'), - ColorPreference.arctic: translate('themes.arctic'), - ColorPreference.lapis: translate('themes.lapis'), - ColorPreference.eggplant: translate('themes.eggplant'), - ColorPreference.lime: translate('themes.lime'), - ColorPreference.grim: translate('themes.grim'), - ColorPreference.contrast: translate('themes.contrast') - }; - - return colorPrefs - .map((e) => DropdownMenuItem(value: e, child: Text(colorNames[e]!))) - .toList(); - } - - List> _getBrightnessDropdownItems() { - const brightnessPrefs = BrightnessPreference.values; - final brightnessNames = { - BrightnessPreference.system: translate('brightness.system'), - BrightnessPreference.light: translate('brightness.light'), - BrightnessPreference.dark: translate('brightness.dark') - }; - - return brightnessPrefs - .map( - (e) => DropdownMenuItem(value: e, child: Text(brightnessNames[e]!))) - .toList(); - } - - @override - Widget build(BuildContext context) { - ref.watch(windowControlProvider); - final themeService = ref.watch(themeServiceProvider).valueOrNull; - if (themeService == null) { - return waitingPage(context); - } - final themePreferences = themeService.load(); - - return ThemeSwitchingArea( - child: Scaffold( - // resizeToAvoidBottomInset: false, - appBar: DefaultAppBar( - title: Text(translate('settings_page.titlebar')), - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => context.pop(), - ), - actions: [ - const SignalStrengthMeterWidget().paddingLTRB(16, 0, 16, 0), - ]), - - body: FormBuilder( - key: _formKey, - child: ListView( - children: [ - ThemeSwitcher.withTheme( - builder: (_, switcher, theme) => FormBuilderDropdown( - name: formFieldTheme, - decoration: InputDecoration( - label: Text(translate('settings_page.color_theme'))), - items: _getThemeDropdownItems(), - initialValue: themePreferences.colorPreference, - onChanged: (value) async { - final newPrefs = themePreferences.copyWith( - colorPreference: value as ColorPreference); - await themeService.save(newPrefs); - switcher.changeTheme(theme: themeService.get(newPrefs)); - ref.invalidate(themeServiceProvider); - setState(() {}); - })), - ThemeSwitcher.withTheme( - builder: (_, switcher, theme) => FormBuilderDropdown( - name: formFieldBrightness, - decoration: InputDecoration( - label: - Text(translate('settings_page.brightness_mode'))), - items: _getBrightnessDropdownItems(), - initialValue: themePreferences.brightnessPreference, - onChanged: (value) async { - final newPrefs = themePreferences.copyWith( - brightnessPreference: value as BrightnessPreference); - await themeService.save(newPrefs); - switcher.changeTheme(theme: themeService.get(newPrefs)); - ref.invalidate(themeServiceProvider); - setState(() {}); - })), - ], - ), - ).paddingSymmetric(horizontal: 24, vertical: 8), - )); - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(DiagnosticsProperty('isInAsyncCall', isInAsyncCall)); - } -} diff --git a/lib/processor.dart b/lib/processor.dart deleted file mode 100644 index be414b1..0000000 --- a/lib/processor.dart +++ /dev/null @@ -1,100 +0,0 @@ -import 'dart:async'; - -import 'package:veilid/veilid.dart'; - -import 'providers/connection_state.dart'; -import 'tools/tools.dart'; -import 'veilid_support/src/config.dart'; -import 'veilid_support/src/veilid_log.dart'; - -class Processor { - Processor(); - String _veilidVersion = ''; - bool _startedUp = false; - Stream? _updateStream; - Future? _updateProcessor; - - Future startup() async { - if (_startedUp) { - return; - } - - try { - _veilidVersion = Veilid.instance.veilidVersionString(); - } on Exception { - _veilidVersion = 'Failed to get veilid version.'; - } - - log.info('Veilid version: $_veilidVersion'); - - // In case of hot restart shut down first - try { - await Veilid.instance.shutdownVeilidCore(); - } on Exception {} - - final updateStream = - await Veilid.instance.startupVeilidCore(await getVeilidChatConfig()); - _updateStream = updateStream; - _updateProcessor = processUpdates(); - _startedUp = true; - - await Veilid.instance.attach(); - } - - Future shutdown() async { - if (!_startedUp) { - return; - } - await Veilid.instance.shutdownVeilidCore(); - if (_updateProcessor != null) { - await _updateProcessor; - } - _updateProcessor = null; - _updateStream = null; - _startedUp = false; - } - - Future processUpdateAttachment( - VeilidUpdateAttachment updateAttachment) async { - //loggy.info("Attachment: ${updateAttachment.json}"); - - // // Set connection meter and ui state for connection state - - connectionState.state = ConnectionState( - attachment: VeilidStateAttachment( - state: updateAttachment.state, - publicInternetReady: updateAttachment.publicInternetReady, - localNetworkReady: updateAttachment.localNetworkReady)); - } - - Future processUpdateConfig(VeilidUpdateConfig updateConfig) async { - //loggy.info("Config: ${updateConfig.json}"); - } - - Future processUpdateNetwork(VeilidUpdateNetwork updateNetwork) async { - //loggy.info("Network: ${updateNetwork.json}"); - } - - Future processUpdates() async { - final stream = _updateStream; - if (stream != null) { - await for (final update in stream) { - if (update is VeilidLog) { - await processLog(update); - } else if (update is VeilidUpdateAttachment) { - await processUpdateAttachment(update); - } else if (update is VeilidUpdateConfig) { - await processUpdateConfig(update); - } else if (update is VeilidUpdateNetwork) { - await processUpdateNetwork(update); - } else if (update is VeilidAppMessage) { - log.info('AppMessage: ${update.toJson()}'); - } else if (update is VeilidAppCall) { - log.info('AppCall: ${update.toJson()}'); - } else { - log.trace('Update: ${update.toJson()}'); - } - } - } - } -} diff --git a/lib/proto/extensions.dart b/lib/proto/extensions.dart new file mode 100644 index 0000000..2f4ad68 --- /dev/null +++ b/lib/proto/extensions.dart @@ -0,0 +1,50 @@ +import 'dart:typed_data'; + +import 'package:veilid_support/veilid_support.dart'; + +import 'proto.dart' as proto; + +proto.Message messageFromJson(Map j) => + proto.Message.create()..mergeFromJsonMap(j); + +Map messageToJson(proto.Message m) => m.writeToJsonMap(); + +proto.ReconciledMessage reconciledMessageFromJson(Map j) => + proto.ReconciledMessage.create()..mergeFromJsonMap(j); + +Map reconciledMessageToJson(proto.ReconciledMessage m) => + m.writeToJsonMap(); + +extension MessageExt on proto.Message { + Uint8List get idBytes => Uint8List.fromList(id); + + Uint8List get authorUniqueIdBytes { + final author = this.author.toVeilid().decode(); + final id = this.id; + return Uint8List.fromList([...author, ...id]); + } + + String get authorUniqueIdString => base64UrlNoPadEncode(authorUniqueIdBytes); + + static int compareTimestamp(proto.Message a, proto.Message b) => + a.timestamp.compareTo(b.timestamp); +} + +extension ContactExt on proto.Contact { + String get nameOrNickname => nickname.isNotEmpty ? nickname : profile.name; + String get displayName => + nickname.isNotEmpty ? '$nickname (${profile.name})' : profile.name; +} + +extension ChatExt on proto.Chat { + TypedKey get localConversationRecordKey { + switch (whichKind()) { + case proto.Chat_Kind.direct: + return direct.localConversationRecordKey.toVeilid(); + case proto.Chat_Kind.group: + return group.localConversationRecordKey.toVeilid(); + case proto.Chat_Kind.notSet: + throw StateError('unknown chat kind'); + } + } +} diff --git a/lib/proto/proto.dart b/lib/proto/proto.dart index 9d1aeb6..21c988a 100644 --- a/lib/proto/proto.dart +++ b/lib/proto/proto.dart @@ -1,6 +1,300 @@ -export '../veilid_support/dht_support/proto/proto.dart'; -export '../veilid_support/proto/proto.dart'; +import 'package:veilid_support/veilid_support.dart'; +import 'veilidchat.pb.dart' as vcproto; + +export 'package:veilid_support/dht_support/proto/proto.dart'; +export 'package:veilid_support/proto/proto.dart'; + +export 'extensions.dart'; export 'veilidchat.pb.dart'; export 'veilidchat.pbenum.dart'; export 'veilidchat.pbjson.dart'; export 'veilidchat.pbserver.dart'; + +void registerVeilidchatProtoToDebug() { + dynamic toDebug(dynamic obj) { + if (obj is vcproto.DHTDataReference) { + return { + 'dhtData': obj.dhtData, + 'hash': obj.hash, + }; + } + if (obj is vcproto.BlockStoreDataReference) { + return { + 'block': obj.block, + }; + } + if (obj is vcproto.DataReference) { + return { + 'kind': obj.whichKind(), + if (obj.whichKind() == vcproto.DataReference_Kind.dhtData) + 'dhtData': obj.dhtData, + if (obj.whichKind() == vcproto.DataReference_Kind.blockStoreData) + 'blockStoreData': obj.blockStoreData, + }; + } + if (obj is vcproto.Attachment) { + return { + 'kind': obj.whichKind(), + if (obj.whichKind() == vcproto.Attachment_Kind.media) + 'media': obj.media, + 'signature': obj.signature, + }; + } + if (obj is vcproto.AttachmentMedia) { + return { + 'mime': obj.mime, + 'name': obj.name, + 'content': obj.content, + }; + } + if (obj is vcproto.Permissions) { + return { + 'canAddMembers': obj.canAddMembers, + 'canEditInfo': obj.canEditInfo, + 'moderated': obj.moderated, + }; + } + if (obj is vcproto.Membership) { + return { + 'watchers': obj.watchers, + 'moderated': obj.moderated, + 'talkers': obj.talkers, + 'moderators': obj.moderators, + 'admins': obj.admins, + }; + } + if (obj is vcproto.ChatSettings) { + return { + 'title': obj.title, + 'description': obj.description, + 'icon': obj.icon, + 'defaultExpiration': obj.defaultExpiration, + }; + } + if (obj is vcproto.ChatSettings) { + return { + 'title': obj.title, + 'description': obj.description, + 'icon': obj.icon, + 'defaultExpiration': obj.defaultExpiration, + }; + } + if (obj is vcproto.Message) { + return { + 'id': obj.id, + 'author': obj.author, + 'timestamp': obj.timestamp, + 'kind': obj.whichKind(), + if (obj.whichKind() == vcproto.Message_Kind.text) 'text': obj.text, + if (obj.whichKind() == vcproto.Message_Kind.secret) + 'secret': obj.secret, + if (obj.whichKind() == vcproto.Message_Kind.delete) + 'delete': obj.delete, + if (obj.whichKind() == vcproto.Message_Kind.erase) 'erase': obj.erase, + if (obj.whichKind() == vcproto.Message_Kind.settings) + 'settings': obj.settings, + if (obj.whichKind() == vcproto.Message_Kind.permissions) + 'permissions': obj.permissions, + if (obj.whichKind() == vcproto.Message_Kind.membership) + 'membership': obj.membership, + if (obj.whichKind() == vcproto.Message_Kind.moderation) + 'moderation': obj.moderation, + 'signature': obj.signature, + }; + } + if (obj is vcproto.Message_Text) { + return { + 'text': obj.text, + 'topic': obj.topic, + 'replyId': obj.replyId, + 'expiration': obj.expiration, + 'viewLimit': obj.viewLimit, + 'attachments': obj.attachments, + }; + } + if (obj is vcproto.Message_Secret) { + return { + 'ciphertext': obj.ciphertext, + 'expiration': obj.expiration, + }; + } + if (obj is vcproto.Message_ControlDelete) { + return { + 'ids': obj.ids, + }; + } + if (obj is vcproto.Message_ControlErase) { + return { + 'timestamp': obj.timestamp, + }; + } + if (obj is vcproto.Message_ControlSettings) { + return { + 'settings': obj.settings, + }; + } + if (obj is vcproto.Message_ControlPermissions) { + return { + 'permissions': obj.permissions, + }; + } + if (obj is vcproto.Message_ControlMembership) { + return { + 'membership': obj.membership, + }; + } + if (obj is vcproto.Message_ControlModeration) { + return { + 'acceptedIds': obj.acceptedIds, + 'rejectdIds': obj.rejectedIds, + }; + } + if (obj is vcproto.Message_ControlModeration) { + return { + 'acceptedIds': obj.acceptedIds, + 'rejectdIds': obj.rejectedIds, + }; + } + if (obj is vcproto.Message_ControlReadReceipt) { + return { + 'readIds': obj.readIds, + }; + } + if (obj is vcproto.ReconciledMessage) { + return { + 'content': obj.content, + 'reconciledTime': obj.reconciledTime, + }; + } + if (obj is vcproto.Conversation) { + return { + 'profile': obj.profile, + 'superIdentityJson': obj.superIdentityJson, + 'messages': obj.messages + }; + } + if (obj is vcproto.ChatMember) { + return { + 'remoteIdentityPublicKey': obj.remoteIdentityPublicKey, + 'remoteConversationRecordKey': obj.remoteConversationRecordKey, + }; + } + if (obj is vcproto.DirectChat) { + return { + 'settings': obj.settings, + 'localConversationRecordKey': obj.localConversationRecordKey, + 'remoteMember': obj.remoteMember, + }; + } + if (obj is vcproto.GroupChat) { + return { + 'settings': obj.settings, + 'membership': obj.membership, + 'permissions': obj.permissions, + 'localConversationRecordKey': obj.localConversationRecordKey, + 'remoteMembers': obj.remoteMembers, + }; + } + if (obj is vcproto.Chat) { + return { + 'kind': obj.whichKind(), + if (obj.whichKind() == vcproto.Chat_Kind.direct) 'direct': obj.direct, + if (obj.whichKind() == vcproto.Chat_Kind.group) 'group': obj.group, + }; + } + if (obj is vcproto.Profile) { + return { + 'name': obj.name, + 'pronouns': obj.pronouns, + 'about': obj.about, + 'status': obj.status, + 'availability': obj.availability, + 'avatar': obj.avatar, + 'timestamp': obj.timestamp, + }; + } + if (obj is vcproto.Account) { + return { + 'profile': obj.profile, + 'invisible': obj.invisible, + 'autoAwayTimeoutMin': obj.autoAwayTimeoutMin, + 'contact_list': obj.contactList, + 'contactInvitationRecords': obj.contactInvitationRecords, + 'chatList': obj.chatList, + 'groupChatList': obj.groupChatList, + 'freeMessage': obj.freeMessage, + 'busyMessage': obj.busyMessage, + 'awayMessage': obj.awayMessage, + 'autodetectAway': obj.autodetectAway, + }; + } + if (obj is vcproto.Contact) { + return { + 'nickname': obj.nickname, + 'profile': obj.profile, + 'superIdentityJson': obj.superIdentityJson, + 'identityPublicKey': obj.identityPublicKey, + 'remoteConversationRecordKey': obj.remoteConversationRecordKey, + 'localConversationRecordKey': obj.localConversationRecordKey, + 'showAvailability': obj.showAvailability, + 'notes': obj.notes, + }; + } + if (obj is vcproto.ContactInvitation) { + return { + 'contactRequestInboxKey': obj.contactRequestInboxKey, + 'writerSecret': obj.writerSecret, + }; + } + if (obj is vcproto.SignedContactInvitation) { + return { + 'contactInvitation': obj.contactInvitation, + 'identitySignature': obj.identitySignature, + }; + } + if (obj is vcproto.ContactRequest) { + return { + 'encryptionKeyType': obj.encryptionKeyType, + 'private': obj.private, + }; + } + if (obj is vcproto.ContactRequestPrivate) { + return { + 'writerKey': obj.writerKey, + 'profile': obj.profile, + 'superIdentityRecordKey': obj.superIdentityRecordKey, + 'chatRecordKey': obj.chatRecordKey, + 'expiration': obj.expiration, + }; + } + if (obj is vcproto.ContactResponse) { + return { + 'accept': obj.accept, + 'superIdentityRecordKey': obj.superIdentityRecordKey, + 'remoteConversationRecordKey': obj.remoteConversationRecordKey, + }; + } + if (obj is vcproto.SignedContactResponse) { + return { + 'contactResponse': obj.contactResponse, + 'identitySignature': obj.identitySignature, + }; + } + if (obj is vcproto.ContactInvitationRecord) { + return { + 'contactRequestInbox': obj.contactRequestInbox, + 'writerKey': obj.writerKey, + 'writerSecret': obj.writerSecret, + 'localConversationRecordKey': obj.localConversationRecordKey, + 'expiration': obj.expiration, + 'invitation': obj.invitation, + 'message': obj.message, + 'recipient': obj.recipient, + }; + } + + return obj; + } + + DynamicDebug.registerToDebug(toDebug); +} diff --git a/lib/proto/veilidchat.pb.dart b/lib/proto/veilidchat.pb.dart index 6628b46..67805ae 100644 --- a/lib/proto/veilidchat.pb.dart +++ b/lib/proto/veilidchat.pb.dart @@ -4,7 +4,7 @@ // // @dart = 2.12 -// ignore_for_file: annotate_overrides, camel_case_types +// ignore_for_file: annotate_overrides, camel_case_types, comment_references // ignore_for_file: constant_identifier_names, library_prefixes // ignore_for_file: non_constant_identifier_names, prefer_final_fields // ignore_for_file: unnecessary_import, unnecessary_this, unused_import @@ -14,24 +14,252 @@ import 'dart:core' as $core; import 'package:fixnum/fixnum.dart' as $fixnum; import 'package:protobuf/protobuf.dart' as $pb; -import 'dht.pb.dart' as $0; -import 'veilid.pb.dart' as $1; +import 'package:veilid_support/proto/dht.pb.dart' as $1; +import 'package:veilid_support/proto/veilid.pb.dart' as $0; import 'veilidchat.pbenum.dart'; export 'veilidchat.pbenum.dart'; +/// Reference to data on the DHT +class DHTDataReference extends $pb.GeneratedMessage { + factory DHTDataReference({ + $0.TypedKey? dhtData, + $0.TypedKey? hash, + }) { + final $result = create(); + if (dhtData != null) { + $result.dhtData = dhtData; + } + if (hash != null) { + $result.hash = hash; + } + return $result; + } + DHTDataReference._() : super(); + factory DHTDataReference.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory DHTDataReference.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'DHTDataReference', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) + ..aOM<$0.TypedKey>(1, _omitFieldNames ? '' : 'dhtData', subBuilder: $0.TypedKey.create) + ..aOM<$0.TypedKey>(2, _omitFieldNames ? '' : 'hash', subBuilder: $0.TypedKey.create) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + DHTDataReference clone() => DHTDataReference()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + DHTDataReference copyWith(void Function(DHTDataReference) updates) => super.copyWith((message) => updates(message as DHTDataReference)) as DHTDataReference; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static DHTDataReference create() => DHTDataReference._(); + DHTDataReference createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static DHTDataReference getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static DHTDataReference? _defaultInstance; + + @$pb.TagNumber(1) + $0.TypedKey get dhtData => $_getN(0); + @$pb.TagNumber(1) + set dhtData($0.TypedKey v) { setField(1, v); } + @$pb.TagNumber(1) + $core.bool hasDhtData() => $_has(0); + @$pb.TagNumber(1) + void clearDhtData() => clearField(1); + @$pb.TagNumber(1) + $0.TypedKey ensureDhtData() => $_ensure(0); + + @$pb.TagNumber(2) + $0.TypedKey get hash => $_getN(1); + @$pb.TagNumber(2) + set hash($0.TypedKey v) { setField(2, v); } + @$pb.TagNumber(2) + $core.bool hasHash() => $_has(1); + @$pb.TagNumber(2) + void clearHash() => clearField(2); + @$pb.TagNumber(2) + $0.TypedKey ensureHash() => $_ensure(1); +} + +/// Reference to data on the BlockStore +class BlockStoreDataReference extends $pb.GeneratedMessage { + factory BlockStoreDataReference({ + $0.TypedKey? block, + }) { + final $result = create(); + if (block != null) { + $result.block = block; + } + return $result; + } + BlockStoreDataReference._() : super(); + factory BlockStoreDataReference.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory BlockStoreDataReference.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'BlockStoreDataReference', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) + ..aOM<$0.TypedKey>(1, _omitFieldNames ? '' : 'block', subBuilder: $0.TypedKey.create) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + BlockStoreDataReference clone() => BlockStoreDataReference()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + BlockStoreDataReference copyWith(void Function(BlockStoreDataReference) updates) => super.copyWith((message) => updates(message as BlockStoreDataReference)) as BlockStoreDataReference; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static BlockStoreDataReference create() => BlockStoreDataReference._(); + BlockStoreDataReference createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static BlockStoreDataReference getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static BlockStoreDataReference? _defaultInstance; + + @$pb.TagNumber(1) + $0.TypedKey get block => $_getN(0); + @$pb.TagNumber(1) + set block($0.TypedKey v) { setField(1, v); } + @$pb.TagNumber(1) + $core.bool hasBlock() => $_has(0); + @$pb.TagNumber(1) + void clearBlock() => clearField(1); + @$pb.TagNumber(1) + $0.TypedKey ensureBlock() => $_ensure(0); +} + +enum DataReference_Kind { + dhtData, + blockStoreData, + notSet +} + +/// DataReference +/// Pointer to data somewhere in Veilid +/// Abstraction over DHTData and BlockStore +class DataReference extends $pb.GeneratedMessage { + factory DataReference({ + DHTDataReference? dhtData, + BlockStoreDataReference? blockStoreData, + }) { + final $result = create(); + if (dhtData != null) { + $result.dhtData = dhtData; + } + if (blockStoreData != null) { + $result.blockStoreData = blockStoreData; + } + return $result; + } + DataReference._() : super(); + factory DataReference.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory DataReference.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static const $core.Map<$core.int, DataReference_Kind> _DataReference_KindByTag = { + 1 : DataReference_Kind.dhtData, + 2 : DataReference_Kind.blockStoreData, + 0 : DataReference_Kind.notSet + }; + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'DataReference', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) + ..oo(0, [1, 2]) + ..aOM(1, _omitFieldNames ? '' : 'dhtData', subBuilder: DHTDataReference.create) + ..aOM(2, _omitFieldNames ? '' : 'blockStoreData', subBuilder: BlockStoreDataReference.create) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + DataReference clone() => DataReference()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + DataReference copyWith(void Function(DataReference) updates) => super.copyWith((message) => updates(message as DataReference)) as DataReference; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static DataReference create() => DataReference._(); + DataReference createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static DataReference getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static DataReference? _defaultInstance; + + DataReference_Kind whichKind() => _DataReference_KindByTag[$_whichOneof(0)]!; + void clearKind() => clearField($_whichOneof(0)); + + @$pb.TagNumber(1) + DHTDataReference get dhtData => $_getN(0); + @$pb.TagNumber(1) + set dhtData(DHTDataReference v) { setField(1, v); } + @$pb.TagNumber(1) + $core.bool hasDhtData() => $_has(0); + @$pb.TagNumber(1) + void clearDhtData() => clearField(1); + @$pb.TagNumber(1) + DHTDataReference ensureDhtData() => $_ensure(0); + + @$pb.TagNumber(2) + BlockStoreDataReference get blockStoreData => $_getN(1); + @$pb.TagNumber(2) + set blockStoreData(BlockStoreDataReference v) { setField(2, v); } + @$pb.TagNumber(2) + $core.bool hasBlockStoreData() => $_has(1); + @$pb.TagNumber(2) + void clearBlockStoreData() => clearField(2); + @$pb.TagNumber(2) + BlockStoreDataReference ensureBlockStoreData() => $_ensure(1); +} + +enum Attachment_Kind { + media, + notSet +} + +/// A single attachment class Attachment extends $pb.GeneratedMessage { - factory Attachment() => create(); + factory Attachment({ + AttachmentMedia? media, + $0.Signature? signature, + }) { + final $result = create(); + if (media != null) { + $result.media = media; + } + if (signature != null) { + $result.signature = signature; + } + return $result; + } Attachment._() : super(); factory Attachment.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory Attachment.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + static const $core.Map<$core.int, Attachment_Kind> _Attachment_KindByTag = { + 1 : Attachment_Kind.media, + 0 : Attachment_Kind.notSet + }; static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'Attachment', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) - ..e(1, _omitFieldNames ? '' : 'kind', $pb.PbFieldType.OE, defaultOrMaker: AttachmentKind.ATTACHMENT_KIND_UNSPECIFIED, valueOf: AttachmentKind.valueOf, enumValues: AttachmentKind.values) - ..aOS(2, _omitFieldNames ? '' : 'mime') - ..aOS(3, _omitFieldNames ? '' : 'name') - ..aOM<$0.DataReference>(4, _omitFieldNames ? '' : 'content', subBuilder: $0.DataReference.create) - ..aOM<$1.Signature>(5, _omitFieldNames ? '' : 'signature', subBuilder: $1.Signature.create) + ..oo(0, [1]) + ..aOM(1, _omitFieldNames ? '' : 'media', subBuilder: AttachmentMedia.create) + ..aOM<$0.Signature>(2, _omitFieldNames ? '' : 'signature', subBuilder: $0.Signature.create) ..hasRequiredFields = false ; @@ -56,68 +284,1032 @@ class Attachment extends $pb.GeneratedMessage { static Attachment getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static Attachment? _defaultInstance; - @$pb.TagNumber(1) - AttachmentKind get kind => $_getN(0); - @$pb.TagNumber(1) - set kind(AttachmentKind v) { setField(1, v); } - @$pb.TagNumber(1) - $core.bool hasKind() => $_has(0); - @$pb.TagNumber(1) - void clearKind() => clearField(1); + Attachment_Kind whichKind() => _Attachment_KindByTag[$_whichOneof(0)]!; + void clearKind() => clearField($_whichOneof(0)); - @$pb.TagNumber(2) - $core.String get mime => $_getSZ(1); - @$pb.TagNumber(2) - set mime($core.String v) { $_setString(1, v); } - @$pb.TagNumber(2) - $core.bool hasMime() => $_has(1); - @$pb.TagNumber(2) - void clearMime() => clearField(2); + @$pb.TagNumber(1) + AttachmentMedia get media => $_getN(0); + @$pb.TagNumber(1) + set media(AttachmentMedia v) { setField(1, v); } + @$pb.TagNumber(1) + $core.bool hasMedia() => $_has(0); + @$pb.TagNumber(1) + void clearMedia() => clearField(1); + @$pb.TagNumber(1) + AttachmentMedia ensureMedia() => $_ensure(0); - @$pb.TagNumber(3) - $core.String get name => $_getSZ(2); - @$pb.TagNumber(3) - set name($core.String v) { $_setString(2, v); } - @$pb.TagNumber(3) - $core.bool hasName() => $_has(2); - @$pb.TagNumber(3) - void clearName() => clearField(3); - - @$pb.TagNumber(4) - $0.DataReference get content => $_getN(3); - @$pb.TagNumber(4) - set content($0.DataReference v) { setField(4, v); } - @$pb.TagNumber(4) - $core.bool hasContent() => $_has(3); - @$pb.TagNumber(4) - void clearContent() => clearField(4); - @$pb.TagNumber(4) - $0.DataReference ensureContent() => $_ensure(3); - - @$pb.TagNumber(5) - $1.Signature get signature => $_getN(4); - @$pb.TagNumber(5) - set signature($1.Signature v) { setField(5, v); } - @$pb.TagNumber(5) - $core.bool hasSignature() => $_has(4); - @$pb.TagNumber(5) - void clearSignature() => clearField(5); - @$pb.TagNumber(5) - $1.Signature ensureSignature() => $_ensure(4); + /// Author signature over all attachment fields and content fields and bytes + @$pb.TagNumber(2) + $0.Signature get signature => $_getN(1); + @$pb.TagNumber(2) + set signature($0.Signature v) { setField(2, v); } + @$pb.TagNumber(2) + $core.bool hasSignature() => $_has(1); + @$pb.TagNumber(2) + void clearSignature() => clearField(2); + @$pb.TagNumber(2) + $0.Signature ensureSignature() => $_ensure(1); } +/// A file, audio, image, or video attachment +class AttachmentMedia extends $pb.GeneratedMessage { + factory AttachmentMedia({ + $core.String? mime, + $core.String? name, + DataReference? content, + }) { + final $result = create(); + if (mime != null) { + $result.mime = mime; + } + if (name != null) { + $result.name = name; + } + if (content != null) { + $result.content = content; + } + return $result; + } + AttachmentMedia._() : super(); + factory AttachmentMedia.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory AttachmentMedia.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'AttachmentMedia', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) + ..aOS(1, _omitFieldNames ? '' : 'mime') + ..aOS(2, _omitFieldNames ? '' : 'name') + ..aOM(3, _omitFieldNames ? '' : 'content', subBuilder: DataReference.create) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + AttachmentMedia clone() => AttachmentMedia()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + AttachmentMedia copyWith(void Function(AttachmentMedia) updates) => super.copyWith((message) => updates(message as AttachmentMedia)) as AttachmentMedia; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static AttachmentMedia create() => AttachmentMedia._(); + AttachmentMedia createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static AttachmentMedia getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static AttachmentMedia? _defaultInstance; + + /// MIME type of the data + @$pb.TagNumber(1) + $core.String get mime => $_getSZ(0); + @$pb.TagNumber(1) + set mime($core.String v) { $_setString(0, v); } + @$pb.TagNumber(1) + $core.bool hasMime() => $_has(0); + @$pb.TagNumber(1) + void clearMime() => clearField(1); + + /// Title or filename + @$pb.TagNumber(2) + $core.String get name => $_getSZ(1); + @$pb.TagNumber(2) + set name($core.String v) { $_setString(1, v); } + @$pb.TagNumber(2) + $core.bool hasName() => $_has(1); + @$pb.TagNumber(2) + void clearName() => clearField(2); + + /// Pointer to the data content + @$pb.TagNumber(3) + DataReference get content => $_getN(2); + @$pb.TagNumber(3) + set content(DataReference v) { setField(3, v); } + @$pb.TagNumber(3) + $core.bool hasContent() => $_has(2); + @$pb.TagNumber(3) + void clearContent() => clearField(3); + @$pb.TagNumber(3) + DataReference ensureContent() => $_ensure(2); +} + +/// Permissions of a chat +class Permissions extends $pb.GeneratedMessage { + factory Permissions({ + Scope? canAddMembers, + Scope? canEditInfo, + $core.bool? moderated, + }) { + final $result = create(); + if (canAddMembers != null) { + $result.canAddMembers = canAddMembers; + } + if (canEditInfo != null) { + $result.canEditInfo = canEditInfo; + } + if (moderated != null) { + $result.moderated = moderated; + } + return $result; + } + Permissions._() : super(); + factory Permissions.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory Permissions.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'Permissions', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) + ..e(1, _omitFieldNames ? '' : 'canAddMembers', $pb.PbFieldType.OE, defaultOrMaker: Scope.WATCHERS, valueOf: Scope.valueOf, enumValues: Scope.values) + ..e(2, _omitFieldNames ? '' : 'canEditInfo', $pb.PbFieldType.OE, defaultOrMaker: Scope.WATCHERS, valueOf: Scope.valueOf, enumValues: Scope.values) + ..aOB(3, _omitFieldNames ? '' : 'moderated') + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + Permissions clone() => Permissions()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + Permissions copyWith(void Function(Permissions) updates) => super.copyWith((message) => updates(message as Permissions)) as Permissions; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static Permissions create() => Permissions._(); + Permissions createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static Permissions getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static Permissions? _defaultInstance; + + /// Parties in this scope or higher can add members to their own group or lower + @$pb.TagNumber(1) + Scope get canAddMembers => $_getN(0); + @$pb.TagNumber(1) + set canAddMembers(Scope v) { setField(1, v); } + @$pb.TagNumber(1) + $core.bool hasCanAddMembers() => $_has(0); + @$pb.TagNumber(1) + void clearCanAddMembers() => clearField(1); + + /// Parties in this scope or higher can change the 'info' of a group + @$pb.TagNumber(2) + Scope get canEditInfo => $_getN(1); + @$pb.TagNumber(2) + set canEditInfo(Scope v) { setField(2, v); } + @$pb.TagNumber(2) + $core.bool hasCanEditInfo() => $_has(1); + @$pb.TagNumber(2) + void clearCanEditInfo() => clearField(2); + + /// If moderation is enabled or not. + @$pb.TagNumber(3) + $core.bool get moderated => $_getBF(2); + @$pb.TagNumber(3) + set moderated($core.bool v) { $_setBool(2, v); } + @$pb.TagNumber(3) + $core.bool hasModerated() => $_has(2); + @$pb.TagNumber(3) + void clearModerated() => clearField(3); +} + +/// The membership of a chat +class Membership extends $pb.GeneratedMessage { + factory Membership({ + $core.Iterable<$0.TypedKey>? watchers, + $core.Iterable<$0.TypedKey>? moderated, + $core.Iterable<$0.TypedKey>? talkers, + $core.Iterable<$0.TypedKey>? moderators, + $core.Iterable<$0.TypedKey>? admins, + }) { + final $result = create(); + if (watchers != null) { + $result.watchers.addAll(watchers); + } + if (moderated != null) { + $result.moderated.addAll(moderated); + } + if (talkers != null) { + $result.talkers.addAll(talkers); + } + if (moderators != null) { + $result.moderators.addAll(moderators); + } + if (admins != null) { + $result.admins.addAll(admins); + } + return $result; + } + Membership._() : super(); + factory Membership.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory Membership.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'Membership', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) + ..pc<$0.TypedKey>(1, _omitFieldNames ? '' : 'watchers', $pb.PbFieldType.PM, subBuilder: $0.TypedKey.create) + ..pc<$0.TypedKey>(2, _omitFieldNames ? '' : 'moderated', $pb.PbFieldType.PM, subBuilder: $0.TypedKey.create) + ..pc<$0.TypedKey>(3, _omitFieldNames ? '' : 'talkers', $pb.PbFieldType.PM, subBuilder: $0.TypedKey.create) + ..pc<$0.TypedKey>(4, _omitFieldNames ? '' : 'moderators', $pb.PbFieldType.PM, subBuilder: $0.TypedKey.create) + ..pc<$0.TypedKey>(5, _omitFieldNames ? '' : 'admins', $pb.PbFieldType.PM, subBuilder: $0.TypedKey.create) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + Membership clone() => Membership()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + Membership copyWith(void Function(Membership) updates) => super.copyWith((message) => updates(message as Membership)) as Membership; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static Membership create() => Membership._(); + Membership createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static Membership getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static Membership? _defaultInstance; + + /// Conversation keys for parties in the 'watchers' group + @$pb.TagNumber(1) + $core.List<$0.TypedKey> get watchers => $_getList(0); + + /// Conversation keys for parties in the 'moderated' group + @$pb.TagNumber(2) + $core.List<$0.TypedKey> get moderated => $_getList(1); + + /// Conversation keys for parties in the 'talkers' group + @$pb.TagNumber(3) + $core.List<$0.TypedKey> get talkers => $_getList(2); + + /// Conversation keys for parties in the 'moderators' group + @$pb.TagNumber(4) + $core.List<$0.TypedKey> get moderators => $_getList(3); + + /// Conversation keys for parties in the 'admins' group + @$pb.TagNumber(5) + $core.List<$0.TypedKey> get admins => $_getList(4); +} + +/// The chat settings +class ChatSettings extends $pb.GeneratedMessage { + factory ChatSettings({ + $core.String? title, + $core.String? description, + DataReference? icon, + $fixnum.Int64? defaultExpiration, + }) { + final $result = create(); + if (title != null) { + $result.title = title; + } + if (description != null) { + $result.description = description; + } + if (icon != null) { + $result.icon = icon; + } + if (defaultExpiration != null) { + $result.defaultExpiration = defaultExpiration; + } + return $result; + } + ChatSettings._() : super(); + factory ChatSettings.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory ChatSettings.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'ChatSettings', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) + ..aOS(1, _omitFieldNames ? '' : 'title') + ..aOS(2, _omitFieldNames ? '' : 'description') + ..aOM(3, _omitFieldNames ? '' : 'icon', subBuilder: DataReference.create) + ..a<$fixnum.Int64>(4, _omitFieldNames ? '' : 'defaultExpiration', $pb.PbFieldType.OU6, defaultOrMaker: $fixnum.Int64.ZERO) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + ChatSettings clone() => ChatSettings()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + ChatSettings copyWith(void Function(ChatSettings) updates) => super.copyWith((message) => updates(message as ChatSettings)) as ChatSettings; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static ChatSettings create() => ChatSettings._(); + ChatSettings createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static ChatSettings getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static ChatSettings? _defaultInstance; + + /// Title for the chat + @$pb.TagNumber(1) + $core.String get title => $_getSZ(0); + @$pb.TagNumber(1) + set title($core.String v) { $_setString(0, v); } + @$pb.TagNumber(1) + $core.bool hasTitle() => $_has(0); + @$pb.TagNumber(1) + void clearTitle() => clearField(1); + + /// Description for the chat + @$pb.TagNumber(2) + $core.String get description => $_getSZ(1); + @$pb.TagNumber(2) + set description($core.String v) { $_setString(1, v); } + @$pb.TagNumber(2) + $core.bool hasDescription() => $_has(1); + @$pb.TagNumber(2) + void clearDescription() => clearField(2); + + /// Icon for the chat + @$pb.TagNumber(3) + DataReference get icon => $_getN(2); + @$pb.TagNumber(3) + set icon(DataReference v) { setField(3, v); } + @$pb.TagNumber(3) + $core.bool hasIcon() => $_has(2); + @$pb.TagNumber(3) + void clearIcon() => clearField(3); + @$pb.TagNumber(3) + DataReference ensureIcon() => $_ensure(2); + + /// Default message expiration duration (in us) + @$pb.TagNumber(4) + $fixnum.Int64 get defaultExpiration => $_getI64(3); + @$pb.TagNumber(4) + set defaultExpiration($fixnum.Int64 v) { $_setInt64(3, v); } + @$pb.TagNumber(4) + $core.bool hasDefaultExpiration() => $_has(3); + @$pb.TagNumber(4) + void clearDefaultExpiration() => clearField(4); +} + +/// A text message +class Message_Text extends $pb.GeneratedMessage { + factory Message_Text({ + $core.String? text, + $core.String? topic, + $core.List<$core.int>? replyId, + $fixnum.Int64? expiration, + $core.int? viewLimit, + $core.Iterable? attachments, + }) { + final $result = create(); + if (text != null) { + $result.text = text; + } + if (topic != null) { + $result.topic = topic; + } + if (replyId != null) { + $result.replyId = replyId; + } + if (expiration != null) { + $result.expiration = expiration; + } + if (viewLimit != null) { + $result.viewLimit = viewLimit; + } + if (attachments != null) { + $result.attachments.addAll(attachments); + } + return $result; + } + Message_Text._() : super(); + factory Message_Text.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory Message_Text.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'Message.Text', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) + ..aOS(1, _omitFieldNames ? '' : 'text') + ..aOS(2, _omitFieldNames ? '' : 'topic') + ..a<$core.List<$core.int>>(3, _omitFieldNames ? '' : 'replyId', $pb.PbFieldType.OY) + ..a<$fixnum.Int64>(4, _omitFieldNames ? '' : 'expiration', $pb.PbFieldType.OU6, defaultOrMaker: $fixnum.Int64.ZERO) + ..a<$core.int>(5, _omitFieldNames ? '' : 'viewLimit', $pb.PbFieldType.OU3) + ..pc(6, _omitFieldNames ? '' : 'attachments', $pb.PbFieldType.PM, subBuilder: Attachment.create) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + Message_Text clone() => Message_Text()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + Message_Text copyWith(void Function(Message_Text) updates) => super.copyWith((message) => updates(message as Message_Text)) as Message_Text; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static Message_Text create() => Message_Text._(); + Message_Text createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static Message_Text getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static Message_Text? _defaultInstance; + + /// Text of the message + @$pb.TagNumber(1) + $core.String get text => $_getSZ(0); + @$pb.TagNumber(1) + set text($core.String v) { $_setString(0, v); } + @$pb.TagNumber(1) + $core.bool hasText() => $_has(0); + @$pb.TagNumber(1) + void clearText() => clearField(1); + + /// Topic of the message / Content warning + @$pb.TagNumber(2) + $core.String get topic => $_getSZ(1); + @$pb.TagNumber(2) + set topic($core.String v) { $_setString(1, v); } + @$pb.TagNumber(2) + $core.bool hasTopic() => $_has(1); + @$pb.TagNumber(2) + void clearTopic() => clearField(2); + + /// Message id replied to (author id + message id) + @$pb.TagNumber(3) + $core.List<$core.int> get replyId => $_getN(2); + @$pb.TagNumber(3) + set replyId($core.List<$core.int> v) { $_setBytes(2, v); } + @$pb.TagNumber(3) + $core.bool hasReplyId() => $_has(2); + @$pb.TagNumber(3) + void clearReplyId() => clearField(3); + + /// Message expiration timestamp + @$pb.TagNumber(4) + $fixnum.Int64 get expiration => $_getI64(3); + @$pb.TagNumber(4) + set expiration($fixnum.Int64 v) { $_setInt64(3, v); } + @$pb.TagNumber(4) + $core.bool hasExpiration() => $_has(3); + @$pb.TagNumber(4) + void clearExpiration() => clearField(4); + + /// Message view limit before deletion + @$pb.TagNumber(5) + $core.int get viewLimit => $_getIZ(4); + @$pb.TagNumber(5) + set viewLimit($core.int v) { $_setUnsignedInt32(4, v); } + @$pb.TagNumber(5) + $core.bool hasViewLimit() => $_has(4); + @$pb.TagNumber(5) + void clearViewLimit() => clearField(5); + + /// Attachments on the message + @$pb.TagNumber(6) + $core.List get attachments => $_getList(5); +} + +/// A secret message +class Message_Secret extends $pb.GeneratedMessage { + factory Message_Secret({ + $core.List<$core.int>? ciphertext, + $fixnum.Int64? expiration, + }) { + final $result = create(); + if (ciphertext != null) { + $result.ciphertext = ciphertext; + } + if (expiration != null) { + $result.expiration = expiration; + } + return $result; + } + Message_Secret._() : super(); + factory Message_Secret.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory Message_Secret.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'Message.Secret', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) + ..a<$core.List<$core.int>>(1, _omitFieldNames ? '' : 'ciphertext', $pb.PbFieldType.OY) + ..a<$fixnum.Int64>(2, _omitFieldNames ? '' : 'expiration', $pb.PbFieldType.OU6, defaultOrMaker: $fixnum.Int64.ZERO) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + Message_Secret clone() => Message_Secret()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + Message_Secret copyWith(void Function(Message_Secret) updates) => super.copyWith((message) => updates(message as Message_Secret)) as Message_Secret; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static Message_Secret create() => Message_Secret._(); + Message_Secret createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static Message_Secret getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static Message_Secret? _defaultInstance; + + /// Text message protobuf encrypted by a key + @$pb.TagNumber(1) + $core.List<$core.int> get ciphertext => $_getN(0); + @$pb.TagNumber(1) + set ciphertext($core.List<$core.int> v) { $_setBytes(0, v); } + @$pb.TagNumber(1) + $core.bool hasCiphertext() => $_has(0); + @$pb.TagNumber(1) + void clearCiphertext() => clearField(1); + + /// Secret expiration timestamp + /// This is the time after which an un-revealed secret will get deleted + @$pb.TagNumber(2) + $fixnum.Int64 get expiration => $_getI64(1); + @$pb.TagNumber(2) + set expiration($fixnum.Int64 v) { $_setInt64(1, v); } + @$pb.TagNumber(2) + $core.bool hasExpiration() => $_has(1); + @$pb.TagNumber(2) + void clearExpiration() => clearField(2); +} + +/// A 'delete' control message +/// Deletes a set of messages by their ids +class Message_ControlDelete extends $pb.GeneratedMessage { + factory Message_ControlDelete({ + $core.Iterable<$core.List<$core.int>>? ids, + }) { + final $result = create(); + if (ids != null) { + $result.ids.addAll(ids); + } + return $result; + } + Message_ControlDelete._() : super(); + factory Message_ControlDelete.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory Message_ControlDelete.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'Message.ControlDelete', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) + ..p<$core.List<$core.int>>(1, _omitFieldNames ? '' : 'ids', $pb.PbFieldType.PY) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + Message_ControlDelete clone() => Message_ControlDelete()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + Message_ControlDelete copyWith(void Function(Message_ControlDelete) updates) => super.copyWith((message) => updates(message as Message_ControlDelete)) as Message_ControlDelete; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static Message_ControlDelete create() => Message_ControlDelete._(); + Message_ControlDelete createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static Message_ControlDelete getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static Message_ControlDelete? _defaultInstance; + + @$pb.TagNumber(1) + $core.List<$core.List<$core.int>> get ids => $_getList(0); +} + +/// An 'erase' control message +/// Deletes a set of messages from before some timestamp +class Message_ControlErase extends $pb.GeneratedMessage { + factory Message_ControlErase({ + $fixnum.Int64? timestamp, + }) { + final $result = create(); + if (timestamp != null) { + $result.timestamp = timestamp; + } + return $result; + } + Message_ControlErase._() : super(); + factory Message_ControlErase.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory Message_ControlErase.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'Message.ControlErase', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) + ..a<$fixnum.Int64>(1, _omitFieldNames ? '' : 'timestamp', $pb.PbFieldType.OU6, defaultOrMaker: $fixnum.Int64.ZERO) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + Message_ControlErase clone() => Message_ControlErase()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + Message_ControlErase copyWith(void Function(Message_ControlErase) updates) => super.copyWith((message) => updates(message as Message_ControlErase)) as Message_ControlErase; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static Message_ControlErase create() => Message_ControlErase._(); + Message_ControlErase createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static Message_ControlErase getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static Message_ControlErase? _defaultInstance; + + /// The latest timestamp to delete messages before + /// If this is zero then all messages are cleared + @$pb.TagNumber(1) + $fixnum.Int64 get timestamp => $_getI64(0); + @$pb.TagNumber(1) + set timestamp($fixnum.Int64 v) { $_setInt64(0, v); } + @$pb.TagNumber(1) + $core.bool hasTimestamp() => $_has(0); + @$pb.TagNumber(1) + void clearTimestamp() => clearField(1); +} + +/// A 'change settings' control message +class Message_ControlSettings extends $pb.GeneratedMessage { + factory Message_ControlSettings({ + ChatSettings? settings, + }) { + final $result = create(); + if (settings != null) { + $result.settings = settings; + } + return $result; + } + Message_ControlSettings._() : super(); + factory Message_ControlSettings.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory Message_ControlSettings.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'Message.ControlSettings', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) + ..aOM(1, _omitFieldNames ? '' : 'settings', subBuilder: ChatSettings.create) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + Message_ControlSettings clone() => Message_ControlSettings()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + Message_ControlSettings copyWith(void Function(Message_ControlSettings) updates) => super.copyWith((message) => updates(message as Message_ControlSettings)) as Message_ControlSettings; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static Message_ControlSettings create() => Message_ControlSettings._(); + Message_ControlSettings createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static Message_ControlSettings getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static Message_ControlSettings? _defaultInstance; + + @$pb.TagNumber(1) + ChatSettings get settings => $_getN(0); + @$pb.TagNumber(1) + set settings(ChatSettings v) { setField(1, v); } + @$pb.TagNumber(1) + $core.bool hasSettings() => $_has(0); + @$pb.TagNumber(1) + void clearSettings() => clearField(1); + @$pb.TagNumber(1) + ChatSettings ensureSettings() => $_ensure(0); +} + +/// A 'change permissions' control message +/// Changes the permissions of a chat +class Message_ControlPermissions extends $pb.GeneratedMessage { + factory Message_ControlPermissions({ + Permissions? permissions, + }) { + final $result = create(); + if (permissions != null) { + $result.permissions = permissions; + } + return $result; + } + Message_ControlPermissions._() : super(); + factory Message_ControlPermissions.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory Message_ControlPermissions.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'Message.ControlPermissions', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) + ..aOM(1, _omitFieldNames ? '' : 'permissions', subBuilder: Permissions.create) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + Message_ControlPermissions clone() => Message_ControlPermissions()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + Message_ControlPermissions copyWith(void Function(Message_ControlPermissions) updates) => super.copyWith((message) => updates(message as Message_ControlPermissions)) as Message_ControlPermissions; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static Message_ControlPermissions create() => Message_ControlPermissions._(); + Message_ControlPermissions createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static Message_ControlPermissions getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static Message_ControlPermissions? _defaultInstance; + + @$pb.TagNumber(1) + Permissions get permissions => $_getN(0); + @$pb.TagNumber(1) + set permissions(Permissions v) { setField(1, v); } + @$pb.TagNumber(1) + $core.bool hasPermissions() => $_has(0); + @$pb.TagNumber(1) + void clearPermissions() => clearField(1); + @$pb.TagNumber(1) + Permissions ensurePermissions() => $_ensure(0); +} + +/// A 'change membership' control message +/// Changes the +class Message_ControlMembership extends $pb.GeneratedMessage { + factory Message_ControlMembership({ + Membership? membership, + }) { + final $result = create(); + if (membership != null) { + $result.membership = membership; + } + return $result; + } + Message_ControlMembership._() : super(); + factory Message_ControlMembership.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory Message_ControlMembership.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'Message.ControlMembership', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) + ..aOM(1, _omitFieldNames ? '' : 'membership', subBuilder: Membership.create) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + Message_ControlMembership clone() => Message_ControlMembership()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + Message_ControlMembership copyWith(void Function(Message_ControlMembership) updates) => super.copyWith((message) => updates(message as Message_ControlMembership)) as Message_ControlMembership; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static Message_ControlMembership create() => Message_ControlMembership._(); + Message_ControlMembership createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static Message_ControlMembership getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static Message_ControlMembership? _defaultInstance; + + @$pb.TagNumber(1) + Membership get membership => $_getN(0); + @$pb.TagNumber(1) + set membership(Membership v) { setField(1, v); } + @$pb.TagNumber(1) + $core.bool hasMembership() => $_has(0); + @$pb.TagNumber(1) + void clearMembership() => clearField(1); + @$pb.TagNumber(1) + Membership ensureMembership() => $_ensure(0); +} + +/// A 'moderation' control message +/// Accepts or rejects a set of messages +class Message_ControlModeration extends $pb.GeneratedMessage { + factory Message_ControlModeration({ + $core.Iterable<$core.List<$core.int>>? acceptedIds, + $core.Iterable<$core.List<$core.int>>? rejectedIds, + }) { + final $result = create(); + if (acceptedIds != null) { + $result.acceptedIds.addAll(acceptedIds); + } + if (rejectedIds != null) { + $result.rejectedIds.addAll(rejectedIds); + } + return $result; + } + Message_ControlModeration._() : super(); + factory Message_ControlModeration.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory Message_ControlModeration.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'Message.ControlModeration', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) + ..p<$core.List<$core.int>>(1, _omitFieldNames ? '' : 'acceptedIds', $pb.PbFieldType.PY) + ..p<$core.List<$core.int>>(2, _omitFieldNames ? '' : 'rejectedIds', $pb.PbFieldType.PY) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + Message_ControlModeration clone() => Message_ControlModeration()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + Message_ControlModeration copyWith(void Function(Message_ControlModeration) updates) => super.copyWith((message) => updates(message as Message_ControlModeration)) as Message_ControlModeration; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static Message_ControlModeration create() => Message_ControlModeration._(); + Message_ControlModeration createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static Message_ControlModeration getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static Message_ControlModeration? _defaultInstance; + + @$pb.TagNumber(1) + $core.List<$core.List<$core.int>> get acceptedIds => $_getList(0); + + @$pb.TagNumber(2) + $core.List<$core.List<$core.int>> get rejectedIds => $_getList(1); +} + +/// A 'read receipt' control message +class Message_ControlReadReceipt extends $pb.GeneratedMessage { + factory Message_ControlReadReceipt({ + $core.Iterable<$core.List<$core.int>>? readIds, + }) { + final $result = create(); + if (readIds != null) { + $result.readIds.addAll(readIds); + } + return $result; + } + Message_ControlReadReceipt._() : super(); + factory Message_ControlReadReceipt.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory Message_ControlReadReceipt.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'Message.ControlReadReceipt', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) + ..p<$core.List<$core.int>>(1, _omitFieldNames ? '' : 'readIds', $pb.PbFieldType.PY) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + Message_ControlReadReceipt clone() => Message_ControlReadReceipt()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + Message_ControlReadReceipt copyWith(void Function(Message_ControlReadReceipt) updates) => super.copyWith((message) => updates(message as Message_ControlReadReceipt)) as Message_ControlReadReceipt; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static Message_ControlReadReceipt create() => Message_ControlReadReceipt._(); + Message_ControlReadReceipt createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static Message_ControlReadReceipt getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static Message_ControlReadReceipt? _defaultInstance; + + @$pb.TagNumber(1) + $core.List<$core.List<$core.int>> get readIds => $_getList(0); +} + +enum Message_Kind { + text, + secret, + delete, + erase, + settings, + permissions, + membership, + moderation, + readReceipt, + notSet +} + +/// A single message as part of a series of messages class Message extends $pb.GeneratedMessage { - factory Message() => create(); + factory Message({ + $core.List<$core.int>? id, + $0.TypedKey? author, + $fixnum.Int64? timestamp, + Message_Text? text, + Message_Secret? secret, + Message_ControlDelete? delete, + Message_ControlErase? erase, + Message_ControlSettings? settings, + Message_ControlPermissions? permissions, + Message_ControlMembership? membership, + Message_ControlModeration? moderation, + $0.Signature? signature, + Message_ControlReadReceipt? readReceipt, + }) { + final $result = create(); + if (id != null) { + $result.id = id; + } + if (author != null) { + $result.author = author; + } + if (timestamp != null) { + $result.timestamp = timestamp; + } + if (text != null) { + $result.text = text; + } + if (secret != null) { + $result.secret = secret; + } + if (delete != null) { + $result.delete = delete; + } + if (erase != null) { + $result.erase = erase; + } + if (settings != null) { + $result.settings = settings; + } + if (permissions != null) { + $result.permissions = permissions; + } + if (membership != null) { + $result.membership = membership; + } + if (moderation != null) { + $result.moderation = moderation; + } + if (signature != null) { + $result.signature = signature; + } + if (readReceipt != null) { + $result.readReceipt = readReceipt; + } + return $result; + } Message._() : super(); factory Message.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory Message.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + static const $core.Map<$core.int, Message_Kind> _Message_KindByTag = { + 4 : Message_Kind.text, + 5 : Message_Kind.secret, + 6 : Message_Kind.delete, + 7 : Message_Kind.erase, + 8 : Message_Kind.settings, + 9 : Message_Kind.permissions, + 10 : Message_Kind.membership, + 11 : Message_Kind.moderation, + 13 : Message_Kind.readReceipt, + 0 : Message_Kind.notSet + }; static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'Message', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) - ..aOM<$1.TypedKey>(1, _omitFieldNames ? '' : 'author', subBuilder: $1.TypedKey.create) - ..a<$fixnum.Int64>(2, _omitFieldNames ? '' : 'timestamp', $pb.PbFieldType.OU6, defaultOrMaker: $fixnum.Int64.ZERO) - ..aOS(3, _omitFieldNames ? '' : 'text') - ..aOM<$1.Signature>(4, _omitFieldNames ? '' : 'signature', subBuilder: $1.Signature.create) - ..pc(5, _omitFieldNames ? '' : 'attachments', $pb.PbFieldType.PM, subBuilder: Attachment.create) + ..oo(0, [4, 5, 6, 7, 8, 9, 10, 11, 13]) + ..a<$core.List<$core.int>>(1, _omitFieldNames ? '' : 'id', $pb.PbFieldType.OY) + ..aOM<$0.TypedKey>(2, _omitFieldNames ? '' : 'author', subBuilder: $0.TypedKey.create) + ..a<$fixnum.Int64>(3, _omitFieldNames ? '' : 'timestamp', $pb.PbFieldType.OU6, defaultOrMaker: $fixnum.Int64.ZERO) + ..aOM(4, _omitFieldNames ? '' : 'text', subBuilder: Message_Text.create) + ..aOM(5, _omitFieldNames ? '' : 'secret', subBuilder: Message_Secret.create) + ..aOM(6, _omitFieldNames ? '' : 'delete', subBuilder: Message_ControlDelete.create) + ..aOM(7, _omitFieldNames ? '' : 'erase', subBuilder: Message_ControlErase.create) + ..aOM(8, _omitFieldNames ? '' : 'settings', subBuilder: Message_ControlSettings.create) + ..aOM(9, _omitFieldNames ? '' : 'permissions', subBuilder: Message_ControlPermissions.create) + ..aOM(10, _omitFieldNames ? '' : 'membership', subBuilder: Message_ControlMembership.create) + ..aOM(11, _omitFieldNames ? '' : 'moderation', subBuilder: Message_ControlModeration.create) + ..aOM<$0.Signature>(12, _omitFieldNames ? '' : 'signature', subBuilder: $0.Signature.create) + ..aOM(13, _omitFieldNames ? '' : 'readReceipt', protoName: 'readReceipt', subBuilder: Message_ControlReadReceipt.create) ..hasRequiredFields = false ; @@ -142,60 +1334,261 @@ class Message extends $pb.GeneratedMessage { static Message getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static Message? _defaultInstance; - @$pb.TagNumber(1) - $1.TypedKey get author => $_getN(0); - @$pb.TagNumber(1) - set author($1.TypedKey v) { setField(1, v); } - @$pb.TagNumber(1) - $core.bool hasAuthor() => $_has(0); - @$pb.TagNumber(1) - void clearAuthor() => clearField(1); - @$pb.TagNumber(1) - $1.TypedKey ensureAuthor() => $_ensure(0); + Message_Kind whichKind() => _Message_KindByTag[$_whichOneof(0)]!; + void clearKind() => clearField($_whichOneof(0)); - @$pb.TagNumber(2) - $fixnum.Int64 get timestamp => $_getI64(1); - @$pb.TagNumber(2) - set timestamp($fixnum.Int64 v) { $_setInt64(1, v); } - @$pb.TagNumber(2) - $core.bool hasTimestamp() => $_has(1); - @$pb.TagNumber(2) - void clearTimestamp() => clearField(2); + /// Unique id for this author stream + /// Calculated from the hash of the previous message from this author + @$pb.TagNumber(1) + $core.List<$core.int> get id => $_getN(0); + @$pb.TagNumber(1) + set id($core.List<$core.int> v) { $_setBytes(0, v); } + @$pb.TagNumber(1) + $core.bool hasId() => $_has(0); + @$pb.TagNumber(1) + void clearId() => clearField(1); + /// Author of the message (identity public key) + @$pb.TagNumber(2) + $0.TypedKey get author => $_getN(1); + @$pb.TagNumber(2) + set author($0.TypedKey v) { setField(2, v); } + @$pb.TagNumber(2) + $core.bool hasAuthor() => $_has(1); + @$pb.TagNumber(2) + void clearAuthor() => clearField(2); + @$pb.TagNumber(2) + $0.TypedKey ensureAuthor() => $_ensure(1); + + /// Time the message was sent according to sender @$pb.TagNumber(3) - $core.String get text => $_getSZ(2); + $fixnum.Int64 get timestamp => $_getI64(2); @$pb.TagNumber(3) - set text($core.String v) { $_setString(2, v); } + set timestamp($fixnum.Int64 v) { $_setInt64(2, v); } @$pb.TagNumber(3) - $core.bool hasText() => $_has(2); + $core.bool hasTimestamp() => $_has(2); @$pb.TagNumber(3) - void clearText() => clearField(3); + void clearTimestamp() => clearField(3); @$pb.TagNumber(4) - $1.Signature get signature => $_getN(3); + Message_Text get text => $_getN(3); @$pb.TagNumber(4) - set signature($1.Signature v) { setField(4, v); } + set text(Message_Text v) { setField(4, v); } @$pb.TagNumber(4) - $core.bool hasSignature() => $_has(3); + $core.bool hasText() => $_has(3); @$pb.TagNumber(4) - void clearSignature() => clearField(4); + void clearText() => clearField(4); @$pb.TagNumber(4) - $1.Signature ensureSignature() => $_ensure(3); + Message_Text ensureText() => $_ensure(3); @$pb.TagNumber(5) - $core.List get attachments => $_getList(4); + Message_Secret get secret => $_getN(4); + @$pb.TagNumber(5) + set secret(Message_Secret v) { setField(5, v); } + @$pb.TagNumber(5) + $core.bool hasSecret() => $_has(4); + @$pb.TagNumber(5) + void clearSecret() => clearField(5); + @$pb.TagNumber(5) + Message_Secret ensureSecret() => $_ensure(4); + + @$pb.TagNumber(6) + Message_ControlDelete get delete => $_getN(5); + @$pb.TagNumber(6) + set delete(Message_ControlDelete v) { setField(6, v); } + @$pb.TagNumber(6) + $core.bool hasDelete() => $_has(5); + @$pb.TagNumber(6) + void clearDelete() => clearField(6); + @$pb.TagNumber(6) + Message_ControlDelete ensureDelete() => $_ensure(5); + + @$pb.TagNumber(7) + Message_ControlErase get erase => $_getN(6); + @$pb.TagNumber(7) + set erase(Message_ControlErase v) { setField(7, v); } + @$pb.TagNumber(7) + $core.bool hasErase() => $_has(6); + @$pb.TagNumber(7) + void clearErase() => clearField(7); + @$pb.TagNumber(7) + Message_ControlErase ensureErase() => $_ensure(6); + + @$pb.TagNumber(8) + Message_ControlSettings get settings => $_getN(7); + @$pb.TagNumber(8) + set settings(Message_ControlSettings v) { setField(8, v); } + @$pb.TagNumber(8) + $core.bool hasSettings() => $_has(7); + @$pb.TagNumber(8) + void clearSettings() => clearField(8); + @$pb.TagNumber(8) + Message_ControlSettings ensureSettings() => $_ensure(7); + + @$pb.TagNumber(9) + Message_ControlPermissions get permissions => $_getN(8); + @$pb.TagNumber(9) + set permissions(Message_ControlPermissions v) { setField(9, v); } + @$pb.TagNumber(9) + $core.bool hasPermissions() => $_has(8); + @$pb.TagNumber(9) + void clearPermissions() => clearField(9); + @$pb.TagNumber(9) + Message_ControlPermissions ensurePermissions() => $_ensure(8); + + @$pb.TagNumber(10) + Message_ControlMembership get membership => $_getN(9); + @$pb.TagNumber(10) + set membership(Message_ControlMembership v) { setField(10, v); } + @$pb.TagNumber(10) + $core.bool hasMembership() => $_has(9); + @$pb.TagNumber(10) + void clearMembership() => clearField(10); + @$pb.TagNumber(10) + Message_ControlMembership ensureMembership() => $_ensure(9); + + @$pb.TagNumber(11) + Message_ControlModeration get moderation => $_getN(10); + @$pb.TagNumber(11) + set moderation(Message_ControlModeration v) { setField(11, v); } + @$pb.TagNumber(11) + $core.bool hasModeration() => $_has(10); + @$pb.TagNumber(11) + void clearModeration() => clearField(11); + @$pb.TagNumber(11) + Message_ControlModeration ensureModeration() => $_ensure(10); + + /// Author signature over all of the fields and attachment signatures + @$pb.TagNumber(12) + $0.Signature get signature => $_getN(11); + @$pb.TagNumber(12) + set signature($0.Signature v) { setField(12, v); } + @$pb.TagNumber(12) + $core.bool hasSignature() => $_has(11); + @$pb.TagNumber(12) + void clearSignature() => clearField(12); + @$pb.TagNumber(12) + $0.Signature ensureSignature() => $_ensure(11); + + @$pb.TagNumber(13) + Message_ControlReadReceipt get readReceipt => $_getN(12); + @$pb.TagNumber(13) + set readReceipt(Message_ControlReadReceipt v) { setField(13, v); } + @$pb.TagNumber(13) + $core.bool hasReadReceipt() => $_has(12); + @$pb.TagNumber(13) + void clearReadReceipt() => clearField(13); + @$pb.TagNumber(13) + Message_ControlReadReceipt ensureReadReceipt() => $_ensure(12); } +/// Locally stored messages for chats +class ReconciledMessage extends $pb.GeneratedMessage { + factory ReconciledMessage({ + Message? content, + $fixnum.Int64? reconciledTime, + }) { + final $result = create(); + if (content != null) { + $result.content = content; + } + if (reconciledTime != null) { + $result.reconciledTime = reconciledTime; + } + return $result; + } + ReconciledMessage._() : super(); + factory ReconciledMessage.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory ReconciledMessage.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'ReconciledMessage', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) + ..aOM(1, _omitFieldNames ? '' : 'content', subBuilder: Message.create) + ..a<$fixnum.Int64>(2, _omitFieldNames ? '' : 'reconciledTime', $pb.PbFieldType.OU6, defaultOrMaker: $fixnum.Int64.ZERO) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + ReconciledMessage clone() => ReconciledMessage()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + ReconciledMessage copyWith(void Function(ReconciledMessage) updates) => super.copyWith((message) => updates(message as ReconciledMessage)) as ReconciledMessage; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static ReconciledMessage create() => ReconciledMessage._(); + ReconciledMessage createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static ReconciledMessage getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static ReconciledMessage? _defaultInstance; + + /// The message as sent + @$pb.TagNumber(1) + Message get content => $_getN(0); + @$pb.TagNumber(1) + set content(Message v) { setField(1, v); } + @$pb.TagNumber(1) + $core.bool hasContent() => $_has(0); + @$pb.TagNumber(1) + void clearContent() => clearField(1); + @$pb.TagNumber(1) + Message ensureContent() => $_ensure(0); + + /// The timestamp the message was reconciled + @$pb.TagNumber(2) + $fixnum.Int64 get reconciledTime => $_getI64(1); + @$pb.TagNumber(2) + set reconciledTime($fixnum.Int64 v) { $_setInt64(1, v); } + @$pb.TagNumber(2) + $core.bool hasReconciledTime() => $_has(1); + @$pb.TagNumber(2) + void clearReconciledTime() => clearField(2); +} + +/// The means of direct communications that is synchronized between +/// two users. Visible and encrypted for the other party. +/// Includes communications for: +/// * Profile changes +/// * Identity changes +/// * 1-1 chat messages +/// * Group chat messages +/// +/// DHT Schema: SMPL(0,1,[identityPublicKey]) +/// DHT Key (UnicastOutbox): localConversation +/// DHT Secret: None +/// Encryption: DH(IdentityA, IdentityB) class Conversation extends $pb.GeneratedMessage { - factory Conversation() => create(); + factory Conversation({ + Profile? profile, + $core.String? superIdentityJson, + $0.TypedKey? messages, + }) { + final $result = create(); + if (profile != null) { + $result.profile = profile; + } + if (superIdentityJson != null) { + $result.superIdentityJson = superIdentityJson; + } + if (messages != null) { + $result.messages = messages; + } + return $result; + } Conversation._() : super(); factory Conversation.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory Conversation.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'Conversation', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) ..aOM(1, _omitFieldNames ? '' : 'profile', subBuilder: Profile.create) - ..aOS(2, _omitFieldNames ? '' : 'identityMasterJson') - ..aOM<$1.TypedKey>(3, _omitFieldNames ? '' : 'messages', subBuilder: $1.TypedKey.create) + ..aOS(2, _omitFieldNames ? '' : 'superIdentityJson') + ..aOM<$0.TypedKey>(3, _omitFieldNames ? '' : 'messages', subBuilder: $0.TypedKey.create) ..hasRequiredFields = false ; @@ -220,6 +1613,7 @@ class Conversation extends $pb.GeneratedMessage { static Conversation getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static Conversation? _defaultInstance; + /// Profile to publish to friend @$pb.TagNumber(1) Profile get profile => $_getN(0); @$pb.TagNumber(1) @@ -231,41 +1625,51 @@ class Conversation extends $pb.GeneratedMessage { @$pb.TagNumber(1) Profile ensureProfile() => $_ensure(0); + /// SuperIdentity (JSON) to publish to friend or chat room @$pb.TagNumber(2) - $core.String get identityMasterJson => $_getSZ(1); + $core.String get superIdentityJson => $_getSZ(1); @$pb.TagNumber(2) - set identityMasterJson($core.String v) { $_setString(1, v); } + set superIdentityJson($core.String v) { $_setString(1, v); } @$pb.TagNumber(2) - $core.bool hasIdentityMasterJson() => $_has(1); + $core.bool hasSuperIdentityJson() => $_has(1); @$pb.TagNumber(2) - void clearIdentityMasterJson() => clearField(2); + void clearSuperIdentityJson() => clearField(2); + /// Messages DHTLog @$pb.TagNumber(3) - $1.TypedKey get messages => $_getN(2); + $0.TypedKey get messages => $_getN(2); @$pb.TagNumber(3) - set messages($1.TypedKey v) { setField(3, v); } + set messages($0.TypedKey v) { setField(3, v); } @$pb.TagNumber(3) $core.bool hasMessages() => $_has(2); @$pb.TagNumber(3) void clearMessages() => clearField(3); @$pb.TagNumber(3) - $1.TypedKey ensureMessages() => $_ensure(2); + $0.TypedKey ensureMessages() => $_ensure(2); } -class Contact extends $pb.GeneratedMessage { - factory Contact() => create(); - Contact._() : super(); - factory Contact.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); - factory Contact.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); +/// A member of chat which may or may not be associated with a contact +class ChatMember extends $pb.GeneratedMessage { + factory ChatMember({ + $0.TypedKey? remoteIdentityPublicKey, + $0.TypedKey? remoteConversationRecordKey, + }) { + final $result = create(); + if (remoteIdentityPublicKey != null) { + $result.remoteIdentityPublicKey = remoteIdentityPublicKey; + } + if (remoteConversationRecordKey != null) { + $result.remoteConversationRecordKey = remoteConversationRecordKey; + } + return $result; + } + ChatMember._() : super(); + factory ChatMember.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory ChatMember.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); - static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'Contact', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) - ..aOM(1, _omitFieldNames ? '' : 'editedProfile', subBuilder: Profile.create) - ..aOM(2, _omitFieldNames ? '' : 'remoteProfile', subBuilder: Profile.create) - ..aOS(3, _omitFieldNames ? '' : 'identityMasterJson') - ..aOM<$1.TypedKey>(4, _omitFieldNames ? '' : 'identityPublicKey', subBuilder: $1.TypedKey.create) - ..aOM<$1.TypedKey>(5, _omitFieldNames ? '' : 'remoteConversationRecordKey', subBuilder: $1.TypedKey.create) - ..aOM<$1.TypedKey>(6, _omitFieldNames ? '' : 'localConversationRecordKey', subBuilder: $1.TypedKey.create) - ..aOB(7, _omitFieldNames ? '' : 'showAvailability') + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'ChatMember', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) + ..aOM<$0.TypedKey>(1, _omitFieldNames ? '' : 'remoteIdentityPublicKey', subBuilder: $0.TypedKey.create) + ..aOM<$0.TypedKey>(2, _omitFieldNames ? '' : 'remoteConversationRecordKey', subBuilder: $0.TypedKey.create) ..hasRequiredFields = false ; @@ -273,109 +1677,76 @@ class Contact extends $pb.GeneratedMessage { 'Using this can add significant overhead to your binary. ' 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' 'Will be removed in next major version') - Contact clone() => Contact()..mergeFromMessage(this); + ChatMember clone() => ChatMember()..mergeFromMessage(this); @$core.Deprecated( 'Using this can add significant overhead to your binary. ' 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' 'Will be removed in next major version') - Contact copyWith(void Function(Contact) updates) => super.copyWith((message) => updates(message as Contact)) as Contact; + ChatMember copyWith(void Function(ChatMember) updates) => super.copyWith((message) => updates(message as ChatMember)) as ChatMember; $pb.BuilderInfo get info_ => _i; @$core.pragma('dart2js:noInline') - static Contact create() => Contact._(); - Contact createEmptyInstance() => create(); - static $pb.PbList createRepeated() => $pb.PbList(); + static ChatMember create() => ChatMember._(); + ChatMember createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); @$core.pragma('dart2js:noInline') - static Contact getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); - static Contact? _defaultInstance; + static ChatMember getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static ChatMember? _defaultInstance; + /// The identity public key most recently associated with the chat member @$pb.TagNumber(1) - Profile get editedProfile => $_getN(0); + $0.TypedKey get remoteIdentityPublicKey => $_getN(0); @$pb.TagNumber(1) - set editedProfile(Profile v) { setField(1, v); } + set remoteIdentityPublicKey($0.TypedKey v) { setField(1, v); } @$pb.TagNumber(1) - $core.bool hasEditedProfile() => $_has(0); + $core.bool hasRemoteIdentityPublicKey() => $_has(0); @$pb.TagNumber(1) - void clearEditedProfile() => clearField(1); + void clearRemoteIdentityPublicKey() => clearField(1); @$pb.TagNumber(1) - Profile ensureEditedProfile() => $_ensure(0); + $0.TypedKey ensureRemoteIdentityPublicKey() => $_ensure(0); + /// Conversation key for the other party @$pb.TagNumber(2) - Profile get remoteProfile => $_getN(1); + $0.TypedKey get remoteConversationRecordKey => $_getN(1); @$pb.TagNumber(2) - set remoteProfile(Profile v) { setField(2, v); } + set remoteConversationRecordKey($0.TypedKey v) { setField(2, v); } @$pb.TagNumber(2) - $core.bool hasRemoteProfile() => $_has(1); + $core.bool hasRemoteConversationRecordKey() => $_has(1); @$pb.TagNumber(2) - void clearRemoteProfile() => clearField(2); + void clearRemoteConversationRecordKey() => clearField(2); @$pb.TagNumber(2) - Profile ensureRemoteProfile() => $_ensure(1); - - @$pb.TagNumber(3) - $core.String get identityMasterJson => $_getSZ(2); - @$pb.TagNumber(3) - set identityMasterJson($core.String v) { $_setString(2, v); } - @$pb.TagNumber(3) - $core.bool hasIdentityMasterJson() => $_has(2); - @$pb.TagNumber(3) - void clearIdentityMasterJson() => clearField(3); - - @$pb.TagNumber(4) - $1.TypedKey get identityPublicKey => $_getN(3); - @$pb.TagNumber(4) - set identityPublicKey($1.TypedKey v) { setField(4, v); } - @$pb.TagNumber(4) - $core.bool hasIdentityPublicKey() => $_has(3); - @$pb.TagNumber(4) - void clearIdentityPublicKey() => clearField(4); - @$pb.TagNumber(4) - $1.TypedKey ensureIdentityPublicKey() => $_ensure(3); - - @$pb.TagNumber(5) - $1.TypedKey get remoteConversationRecordKey => $_getN(4); - @$pb.TagNumber(5) - set remoteConversationRecordKey($1.TypedKey v) { setField(5, v); } - @$pb.TagNumber(5) - $core.bool hasRemoteConversationRecordKey() => $_has(4); - @$pb.TagNumber(5) - void clearRemoteConversationRecordKey() => clearField(5); - @$pb.TagNumber(5) - $1.TypedKey ensureRemoteConversationRecordKey() => $_ensure(4); - - @$pb.TagNumber(6) - $1.TypedKey get localConversationRecordKey => $_getN(5); - @$pb.TagNumber(6) - set localConversationRecordKey($1.TypedKey v) { setField(6, v); } - @$pb.TagNumber(6) - $core.bool hasLocalConversationRecordKey() => $_has(5); - @$pb.TagNumber(6) - void clearLocalConversationRecordKey() => clearField(6); - @$pb.TagNumber(6) - $1.TypedKey ensureLocalConversationRecordKey() => $_ensure(5); - - @$pb.TagNumber(7) - $core.bool get showAvailability => $_getBF(6); - @$pb.TagNumber(7) - set showAvailability($core.bool v) { $_setBool(6, v); } - @$pb.TagNumber(7) - $core.bool hasShowAvailability() => $_has(6); - @$pb.TagNumber(7) - void clearShowAvailability() => clearField(7); + $0.TypedKey ensureRemoteConversationRecordKey() => $_ensure(1); } -class Profile extends $pb.GeneratedMessage { - factory Profile() => create(); - Profile._() : super(); - factory Profile.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); - factory Profile.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); +/// A 1-1 chat +/// Privately encrypted, this is the local user's copy of the chat +class DirectChat extends $pb.GeneratedMessage { + factory DirectChat({ + ChatSettings? settings, + $0.TypedKey? localConversationRecordKey, + ChatMember? remoteMember, + }) { + final $result = create(); + if (settings != null) { + $result.settings = settings; + } + if (localConversationRecordKey != null) { + $result.localConversationRecordKey = localConversationRecordKey; + } + if (remoteMember != null) { + $result.remoteMember = remoteMember; + } + return $result; + } + DirectChat._() : super(); + factory DirectChat.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory DirectChat.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); - static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'Profile', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) - ..aOS(1, _omitFieldNames ? '' : 'name') - ..aOS(2, _omitFieldNames ? '' : 'pronouns') - ..aOS(3, _omitFieldNames ? '' : 'status') - ..e(4, _omitFieldNames ? '' : 'availability', $pb.PbFieldType.OE, defaultOrMaker: Availability.AVAILABILITY_UNSPECIFIED, valueOf: Availability.valueOf, enumValues: Availability.values) - ..aOM<$1.TypedKey>(5, _omitFieldNames ? '' : 'avatar', subBuilder: $1.TypedKey.create) + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'DirectChat', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) + ..aOM(1, _omitFieldNames ? '' : 'settings', subBuilder: ChatSettings.create) + ..aOM<$0.TypedKey>(2, _omitFieldNames ? '' : 'localConversationRecordKey', subBuilder: $0.TypedKey.create) + ..aOM(3, _omitFieldNames ? '' : 'remoteMember', subBuilder: ChatMember.create) ..hasRequiredFields = false ; @@ -383,80 +1754,209 @@ class Profile extends $pb.GeneratedMessage { 'Using this can add significant overhead to your binary. ' 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' 'Will be removed in next major version') - Profile clone() => Profile()..mergeFromMessage(this); + DirectChat clone() => DirectChat()..mergeFromMessage(this); @$core.Deprecated( 'Using this can add significant overhead to your binary. ' 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' 'Will be removed in next major version') - Profile copyWith(void Function(Profile) updates) => super.copyWith((message) => updates(message as Profile)) as Profile; + DirectChat copyWith(void Function(DirectChat) updates) => super.copyWith((message) => updates(message as DirectChat)) as DirectChat; $pb.BuilderInfo get info_ => _i; @$core.pragma('dart2js:noInline') - static Profile create() => Profile._(); - Profile createEmptyInstance() => create(); - static $pb.PbList createRepeated() => $pb.PbList(); + static DirectChat create() => DirectChat._(); + DirectChat createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); @$core.pragma('dart2js:noInline') - static Profile getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); - static Profile? _defaultInstance; + static DirectChat getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static DirectChat? _defaultInstance; + /// Settings @$pb.TagNumber(1) - $core.String get name => $_getSZ(0); + ChatSettings get settings => $_getN(0); @$pb.TagNumber(1) - set name($core.String v) { $_setString(0, v); } + set settings(ChatSettings v) { setField(1, v); } @$pb.TagNumber(1) - $core.bool hasName() => $_has(0); + $core.bool hasSettings() => $_has(0); @$pb.TagNumber(1) - void clearName() => clearField(1); + void clearSettings() => clearField(1); + @$pb.TagNumber(1) + ChatSettings ensureSettings() => $_ensure(0); + /// Conversation key for this user @$pb.TagNumber(2) - $core.String get pronouns => $_getSZ(1); + $0.TypedKey get localConversationRecordKey => $_getN(1); @$pb.TagNumber(2) - set pronouns($core.String v) { $_setString(1, v); } + set localConversationRecordKey($0.TypedKey v) { setField(2, v); } @$pb.TagNumber(2) - $core.bool hasPronouns() => $_has(1); + $core.bool hasLocalConversationRecordKey() => $_has(1); @$pb.TagNumber(2) - void clearPronouns() => clearField(2); + void clearLocalConversationRecordKey() => clearField(2); + @$pb.TagNumber(2) + $0.TypedKey ensureLocalConversationRecordKey() => $_ensure(1); + /// Conversation key for the other party @$pb.TagNumber(3) - $core.String get status => $_getSZ(2); + ChatMember get remoteMember => $_getN(2); @$pb.TagNumber(3) - set status($core.String v) { $_setString(2, v); } + set remoteMember(ChatMember v) { setField(3, v); } @$pb.TagNumber(3) - $core.bool hasStatus() => $_has(2); + $core.bool hasRemoteMember() => $_has(2); @$pb.TagNumber(3) - void clearStatus() => clearField(3); - - @$pb.TagNumber(4) - Availability get availability => $_getN(3); - @$pb.TagNumber(4) - set availability(Availability v) { setField(4, v); } - @$pb.TagNumber(4) - $core.bool hasAvailability() => $_has(3); - @$pb.TagNumber(4) - void clearAvailability() => clearField(4); - - @$pb.TagNumber(5) - $1.TypedKey get avatar => $_getN(4); - @$pb.TagNumber(5) - set avatar($1.TypedKey v) { setField(5, v); } - @$pb.TagNumber(5) - $core.bool hasAvatar() => $_has(4); - @$pb.TagNumber(5) - void clearAvatar() => clearField(5); - @$pb.TagNumber(5) - $1.TypedKey ensureAvatar() => $_ensure(4); + void clearRemoteMember() => clearField(3); + @$pb.TagNumber(3) + ChatMember ensureRemoteMember() => $_ensure(2); } +/// A group chat +/// Privately encrypted, this is the local user's copy of the chat +class GroupChat extends $pb.GeneratedMessage { + factory GroupChat({ + ChatSettings? settings, + Membership? membership, + Permissions? permissions, + $0.TypedKey? localConversationRecordKey, + $core.Iterable? remoteMembers, + }) { + final $result = create(); + if (settings != null) { + $result.settings = settings; + } + if (membership != null) { + $result.membership = membership; + } + if (permissions != null) { + $result.permissions = permissions; + } + if (localConversationRecordKey != null) { + $result.localConversationRecordKey = localConversationRecordKey; + } + if (remoteMembers != null) { + $result.remoteMembers.addAll(remoteMembers); + } + return $result; + } + GroupChat._() : super(); + factory GroupChat.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory GroupChat.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'GroupChat', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) + ..aOM(1, _omitFieldNames ? '' : 'settings', subBuilder: ChatSettings.create) + ..aOM(2, _omitFieldNames ? '' : 'membership', subBuilder: Membership.create) + ..aOM(3, _omitFieldNames ? '' : 'permissions', subBuilder: Permissions.create) + ..aOM<$0.TypedKey>(4, _omitFieldNames ? '' : 'localConversationRecordKey', subBuilder: $0.TypedKey.create) + ..pc(5, _omitFieldNames ? '' : 'remoteMembers', $pb.PbFieldType.PM, subBuilder: ChatMember.create) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + GroupChat clone() => GroupChat()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + GroupChat copyWith(void Function(GroupChat) updates) => super.copyWith((message) => updates(message as GroupChat)) as GroupChat; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static GroupChat create() => GroupChat._(); + GroupChat createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static GroupChat getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static GroupChat? _defaultInstance; + + /// Settings + @$pb.TagNumber(1) + ChatSettings get settings => $_getN(0); + @$pb.TagNumber(1) + set settings(ChatSettings v) { setField(1, v); } + @$pb.TagNumber(1) + $core.bool hasSettings() => $_has(0); + @$pb.TagNumber(1) + void clearSettings() => clearField(1); + @$pb.TagNumber(1) + ChatSettings ensureSettings() => $_ensure(0); + + /// Membership + @$pb.TagNumber(2) + Membership get membership => $_getN(1); + @$pb.TagNumber(2) + set membership(Membership v) { setField(2, v); } + @$pb.TagNumber(2) + $core.bool hasMembership() => $_has(1); + @$pb.TagNumber(2) + void clearMembership() => clearField(2); + @$pb.TagNumber(2) + Membership ensureMembership() => $_ensure(1); + + /// Permissions + @$pb.TagNumber(3) + Permissions get permissions => $_getN(2); + @$pb.TagNumber(3) + set permissions(Permissions v) { setField(3, v); } + @$pb.TagNumber(3) + $core.bool hasPermissions() => $_has(2); + @$pb.TagNumber(3) + void clearPermissions() => clearField(3); + @$pb.TagNumber(3) + Permissions ensurePermissions() => $_ensure(2); + + /// Conversation key for this user + @$pb.TagNumber(4) + $0.TypedKey get localConversationRecordKey => $_getN(3); + @$pb.TagNumber(4) + set localConversationRecordKey($0.TypedKey v) { setField(4, v); } + @$pb.TagNumber(4) + $core.bool hasLocalConversationRecordKey() => $_has(3); + @$pb.TagNumber(4) + void clearLocalConversationRecordKey() => clearField(4); + @$pb.TagNumber(4) + $0.TypedKey ensureLocalConversationRecordKey() => $_ensure(3); + + /// Conversation keys for the other parties + @$pb.TagNumber(5) + $core.List get remoteMembers => $_getList(4); +} + +enum Chat_Kind { + direct, + group, + notSet +} + +/// Some kind of chat class Chat extends $pb.GeneratedMessage { - factory Chat() => create(); + factory Chat({ + DirectChat? direct, + GroupChat? group, + }) { + final $result = create(); + if (direct != null) { + $result.direct = direct; + } + if (group != null) { + $result.group = group; + } + return $result; + } Chat._() : super(); factory Chat.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory Chat.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + static const $core.Map<$core.int, Chat_Kind> _Chat_KindByTag = { + 1 : Chat_Kind.direct, + 2 : Chat_Kind.group, + 0 : Chat_Kind.notSet + }; static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'Chat', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) - ..e(1, _omitFieldNames ? '' : 'type', $pb.PbFieldType.OE, defaultOrMaker: ChatType.CHAT_TYPE_UNSPECIFIED, valueOf: ChatType.valueOf, enumValues: ChatType.values) - ..aOM<$1.TypedKey>(2, _omitFieldNames ? '' : 'remoteConversationKey', subBuilder: $1.TypedKey.create) + ..oo(0, [1, 2]) + ..aOM(1, _omitFieldNames ? '' : 'direct', subBuilder: DirectChat.create) + ..aOM(2, _omitFieldNames ? '' : 'group', subBuilder: GroupChat.create) ..hasRequiredFields = false ; @@ -481,29 +1981,235 @@ class Chat extends $pb.GeneratedMessage { static Chat getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static Chat? _defaultInstance; + Chat_Kind whichKind() => _Chat_KindByTag[$_whichOneof(0)]!; + void clearKind() => clearField($_whichOneof(0)); + @$pb.TagNumber(1) - ChatType get type => $_getN(0); + DirectChat get direct => $_getN(0); @$pb.TagNumber(1) - set type(ChatType v) { setField(1, v); } + set direct(DirectChat v) { setField(1, v); } @$pb.TagNumber(1) - $core.bool hasType() => $_has(0); + $core.bool hasDirect() => $_has(0); @$pb.TagNumber(1) - void clearType() => clearField(1); + void clearDirect() => clearField(1); + @$pb.TagNumber(1) + DirectChat ensureDirect() => $_ensure(0); @$pb.TagNumber(2) - $1.TypedKey get remoteConversationKey => $_getN(1); + GroupChat get group => $_getN(1); @$pb.TagNumber(2) - set remoteConversationKey($1.TypedKey v) { setField(2, v); } + set group(GroupChat v) { setField(2, v); } @$pb.TagNumber(2) - $core.bool hasRemoteConversationKey() => $_has(1); + $core.bool hasGroup() => $_has(1); @$pb.TagNumber(2) - void clearRemoteConversationKey() => clearField(2); + void clearGroup() => clearField(2); @$pb.TagNumber(2) - $1.TypedKey ensureRemoteConversationKey() => $_ensure(1); + GroupChat ensureGroup() => $_ensure(1); } +/// Publicly shared profile information for both contacts and accounts +/// Contains: +/// Name - Friendly name +/// Pronouns - Pronouns of user +/// Icon - Little picture to represent user in contact list +class Profile extends $pb.GeneratedMessage { + factory Profile({ + $core.String? name, + $core.String? pronouns, + $core.String? about, + $core.String? status, + Availability? availability, + DataReference? avatar, + $fixnum.Int64? timestamp, + }) { + final $result = create(); + if (name != null) { + $result.name = name; + } + if (pronouns != null) { + $result.pronouns = pronouns; + } + if (about != null) { + $result.about = about; + } + if (status != null) { + $result.status = status; + } + if (availability != null) { + $result.availability = availability; + } + if (avatar != null) { + $result.avatar = avatar; + } + if (timestamp != null) { + $result.timestamp = timestamp; + } + return $result; + } + Profile._() : super(); + factory Profile.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory Profile.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'Profile', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) + ..aOS(1, _omitFieldNames ? '' : 'name') + ..aOS(2, _omitFieldNames ? '' : 'pronouns') + ..aOS(3, _omitFieldNames ? '' : 'about') + ..aOS(4, _omitFieldNames ? '' : 'status') + ..e(5, _omitFieldNames ? '' : 'availability', $pb.PbFieldType.OE, defaultOrMaker: Availability.AVAILABILITY_UNSPECIFIED, valueOf: Availability.valueOf, enumValues: Availability.values) + ..aOM(6, _omitFieldNames ? '' : 'avatar', subBuilder: DataReference.create) + ..a<$fixnum.Int64>(7, _omitFieldNames ? '' : 'timestamp', $pb.PbFieldType.OU6, defaultOrMaker: $fixnum.Int64.ZERO) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + Profile clone() => Profile()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + Profile copyWith(void Function(Profile) updates) => super.copyWith((message) => updates(message as Profile)) as Profile; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static Profile create() => Profile._(); + Profile createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static Profile getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static Profile? _defaultInstance; + + /// Friendy name (max length 64) + @$pb.TagNumber(1) + $core.String get name => $_getSZ(0); + @$pb.TagNumber(1) + set name($core.String v) { $_setString(0, v); } + @$pb.TagNumber(1) + $core.bool hasName() => $_has(0); + @$pb.TagNumber(1) + void clearName() => clearField(1); + + /// Pronouns of user (max length 64) + @$pb.TagNumber(2) + $core.String get pronouns => $_getSZ(1); + @$pb.TagNumber(2) + set pronouns($core.String v) { $_setString(1, v); } + @$pb.TagNumber(2) + $core.bool hasPronouns() => $_has(1); + @$pb.TagNumber(2) + void clearPronouns() => clearField(2); + + /// Description of the user (max length 1024) + @$pb.TagNumber(3) + $core.String get about => $_getSZ(2); + @$pb.TagNumber(3) + set about($core.String v) { $_setString(2, v); } + @$pb.TagNumber(3) + $core.bool hasAbout() => $_has(2); + @$pb.TagNumber(3) + void clearAbout() => clearField(3); + + /// Status/away message (max length 128) + @$pb.TagNumber(4) + $core.String get status => $_getSZ(3); + @$pb.TagNumber(4) + set status($core.String v) { $_setString(3, v); } + @$pb.TagNumber(4) + $core.bool hasStatus() => $_has(3); + @$pb.TagNumber(4) + void clearStatus() => clearField(4); + + /// Availability + @$pb.TagNumber(5) + Availability get availability => $_getN(4); + @$pb.TagNumber(5) + set availability(Availability v) { setField(5, v); } + @$pb.TagNumber(5) + $core.bool hasAvailability() => $_has(4); + @$pb.TagNumber(5) + void clearAvailability() => clearField(5); + + /// Avatar + @$pb.TagNumber(6) + DataReference get avatar => $_getN(5); + @$pb.TagNumber(6) + set avatar(DataReference v) { setField(6, v); } + @$pb.TagNumber(6) + $core.bool hasAvatar() => $_has(5); + @$pb.TagNumber(6) + void clearAvatar() => clearField(6); + @$pb.TagNumber(6) + DataReference ensureAvatar() => $_ensure(5); + + /// Timestamp of last change + @$pb.TagNumber(7) + $fixnum.Int64 get timestamp => $_getI64(6); + @$pb.TagNumber(7) + set timestamp($fixnum.Int64 v) { $_setInt64(6, v); } + @$pb.TagNumber(7) + $core.bool hasTimestamp() => $_has(6); + @$pb.TagNumber(7) + void clearTimestamp() => clearField(7); +} + +/// A record of an individual account +/// Pointed to by the identity account map in the identity key +/// +/// DHT Schema: DFLT(1) +/// DHT Private: accountSecretKey class Account extends $pb.GeneratedMessage { - factory Account() => create(); + factory Account({ + Profile? profile, + $core.bool? invisible, + $core.int? autoAwayTimeoutMin, + $1.OwnedDHTRecordPointer? contactList, + $1.OwnedDHTRecordPointer? contactInvitationRecords, + $1.OwnedDHTRecordPointer? chatList, + $1.OwnedDHTRecordPointer? groupChatList, + $core.String? freeMessage, + $core.String? busyMessage, + $core.String? awayMessage, + $core.bool? autodetectAway, + }) { + final $result = create(); + if (profile != null) { + $result.profile = profile; + } + if (invisible != null) { + $result.invisible = invisible; + } + if (autoAwayTimeoutMin != null) { + $result.autoAwayTimeoutMin = autoAwayTimeoutMin; + } + if (contactList != null) { + $result.contactList = contactList; + } + if (contactInvitationRecords != null) { + $result.contactInvitationRecords = contactInvitationRecords; + } + if (chatList != null) { + $result.chatList = chatList; + } + if (groupChatList != null) { + $result.groupChatList = groupChatList; + } + if (freeMessage != null) { + $result.freeMessage = freeMessage; + } + if (busyMessage != null) { + $result.busyMessage = busyMessage; + } + if (awayMessage != null) { + $result.awayMessage = awayMessage; + } + if (autodetectAway != null) { + $result.autodetectAway = autodetectAway; + } + return $result; + } Account._() : super(); factory Account.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory Account.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -511,10 +2217,15 @@ class Account extends $pb.GeneratedMessage { static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'Account', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) ..aOM(1, _omitFieldNames ? '' : 'profile', subBuilder: Profile.create) ..aOB(2, _omitFieldNames ? '' : 'invisible') - ..a<$core.int>(3, _omitFieldNames ? '' : 'autoAwayTimeoutSec', $pb.PbFieldType.OU3) - ..aOM<$0.OwnedDHTRecordPointer>(4, _omitFieldNames ? '' : 'contactList', subBuilder: $0.OwnedDHTRecordPointer.create) - ..aOM<$0.OwnedDHTRecordPointer>(5, _omitFieldNames ? '' : 'contactInvitationRecords', subBuilder: $0.OwnedDHTRecordPointer.create) - ..aOM<$0.OwnedDHTRecordPointer>(6, _omitFieldNames ? '' : 'chatList', subBuilder: $0.OwnedDHTRecordPointer.create) + ..a<$core.int>(3, _omitFieldNames ? '' : 'autoAwayTimeoutMin', $pb.PbFieldType.OU3) + ..aOM<$1.OwnedDHTRecordPointer>(4, _omitFieldNames ? '' : 'contactList', subBuilder: $1.OwnedDHTRecordPointer.create) + ..aOM<$1.OwnedDHTRecordPointer>(5, _omitFieldNames ? '' : 'contactInvitationRecords', subBuilder: $1.OwnedDHTRecordPointer.create) + ..aOM<$1.OwnedDHTRecordPointer>(6, _omitFieldNames ? '' : 'chatList', subBuilder: $1.OwnedDHTRecordPointer.create) + ..aOM<$1.OwnedDHTRecordPointer>(7, _omitFieldNames ? '' : 'groupChatList', subBuilder: $1.OwnedDHTRecordPointer.create) + ..aOS(8, _omitFieldNames ? '' : 'freeMessage') + ..aOS(9, _omitFieldNames ? '' : 'busyMessage') + ..aOS(10, _omitFieldNames ? '' : 'awayMessage') + ..aOB(11, _omitFieldNames ? '' : 'autodetectAway') ..hasRequiredFields = false ; @@ -539,6 +2250,7 @@ class Account extends $pb.GeneratedMessage { static Account getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static Account? _defaultInstance; + /// The user's profile that gets shared with contacts @$pb.TagNumber(1) Profile get profile => $_getN(0); @$pb.TagNumber(1) @@ -550,6 +2262,7 @@ class Account extends $pb.GeneratedMessage { @$pb.TagNumber(1) Profile ensureProfile() => $_ensure(0); + /// Invisibility makes you always look 'Offline' @$pb.TagNumber(2) $core.bool get invisible => $_getBF(1); @$pb.TagNumber(2) @@ -559,57 +2272,304 @@ class Account extends $pb.GeneratedMessage { @$pb.TagNumber(2) void clearInvisible() => clearField(2); + /// Auto-away sets 'away' mode after an inactivity time (only if autodetect_away is set) @$pb.TagNumber(3) - $core.int get autoAwayTimeoutSec => $_getIZ(2); + $core.int get autoAwayTimeoutMin => $_getIZ(2); @$pb.TagNumber(3) - set autoAwayTimeoutSec($core.int v) { $_setUnsignedInt32(2, v); } + set autoAwayTimeoutMin($core.int v) { $_setUnsignedInt32(2, v); } @$pb.TagNumber(3) - $core.bool hasAutoAwayTimeoutSec() => $_has(2); + $core.bool hasAutoAwayTimeoutMin() => $_has(2); @$pb.TagNumber(3) - void clearAutoAwayTimeoutSec() => clearField(3); + void clearAutoAwayTimeoutMin() => clearField(3); + /// The contacts DHTList for this account + /// DHT Private @$pb.TagNumber(4) - $0.OwnedDHTRecordPointer get contactList => $_getN(3); + $1.OwnedDHTRecordPointer get contactList => $_getN(3); @$pb.TagNumber(4) - set contactList($0.OwnedDHTRecordPointer v) { setField(4, v); } + set contactList($1.OwnedDHTRecordPointer v) { setField(4, v); } @$pb.TagNumber(4) $core.bool hasContactList() => $_has(3); @$pb.TagNumber(4) void clearContactList() => clearField(4); @$pb.TagNumber(4) - $0.OwnedDHTRecordPointer ensureContactList() => $_ensure(3); + $1.OwnedDHTRecordPointer ensureContactList() => $_ensure(3); + /// The ContactInvitationRecord DHTShortArray for this account + /// DHT Private @$pb.TagNumber(5) - $0.OwnedDHTRecordPointer get contactInvitationRecords => $_getN(4); + $1.OwnedDHTRecordPointer get contactInvitationRecords => $_getN(4); @$pb.TagNumber(5) - set contactInvitationRecords($0.OwnedDHTRecordPointer v) { setField(5, v); } + set contactInvitationRecords($1.OwnedDHTRecordPointer v) { setField(5, v); } @$pb.TagNumber(5) $core.bool hasContactInvitationRecords() => $_has(4); @$pb.TagNumber(5) void clearContactInvitationRecords() => clearField(5); @$pb.TagNumber(5) - $0.OwnedDHTRecordPointer ensureContactInvitationRecords() => $_ensure(4); + $1.OwnedDHTRecordPointer ensureContactInvitationRecords() => $_ensure(4); + /// The Chats DHTList for this account + /// DHT Private @$pb.TagNumber(6) - $0.OwnedDHTRecordPointer get chatList => $_getN(5); + $1.OwnedDHTRecordPointer get chatList => $_getN(5); @$pb.TagNumber(6) - set chatList($0.OwnedDHTRecordPointer v) { setField(6, v); } + set chatList($1.OwnedDHTRecordPointer v) { setField(6, v); } @$pb.TagNumber(6) $core.bool hasChatList() => $_has(5); @$pb.TagNumber(6) void clearChatList() => clearField(6); @$pb.TagNumber(6) - $0.OwnedDHTRecordPointer ensureChatList() => $_ensure(5); + $1.OwnedDHTRecordPointer ensureChatList() => $_ensure(5); + + /// The GroupChats DHTList for this account + /// DHT Private + @$pb.TagNumber(7) + $1.OwnedDHTRecordPointer get groupChatList => $_getN(6); + @$pb.TagNumber(7) + set groupChatList($1.OwnedDHTRecordPointer v) { setField(7, v); } + @$pb.TagNumber(7) + $core.bool hasGroupChatList() => $_has(6); + @$pb.TagNumber(7) + void clearGroupChatList() => clearField(7); + @$pb.TagNumber(7) + $1.OwnedDHTRecordPointer ensureGroupChatList() => $_ensure(6); + + /// Free message (max length 128) + @$pb.TagNumber(8) + $core.String get freeMessage => $_getSZ(7); + @$pb.TagNumber(8) + set freeMessage($core.String v) { $_setString(7, v); } + @$pb.TagNumber(8) + $core.bool hasFreeMessage() => $_has(7); + @$pb.TagNumber(8) + void clearFreeMessage() => clearField(8); + + /// Busy message (max length 128) + @$pb.TagNumber(9) + $core.String get busyMessage => $_getSZ(8); + @$pb.TagNumber(9) + set busyMessage($core.String v) { $_setString(8, v); } + @$pb.TagNumber(9) + $core.bool hasBusyMessage() => $_has(8); + @$pb.TagNumber(9) + void clearBusyMessage() => clearField(9); + + /// Away message (max length 128) + @$pb.TagNumber(10) + $core.String get awayMessage => $_getSZ(9); + @$pb.TagNumber(10) + set awayMessage($core.String v) { $_setString(9, v); } + @$pb.TagNumber(10) + $core.bool hasAwayMessage() => $_has(9); + @$pb.TagNumber(10) + void clearAwayMessage() => clearField(10); + + /// Auto-detect away + @$pb.TagNumber(11) + $core.bool get autodetectAway => $_getBF(10); + @$pb.TagNumber(11) + set autodetectAway($core.bool v) { $_setBool(10, v); } + @$pb.TagNumber(11) + $core.bool hasAutodetectAway() => $_has(10); + @$pb.TagNumber(11) + void clearAutodetectAway() => clearField(11); } +/// A record of a contact that has accepted a contact invitation +/// Contains a copy of the most recent remote profile as well as +/// a locally edited profile. +/// Contains a copy of the most recent identity from the contact's +/// Master identity dht key +/// +/// Stored in ContactList DHTList +class Contact extends $pb.GeneratedMessage { + factory Contact({ + $core.String? nickname, + Profile? profile, + $core.String? superIdentityJson, + $0.TypedKey? identityPublicKey, + $0.TypedKey? remoteConversationRecordKey, + $0.TypedKey? localConversationRecordKey, + $core.bool? showAvailability, + $core.String? notes, + }) { + final $result = create(); + if (nickname != null) { + $result.nickname = nickname; + } + if (profile != null) { + $result.profile = profile; + } + if (superIdentityJson != null) { + $result.superIdentityJson = superIdentityJson; + } + if (identityPublicKey != null) { + $result.identityPublicKey = identityPublicKey; + } + if (remoteConversationRecordKey != null) { + $result.remoteConversationRecordKey = remoteConversationRecordKey; + } + if (localConversationRecordKey != null) { + $result.localConversationRecordKey = localConversationRecordKey; + } + if (showAvailability != null) { + $result.showAvailability = showAvailability; + } + if (notes != null) { + $result.notes = notes; + } + return $result; + } + Contact._() : super(); + factory Contact.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory Contact.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'Contact', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) + ..aOS(1, _omitFieldNames ? '' : 'nickname') + ..aOM(2, _omitFieldNames ? '' : 'profile', subBuilder: Profile.create) + ..aOS(3, _omitFieldNames ? '' : 'superIdentityJson') + ..aOM<$0.TypedKey>(4, _omitFieldNames ? '' : 'identityPublicKey', subBuilder: $0.TypedKey.create) + ..aOM<$0.TypedKey>(5, _omitFieldNames ? '' : 'remoteConversationRecordKey', subBuilder: $0.TypedKey.create) + ..aOM<$0.TypedKey>(6, _omitFieldNames ? '' : 'localConversationRecordKey', subBuilder: $0.TypedKey.create) + ..aOB(7, _omitFieldNames ? '' : 'showAvailability') + ..aOS(8, _omitFieldNames ? '' : 'notes') + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + Contact clone() => Contact()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + Contact copyWith(void Function(Contact) updates) => super.copyWith((message) => updates(message as Contact)) as Contact; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static Contact create() => Contact._(); + Contact createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static Contact getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static Contact? _defaultInstance; + + /// Friend's nickname + @$pb.TagNumber(1) + $core.String get nickname => $_getSZ(0); + @$pb.TagNumber(1) + set nickname($core.String v) { $_setString(0, v); } + @$pb.TagNumber(1) + $core.bool hasNickname() => $_has(0); + @$pb.TagNumber(1) + void clearNickname() => clearField(1); + + /// Copy of friend's profile from remote conversation + @$pb.TagNumber(2) + Profile get profile => $_getN(1); + @$pb.TagNumber(2) + set profile(Profile v) { setField(2, v); } + @$pb.TagNumber(2) + $core.bool hasProfile() => $_has(1); + @$pb.TagNumber(2) + void clearProfile() => clearField(2); + @$pb.TagNumber(2) + Profile ensureProfile() => $_ensure(1); + + /// Copy of friend's SuperIdentity in JSON from remote conversation + @$pb.TagNumber(3) + $core.String get superIdentityJson => $_getSZ(2); + @$pb.TagNumber(3) + set superIdentityJson($core.String v) { $_setString(2, v); } + @$pb.TagNumber(3) + $core.bool hasSuperIdentityJson() => $_has(2); + @$pb.TagNumber(3) + void clearSuperIdentityJson() => clearField(3); + + /// Copy of friend's most recent identity public key from their identityMaster + @$pb.TagNumber(4) + $0.TypedKey get identityPublicKey => $_getN(3); + @$pb.TagNumber(4) + set identityPublicKey($0.TypedKey v) { setField(4, v); } + @$pb.TagNumber(4) + $core.bool hasIdentityPublicKey() => $_has(3); + @$pb.TagNumber(4) + void clearIdentityPublicKey() => clearField(4); + @$pb.TagNumber(4) + $0.TypedKey ensureIdentityPublicKey() => $_ensure(3); + + /// Remote conversation key to sync from friend + @$pb.TagNumber(5) + $0.TypedKey get remoteConversationRecordKey => $_getN(4); + @$pb.TagNumber(5) + set remoteConversationRecordKey($0.TypedKey v) { setField(5, v); } + @$pb.TagNumber(5) + $core.bool hasRemoteConversationRecordKey() => $_has(4); + @$pb.TagNumber(5) + void clearRemoteConversationRecordKey() => clearField(5); + @$pb.TagNumber(5) + $0.TypedKey ensureRemoteConversationRecordKey() => $_ensure(4); + + /// Our conversation key for friend to sync + @$pb.TagNumber(6) + $0.TypedKey get localConversationRecordKey => $_getN(5); + @$pb.TagNumber(6) + set localConversationRecordKey($0.TypedKey v) { setField(6, v); } + @$pb.TagNumber(6) + $core.bool hasLocalConversationRecordKey() => $_has(5); + @$pb.TagNumber(6) + void clearLocalConversationRecordKey() => clearField(6); + @$pb.TagNumber(6) + $0.TypedKey ensureLocalConversationRecordKey() => $_ensure(5); + + /// Show availability to this contact + @$pb.TagNumber(7) + $core.bool get showAvailability => $_getBF(6); + @$pb.TagNumber(7) + set showAvailability($core.bool v) { $_setBool(6, v); } + @$pb.TagNumber(7) + $core.bool hasShowAvailability() => $_has(6); + @$pb.TagNumber(7) + void clearShowAvailability() => clearField(7); + + /// Notes about this friend + @$pb.TagNumber(8) + $core.String get notes => $_getSZ(7); + @$pb.TagNumber(8) + set notes($core.String v) { $_setString(7, v); } + @$pb.TagNumber(8) + $core.bool hasNotes() => $_has(7); + @$pb.TagNumber(8) + void clearNotes() => clearField(8); +} + +/// Invitation that is shared for VeilidChat contact connections +/// serialized to QR code or data blob, not send over DHT, out of band. +/// Writer secret is unique to this invitation. Writer public key is in the ContactRequestPrivate +/// in the ContactRequestInbox subkey 0 DHT key class ContactInvitation extends $pb.GeneratedMessage { - factory ContactInvitation() => create(); + factory ContactInvitation({ + $0.TypedKey? contactRequestInboxKey, + $core.List<$core.int>? writerSecret, + }) { + final $result = create(); + if (contactRequestInboxKey != null) { + $result.contactRequestInboxKey = contactRequestInboxKey; + } + if (writerSecret != null) { + $result.writerSecret = writerSecret; + } + return $result; + } ContactInvitation._() : super(); factory ContactInvitation.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory ContactInvitation.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'ContactInvitation', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) - ..aOM<$1.TypedKey>(1, _omitFieldNames ? '' : 'contactRequestInboxKey', subBuilder: $1.TypedKey.create) + ..aOM<$0.TypedKey>(1, _omitFieldNames ? '' : 'contactRequestInboxKey', subBuilder: $0.TypedKey.create) ..a<$core.List<$core.int>>(2, _omitFieldNames ? '' : 'writerSecret', $pb.PbFieldType.OY) ..hasRequiredFields = false ; @@ -635,17 +2595,19 @@ class ContactInvitation extends $pb.GeneratedMessage { static ContactInvitation getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static ContactInvitation? _defaultInstance; + /// Contact request DHT record key @$pb.TagNumber(1) - $1.TypedKey get contactRequestInboxKey => $_getN(0); + $0.TypedKey get contactRequestInboxKey => $_getN(0); @$pb.TagNumber(1) - set contactRequestInboxKey($1.TypedKey v) { setField(1, v); } + set contactRequestInboxKey($0.TypedKey v) { setField(1, v); } @$pb.TagNumber(1) $core.bool hasContactRequestInboxKey() => $_has(0); @$pb.TagNumber(1) void clearContactRequestInboxKey() => clearField(1); @$pb.TagNumber(1) - $1.TypedKey ensureContactRequestInboxKey() => $_ensure(0); + $0.TypedKey ensureContactRequestInboxKey() => $_ensure(0); + /// Writer secret key bytes possibly encrypted with nonce appended @$pb.TagNumber(2) $core.List<$core.int> get writerSecret => $_getN(1); @$pb.TagNumber(2) @@ -656,15 +2618,28 @@ class ContactInvitation extends $pb.GeneratedMessage { void clearWriterSecret() => clearField(2); } +/// Signature of invitation with identity class SignedContactInvitation extends $pb.GeneratedMessage { - factory SignedContactInvitation() => create(); + factory SignedContactInvitation({ + $core.List<$core.int>? contactInvitation, + $0.Signature? identitySignature, + }) { + final $result = create(); + if (contactInvitation != null) { + $result.contactInvitation = contactInvitation; + } + if (identitySignature != null) { + $result.identitySignature = identitySignature; + } + return $result; + } SignedContactInvitation._() : super(); factory SignedContactInvitation.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory SignedContactInvitation.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'SignedContactInvitation', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) ..a<$core.List<$core.int>>(1, _omitFieldNames ? '' : 'contactInvitation', $pb.PbFieldType.OY) - ..aOM<$1.Signature>(2, _omitFieldNames ? '' : 'identitySignature', subBuilder: $1.Signature.create) + ..aOM<$0.Signature>(2, _omitFieldNames ? '' : 'identitySignature', subBuilder: $0.Signature.create) ..hasRequiredFields = false ; @@ -689,6 +2664,7 @@ class SignedContactInvitation extends $pb.GeneratedMessage { static SignedContactInvitation getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static SignedContactInvitation? _defaultInstance; + /// The serialized bytes for the contact invitation @$pb.TagNumber(1) $core.List<$core.int> get contactInvitation => $_getN(0); @$pb.TagNumber(1) @@ -698,20 +2674,35 @@ class SignedContactInvitation extends $pb.GeneratedMessage { @$pb.TagNumber(1) void clearContactInvitation() => clearField(1); + /// The signature of the contact_invitation bytes with the identity @$pb.TagNumber(2) - $1.Signature get identitySignature => $_getN(1); + $0.Signature get identitySignature => $_getN(1); @$pb.TagNumber(2) - set identitySignature($1.Signature v) { setField(2, v); } + set identitySignature($0.Signature v) { setField(2, v); } @$pb.TagNumber(2) $core.bool hasIdentitySignature() => $_has(1); @$pb.TagNumber(2) void clearIdentitySignature() => clearField(2); @$pb.TagNumber(2) - $1.Signature ensureIdentitySignature() => $_ensure(1); + $0.Signature ensureIdentitySignature() => $_ensure(1); } +/// Contact request unicastinbox on the DHT +/// DHTSchema: SMPL 1 owner key, 1 writer key symmetrically encrypted with writer secret class ContactRequest extends $pb.GeneratedMessage { - factory ContactRequest() => create(); + factory ContactRequest({ + EncryptionKeyType? encryptionKeyType, + $core.List<$core.int>? private, + }) { + final $result = create(); + if (encryptionKeyType != null) { + $result.encryptionKeyType = encryptionKeyType; + } + if (private != null) { + $result.private = private; + } + return $result; + } ContactRequest._() : super(); factory ContactRequest.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory ContactRequest.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -743,6 +2734,7 @@ class ContactRequest extends $pb.GeneratedMessage { static ContactRequest getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static ContactRequest? _defaultInstance; + /// The kind of encryption used on the unicastinbox writer key @$pb.TagNumber(1) EncryptionKeyType get encryptionKeyType => $_getN(0); @$pb.TagNumber(1) @@ -752,6 +2744,7 @@ class ContactRequest extends $pb.GeneratedMessage { @$pb.TagNumber(1) void clearEncryptionKeyType() => clearField(1); + /// The private part encoded and symmetrically encrypted with the unicastinbox writer secret @$pb.TagNumber(2) $core.List<$core.int> get private => $_getN(1); @$pb.TagNumber(2) @@ -762,17 +2755,43 @@ class ContactRequest extends $pb.GeneratedMessage { void clearPrivate() => clearField(2); } +/// The private part of a possibly encrypted contact request +/// Symmetrically encrypted with writer secret class ContactRequestPrivate extends $pb.GeneratedMessage { - factory ContactRequestPrivate() => create(); + factory ContactRequestPrivate({ + $0.CryptoKey? writerKey, + Profile? profile, + $0.TypedKey? superIdentityRecordKey, + $0.TypedKey? chatRecordKey, + $fixnum.Int64? expiration, + }) { + final $result = create(); + if (writerKey != null) { + $result.writerKey = writerKey; + } + if (profile != null) { + $result.profile = profile; + } + if (superIdentityRecordKey != null) { + $result.superIdentityRecordKey = superIdentityRecordKey; + } + if (chatRecordKey != null) { + $result.chatRecordKey = chatRecordKey; + } + if (expiration != null) { + $result.expiration = expiration; + } + return $result; + } ContactRequestPrivate._() : super(); factory ContactRequestPrivate.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory ContactRequestPrivate.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'ContactRequestPrivate', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) - ..aOM<$1.CryptoKey>(1, _omitFieldNames ? '' : 'writerKey', subBuilder: $1.CryptoKey.create) + ..aOM<$0.CryptoKey>(1, _omitFieldNames ? '' : 'writerKey', subBuilder: $0.CryptoKey.create) ..aOM(2, _omitFieldNames ? '' : 'profile', subBuilder: Profile.create) - ..aOM<$1.TypedKey>(3, _omitFieldNames ? '' : 'identityMasterRecordKey', subBuilder: $1.TypedKey.create) - ..aOM<$1.TypedKey>(4, _omitFieldNames ? '' : 'chatRecordKey', subBuilder: $1.TypedKey.create) + ..aOM<$0.TypedKey>(3, _omitFieldNames ? '' : 'superIdentityRecordKey', subBuilder: $0.TypedKey.create) + ..aOM<$0.TypedKey>(4, _omitFieldNames ? '' : 'chatRecordKey', subBuilder: $0.TypedKey.create) ..a<$fixnum.Int64>(5, _omitFieldNames ? '' : 'expiration', $pb.PbFieldType.OU6, defaultOrMaker: $fixnum.Int64.ZERO) ..hasRequiredFields = false ; @@ -798,17 +2817,19 @@ class ContactRequestPrivate extends $pb.GeneratedMessage { static ContactRequestPrivate getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static ContactRequestPrivate? _defaultInstance; + /// Writer public key for signing writes to contact request unicastinbox @$pb.TagNumber(1) - $1.CryptoKey get writerKey => $_getN(0); + $0.CryptoKey get writerKey => $_getN(0); @$pb.TagNumber(1) - set writerKey($1.CryptoKey v) { setField(1, v); } + set writerKey($0.CryptoKey v) { setField(1, v); } @$pb.TagNumber(1) $core.bool hasWriterKey() => $_has(0); @$pb.TagNumber(1) void clearWriterKey() => clearField(1); @$pb.TagNumber(1) - $1.CryptoKey ensureWriterKey() => $_ensure(0); + $0.CryptoKey ensureWriterKey() => $_ensure(0); + /// Snapshot of profile @$pb.TagNumber(2) Profile get profile => $_getN(1); @$pb.TagNumber(2) @@ -820,28 +2841,31 @@ class ContactRequestPrivate extends $pb.GeneratedMessage { @$pb.TagNumber(2) Profile ensureProfile() => $_ensure(1); + /// SuperIdentity DHT record key @$pb.TagNumber(3) - $1.TypedKey get identityMasterRecordKey => $_getN(2); + $0.TypedKey get superIdentityRecordKey => $_getN(2); @$pb.TagNumber(3) - set identityMasterRecordKey($1.TypedKey v) { setField(3, v); } + set superIdentityRecordKey($0.TypedKey v) { setField(3, v); } @$pb.TagNumber(3) - $core.bool hasIdentityMasterRecordKey() => $_has(2); + $core.bool hasSuperIdentityRecordKey() => $_has(2); @$pb.TagNumber(3) - void clearIdentityMasterRecordKey() => clearField(3); + void clearSuperIdentityRecordKey() => clearField(3); @$pb.TagNumber(3) - $1.TypedKey ensureIdentityMasterRecordKey() => $_ensure(2); + $0.TypedKey ensureSuperIdentityRecordKey() => $_ensure(2); + /// Local chat DHT record key @$pb.TagNumber(4) - $1.TypedKey get chatRecordKey => $_getN(3); + $0.TypedKey get chatRecordKey => $_getN(3); @$pb.TagNumber(4) - set chatRecordKey($1.TypedKey v) { setField(4, v); } + set chatRecordKey($0.TypedKey v) { setField(4, v); } @$pb.TagNumber(4) $core.bool hasChatRecordKey() => $_has(3); @$pb.TagNumber(4) void clearChatRecordKey() => clearField(4); @$pb.TagNumber(4) - $1.TypedKey ensureChatRecordKey() => $_ensure(3); + $0.TypedKey ensureChatRecordKey() => $_ensure(3); + /// Expiration timestamp @$pb.TagNumber(5) $fixnum.Int64 get expiration => $_getI64(4); @$pb.TagNumber(5) @@ -852,16 +2876,33 @@ class ContactRequestPrivate extends $pb.GeneratedMessage { void clearExpiration() => clearField(5); } +/// To accept or reject a contact request, fill this out and send to the ContactRequest unicastinbox class ContactResponse extends $pb.GeneratedMessage { - factory ContactResponse() => create(); + factory ContactResponse({ + $core.bool? accept, + $0.TypedKey? superIdentityRecordKey, + $0.TypedKey? remoteConversationRecordKey, + }) { + final $result = create(); + if (accept != null) { + $result.accept = accept; + } + if (superIdentityRecordKey != null) { + $result.superIdentityRecordKey = superIdentityRecordKey; + } + if (remoteConversationRecordKey != null) { + $result.remoteConversationRecordKey = remoteConversationRecordKey; + } + return $result; + } ContactResponse._() : super(); factory ContactResponse.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory ContactResponse.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'ContactResponse', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) ..aOB(1, _omitFieldNames ? '' : 'accept') - ..aOM<$1.TypedKey>(2, _omitFieldNames ? '' : 'identityMasterRecordKey', subBuilder: $1.TypedKey.create) - ..aOM<$1.TypedKey>(3, _omitFieldNames ? '' : 'remoteConversationRecordKey', subBuilder: $1.TypedKey.create) + ..aOM<$0.TypedKey>(2, _omitFieldNames ? '' : 'superIdentityRecordKey', subBuilder: $0.TypedKey.create) + ..aOM<$0.TypedKey>(3, _omitFieldNames ? '' : 'remoteConversationRecordKey', subBuilder: $0.TypedKey.create) ..hasRequiredFields = false ; @@ -886,6 +2927,7 @@ class ContactResponse extends $pb.GeneratedMessage { static ContactResponse getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static ContactResponse? _defaultInstance; + /// Accept or reject @$pb.TagNumber(1) $core.bool get accept => $_getBF(0); @$pb.TagNumber(1) @@ -895,38 +2937,54 @@ class ContactResponse extends $pb.GeneratedMessage { @$pb.TagNumber(1) void clearAccept() => clearField(1); + /// Remote SuperIdentity DHT record key @$pb.TagNumber(2) - $1.TypedKey get identityMasterRecordKey => $_getN(1); + $0.TypedKey get superIdentityRecordKey => $_getN(1); @$pb.TagNumber(2) - set identityMasterRecordKey($1.TypedKey v) { setField(2, v); } + set superIdentityRecordKey($0.TypedKey v) { setField(2, v); } @$pb.TagNumber(2) - $core.bool hasIdentityMasterRecordKey() => $_has(1); + $core.bool hasSuperIdentityRecordKey() => $_has(1); @$pb.TagNumber(2) - void clearIdentityMasterRecordKey() => clearField(2); + void clearSuperIdentityRecordKey() => clearField(2); @$pb.TagNumber(2) - $1.TypedKey ensureIdentityMasterRecordKey() => $_ensure(1); + $0.TypedKey ensureSuperIdentityRecordKey() => $_ensure(1); + /// Remote chat DHT record key if accepted @$pb.TagNumber(3) - $1.TypedKey get remoteConversationRecordKey => $_getN(2); + $0.TypedKey get remoteConversationRecordKey => $_getN(2); @$pb.TagNumber(3) - set remoteConversationRecordKey($1.TypedKey v) { setField(3, v); } + set remoteConversationRecordKey($0.TypedKey v) { setField(3, v); } @$pb.TagNumber(3) $core.bool hasRemoteConversationRecordKey() => $_has(2); @$pb.TagNumber(3) void clearRemoteConversationRecordKey() => clearField(3); @$pb.TagNumber(3) - $1.TypedKey ensureRemoteConversationRecordKey() => $_ensure(2); + $0.TypedKey ensureRemoteConversationRecordKey() => $_ensure(2); } +/// Signature of response with identity +/// Symmetrically encrypted with writer secret class SignedContactResponse extends $pb.GeneratedMessage { - factory SignedContactResponse() => create(); + factory SignedContactResponse({ + $core.List<$core.int>? contactResponse, + $0.Signature? identitySignature, + }) { + final $result = create(); + if (contactResponse != null) { + $result.contactResponse = contactResponse; + } + if (identitySignature != null) { + $result.identitySignature = identitySignature; + } + return $result; + } SignedContactResponse._() : super(); factory SignedContactResponse.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory SignedContactResponse.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'SignedContactResponse', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) ..a<$core.List<$core.int>>(1, _omitFieldNames ? '' : 'contactResponse', $pb.PbFieldType.OY) - ..aOM<$1.Signature>(2, _omitFieldNames ? '' : 'identitySignature', subBuilder: $1.Signature.create) + ..aOM<$0.Signature>(2, _omitFieldNames ? '' : 'identitySignature', subBuilder: $0.Signature.create) ..hasRequiredFields = false ; @@ -951,6 +3009,7 @@ class SignedContactResponse extends $pb.GeneratedMessage { static SignedContactResponse getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static SignedContactResponse? _defaultInstance; + /// Serialized bytes for ContactResponse @$pb.TagNumber(1) $core.List<$core.int> get contactResponse => $_getN(0); @$pb.TagNumber(1) @@ -960,32 +3019,71 @@ class SignedContactResponse extends $pb.GeneratedMessage { @$pb.TagNumber(1) void clearContactResponse() => clearField(1); + /// Signature of the contact_accept bytes with the identity @$pb.TagNumber(2) - $1.Signature get identitySignature => $_getN(1); + $0.Signature get identitySignature => $_getN(1); @$pb.TagNumber(2) - set identitySignature($1.Signature v) { setField(2, v); } + set identitySignature($0.Signature v) { setField(2, v); } @$pb.TagNumber(2) $core.bool hasIdentitySignature() => $_has(1); @$pb.TagNumber(2) void clearIdentitySignature() => clearField(2); @$pb.TagNumber(2) - $1.Signature ensureIdentitySignature() => $_ensure(1); + $0.Signature ensureIdentitySignature() => $_ensure(1); } +/// Contact request record kept in Account DHTList to keep track of extant contact invitations class ContactInvitationRecord extends $pb.GeneratedMessage { - factory ContactInvitationRecord() => create(); + factory ContactInvitationRecord({ + $1.OwnedDHTRecordPointer? contactRequestInbox, + $0.CryptoKey? writerKey, + $0.CryptoKey? writerSecret, + $0.TypedKey? localConversationRecordKey, + $fixnum.Int64? expiration, + $core.List<$core.int>? invitation, + $core.String? message, + $core.String? recipient, + }) { + final $result = create(); + if (contactRequestInbox != null) { + $result.contactRequestInbox = contactRequestInbox; + } + if (writerKey != null) { + $result.writerKey = writerKey; + } + if (writerSecret != null) { + $result.writerSecret = writerSecret; + } + if (localConversationRecordKey != null) { + $result.localConversationRecordKey = localConversationRecordKey; + } + if (expiration != null) { + $result.expiration = expiration; + } + if (invitation != null) { + $result.invitation = invitation; + } + if (message != null) { + $result.message = message; + } + if (recipient != null) { + $result.recipient = recipient; + } + return $result; + } ContactInvitationRecord._() : super(); factory ContactInvitationRecord.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory ContactInvitationRecord.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'ContactInvitationRecord', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) - ..aOM<$0.OwnedDHTRecordPointer>(1, _omitFieldNames ? '' : 'contactRequestInbox', subBuilder: $0.OwnedDHTRecordPointer.create) - ..aOM<$1.CryptoKey>(2, _omitFieldNames ? '' : 'writerKey', subBuilder: $1.CryptoKey.create) - ..aOM<$1.CryptoKey>(3, _omitFieldNames ? '' : 'writerSecret', subBuilder: $1.CryptoKey.create) - ..aOM<$1.TypedKey>(4, _omitFieldNames ? '' : 'localConversationRecordKey', subBuilder: $1.TypedKey.create) + ..aOM<$1.OwnedDHTRecordPointer>(1, _omitFieldNames ? '' : 'contactRequestInbox', subBuilder: $1.OwnedDHTRecordPointer.create) + ..aOM<$0.CryptoKey>(2, _omitFieldNames ? '' : 'writerKey', subBuilder: $0.CryptoKey.create) + ..aOM<$0.CryptoKey>(3, _omitFieldNames ? '' : 'writerSecret', subBuilder: $0.CryptoKey.create) + ..aOM<$0.TypedKey>(4, _omitFieldNames ? '' : 'localConversationRecordKey', subBuilder: $0.TypedKey.create) ..a<$fixnum.Int64>(5, _omitFieldNames ? '' : 'expiration', $pb.PbFieldType.OU6, defaultOrMaker: $fixnum.Int64.ZERO) ..a<$core.List<$core.int>>(6, _omitFieldNames ? '' : 'invitation', $pb.PbFieldType.OY) ..aOS(7, _omitFieldNames ? '' : 'message') + ..aOS(8, _omitFieldNames ? '' : 'recipient') ..hasRequiredFields = false ; @@ -1010,50 +3108,55 @@ class ContactInvitationRecord extends $pb.GeneratedMessage { static ContactInvitationRecord getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static ContactInvitationRecord? _defaultInstance; + /// Contact request unicastinbox DHT record key (parent is accountkey) @$pb.TagNumber(1) - $0.OwnedDHTRecordPointer get contactRequestInbox => $_getN(0); + $1.OwnedDHTRecordPointer get contactRequestInbox => $_getN(0); @$pb.TagNumber(1) - set contactRequestInbox($0.OwnedDHTRecordPointer v) { setField(1, v); } + set contactRequestInbox($1.OwnedDHTRecordPointer v) { setField(1, v); } @$pb.TagNumber(1) $core.bool hasContactRequestInbox() => $_has(0); @$pb.TagNumber(1) void clearContactRequestInbox() => clearField(1); @$pb.TagNumber(1) - $0.OwnedDHTRecordPointer ensureContactRequestInbox() => $_ensure(0); + $1.OwnedDHTRecordPointer ensureContactRequestInbox() => $_ensure(0); + /// Writer key sent to contact for the contact_request_inbox smpl inbox subkey @$pb.TagNumber(2) - $1.CryptoKey get writerKey => $_getN(1); + $0.CryptoKey get writerKey => $_getN(1); @$pb.TagNumber(2) - set writerKey($1.CryptoKey v) { setField(2, v); } + set writerKey($0.CryptoKey v) { setField(2, v); } @$pb.TagNumber(2) $core.bool hasWriterKey() => $_has(1); @$pb.TagNumber(2) void clearWriterKey() => clearField(2); @$pb.TagNumber(2) - $1.CryptoKey ensureWriterKey() => $_ensure(1); + $0.CryptoKey ensureWriterKey() => $_ensure(1); + /// Writer secret sent encrypted in the invitation @$pb.TagNumber(3) - $1.CryptoKey get writerSecret => $_getN(2); + $0.CryptoKey get writerSecret => $_getN(2); @$pb.TagNumber(3) - set writerSecret($1.CryptoKey v) { setField(3, v); } + set writerSecret($0.CryptoKey v) { setField(3, v); } @$pb.TagNumber(3) $core.bool hasWriterSecret() => $_has(2); @$pb.TagNumber(3) void clearWriterSecret() => clearField(3); @$pb.TagNumber(3) - $1.CryptoKey ensureWriterSecret() => $_ensure(2); + $0.CryptoKey ensureWriterSecret() => $_ensure(2); + /// Local chat DHT record key (parent is accountkey, will be moved to Contact if accepted) @$pb.TagNumber(4) - $1.TypedKey get localConversationRecordKey => $_getN(3); + $0.TypedKey get localConversationRecordKey => $_getN(3); @$pb.TagNumber(4) - set localConversationRecordKey($1.TypedKey v) { setField(4, v); } + set localConversationRecordKey($0.TypedKey v) { setField(4, v); } @$pb.TagNumber(4) $core.bool hasLocalConversationRecordKey() => $_has(3); @$pb.TagNumber(4) void clearLocalConversationRecordKey() => clearField(4); @$pb.TagNumber(4) - $1.TypedKey ensureLocalConversationRecordKey() => $_ensure(3); + $0.TypedKey ensureLocalConversationRecordKey() => $_ensure(3); + /// Expiration timestamp @$pb.TagNumber(5) $fixnum.Int64 get expiration => $_getI64(4); @$pb.TagNumber(5) @@ -1063,6 +3166,7 @@ class ContactInvitationRecord extends $pb.GeneratedMessage { @$pb.TagNumber(5) void clearExpiration() => clearField(5); + /// A copy of the raw SignedContactInvitation invitation bytes post-encryption and signing @$pb.TagNumber(6) $core.List<$core.int> get invitation => $_getN(5); @$pb.TagNumber(6) @@ -1072,6 +3176,7 @@ class ContactInvitationRecord extends $pb.GeneratedMessage { @$pb.TagNumber(6) void clearInvitation() => clearField(6); + /// The message sent along with the invitation @$pb.TagNumber(7) $core.String get message => $_getSZ(6); @$pb.TagNumber(7) @@ -1080,6 +3185,16 @@ class ContactInvitationRecord extends $pb.GeneratedMessage { $core.bool hasMessage() => $_has(6); @$pb.TagNumber(7) void clearMessage() => clearField(7); + + /// The recipient sent along with the invitation + @$pb.TagNumber(8) + $core.String get recipient => $_getSZ(7); + @$pb.TagNumber(8) + set recipient($core.String v) { $_setString(7, v); } + @$pb.TagNumber(8) + $core.bool hasRecipient() => $_has(7); + @$pb.TagNumber(8) + void clearRecipient() => clearField(8); } diff --git a/lib/proto/veilidchat.pbenum.dart b/lib/proto/veilidchat.pbenum.dart index 7bef00f..42009e8 100644 --- a/lib/proto/veilidchat.pbenum.dart +++ b/lib/proto/veilidchat.pbenum.dart @@ -4,7 +4,7 @@ // // @dart = 2.12 -// ignore_for_file: annotate_overrides, camel_case_types +// ignore_for_file: annotate_overrides, camel_case_types, comment_references // ignore_for_file: constant_identifier_names, library_prefixes // ignore_for_file: non_constant_identifier_names, prefer_final_fields // ignore_for_file: unnecessary_import, unnecessary_this, unused_import @@ -13,23 +13,7 @@ import 'dart:core' as $core; import 'package:protobuf/protobuf.dart' as $pb; -class AttachmentKind extends $pb.ProtobufEnum { - static const AttachmentKind ATTACHMENT_KIND_UNSPECIFIED = AttachmentKind._(0, _omitEnumNames ? '' : 'ATTACHMENT_KIND_UNSPECIFIED'); - static const AttachmentKind ATTACHMENT_KIND_FILE = AttachmentKind._(1, _omitEnumNames ? '' : 'ATTACHMENT_KIND_FILE'); - static const AttachmentKind ATTACHMENT_KIND_IMAGE = AttachmentKind._(2, _omitEnumNames ? '' : 'ATTACHMENT_KIND_IMAGE'); - - static const $core.List values = [ - ATTACHMENT_KIND_UNSPECIFIED, - ATTACHMENT_KIND_FILE, - ATTACHMENT_KIND_IMAGE, - ]; - - static final $core.Map<$core.int, AttachmentKind> _byValue = $pb.ProtobufEnum.initByValue(values); - static AttachmentKind? valueOf($core.int value) => _byValue[value]; - - const AttachmentKind._($core.int v, $core.String n) : super(v, n); -} - +/// Contact availability class Availability extends $pb.ProtobufEnum { static const Availability AVAILABILITY_UNSPECIFIED = Availability._(0, _omitEnumNames ? '' : 'AVAILABILITY_UNSPECIFIED'); static const Availability AVAILABILITY_OFFLINE = Availability._(1, _omitEnumNames ? '' : 'AVAILABILITY_OFFLINE'); @@ -51,23 +35,7 @@ class Availability extends $pb.ProtobufEnum { const Availability._($core.int v, $core.String n) : super(v, n); } -class ChatType extends $pb.ProtobufEnum { - static const ChatType CHAT_TYPE_UNSPECIFIED = ChatType._(0, _omitEnumNames ? '' : 'CHAT_TYPE_UNSPECIFIED'); - static const ChatType SINGLE_CONTACT = ChatType._(1, _omitEnumNames ? '' : 'SINGLE_CONTACT'); - static const ChatType GROUP = ChatType._(2, _omitEnumNames ? '' : 'GROUP'); - - static const $core.List values = [ - CHAT_TYPE_UNSPECIFIED, - SINGLE_CONTACT, - GROUP, - ]; - - static final $core.Map<$core.int, ChatType> _byValue = $pb.ProtobufEnum.initByValue(values); - static ChatType? valueOf($core.int value) => _byValue[value]; - - const ChatType._($core.int v, $core.String n) : super(v, n); -} - +/// Encryption used on secret keys class EncryptionKeyType extends $pb.ProtobufEnum { static const EncryptionKeyType ENCRYPTION_KEY_TYPE_UNSPECIFIED = EncryptionKeyType._(0, _omitEnumNames ? '' : 'ENCRYPTION_KEY_TYPE_UNSPECIFIED'); static const EncryptionKeyType ENCRYPTION_KEY_TYPE_NONE = EncryptionKeyType._(1, _omitEnumNames ? '' : 'ENCRYPTION_KEY_TYPE_NONE'); @@ -87,5 +55,27 @@ class EncryptionKeyType extends $pb.ProtobufEnum { const EncryptionKeyType._($core.int v, $core.String n) : super(v, n); } +/// Scope of a chat +class Scope extends $pb.ProtobufEnum { + static const Scope WATCHERS = Scope._(0, _omitEnumNames ? '' : 'WATCHERS'); + static const Scope MODERATED = Scope._(1, _omitEnumNames ? '' : 'MODERATED'); + static const Scope TALKERS = Scope._(2, _omitEnumNames ? '' : 'TALKERS'); + static const Scope MODERATORS = Scope._(3, _omitEnumNames ? '' : 'MODERATORS'); + static const Scope ADMINS = Scope._(4, _omitEnumNames ? '' : 'ADMINS'); + + static const $core.List values = [ + WATCHERS, + MODERATED, + TALKERS, + MODERATORS, + ADMINS, + ]; + + static final $core.Map<$core.int, Scope> _byValue = $pb.ProtobufEnum.initByValue(values); + static Scope? valueOf($core.int value) => _byValue[value]; + + const Scope._($core.int v, $core.String n) : super(v, n); +} + const _omitEnumNames = $core.bool.fromEnvironment('protobuf.omit_enum_names'); diff --git a/lib/proto/veilidchat.pbjson.dart b/lib/proto/veilidchat.pbjson.dart index c8eb2c8..0958343 100644 --- a/lib/proto/veilidchat.pbjson.dart +++ b/lib/proto/veilidchat.pbjson.dart @@ -4,7 +4,7 @@ // // @dart = 2.12 -// ignore_for_file: annotate_overrides, camel_case_types +// ignore_for_file: annotate_overrides, camel_case_types, comment_references // ignore_for_file: constant_identifier_names, library_prefixes // ignore_for_file: non_constant_identifier_names, prefer_final_fields // ignore_for_file: unnecessary_import, unnecessary_this, unused_import @@ -13,21 +13,6 @@ import 'dart:convert' as $convert; import 'dart:core' as $core; import 'dart:typed_data' as $typed_data; -@$core.Deprecated('Use attachmentKindDescriptor instead') -const AttachmentKind$json = { - '1': 'AttachmentKind', - '2': [ - {'1': 'ATTACHMENT_KIND_UNSPECIFIED', '2': 0}, - {'1': 'ATTACHMENT_KIND_FILE', '2': 1}, - {'1': 'ATTACHMENT_KIND_IMAGE', '2': 2}, - ], -}; - -/// Descriptor for `AttachmentKind`. Decode as a `google.protobuf.EnumDescriptorProto`. -final $typed_data.Uint8List attachmentKindDescriptor = $convert.base64Decode( - 'Cg5BdHRhY2htZW50S2luZBIfChtBVFRBQ0hNRU5UX0tJTkRfVU5TUEVDSUZJRUQQABIYChRBVF' - 'RBQ0hNRU5UX0tJTkRfRklMRRABEhkKFUFUVEFDSE1FTlRfS0lORF9JTUFHRRAC'); - @$core.Deprecated('Use availabilityDescriptor instead') const Availability$json = { '1': 'Availability', @@ -46,21 +31,6 @@ final $typed_data.Uint8List availabilityDescriptor = $convert.base64Decode( 'lMSVRZX09GRkxJTkUQARIVChFBVkFJTEFCSUxJVFlfRlJFRRACEhUKEUFWQUlMQUJJTElUWV9C' 'VVNZEAMSFQoRQVZBSUxBQklMSVRZX0FXQVkQBA=='); -@$core.Deprecated('Use chatTypeDescriptor instead') -const ChatType$json = { - '1': 'ChatType', - '2': [ - {'1': 'CHAT_TYPE_UNSPECIFIED', '2': 0}, - {'1': 'SINGLE_CONTACT', '2': 1}, - {'1': 'GROUP', '2': 2}, - ], -}; - -/// Descriptor for `ChatType`. Decode as a `google.protobuf.EnumDescriptorProto`. -final $typed_data.Uint8List chatTypeDescriptor = $convert.base64Decode( - 'CghDaGF0VHlwZRIZChVDSEFUX1RZUEVfVU5TUEVDSUZJRUQQABISCg5TSU5HTEVfQ09OVEFDVB' - 'ABEgkKBUdST1VQEAI='); - @$core.Deprecated('Use encryptionKeyTypeDescriptor instead') const EncryptionKeyType$json = { '1': 'EncryptionKeyType', @@ -78,50 +48,316 @@ final $typed_data.Uint8List encryptionKeyTypeDescriptor = $convert.base64Decode( 'ASHAoYRU5DUllQVElPTl9LRVlfVFlQRV9OT05FEAESGwoXRU5DUllQVElPTl9LRVlfVFlQRV9Q' 'SU4QAhIgChxFTkNSWVBUSU9OX0tFWV9UWVBFX1BBU1NXT1JEEAM='); +@$core.Deprecated('Use scopeDescriptor instead') +const Scope$json = { + '1': 'Scope', + '2': [ + {'1': 'WATCHERS', '2': 0}, + {'1': 'MODERATED', '2': 1}, + {'1': 'TALKERS', '2': 2}, + {'1': 'MODERATORS', '2': 3}, + {'1': 'ADMINS', '2': 4}, + ], +}; + +/// Descriptor for `Scope`. Decode as a `google.protobuf.EnumDescriptorProto`. +final $typed_data.Uint8List scopeDescriptor = $convert.base64Decode( + 'CgVTY29wZRIMCghXQVRDSEVSUxAAEg0KCU1PREVSQVRFRBABEgsKB1RBTEtFUlMQAhIOCgpNT0' + 'RFUkFUT1JTEAMSCgoGQURNSU5TEAQ='); + +@$core.Deprecated('Use dHTDataReferenceDescriptor instead') +const DHTDataReference$json = { + '1': 'DHTDataReference', + '2': [ + {'1': 'dht_data', '3': 1, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'dhtData'}, + {'1': 'hash', '3': 2, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'hash'}, + ], +}; + +/// Descriptor for `DHTDataReference`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List dHTDataReferenceDescriptor = $convert.base64Decode( + 'ChBESFREYXRhUmVmZXJlbmNlEisKCGRodF9kYXRhGAEgASgLMhAudmVpbGlkLlR5cGVkS2V5Ug' + 'dkaHREYXRhEiQKBGhhc2gYAiABKAsyEC52ZWlsaWQuVHlwZWRLZXlSBGhhc2g='); + +@$core.Deprecated('Use blockStoreDataReferenceDescriptor instead') +const BlockStoreDataReference$json = { + '1': 'BlockStoreDataReference', + '2': [ + {'1': 'block', '3': 1, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'block'}, + ], +}; + +/// Descriptor for `BlockStoreDataReference`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List blockStoreDataReferenceDescriptor = $convert.base64Decode( + 'ChdCbG9ja1N0b3JlRGF0YVJlZmVyZW5jZRImCgVibG9jaxgBIAEoCzIQLnZlaWxpZC5UeXBlZE' + 'tleVIFYmxvY2s='); + +@$core.Deprecated('Use dataReferenceDescriptor instead') +const DataReference$json = { + '1': 'DataReference', + '2': [ + {'1': 'dht_data', '3': 1, '4': 1, '5': 11, '6': '.veilidchat.DHTDataReference', '9': 0, '10': 'dhtData'}, + {'1': 'block_store_data', '3': 2, '4': 1, '5': 11, '6': '.veilidchat.BlockStoreDataReference', '9': 0, '10': 'blockStoreData'}, + ], + '8': [ + {'1': 'kind'}, + ], +}; + +/// Descriptor for `DataReference`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List dataReferenceDescriptor = $convert.base64Decode( + 'Cg1EYXRhUmVmZXJlbmNlEjkKCGRodF9kYXRhGAEgASgLMhwudmVpbGlkY2hhdC5ESFREYXRhUm' + 'VmZXJlbmNlSABSB2RodERhdGESTwoQYmxvY2tfc3RvcmVfZGF0YRgCIAEoCzIjLnZlaWxpZGNo' + 'YXQuQmxvY2tTdG9yZURhdGFSZWZlcmVuY2VIAFIOYmxvY2tTdG9yZURhdGFCBgoEa2luZA=='); + @$core.Deprecated('Use attachmentDescriptor instead') const Attachment$json = { '1': 'Attachment', '2': [ - {'1': 'kind', '3': 1, '4': 1, '5': 14, '6': '.veilidchat.AttachmentKind', '10': 'kind'}, - {'1': 'mime', '3': 2, '4': 1, '5': 9, '10': 'mime'}, - {'1': 'name', '3': 3, '4': 1, '5': 9, '10': 'name'}, - {'1': 'content', '3': 4, '4': 1, '5': 11, '6': '.dht.DataReference', '10': 'content'}, - {'1': 'signature', '3': 5, '4': 1, '5': 11, '6': '.veilid.Signature', '10': 'signature'}, + {'1': 'media', '3': 1, '4': 1, '5': 11, '6': '.veilidchat.AttachmentMedia', '9': 0, '10': 'media'}, + {'1': 'signature', '3': 2, '4': 1, '5': 11, '6': '.veilid.Signature', '10': 'signature'}, + ], + '8': [ + {'1': 'kind'}, ], }; /// Descriptor for `Attachment`. Decode as a `google.protobuf.DescriptorProto`. final $typed_data.Uint8List attachmentDescriptor = $convert.base64Decode( - 'CgpBdHRhY2htZW50Ei4KBGtpbmQYASABKA4yGi52ZWlsaWRjaGF0LkF0dGFjaG1lbnRLaW5kUg' - 'RraW5kEhIKBG1pbWUYAiABKAlSBG1pbWUSEgoEbmFtZRgDIAEoCVIEbmFtZRIsCgdjb250ZW50' - 'GAQgASgLMhIuZGh0LkRhdGFSZWZlcmVuY2VSB2NvbnRlbnQSLwoJc2lnbmF0dXJlGAUgASgLMh' - 'EudmVpbGlkLlNpZ25hdHVyZVIJc2lnbmF0dXJl'); + 'CgpBdHRhY2htZW50EjMKBW1lZGlhGAEgASgLMhsudmVpbGlkY2hhdC5BdHRhY2htZW50TWVkaW' + 'FIAFIFbWVkaWESLwoJc2lnbmF0dXJlGAIgASgLMhEudmVpbGlkLlNpZ25hdHVyZVIJc2lnbmF0' + 'dXJlQgYKBGtpbmQ='); + +@$core.Deprecated('Use attachmentMediaDescriptor instead') +const AttachmentMedia$json = { + '1': 'AttachmentMedia', + '2': [ + {'1': 'mime', '3': 1, '4': 1, '5': 9, '10': 'mime'}, + {'1': 'name', '3': 2, '4': 1, '5': 9, '10': 'name'}, + {'1': 'content', '3': 3, '4': 1, '5': 11, '6': '.veilidchat.DataReference', '10': 'content'}, + ], +}; + +/// Descriptor for `AttachmentMedia`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List attachmentMediaDescriptor = $convert.base64Decode( + 'Cg9BdHRhY2htZW50TWVkaWESEgoEbWltZRgBIAEoCVIEbWltZRISCgRuYW1lGAIgASgJUgRuYW' + '1lEjMKB2NvbnRlbnQYAyABKAsyGS52ZWlsaWRjaGF0LkRhdGFSZWZlcmVuY2VSB2NvbnRlbnQ='); + +@$core.Deprecated('Use permissionsDescriptor instead') +const Permissions$json = { + '1': 'Permissions', + '2': [ + {'1': 'can_add_members', '3': 1, '4': 1, '5': 14, '6': '.veilidchat.Scope', '10': 'canAddMembers'}, + {'1': 'can_edit_info', '3': 2, '4': 1, '5': 14, '6': '.veilidchat.Scope', '10': 'canEditInfo'}, + {'1': 'moderated', '3': 3, '4': 1, '5': 8, '10': 'moderated'}, + ], +}; + +/// Descriptor for `Permissions`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List permissionsDescriptor = $convert.base64Decode( + 'CgtQZXJtaXNzaW9ucxI5Cg9jYW5fYWRkX21lbWJlcnMYASABKA4yES52ZWlsaWRjaGF0LlNjb3' + 'BlUg1jYW5BZGRNZW1iZXJzEjUKDWNhbl9lZGl0X2luZm8YAiABKA4yES52ZWlsaWRjaGF0LlNj' + 'b3BlUgtjYW5FZGl0SW5mbxIcCgltb2RlcmF0ZWQYAyABKAhSCW1vZGVyYXRlZA=='); + +@$core.Deprecated('Use membershipDescriptor instead') +const Membership$json = { + '1': 'Membership', + '2': [ + {'1': 'watchers', '3': 1, '4': 3, '5': 11, '6': '.veilid.TypedKey', '10': 'watchers'}, + {'1': 'moderated', '3': 2, '4': 3, '5': 11, '6': '.veilid.TypedKey', '10': 'moderated'}, + {'1': 'talkers', '3': 3, '4': 3, '5': 11, '6': '.veilid.TypedKey', '10': 'talkers'}, + {'1': 'moderators', '3': 4, '4': 3, '5': 11, '6': '.veilid.TypedKey', '10': 'moderators'}, + {'1': 'admins', '3': 5, '4': 3, '5': 11, '6': '.veilid.TypedKey', '10': 'admins'}, + ], +}; + +/// Descriptor for `Membership`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List membershipDescriptor = $convert.base64Decode( + 'CgpNZW1iZXJzaGlwEiwKCHdhdGNoZXJzGAEgAygLMhAudmVpbGlkLlR5cGVkS2V5Ugh3YXRjaG' + 'VycxIuCgltb2RlcmF0ZWQYAiADKAsyEC52ZWlsaWQuVHlwZWRLZXlSCW1vZGVyYXRlZBIqCgd0' + 'YWxrZXJzGAMgAygLMhAudmVpbGlkLlR5cGVkS2V5Ugd0YWxrZXJzEjAKCm1vZGVyYXRvcnMYBC' + 'ADKAsyEC52ZWlsaWQuVHlwZWRLZXlSCm1vZGVyYXRvcnMSKAoGYWRtaW5zGAUgAygLMhAudmVp' + 'bGlkLlR5cGVkS2V5UgZhZG1pbnM='); + +@$core.Deprecated('Use chatSettingsDescriptor instead') +const ChatSettings$json = { + '1': 'ChatSettings', + '2': [ + {'1': 'title', '3': 1, '4': 1, '5': 9, '10': 'title'}, + {'1': 'description', '3': 2, '4': 1, '5': 9, '10': 'description'}, + {'1': 'icon', '3': 3, '4': 1, '5': 11, '6': '.veilidchat.DataReference', '9': 0, '10': 'icon', '17': true}, + {'1': 'default_expiration', '3': 4, '4': 1, '5': 4, '10': 'defaultExpiration'}, + ], + '8': [ + {'1': '_icon'}, + ], +}; + +/// Descriptor for `ChatSettings`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List chatSettingsDescriptor = $convert.base64Decode( + 'CgxDaGF0U2V0dGluZ3MSFAoFdGl0bGUYASABKAlSBXRpdGxlEiAKC2Rlc2NyaXB0aW9uGAIgAS' + 'gJUgtkZXNjcmlwdGlvbhIyCgRpY29uGAMgASgLMhkudmVpbGlkY2hhdC5EYXRhUmVmZXJlbmNl' + 'SABSBGljb26IAQESLQoSZGVmYXVsdF9leHBpcmF0aW9uGAQgASgEUhFkZWZhdWx0RXhwaXJhdG' + 'lvbkIHCgVfaWNvbg=='); @$core.Deprecated('Use messageDescriptor instead') const Message$json = { '1': 'Message', '2': [ - {'1': 'author', '3': 1, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'author'}, - {'1': 'timestamp', '3': 2, '4': 1, '5': 4, '10': 'timestamp'}, - {'1': 'text', '3': 3, '4': 1, '5': 9, '10': 'text'}, - {'1': 'signature', '3': 4, '4': 1, '5': 11, '6': '.veilid.Signature', '10': 'signature'}, - {'1': 'attachments', '3': 5, '4': 3, '5': 11, '6': '.veilidchat.Attachment', '10': 'attachments'}, + {'1': 'id', '3': 1, '4': 1, '5': 12, '10': 'id'}, + {'1': 'author', '3': 2, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'author'}, + {'1': 'timestamp', '3': 3, '4': 1, '5': 4, '10': 'timestamp'}, + {'1': 'text', '3': 4, '4': 1, '5': 11, '6': '.veilidchat.Message.Text', '9': 0, '10': 'text'}, + {'1': 'secret', '3': 5, '4': 1, '5': 11, '6': '.veilidchat.Message.Secret', '9': 0, '10': 'secret'}, + {'1': 'delete', '3': 6, '4': 1, '5': 11, '6': '.veilidchat.Message.ControlDelete', '9': 0, '10': 'delete'}, + {'1': 'erase', '3': 7, '4': 1, '5': 11, '6': '.veilidchat.Message.ControlErase', '9': 0, '10': 'erase'}, + {'1': 'settings', '3': 8, '4': 1, '5': 11, '6': '.veilidchat.Message.ControlSettings', '9': 0, '10': 'settings'}, + {'1': 'permissions', '3': 9, '4': 1, '5': 11, '6': '.veilidchat.Message.ControlPermissions', '9': 0, '10': 'permissions'}, + {'1': 'membership', '3': 10, '4': 1, '5': 11, '6': '.veilidchat.Message.ControlMembership', '9': 0, '10': 'membership'}, + {'1': 'moderation', '3': 11, '4': 1, '5': 11, '6': '.veilidchat.Message.ControlModeration', '9': 0, '10': 'moderation'}, + {'1': 'readReceipt', '3': 13, '4': 1, '5': 11, '6': '.veilidchat.Message.ControlReadReceipt', '9': 0, '10': 'readReceipt'}, + {'1': 'signature', '3': 12, '4': 1, '5': 11, '6': '.veilid.Signature', '10': 'signature'}, + ], + '3': [Message_Text$json, Message_Secret$json, Message_ControlDelete$json, Message_ControlErase$json, Message_ControlSettings$json, Message_ControlPermissions$json, Message_ControlMembership$json, Message_ControlModeration$json, Message_ControlReadReceipt$json], + '8': [ + {'1': 'kind'}, + ], +}; + +@$core.Deprecated('Use messageDescriptor instead') +const Message_Text$json = { + '1': 'Text', + '2': [ + {'1': 'text', '3': 1, '4': 1, '5': 9, '10': 'text'}, + {'1': 'topic', '3': 2, '4': 1, '5': 9, '9': 0, '10': 'topic', '17': true}, + {'1': 'reply_id', '3': 3, '4': 1, '5': 12, '9': 1, '10': 'replyId', '17': true}, + {'1': 'expiration', '3': 4, '4': 1, '5': 4, '10': 'expiration'}, + {'1': 'view_limit', '3': 5, '4': 1, '5': 13, '10': 'viewLimit'}, + {'1': 'attachments', '3': 6, '4': 3, '5': 11, '6': '.veilidchat.Attachment', '10': 'attachments'}, + ], + '8': [ + {'1': '_topic'}, + {'1': '_reply_id'}, + ], +}; + +@$core.Deprecated('Use messageDescriptor instead') +const Message_Secret$json = { + '1': 'Secret', + '2': [ + {'1': 'ciphertext', '3': 1, '4': 1, '5': 12, '10': 'ciphertext'}, + {'1': 'expiration', '3': 2, '4': 1, '5': 4, '10': 'expiration'}, + ], +}; + +@$core.Deprecated('Use messageDescriptor instead') +const Message_ControlDelete$json = { + '1': 'ControlDelete', + '2': [ + {'1': 'ids', '3': 1, '4': 3, '5': 12, '10': 'ids'}, + ], +}; + +@$core.Deprecated('Use messageDescriptor instead') +const Message_ControlErase$json = { + '1': 'ControlErase', + '2': [ + {'1': 'timestamp', '3': 1, '4': 1, '5': 4, '10': 'timestamp'}, + ], +}; + +@$core.Deprecated('Use messageDescriptor instead') +const Message_ControlSettings$json = { + '1': 'ControlSettings', + '2': [ + {'1': 'settings', '3': 1, '4': 1, '5': 11, '6': '.veilidchat.ChatSettings', '10': 'settings'}, + ], +}; + +@$core.Deprecated('Use messageDescriptor instead') +const Message_ControlPermissions$json = { + '1': 'ControlPermissions', + '2': [ + {'1': 'permissions', '3': 1, '4': 1, '5': 11, '6': '.veilidchat.Permissions', '10': 'permissions'}, + ], +}; + +@$core.Deprecated('Use messageDescriptor instead') +const Message_ControlMembership$json = { + '1': 'ControlMembership', + '2': [ + {'1': 'membership', '3': 1, '4': 1, '5': 11, '6': '.veilidchat.Membership', '10': 'membership'}, + ], +}; + +@$core.Deprecated('Use messageDescriptor instead') +const Message_ControlModeration$json = { + '1': 'ControlModeration', + '2': [ + {'1': 'accepted_ids', '3': 1, '4': 3, '5': 12, '10': 'acceptedIds'}, + {'1': 'rejected_ids', '3': 2, '4': 3, '5': 12, '10': 'rejectedIds'}, + ], +}; + +@$core.Deprecated('Use messageDescriptor instead') +const Message_ControlReadReceipt$json = { + '1': 'ControlReadReceipt', + '2': [ + {'1': 'read_ids', '3': 1, '4': 3, '5': 12, '10': 'readIds'}, ], }; /// Descriptor for `Message`. Decode as a `google.protobuf.DescriptorProto`. final $typed_data.Uint8List messageDescriptor = $convert.base64Decode( - 'CgdNZXNzYWdlEigKBmF1dGhvchgBIAEoCzIQLnZlaWxpZC5UeXBlZEtleVIGYXV0aG9yEhwKCX' - 'RpbWVzdGFtcBgCIAEoBFIJdGltZXN0YW1wEhIKBHRleHQYAyABKAlSBHRleHQSLwoJc2lnbmF0' - 'dXJlGAQgASgLMhEudmVpbGlkLlNpZ25hdHVyZVIJc2lnbmF0dXJlEjgKC2F0dGFjaG1lbnRzGA' - 'UgAygLMhYudmVpbGlkY2hhdC5BdHRhY2htZW50UgthdHRhY2htZW50cw=='); + 'CgdNZXNzYWdlEg4KAmlkGAEgASgMUgJpZBIoCgZhdXRob3IYAiABKAsyEC52ZWlsaWQuVHlwZW' + 'RLZXlSBmF1dGhvchIcCgl0aW1lc3RhbXAYAyABKARSCXRpbWVzdGFtcBIuCgR0ZXh0GAQgASgL' + 'MhgudmVpbGlkY2hhdC5NZXNzYWdlLlRleHRIAFIEdGV4dBI0CgZzZWNyZXQYBSABKAsyGi52ZW' + 'lsaWRjaGF0Lk1lc3NhZ2UuU2VjcmV0SABSBnNlY3JldBI7CgZkZWxldGUYBiABKAsyIS52ZWls' + 'aWRjaGF0Lk1lc3NhZ2UuQ29udHJvbERlbGV0ZUgAUgZkZWxldGUSOAoFZXJhc2UYByABKAsyIC' + '52ZWlsaWRjaGF0Lk1lc3NhZ2UuQ29udHJvbEVyYXNlSABSBWVyYXNlEkEKCHNldHRpbmdzGAgg' + 'ASgLMiMudmVpbGlkY2hhdC5NZXNzYWdlLkNvbnRyb2xTZXR0aW5nc0gAUghzZXR0aW5ncxJKCg' + 'twZXJtaXNzaW9ucxgJIAEoCzImLnZlaWxpZGNoYXQuTWVzc2FnZS5Db250cm9sUGVybWlzc2lv' + 'bnNIAFILcGVybWlzc2lvbnMSRwoKbWVtYmVyc2hpcBgKIAEoCzIlLnZlaWxpZGNoYXQuTWVzc2' + 'FnZS5Db250cm9sTWVtYmVyc2hpcEgAUgptZW1iZXJzaGlwEkcKCm1vZGVyYXRpb24YCyABKAsy' + 'JS52ZWlsaWRjaGF0Lk1lc3NhZ2UuQ29udHJvbE1vZGVyYXRpb25IAFIKbW9kZXJhdGlvbhJKCg' + 'tyZWFkUmVjZWlwdBgNIAEoCzImLnZlaWxpZGNoYXQuTWVzc2FnZS5Db250cm9sUmVhZFJlY2Vp' + 'cHRIAFILcmVhZFJlY2VpcHQSLwoJc2lnbmF0dXJlGAwgASgLMhEudmVpbGlkLlNpZ25hdHVyZV' + 'IJc2lnbmF0dXJlGuUBCgRUZXh0EhIKBHRleHQYASABKAlSBHRleHQSGQoFdG9waWMYAiABKAlI' + 'AFIFdG9waWOIAQESHgoIcmVwbHlfaWQYAyABKAxIAVIHcmVwbHlJZIgBARIeCgpleHBpcmF0aW' + '9uGAQgASgEUgpleHBpcmF0aW9uEh0KCnZpZXdfbGltaXQYBSABKA1SCXZpZXdMaW1pdBI4Cgth' + 'dHRhY2htZW50cxgGIAMoCzIWLnZlaWxpZGNoYXQuQXR0YWNobWVudFILYXR0YWNobWVudHNCCA' + 'oGX3RvcGljQgsKCV9yZXBseV9pZBpICgZTZWNyZXQSHgoKY2lwaGVydGV4dBgBIAEoDFIKY2lw' + 'aGVydGV4dBIeCgpleHBpcmF0aW9uGAIgASgEUgpleHBpcmF0aW9uGiEKDUNvbnRyb2xEZWxldG' + 'USEAoDaWRzGAEgAygMUgNpZHMaLAoMQ29udHJvbEVyYXNlEhwKCXRpbWVzdGFtcBgBIAEoBFIJ' + 'dGltZXN0YW1wGkcKD0NvbnRyb2xTZXR0aW5ncxI0CghzZXR0aW5ncxgBIAEoCzIYLnZlaWxpZG' + 'NoYXQuQ2hhdFNldHRpbmdzUghzZXR0aW5ncxpPChJDb250cm9sUGVybWlzc2lvbnMSOQoLcGVy' + 'bWlzc2lvbnMYASABKAsyFy52ZWlsaWRjaGF0LlBlcm1pc3Npb25zUgtwZXJtaXNzaW9ucxpLCh' + 'FDb250cm9sTWVtYmVyc2hpcBI2CgptZW1iZXJzaGlwGAEgASgLMhYudmVpbGlkY2hhdC5NZW1i' + 'ZXJzaGlwUgptZW1iZXJzaGlwGlkKEUNvbnRyb2xNb2RlcmF0aW9uEiEKDGFjY2VwdGVkX2lkcx' + 'gBIAMoDFILYWNjZXB0ZWRJZHMSIQoMcmVqZWN0ZWRfaWRzGAIgAygMUgtyZWplY3RlZElkcxov' + 'ChJDb250cm9sUmVhZFJlY2VpcHQSGQoIcmVhZF9pZHMYASADKAxSB3JlYWRJZHNCBgoEa2luZA' + '=='); + +@$core.Deprecated('Use reconciledMessageDescriptor instead') +const ReconciledMessage$json = { + '1': 'ReconciledMessage', + '2': [ + {'1': 'content', '3': 1, '4': 1, '5': 11, '6': '.veilidchat.Message', '10': 'content'}, + {'1': 'reconciled_time', '3': 2, '4': 1, '5': 4, '10': 'reconciledTime'}, + ], +}; + +/// Descriptor for `ReconciledMessage`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List reconciledMessageDescriptor = $convert.base64Decode( + 'ChFSZWNvbmNpbGVkTWVzc2FnZRItCgdjb250ZW50GAEgASgLMhMudmVpbGlkY2hhdC5NZXNzYW' + 'dlUgdjb250ZW50EicKD3JlY29uY2lsZWRfdGltZRgCIAEoBFIOcmVjb25jaWxlZFRpbWU='); @$core.Deprecated('Use conversationDescriptor instead') const Conversation$json = { '1': 'Conversation', '2': [ {'1': 'profile', '3': 1, '4': 1, '5': 11, '6': '.veilidchat.Profile', '10': 'profile'}, - {'1': 'identity_master_json', '3': 2, '4': 1, '5': 9, '10': 'identityMasterJson'}, + {'1': 'super_identity_json', '3': 2, '4': 1, '5': 9, '10': 'superIdentityJson'}, {'1': 'messages', '3': 3, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'messages'}, ], }; @@ -129,34 +365,79 @@ const Conversation$json = { /// Descriptor for `Conversation`. Decode as a `google.protobuf.DescriptorProto`. final $typed_data.Uint8List conversationDescriptor = $convert.base64Decode( 'CgxDb252ZXJzYXRpb24SLQoHcHJvZmlsZRgBIAEoCzITLnZlaWxpZGNoYXQuUHJvZmlsZVIHcH' - 'JvZmlsZRIwChRpZGVudGl0eV9tYXN0ZXJfanNvbhgCIAEoCVISaWRlbnRpdHlNYXN0ZXJKc29u' - 'EiwKCG1lc3NhZ2VzGAMgASgLMhAudmVpbGlkLlR5cGVkS2V5UghtZXNzYWdlcw=='); + 'JvZmlsZRIuChNzdXBlcl9pZGVudGl0eV9qc29uGAIgASgJUhFzdXBlcklkZW50aXR5SnNvbhIs' + 'CghtZXNzYWdlcxgDIAEoCzIQLnZlaWxpZC5UeXBlZEtleVIIbWVzc2FnZXM='); -@$core.Deprecated('Use contactDescriptor instead') -const Contact$json = { - '1': 'Contact', +@$core.Deprecated('Use chatMemberDescriptor instead') +const ChatMember$json = { + '1': 'ChatMember', '2': [ - {'1': 'edited_profile', '3': 1, '4': 1, '5': 11, '6': '.veilidchat.Profile', '10': 'editedProfile'}, - {'1': 'remote_profile', '3': 2, '4': 1, '5': 11, '6': '.veilidchat.Profile', '10': 'remoteProfile'}, - {'1': 'identity_master_json', '3': 3, '4': 1, '5': 9, '10': 'identityMasterJson'}, - {'1': 'identity_public_key', '3': 4, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'identityPublicKey'}, - {'1': 'remote_conversation_record_key', '3': 5, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'remoteConversationRecordKey'}, - {'1': 'local_conversation_record_key', '3': 6, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'localConversationRecordKey'}, - {'1': 'show_availability', '3': 7, '4': 1, '5': 8, '10': 'showAvailability'}, + {'1': 'remote_identity_public_key', '3': 1, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'remoteIdentityPublicKey'}, + {'1': 'remote_conversation_record_key', '3': 2, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'remoteConversationRecordKey'}, ], }; -/// Descriptor for `Contact`. Decode as a `google.protobuf.DescriptorProto`. -final $typed_data.Uint8List contactDescriptor = $convert.base64Decode( - 'CgdDb250YWN0EjoKDmVkaXRlZF9wcm9maWxlGAEgASgLMhMudmVpbGlkY2hhdC5Qcm9maWxlUg' - '1lZGl0ZWRQcm9maWxlEjoKDnJlbW90ZV9wcm9maWxlGAIgASgLMhMudmVpbGlkY2hhdC5Qcm9m' - 'aWxlUg1yZW1vdGVQcm9maWxlEjAKFGlkZW50aXR5X21hc3Rlcl9qc29uGAMgASgJUhJpZGVudG' - 'l0eU1hc3Rlckpzb24SQAoTaWRlbnRpdHlfcHVibGljX2tleRgEIAEoCzIQLnZlaWxpZC5UeXBl' - 'ZEtleVIRaWRlbnRpdHlQdWJsaWNLZXkSVQoecmVtb3RlX2NvbnZlcnNhdGlvbl9yZWNvcmRfa2' - 'V5GAUgASgLMhAudmVpbGlkLlR5cGVkS2V5UhtyZW1vdGVDb252ZXJzYXRpb25SZWNvcmRLZXkS' - 'UwodbG9jYWxfY29udmVyc2F0aW9uX3JlY29yZF9rZXkYBiABKAsyEC52ZWlsaWQuVHlwZWRLZX' - 'lSGmxvY2FsQ29udmVyc2F0aW9uUmVjb3JkS2V5EisKEXNob3dfYXZhaWxhYmlsaXR5GAcgASgI' - 'UhBzaG93QXZhaWxhYmlsaXR5'); +/// Descriptor for `ChatMember`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List chatMemberDescriptor = $convert.base64Decode( + 'CgpDaGF0TWVtYmVyEk0KGnJlbW90ZV9pZGVudGl0eV9wdWJsaWNfa2V5GAEgASgLMhAudmVpbG' + 'lkLlR5cGVkS2V5UhdyZW1vdGVJZGVudGl0eVB1YmxpY0tleRJVCh5yZW1vdGVfY29udmVyc2F0' + 'aW9uX3JlY29yZF9rZXkYAiABKAsyEC52ZWlsaWQuVHlwZWRLZXlSG3JlbW90ZUNvbnZlcnNhdG' + 'lvblJlY29yZEtleQ=='); + +@$core.Deprecated('Use directChatDescriptor instead') +const DirectChat$json = { + '1': 'DirectChat', + '2': [ + {'1': 'settings', '3': 1, '4': 1, '5': 11, '6': '.veilidchat.ChatSettings', '10': 'settings'}, + {'1': 'local_conversation_record_key', '3': 2, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'localConversationRecordKey'}, + {'1': 'remote_member', '3': 3, '4': 1, '5': 11, '6': '.veilidchat.ChatMember', '10': 'remoteMember'}, + ], +}; + +/// Descriptor for `DirectChat`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List directChatDescriptor = $convert.base64Decode( + 'CgpEaXJlY3RDaGF0EjQKCHNldHRpbmdzGAEgASgLMhgudmVpbGlkY2hhdC5DaGF0U2V0dGluZ3' + 'NSCHNldHRpbmdzElMKHWxvY2FsX2NvbnZlcnNhdGlvbl9yZWNvcmRfa2V5GAIgASgLMhAudmVp' + 'bGlkLlR5cGVkS2V5Uhpsb2NhbENvbnZlcnNhdGlvblJlY29yZEtleRI7Cg1yZW1vdGVfbWVtYm' + 'VyGAMgASgLMhYudmVpbGlkY2hhdC5DaGF0TWVtYmVyUgxyZW1vdGVNZW1iZXI='); + +@$core.Deprecated('Use groupChatDescriptor instead') +const GroupChat$json = { + '1': 'GroupChat', + '2': [ + {'1': 'settings', '3': 1, '4': 1, '5': 11, '6': '.veilidchat.ChatSettings', '10': 'settings'}, + {'1': 'membership', '3': 2, '4': 1, '5': 11, '6': '.veilidchat.Membership', '10': 'membership'}, + {'1': 'permissions', '3': 3, '4': 1, '5': 11, '6': '.veilidchat.Permissions', '10': 'permissions'}, + {'1': 'local_conversation_record_key', '3': 4, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'localConversationRecordKey'}, + {'1': 'remote_members', '3': 5, '4': 3, '5': 11, '6': '.veilidchat.ChatMember', '10': 'remoteMembers'}, + ], +}; + +/// Descriptor for `GroupChat`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List groupChatDescriptor = $convert.base64Decode( + 'CglHcm91cENoYXQSNAoIc2V0dGluZ3MYASABKAsyGC52ZWlsaWRjaGF0LkNoYXRTZXR0aW5nc1' + 'IIc2V0dGluZ3MSNgoKbWVtYmVyc2hpcBgCIAEoCzIWLnZlaWxpZGNoYXQuTWVtYmVyc2hpcFIK' + 'bWVtYmVyc2hpcBI5CgtwZXJtaXNzaW9ucxgDIAEoCzIXLnZlaWxpZGNoYXQuUGVybWlzc2lvbn' + 'NSC3Blcm1pc3Npb25zElMKHWxvY2FsX2NvbnZlcnNhdGlvbl9yZWNvcmRfa2V5GAQgASgLMhAu' + 'dmVpbGlkLlR5cGVkS2V5Uhpsb2NhbENvbnZlcnNhdGlvblJlY29yZEtleRI9Cg5yZW1vdGVfbW' + 'VtYmVycxgFIAMoCzIWLnZlaWxpZGNoYXQuQ2hhdE1lbWJlclINcmVtb3RlTWVtYmVycw=='); + +@$core.Deprecated('Use chatDescriptor instead') +const Chat$json = { + '1': 'Chat', + '2': [ + {'1': 'direct', '3': 1, '4': 1, '5': 11, '6': '.veilidchat.DirectChat', '9': 0, '10': 'direct'}, + {'1': 'group', '3': 2, '4': 1, '5': 11, '6': '.veilidchat.GroupChat', '9': 0, '10': 'group'}, + ], + '8': [ + {'1': 'kind'}, + ], +}; + +/// Descriptor for `Chat`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List chatDescriptor = $convert.base64Decode( + 'CgRDaGF0EjAKBmRpcmVjdBgBIAEoCzIWLnZlaWxpZGNoYXQuRGlyZWN0Q2hhdEgAUgZkaXJlY3' + 'QSLQoFZ3JvdXAYAiABKAsyFS52ZWlsaWRjaGF0Lkdyb3VwQ2hhdEgAUgVncm91cEIGCgRraW5k'); @$core.Deprecated('Use profileDescriptor instead') const Profile$json = { @@ -164,9 +445,11 @@ const Profile$json = { '2': [ {'1': 'name', '3': 1, '4': 1, '5': 9, '10': 'name'}, {'1': 'pronouns', '3': 2, '4': 1, '5': 9, '10': 'pronouns'}, - {'1': 'status', '3': 3, '4': 1, '5': 9, '10': 'status'}, - {'1': 'availability', '3': 4, '4': 1, '5': 14, '6': '.veilidchat.Availability', '10': 'availability'}, - {'1': 'avatar', '3': 5, '4': 1, '5': 11, '6': '.veilid.TypedKey', '9': 0, '10': 'avatar', '17': true}, + {'1': 'about', '3': 3, '4': 1, '5': 9, '10': 'about'}, + {'1': 'status', '3': 4, '4': 1, '5': 9, '10': 'status'}, + {'1': 'availability', '3': 5, '4': 1, '5': 14, '6': '.veilidchat.Availability', '10': 'availability'}, + {'1': 'avatar', '3': 6, '4': 1, '5': 11, '6': '.veilidchat.DataReference', '9': 0, '10': 'avatar', '17': true}, + {'1': 'timestamp', '3': 7, '4': 1, '5': 4, '10': 'timestamp'}, ], '8': [ {'1': '_avatar'}, @@ -176,24 +459,10 @@ const Profile$json = { /// Descriptor for `Profile`. Decode as a `google.protobuf.DescriptorProto`. final $typed_data.Uint8List profileDescriptor = $convert.base64Decode( 'CgdQcm9maWxlEhIKBG5hbWUYASABKAlSBG5hbWUSGgoIcHJvbm91bnMYAiABKAlSCHByb25vdW' - '5zEhYKBnN0YXR1cxgDIAEoCVIGc3RhdHVzEjwKDGF2YWlsYWJpbGl0eRgEIAEoDjIYLnZlaWxp' - 'ZGNoYXQuQXZhaWxhYmlsaXR5UgxhdmFpbGFiaWxpdHkSLQoGYXZhdGFyGAUgASgLMhAudmVpbG' - 'lkLlR5cGVkS2V5SABSBmF2YXRhcogBAUIJCgdfYXZhdGFy'); - -@$core.Deprecated('Use chatDescriptor instead') -const Chat$json = { - '1': 'Chat', - '2': [ - {'1': 'type', '3': 1, '4': 1, '5': 14, '6': '.veilidchat.ChatType', '10': 'type'}, - {'1': 'remote_conversation_key', '3': 2, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'remoteConversationKey'}, - ], -}; - -/// Descriptor for `Chat`. Decode as a `google.protobuf.DescriptorProto`. -final $typed_data.Uint8List chatDescriptor = $convert.base64Decode( - 'CgRDaGF0EigKBHR5cGUYASABKA4yFC52ZWlsaWRjaGF0LkNoYXRUeXBlUgR0eXBlEkgKF3JlbW' - '90ZV9jb252ZXJzYXRpb25fa2V5GAIgASgLMhAudmVpbGlkLlR5cGVkS2V5UhVyZW1vdGVDb252' - 'ZXJzYXRpb25LZXk='); + '5zEhQKBWFib3V0GAMgASgJUgVhYm91dBIWCgZzdGF0dXMYBCABKAlSBnN0YXR1cxI8CgxhdmFp' + 'bGFiaWxpdHkYBSABKA4yGC52ZWlsaWRjaGF0LkF2YWlsYWJpbGl0eVIMYXZhaWxhYmlsaXR5Ej' + 'YKBmF2YXRhchgGIAEoCzIZLnZlaWxpZGNoYXQuRGF0YVJlZmVyZW5jZUgAUgZhdmF0YXKIAQES' + 'HAoJdGltZXN0YW1wGAcgASgEUgl0aW1lc3RhbXBCCQoHX2F2YXRhcg=='); @$core.Deprecated('Use accountDescriptor instead') const Account$json = { @@ -201,22 +470,57 @@ const Account$json = { '2': [ {'1': 'profile', '3': 1, '4': 1, '5': 11, '6': '.veilidchat.Profile', '10': 'profile'}, {'1': 'invisible', '3': 2, '4': 1, '5': 8, '10': 'invisible'}, - {'1': 'auto_away_timeout_sec', '3': 3, '4': 1, '5': 13, '10': 'autoAwayTimeoutSec'}, + {'1': 'auto_away_timeout_min', '3': 3, '4': 1, '5': 13, '10': 'autoAwayTimeoutMin'}, {'1': 'contact_list', '3': 4, '4': 1, '5': 11, '6': '.dht.OwnedDHTRecordPointer', '10': 'contactList'}, {'1': 'contact_invitation_records', '3': 5, '4': 1, '5': 11, '6': '.dht.OwnedDHTRecordPointer', '10': 'contactInvitationRecords'}, {'1': 'chat_list', '3': 6, '4': 1, '5': 11, '6': '.dht.OwnedDHTRecordPointer', '10': 'chatList'}, + {'1': 'group_chat_list', '3': 7, '4': 1, '5': 11, '6': '.dht.OwnedDHTRecordPointer', '10': 'groupChatList'}, + {'1': 'free_message', '3': 8, '4': 1, '5': 9, '10': 'freeMessage'}, + {'1': 'busy_message', '3': 9, '4': 1, '5': 9, '10': 'busyMessage'}, + {'1': 'away_message', '3': 10, '4': 1, '5': 9, '10': 'awayMessage'}, + {'1': 'autodetect_away', '3': 11, '4': 1, '5': 8, '10': 'autodetectAway'}, ], }; /// Descriptor for `Account`. Decode as a `google.protobuf.DescriptorProto`. final $typed_data.Uint8List accountDescriptor = $convert.base64Decode( 'CgdBY2NvdW50Ei0KB3Byb2ZpbGUYASABKAsyEy52ZWlsaWRjaGF0LlByb2ZpbGVSB3Byb2ZpbG' - 'USHAoJaW52aXNpYmxlGAIgASgIUglpbnZpc2libGUSMQoVYXV0b19hd2F5X3RpbWVvdXRfc2Vj' - 'GAMgASgNUhJhdXRvQXdheVRpbWVvdXRTZWMSPQoMY29udGFjdF9saXN0GAQgASgLMhouZGh0Lk' + 'USHAoJaW52aXNpYmxlGAIgASgIUglpbnZpc2libGUSMQoVYXV0b19hd2F5X3RpbWVvdXRfbWlu' + 'GAMgASgNUhJhdXRvQXdheVRpbWVvdXRNaW4SPQoMY29udGFjdF9saXN0GAQgASgLMhouZGh0Lk' '93bmVkREhUUmVjb3JkUG9pbnRlclILY29udGFjdExpc3QSWAoaY29udGFjdF9pbnZpdGF0aW9u' 'X3JlY29yZHMYBSABKAsyGi5kaHQuT3duZWRESFRSZWNvcmRQb2ludGVyUhhjb250YWN0SW52aX' 'RhdGlvblJlY29yZHMSNwoJY2hhdF9saXN0GAYgASgLMhouZGh0Lk93bmVkREhUUmVjb3JkUG9p' - 'bnRlclIIY2hhdExpc3Q='); + 'bnRlclIIY2hhdExpc3QSQgoPZ3JvdXBfY2hhdF9saXN0GAcgASgLMhouZGh0Lk93bmVkREhUUm' + 'Vjb3JkUG9pbnRlclINZ3JvdXBDaGF0TGlzdBIhCgxmcmVlX21lc3NhZ2UYCCABKAlSC2ZyZWVN' + 'ZXNzYWdlEiEKDGJ1c3lfbWVzc2FnZRgJIAEoCVILYnVzeU1lc3NhZ2USIQoMYXdheV9tZXNzYW' + 'dlGAogASgJUgthd2F5TWVzc2FnZRInCg9hdXRvZGV0ZWN0X2F3YXkYCyABKAhSDmF1dG9kZXRl' + 'Y3RBd2F5'); + +@$core.Deprecated('Use contactDescriptor instead') +const Contact$json = { + '1': 'Contact', + '2': [ + {'1': 'nickname', '3': 1, '4': 1, '5': 9, '10': 'nickname'}, + {'1': 'profile', '3': 2, '4': 1, '5': 11, '6': '.veilidchat.Profile', '10': 'profile'}, + {'1': 'super_identity_json', '3': 3, '4': 1, '5': 9, '10': 'superIdentityJson'}, + {'1': 'identity_public_key', '3': 4, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'identityPublicKey'}, + {'1': 'remote_conversation_record_key', '3': 5, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'remoteConversationRecordKey'}, + {'1': 'local_conversation_record_key', '3': 6, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'localConversationRecordKey'}, + {'1': 'show_availability', '3': 7, '4': 1, '5': 8, '10': 'showAvailability'}, + {'1': 'notes', '3': 8, '4': 1, '5': 9, '10': 'notes'}, + ], +}; + +/// Descriptor for `Contact`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List contactDescriptor = $convert.base64Decode( + 'CgdDb250YWN0EhoKCG5pY2tuYW1lGAEgASgJUghuaWNrbmFtZRItCgdwcm9maWxlGAIgASgLMh' + 'MudmVpbGlkY2hhdC5Qcm9maWxlUgdwcm9maWxlEi4KE3N1cGVyX2lkZW50aXR5X2pzb24YAyAB' + 'KAlSEXN1cGVySWRlbnRpdHlKc29uEkAKE2lkZW50aXR5X3B1YmxpY19rZXkYBCABKAsyEC52ZW' + 'lsaWQuVHlwZWRLZXlSEWlkZW50aXR5UHVibGljS2V5ElUKHnJlbW90ZV9jb252ZXJzYXRpb25f' + 'cmVjb3JkX2tleRgFIAEoCzIQLnZlaWxpZC5UeXBlZEtleVIbcmVtb3RlQ29udmVyc2F0aW9uUm' + 'Vjb3JkS2V5ElMKHWxvY2FsX2NvbnZlcnNhdGlvbl9yZWNvcmRfa2V5GAYgASgLMhAudmVpbGlk' + 'LlR5cGVkS2V5Uhpsb2NhbENvbnZlcnNhdGlvblJlY29yZEtleRIrChFzaG93X2F2YWlsYWJpbG' + 'l0eRgHIAEoCFIQc2hvd0F2YWlsYWJpbGl0eRIUCgVub3RlcxgIIAEoCVIFbm90ZXM='); @$core.Deprecated('Use contactInvitationDescriptor instead') const ContactInvitation$json = { @@ -269,7 +573,7 @@ const ContactRequestPrivate$json = { '2': [ {'1': 'writer_key', '3': 1, '4': 1, '5': 11, '6': '.veilid.CryptoKey', '10': 'writerKey'}, {'1': 'profile', '3': 2, '4': 1, '5': 11, '6': '.veilidchat.Profile', '10': 'profile'}, - {'1': 'identity_master_record_key', '3': 3, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'identityMasterRecordKey'}, + {'1': 'super_identity_record_key', '3': 3, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'superIdentityRecordKey'}, {'1': 'chat_record_key', '3': 4, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'chatRecordKey'}, {'1': 'expiration', '3': 5, '4': 1, '5': 4, '10': 'expiration'}, ], @@ -279,27 +583,27 @@ const ContactRequestPrivate$json = { final $typed_data.Uint8List contactRequestPrivateDescriptor = $convert.base64Decode( 'ChVDb250YWN0UmVxdWVzdFByaXZhdGUSMAoKd3JpdGVyX2tleRgBIAEoCzIRLnZlaWxpZC5Dcn' 'lwdG9LZXlSCXdyaXRlcktleRItCgdwcm9maWxlGAIgASgLMhMudmVpbGlkY2hhdC5Qcm9maWxl' - 'Ugdwcm9maWxlEk0KGmlkZW50aXR5X21hc3Rlcl9yZWNvcmRfa2V5GAMgASgLMhAudmVpbGlkLl' - 'R5cGVkS2V5UhdpZGVudGl0eU1hc3RlclJlY29yZEtleRI4Cg9jaGF0X3JlY29yZF9rZXkYBCAB' - 'KAsyEC52ZWlsaWQuVHlwZWRLZXlSDWNoYXRSZWNvcmRLZXkSHgoKZXhwaXJhdGlvbhgFIAEoBF' - 'IKZXhwaXJhdGlvbg=='); + 'Ugdwcm9maWxlEksKGXN1cGVyX2lkZW50aXR5X3JlY29yZF9rZXkYAyABKAsyEC52ZWlsaWQuVH' + 'lwZWRLZXlSFnN1cGVySWRlbnRpdHlSZWNvcmRLZXkSOAoPY2hhdF9yZWNvcmRfa2V5GAQgASgL' + 'MhAudmVpbGlkLlR5cGVkS2V5Ug1jaGF0UmVjb3JkS2V5Eh4KCmV4cGlyYXRpb24YBSABKARSCm' + 'V4cGlyYXRpb24='); @$core.Deprecated('Use contactResponseDescriptor instead') const ContactResponse$json = { '1': 'ContactResponse', '2': [ {'1': 'accept', '3': 1, '4': 1, '5': 8, '10': 'accept'}, - {'1': 'identity_master_record_key', '3': 2, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'identityMasterRecordKey'}, + {'1': 'super_identity_record_key', '3': 2, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'superIdentityRecordKey'}, {'1': 'remote_conversation_record_key', '3': 3, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'remoteConversationRecordKey'}, ], }; /// Descriptor for `ContactResponse`. Decode as a `google.protobuf.DescriptorProto`. final $typed_data.Uint8List contactResponseDescriptor = $convert.base64Decode( - 'Cg9Db250YWN0UmVzcG9uc2USFgoGYWNjZXB0GAEgASgIUgZhY2NlcHQSTQoaaWRlbnRpdHlfbW' - 'FzdGVyX3JlY29yZF9rZXkYAiABKAsyEC52ZWlsaWQuVHlwZWRLZXlSF2lkZW50aXR5TWFzdGVy' - 'UmVjb3JkS2V5ElUKHnJlbW90ZV9jb252ZXJzYXRpb25fcmVjb3JkX2tleRgDIAEoCzIQLnZlaW' - 'xpZC5UeXBlZEtleVIbcmVtb3RlQ29udmVyc2F0aW9uUmVjb3JkS2V5'); + 'Cg9Db250YWN0UmVzcG9uc2USFgoGYWNjZXB0GAEgASgIUgZhY2NlcHQSSwoZc3VwZXJfaWRlbn' + 'RpdHlfcmVjb3JkX2tleRgCIAEoCzIQLnZlaWxpZC5UeXBlZEtleVIWc3VwZXJJZGVudGl0eVJl' + 'Y29yZEtleRJVCh5yZW1vdGVfY29udmVyc2F0aW9uX3JlY29yZF9rZXkYAyABKAsyEC52ZWlsaW' + 'QuVHlwZWRLZXlSG3JlbW90ZUNvbnZlcnNhdGlvblJlY29yZEtleQ=='); @$core.Deprecated('Use signedContactResponseDescriptor instead') const SignedContactResponse$json = { @@ -327,6 +631,7 @@ const ContactInvitationRecord$json = { {'1': 'expiration', '3': 5, '4': 1, '5': 4, '10': 'expiration'}, {'1': 'invitation', '3': 6, '4': 1, '5': 12, '10': 'invitation'}, {'1': 'message', '3': 7, '4': 1, '5': 9, '10': 'message'}, + {'1': 'recipient', '3': 8, '4': 1, '5': 9, '10': 'recipient'}, ], }; @@ -338,5 +643,6 @@ final $typed_data.Uint8List contactInvitationRecordDescriptor = $convert.base64D 'NlY3JldBgDIAEoCzIRLnZlaWxpZC5DcnlwdG9LZXlSDHdyaXRlclNlY3JldBJTCh1sb2NhbF9j' 'b252ZXJzYXRpb25fcmVjb3JkX2tleRgEIAEoCzIQLnZlaWxpZC5UeXBlZEtleVIabG9jYWxDb2' '52ZXJzYXRpb25SZWNvcmRLZXkSHgoKZXhwaXJhdGlvbhgFIAEoBFIKZXhwaXJhdGlvbhIeCgpp' - 'bnZpdGF0aW9uGAYgASgMUgppbnZpdGF0aW9uEhgKB21lc3NhZ2UYByABKAlSB21lc3NhZ2U='); + 'bnZpdGF0aW9uGAYgASgMUgppbnZpdGF0aW9uEhgKB21lc3NhZ2UYByABKAlSB21lc3NhZ2USHA' + 'oJcmVjaXBpZW50GAggASgJUglyZWNpcGllbnQ='); diff --git a/lib/proto/veilidchat.pbserver.dart b/lib/proto/veilidchat.pbserver.dart index 02a9ae4..047feed 100644 --- a/lib/proto/veilidchat.pbserver.dart +++ b/lib/proto/veilidchat.pbserver.dart @@ -4,7 +4,7 @@ // // @dart = 2.12 -// ignore_for_file: annotate_overrides, camel_case_types +// ignore_for_file: annotate_overrides, camel_case_types, comment_references // ignore_for_file: constant_identifier_names // ignore_for_file: deprecated_member_use_from_same_package, library_prefixes // ignore_for_file: non_constant_identifier_names, prefer_final_fields diff --git a/lib/proto/veilidchat.proto b/lib/proto/veilidchat.proto index 8e5f231..5bff89c 100644 --- a/lib/proto/veilidchat.proto +++ b/lib/proto/veilidchat.proto @@ -1,87 +1,19 @@ +//////////////////////////////////////////////////////////////////////////////////// +// VeilidChat Protocol Buffer Definitions +// +// * Timestamps are in microseconds (us) since epoch +// * Durations are in microseconds (us) +//////////////////////////////////////////////////////////////////////////////////// + syntax = "proto3"; package veilidchat; import "veilid.proto"; import "dht.proto"; -// AttachmentKind -// Enumeration of well-known attachment types -enum AttachmentKind { - ATTACHMENT_KIND_UNSPECIFIED = 0; - ATTACHMENT_KIND_FILE = 1; - ATTACHMENT_KIND_IMAGE = 2; -} - -// A single attachment -message Attachment { - // Type of the data - AttachmentKind kind = 1; - // MIME type of the data - string mime = 2; - // Title or filename - string name = 3; - // Pointer to the data content - dht.DataReference content = 4; - // Author signature over all attachment fields and content fields and bytes - veilid.Signature signature = 5; -} - -// A single message as part of a series of messages -// Messages are stored in a DHTLog -// DHT Schema: SMPL(0,1,[identityPublicKey]) -message Message { - // Author of the message - veilid.TypedKey author = 1; - // Time the message was sent (us since epoch) - uint64 timestamp = 2; - // Text of the message - string text = 3; - // Author signature over all of the fields and attachment signatures - veilid.Signature signature = 4; - // Attachments on the message - repeated Attachment attachments = 5; -} - -// A record of a 1-1 chat that is synchronized between -// two users. Visible and encrypted for the other party -// -// DHT Schema: SMPL(0,1,[identityPublicKey]) -// DHT Key (UnicastOutbox): localConversation -// DHT Secret: None -// Encryption: DH(IdentityA, IdentityB) - -message Conversation { - // Profile to publish to friend - Profile profile = 1; - // Identity master (JSON) to publish to friend - string identity_master_json = 2; - // Messages DHTLog (xxx for now DHTShortArray) - veilid.TypedKey messages = 3; -} - -// A record of a contact that has accepted a contact invitation -// Contains a copy of the most recent remote profile as well as -// a locally edited profile. -// Contains a copy of the most recent identity from the contact's -// Master identity dht key -// -// Stored in ContactList DHTList -message Contact { - // Friend's profile as locally edited - Profile edited_profile = 1; - // Copy of friend's profile from remote conversation - Profile remote_profile = 2; - // Copy of friend's IdentityMaster in JSON from remote conversation - string identity_master_json = 3; - // Copy of friend's most recent identity public key from their identityMaster - veilid.TypedKey identity_public_key = 4; - // Remote conversation key to sync from friend - veilid.TypedKey remote_conversation_record_key = 5; - // Our conversation key for friend to sync - veilid.TypedKey local_conversation_record_key = 6; - // Show availability - bool show_availability = 7; -} +//////////////////////////////////////////////////////////////////////////////////// +// Enumerations +//////////////////////////////////////////////////////////////////////////////////// // Contact availability enum Availability { @@ -92,37 +24,316 @@ enum Availability { AVAILABILITY_AWAY = 4; } +// Encryption used on secret keys +enum EncryptionKeyType { + ENCRYPTION_KEY_TYPE_UNSPECIFIED = 0; + ENCRYPTION_KEY_TYPE_NONE = 1; + ENCRYPTION_KEY_TYPE_PIN = 2; + ENCRYPTION_KEY_TYPE_PASSWORD = 3; +} + +// Scope of a chat +enum Scope { + // Can read chats but not send messages + WATCHERS = 0; + // Can send messages subject to moderation + // If moderation is disabled, this is equivalent to WATCHERS + MODERATED = 1; + // Can send messages without moderation + TALKERS = 2; + // Can moderate messages sent my members if moderation is enabled + MODERATORS = 3; + // Can perform all actions + ADMINS = 4; +} + +//////////////////////////////////////////////////////////////////////////////////// +// Data +//////////////////////////////////////////////////////////////////////////////////// + +// Reference to data on the DHT +message DHTDataReference { + veilid.TypedKey dht_data = 1; + veilid.TypedKey hash = 2; +} + +// Reference to data on the BlockStore +message BlockStoreDataReference { + veilid.TypedKey block = 1; +} + +// DataReference +// Pointer to data somewhere in Veilid +// Abstraction over DHTData and BlockStore +message DataReference { + oneof kind { + DHTDataReference dht_data = 1; + BlockStoreDataReference block_store_data = 2; + } +} + +//////////////////////////////////////////////////////////////////////////////////// +// Attachments +//////////////////////////////////////////////////////////////////////////////////// + +// A single attachment +message Attachment { + oneof kind { + AttachmentMedia media = 1; + } + // Author signature over all attachment fields and content fields and bytes + veilid.Signature signature = 2; +} + +// A file, audio, image, or video attachment +message AttachmentMedia { + // MIME type of the data + string mime = 1; + // Title or filename + string name = 2; + // Pointer to the data content + DataReference content = 3; +} + +//////////////////////////////////////////////////////////////////////////////////// +// Chat room controls +//////////////////////////////////////////////////////////////////////////////////// + +// Permissions of a chat +message Permissions { + // Parties in this scope or higher can add members to their own group or lower + Scope can_add_members = 1; + // Parties in this scope or higher can change the 'info' of a group + Scope can_edit_info = 2; + // If moderation is enabled or not. + bool moderated = 3; +} + +// The membership of a chat +message Membership { + // Conversation keys for parties in the 'watchers' group + repeated veilid.TypedKey watchers = 1; + // Conversation keys for parties in the 'moderated' group + repeated veilid.TypedKey moderated = 2; + // Conversation keys for parties in the 'talkers' group + repeated veilid.TypedKey talkers = 3; + // Conversation keys for parties in the 'moderators' group + repeated veilid.TypedKey moderators = 4; + // Conversation keys for parties in the 'admins' group + repeated veilid.TypedKey admins = 5; +} + +// The chat settings +message ChatSettings { + // Title for the chat + string title = 1; + // Description for the chat + string description = 2; + // Icon for the chat + optional DataReference icon = 3; + // Default message expiration duration (in us) + uint64 default_expiration = 4; +} + +//////////////////////////////////////////////////////////////////////////////////// +// Messages +//////////////////////////////////////////////////////////////////////////////////// + +// A single message as part of a series of messages +message Message { + + // A text message + message Text { + // Text of the message + string text = 1; + // Topic of the message / Content warning + optional string topic = 2; + // Message id replied to (author id + message id) + optional bytes reply_id = 3; + // Message expiration timestamp + uint64 expiration = 4; + // Message view limit before deletion + uint32 view_limit = 5; + // Attachments on the message + repeated Attachment attachments = 6; + } + + // A secret message + message Secret { + // Text message protobuf encrypted by a key + bytes ciphertext = 1; + // Secret expiration timestamp + // This is the time after which an un-revealed secret will get deleted + uint64 expiration = 2; + } + + // A 'delete' control message + // Deletes a set of messages by their ids + message ControlDelete { + repeated bytes ids = 1; + } + // An 'erase' control message + // Deletes a set of messages from before some timestamp + message ControlErase { + // The latest timestamp to delete messages before + // If this is zero then all messages are cleared + uint64 timestamp = 1; + } + // A 'change settings' control message + message ControlSettings { + ChatSettings settings = 1; + } + + // A 'change permissions' control message + // Changes the permissions of a chat + message ControlPermissions { + Permissions permissions = 1; + } + + // A 'change membership' control message + // Changes the + message ControlMembership { + Membership membership = 1; + } + + // A 'moderation' control message + // Accepts or rejects a set of messages + message ControlModeration { + repeated bytes accepted_ids = 1; + repeated bytes rejected_ids = 2; + } + + // A 'read receipt' control message + message ControlReadReceipt { + repeated bytes read_ids = 1; + } + + ////////////////////////////////////////////////////////////////////////// + + // Unique id for this author stream + // Calculated from the hash of the previous message from this author + bytes id = 1; + // Author of the message (identity public key) + veilid.TypedKey author = 2; + // Time the message was sent according to sender + uint64 timestamp = 3; + + // Message kind + oneof kind { + Text text = 4; + Secret secret = 5; + ControlDelete delete = 6; + ControlErase erase = 7; + ControlSettings settings = 8; + ControlPermissions permissions = 9; + ControlMembership membership = 10; + ControlModeration moderation = 11; + ControlReadReceipt readReceipt = 13; + } + + // Author signature over all of the fields and attachment signatures + veilid.Signature signature = 12; +} + +// Locally stored messages for chats +message ReconciledMessage { + // The message as sent + Message content = 1; + // The timestamp the message was reconciled + uint64 reconciled_time = 2; +} + +//////////////////////////////////////////////////////////////////////////////////// +// Chats +//////////////////////////////////////////////////////////////////////////////////// + +// The means of direct communications that is synchronized between +// two users. Visible and encrypted for the other party. +// Includes communications for: +// * Profile changes +// * Identity changes +// * 1-1 chat messages +// * Group chat messages +// +// DHT Schema: SMPL(0,1,[identityPublicKey]) +// DHT Key (UnicastOutbox): localConversation +// DHT Secret: None +// Encryption: DH(IdentityA, IdentityB) +message Conversation { + // Profile to publish to friend + Profile profile = 1; + // SuperIdentity (JSON) to publish to friend or chat room + string super_identity_json = 2; + // Messages DHTLog + veilid.TypedKey messages = 3; +} + +// A member of chat which may or may not be associated with a contact +message ChatMember { + // The identity public key most recently associated with the chat member + veilid.TypedKey remote_identity_public_key = 1; + // Conversation key for the other party + veilid.TypedKey remote_conversation_record_key = 2; +} + +// A 1-1 chat +// Privately encrypted, this is the local user's copy of the chat +message DirectChat { + // Settings + ChatSettings settings = 1; + // Conversation key for this user + veilid.TypedKey local_conversation_record_key = 2; + // Conversation key for the other party + ChatMember remote_member = 3; +} + +// A group chat +// Privately encrypted, this is the local user's copy of the chat +message GroupChat { + // Settings + ChatSettings settings = 1; + // Membership + Membership membership = 2; + // Permissions + Permissions permissions = 3; + // Conversation key for this user + veilid.TypedKey local_conversation_record_key = 4; + // Conversation keys for the other parties + repeated ChatMember remote_members = 5; +} + +// Some kind of chat +message Chat { + oneof kind { + DirectChat direct = 1; + GroupChat group = 2; + } +} + +//////////////////////////////////////////////////////////////////////////////////// +// Accounts +//////////////////////////////////////////////////////////////////////////////////// + // Publicly shared profile information for both contacts and accounts // Contains: // Name - Friendly name // Pronouns - Pronouns of user // Icon - Little picture to represent user in contact list message Profile { - // Friendy name + // Friendy name (max length 64) string name = 1; - // Pronouns of user + // Pronouns of user (max length 64) string pronouns = 2; - // Status/away message - string status = 3; + // Description of the user (max length 1024) + string about = 3; + // Status/away message (max length 128) + string status = 4; // Availability - Availability availability = 4; - // Avatar DHTData - optional veilid.TypedKey avatar = 5; -} - - -enum ChatType { - CHAT_TYPE_UNSPECIFIED = 0; - SINGLE_CONTACT = 1; - GROUP = 2; -} - -// Either a 1-1 converation or a group chat (eventually) -message Chat { - // What kind of chat is this - ChatType type = 1; - // 1-1 Chat key - veilid.TypedKey remote_conversation_key = 2; + Availability availability = 5; + // Avatar + optional DataReference avatar = 6; + // Timestamp of last change + uint64 timestamp = 7; } // A record of an individual account @@ -135,29 +346,61 @@ message Account { Profile profile = 1; // Invisibility makes you always look 'Offline' bool invisible = 2; - // Auto-away sets 'away' mode after an inactivity time - uint32 auto_away_timeout_sec = 3; + // Auto-away sets 'away' mode after an inactivity time (only if autodetect_away is set) + uint32 auto_away_timeout_min = 3; // The contacts DHTList for this account // DHT Private dht.OwnedDHTRecordPointer contact_list = 4; // The ContactInvitationRecord DHTShortArray for this account // DHT Private dht.OwnedDHTRecordPointer contact_invitation_records = 5; - // The chats DHTList for this account + // The Chats DHTList for this account // DHT Private dht.OwnedDHTRecordPointer chat_list = 6; - + // The GroupChats DHTList for this account + // DHT Private + dht.OwnedDHTRecordPointer group_chat_list = 7; + // Free message (max length 128) + string free_message = 8; + // Busy message (max length 128) + string busy_message = 9; + // Away message (max length 128) + string away_message = 10; + // Auto-detect away + bool autodetect_away = 11; + } -// EncryptionKeyType -// Encryption of secret -enum EncryptionKeyType { - ENCRYPTION_KEY_TYPE_UNSPECIFIED = 0; - ENCRYPTION_KEY_TYPE_NONE = 1; - ENCRYPTION_KEY_TYPE_PIN = 2; - ENCRYPTION_KEY_TYPE_PASSWORD = 3; +// A record of a contact that has accepted a contact invitation +// Contains a copy of the most recent remote profile as well as +// a locally edited profile. +// Contains a copy of the most recent identity from the contact's +// Master identity dht key +// +// Stored in ContactList DHTList +message Contact { + // Friend's nickname + string nickname = 1; + // Copy of friend's profile from remote conversation + Profile profile = 2; + // Copy of friend's SuperIdentity in JSON from remote conversation + string super_identity_json = 3; + // Copy of friend's most recent identity public key from their identityMaster + veilid.TypedKey identity_public_key = 4; + // Remote conversation key to sync from friend + veilid.TypedKey remote_conversation_record_key = 5; + // Our conversation key for friend to sync + veilid.TypedKey local_conversation_record_key = 6; + // Show availability to this contact + bool show_availability = 7; + // Notes about this friend + string notes = 8; } +//////////////////////////////////////////////////////////////////////////////////// +// Invitations +//////////////////////////////////////////////////////////////////////////////////// + // Invitation that is shared for VeilidChat contact connections // serialized to QR code or data blob, not send over DHT, out of band. // Writer secret is unique to this invitation. Writer public key is in the ContactRequestPrivate @@ -193,8 +436,8 @@ message ContactRequestPrivate { veilid.CryptoKey writer_key = 1; // Snapshot of profile Profile profile = 2; - // Identity master DHT record key - veilid.TypedKey identity_master_record_key = 3; + // SuperIdentity DHT record key + veilid.TypedKey super_identity_record_key = 3; // Local chat DHT record key veilid.TypedKey chat_record_key = 4; // Expiration timestamp @@ -205,8 +448,8 @@ message ContactRequestPrivate { message ContactResponse { // Accept or reject bool accept = 1; - // Remote identity master DHT record key - veilid.TypedKey identity_master_record_key = 2; + // Remote SuperIdentity DHT record key + veilid.TypedKey super_identity_record_key = 2; // Remote chat DHT record key if accepted veilid.TypedKey remote_conversation_record_key = 3; } @@ -236,4 +479,6 @@ message ContactInvitationRecord { bytes invitation = 6; // The message sent along with the invitation string message = 7; + // The recipient sent along with the invitation + string recipient = 8; } \ No newline at end of file diff --git a/lib/providers/account.dart b/lib/providers/account.dart deleted file mode 100644 index 22e532f..0000000 --- a/lib/providers/account.dart +++ /dev/null @@ -1,133 +0,0 @@ -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -import '../entities/local_account.dart'; -import '../proto/proto.dart' as proto; -import '../entities/user_login.dart'; -import '../veilid_support/veilid_support.dart'; - -import 'local_accounts.dart'; -import 'logins.dart'; - -part 'account.g.dart'; - -enum AccountInfoStatus { - noAccount, - accountInvalid, - accountLocked, - accountReady, -} - -class AccountInfo { - AccountInfo({ - required this.status, - required this.active, - this.account, - }); - - AccountInfoStatus status; - bool active; - proto.Account? account; -} - -/// Get an account from the identity key and if it is logged in and we -/// have its secret available, return the account record contents -@riverpod -Future fetchAccount(FetchAccountRef ref, - {required TypedKey accountMasterRecordKey}) async { - // Get which local account we want to fetch the profile for - final localAccount = await ref.watch( - fetchLocalAccountProvider(accountMasterRecordKey: accountMasterRecordKey) - .future); - if (localAccount == null) { - // Local account does not exist - return AccountInfo(status: AccountInfoStatus.noAccount, active: false); - } - - // See if we've logged into this account or if it is locked - final activeUserLogin = await ref.watch(loginsProvider.future - .select((value) async => (await value).activeUserLogin)); - final active = activeUserLogin == accountMasterRecordKey; - - final login = await ref.watch( - fetchLoginProvider(accountMasterRecordKey: accountMasterRecordKey) - .future); - if (login == null) { - // Account was locked - return AccountInfo(status: AccountInfoStatus.accountLocked, active: active); - } - - // Pull the account DHT key, decode it and return it - final pool = await DHTRecordPool.instance(); - final account = await (await pool.openOwned( - login.accountRecordInfo.accountRecord, - parent: localAccount.identityMaster.identityRecordKey)) - .scope((accountRec) => accountRec.getProtobuf(proto.Account.fromBuffer)); - if (account == null) { - // Account could not be read or decrypted from DHT - ref.invalidateSelf(); - return AccountInfo( - status: AccountInfoStatus.accountInvalid, active: active); - } - - // Got account, decrypted and decoded - return AccountInfo( - status: AccountInfoStatus.accountReady, active: active, account: account); -} - -class ActiveAccountInfo { - ActiveAccountInfo({ - required this.localAccount, - required this.userLogin, - required this.account, - }); - - LocalAccount localAccount; - UserLogin userLogin; - proto.Account account; -} - -/// Get the active account info -@riverpod -Future fetchActiveAccount(FetchActiveAccountRef ref) async { - // See if we've logged into this account or if it is locked - final activeUserLogin = await ref.watch(loginsProvider.future - .select((value) async => (await value).activeUserLogin)); - if (activeUserLogin == null) { - return null; - } - - // Get the user login - final userLogin = await ref.watch( - fetchLoginProvider(accountMasterRecordKey: activeUserLogin).future); - if (userLogin == null) { - // Account was locked - return null; - } - - // Get which local account we want to fetch the profile for - final localAccount = await ref.watch( - fetchLocalAccountProvider(accountMasterRecordKey: activeUserLogin) - .future); - if (localAccount == null) { - // Local account does not exist - return null; - } - - // Pull the account DHT key, decode it and return it - final pool = await DHTRecordPool.instance(); - final account = await (await pool.openOwned( - userLogin.accountRecordInfo.accountRecord, - parent: localAccount.identityMaster.identityRecordKey)) - .scope((accountRec) => accountRec.getProtobuf(proto.Account.fromBuffer)); - if (account == null) { - ref.invalidateSelf(); - return null; - } - - // Got account, decrypted and decoded - return ActiveAccountInfo( - localAccount: localAccount, - userLogin: userLogin, - account: account, - ); -} diff --git a/lib/providers/account.g.dart b/lib/providers/account.g.dart deleted file mode 100644 index 6fba2b3..0000000 --- a/lib/providers/account.g.dart +++ /dev/null @@ -1,199 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'account.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$fetchAccountHash() => r'f3072fdd89611b53cd9821613acab450b3c08820'; - -/// Copied from Dart SDK -class _SystemHash { - _SystemHash._(); - - static int combine(int hash, int value) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + value); - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); - return hash ^ (hash >> 6); - } - - static int finish(int hash) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); - // ignore: parameter_assignments - hash = hash ^ (hash >> 11); - return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); - } -} - -/// Get an account from the identity key and if it is logged in and we -/// have its secret available, return the account record contents -/// -/// Copied from [fetchAccount]. -@ProviderFor(fetchAccount) -const fetchAccountProvider = FetchAccountFamily(); - -/// Get an account from the identity key and if it is logged in and we -/// have its secret available, return the account record contents -/// -/// Copied from [fetchAccount]. -class FetchAccountFamily extends Family> { - /// Get an account from the identity key and if it is logged in and we - /// have its secret available, return the account record contents - /// - /// Copied from [fetchAccount]. - const FetchAccountFamily(); - - /// Get an account from the identity key and if it is logged in and we - /// have its secret available, return the account record contents - /// - /// Copied from [fetchAccount]. - FetchAccountProvider call({ - required Typed accountMasterRecordKey, - }) { - return FetchAccountProvider( - accountMasterRecordKey: accountMasterRecordKey, - ); - } - - @override - FetchAccountProvider getProviderOverride( - covariant FetchAccountProvider provider, - ) { - return call( - accountMasterRecordKey: provider.accountMasterRecordKey, - ); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'fetchAccountProvider'; -} - -/// Get an account from the identity key and if it is logged in and we -/// have its secret available, return the account record contents -/// -/// Copied from [fetchAccount]. -class FetchAccountProvider extends AutoDisposeFutureProvider { - /// Get an account from the identity key and if it is logged in and we - /// have its secret available, return the account record contents - /// - /// Copied from [fetchAccount]. - FetchAccountProvider({ - required Typed accountMasterRecordKey, - }) : this._internal( - (ref) => fetchAccount( - ref as FetchAccountRef, - accountMasterRecordKey: accountMasterRecordKey, - ), - from: fetchAccountProvider, - name: r'fetchAccountProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') - ? null - : _$fetchAccountHash, - dependencies: FetchAccountFamily._dependencies, - allTransitiveDependencies: - FetchAccountFamily._allTransitiveDependencies, - accountMasterRecordKey: accountMasterRecordKey, - ); - - FetchAccountProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.accountMasterRecordKey, - }) : super.internal(); - - final Typed accountMasterRecordKey; - - @override - Override overrideWith( - FutureOr Function(FetchAccountRef provider) create, - ) { - return ProviderOverride( - origin: this, - override: FetchAccountProvider._internal( - (ref) => create(ref as FetchAccountRef), - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - accountMasterRecordKey: accountMasterRecordKey, - ), - ); - } - - @override - AutoDisposeFutureProviderElement createElement() { - return _FetchAccountProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is FetchAccountProvider && - other.accountMasterRecordKey == accountMasterRecordKey; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, accountMasterRecordKey.hashCode); - - return _SystemHash.finish(hash); - } -} - -mixin FetchAccountRef on AutoDisposeFutureProviderRef { - /// The parameter `accountMasterRecordKey` of this provider. - Typed get accountMasterRecordKey; -} - -class _FetchAccountProviderElement - extends AutoDisposeFutureProviderElement with FetchAccountRef { - _FetchAccountProviderElement(super.provider); - - @override - Typed get accountMasterRecordKey => - (origin as FetchAccountProvider).accountMasterRecordKey; -} - -String _$fetchActiveAccountHash() => - r'197e5dd793563ff1d9927309a5ec9db1c9f67f07'; - -/// Get the active account info -/// -/// Copied from [fetchActiveAccount]. -@ProviderFor(fetchActiveAccount) -final fetchActiveAccountProvider = - AutoDisposeFutureProvider.internal( - fetchActiveAccount, - name: r'fetchActiveAccountProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$fetchActiveAccountHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef FetchActiveAccountRef - = AutoDisposeFutureProviderRef; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/providers/chat.dart b/lib/providers/chat.dart deleted file mode 100644 index 7ddbe1d..0000000 --- a/lib/providers/chat.dart +++ /dev/null @@ -1,119 +0,0 @@ -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -import '../proto/proto.dart' as proto; -import '../proto/proto.dart' show Chat, ChatType; - -import '../veilid_support/veilid_support.dart'; -import 'account.dart'; - -part 'chat.g.dart'; - -/// Create a new chat (singleton for single contact chats) -Future getOrCreateChatSingleContact({ - required ActiveAccountInfo activeAccountInfo, - required TypedKey remoteConversationRecordKey, -}) async { - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; - - // Create conversation type Chat - final chat = Chat() - ..type = ChatType.SINGLE_CONTACT - ..remoteConversationKey = remoteConversationRecordKey.toProto(); - - // Add Chat to account's list - // if this fails, don't keep retrying, user can try again later - await (await DHTShortArray.openOwned( - proto.OwnedDHTRecordPointerProto.fromProto( - activeAccountInfo.account.chatList), - parent: accountRecordKey)) - .scope((chatList) async { - for (var i = 0; i < chatList.length; i++) { - final cbuf = await chatList.getItem(i); - if (cbuf == null) { - throw Exception('Failed to get chat'); - } - final c = Chat.fromBuffer(cbuf); - if (c == chat) { - return; - } - } - if (await chatList.tryAddItem(chat.writeToBuffer()) == false) { - throw Exception('Failed to add chat'); - } - }); -} - -/// Delete a chat -Future deleteChat( - {required ActiveAccountInfo activeAccountInfo, - required TypedKey remoteConversationRecordKey}) async { - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; - - // Create conversation type Chat - final remoteConversationKey = remoteConversationRecordKey.toProto(); - - // Add Chat to account's list - // if this fails, don't keep retrying, user can try again later - await (await DHTShortArray.openOwned( - proto.OwnedDHTRecordPointerProto.fromProto( - activeAccountInfo.account.chatList), - parent: accountRecordKey)) - .scope((chatList) async { - for (var i = 0; i < chatList.length; i++) { - final cbuf = await chatList.getItem(i); - if (cbuf == null) { - throw Exception('Failed to get chat'); - } - final c = Chat.fromBuffer(cbuf); - if (c.remoteConversationKey == remoteConversationKey) { - await chatList.tryRemoveItem(i); - - if (activeChatState.state == remoteConversationRecordKey) { - activeChatState.state = null; - } - - return; - } - } - }); -} - -/// Get the active account contact list -@riverpod -Future?> fetchChatList(FetchChatListRef ref) async { - // See if we've logged into this account or if it is locked - final activeAccountInfo = await ref.watch(fetchActiveAccountProvider.future); - if (activeAccountInfo == null) { - return null; - } - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; - - // Decode the chat list from the DHT - IList out = const IListConst([]); - await (await DHTShortArray.openOwned( - proto.OwnedDHTRecordPointerProto.fromProto( - activeAccountInfo.account.chatList), - parent: accountRecordKey)) - .scope((cList) async { - for (var i = 0; i < cList.length; i++) { - final cir = await cList.getItem(i); - if (cir == null) { - throw Exception('Failed to get chat'); - } - out = out.add(Chat.fromBuffer(cir)); - } - }); - - return out; -} - -// The selected chat -final activeChatState = StateController(null); -final activeChatStateProvider = - StateNotifierProvider, TypedKey?>( - (ref) => activeChatState); diff --git a/lib/providers/chat.g.dart b/lib/providers/chat.g.dart deleted file mode 100644 index 411eae1..0000000 --- a/lib/providers/chat.g.dart +++ /dev/null @@ -1,27 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'chat.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$fetchChatListHash() => r'407692f9d6794a5a2b356d7a34240624b211daa8'; - -/// Get the active account contact list -/// -/// Copied from [fetchChatList]. -@ProviderFor(fetchChatList) -final fetchChatListProvider = AutoDisposeFutureProvider?>.internal( - fetchChatList, - name: r'fetchChatListProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$fetchChatListHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef FetchChatListRef = AutoDisposeFutureProviderRef?>; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/providers/connection_state.dart b/lib/providers/connection_state.dart deleted file mode 100644 index c7360bc..0000000 --- a/lib/providers/connection_state.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; - -import '../veilid_support/veilid_support.dart'; - -part 'connection_state.freezed.dart'; - -@freezed -class ConnectionState with _$ConnectionState { - const factory ConnectionState({ - required VeilidStateAttachment attachment, - }) = _ConnectionState; - const ConnectionState._(); - - bool get isAttached => !(attachment.state == AttachmentState.detached || - attachment.state == AttachmentState.detaching || - attachment.state == AttachmentState.attaching); - - bool get isPublicInternetReady => attachment.publicInternetReady; -} - -final connectionState = StateController(const ConnectionState( - attachment: VeilidStateAttachment( - state: AttachmentState.detached, - publicInternetReady: false, - localNetworkReady: false))); -final connectionStateProvider = - StateNotifierProvider, ConnectionState>( - (ref) => connectionState); diff --git a/lib/providers/connection_state.freezed.dart b/lib/providers/connection_state.freezed.dart deleted file mode 100644 index 350a1af..0000000 --- a/lib/providers/connection_state.freezed.dart +++ /dev/null @@ -1,150 +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 'connection_state.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'); - -/// @nodoc -mixin _$ConnectionState { - VeilidStateAttachment get attachment => throw _privateConstructorUsedError; - - @JsonKey(ignore: true) - $ConnectionStateCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $ConnectionStateCopyWith<$Res> { - factory $ConnectionStateCopyWith( - ConnectionState value, $Res Function(ConnectionState) then) = - _$ConnectionStateCopyWithImpl<$Res, ConnectionState>; - @useResult - $Res call({VeilidStateAttachment attachment}); - - $VeilidStateAttachmentCopyWith<$Res> get attachment; -} - -/// @nodoc -class _$ConnectionStateCopyWithImpl<$Res, $Val extends ConnectionState> - implements $ConnectionStateCopyWith<$Res> { - _$ConnectionStateCopyWithImpl(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? attachment = null, - }) { - return _then(_value.copyWith( - attachment: null == attachment - ? _value.attachment - : attachment // ignore: cast_nullable_to_non_nullable - as VeilidStateAttachment, - ) as $Val); - } - - @override - @pragma('vm:prefer-inline') - $VeilidStateAttachmentCopyWith<$Res> get attachment { - return $VeilidStateAttachmentCopyWith<$Res>(_value.attachment, (value) { - return _then(_value.copyWith(attachment: value) as $Val); - }); - } -} - -/// @nodoc -abstract class _$$ConnectionStateImplCopyWith<$Res> - implements $ConnectionStateCopyWith<$Res> { - factory _$$ConnectionStateImplCopyWith(_$ConnectionStateImpl value, - $Res Function(_$ConnectionStateImpl) then) = - __$$ConnectionStateImplCopyWithImpl<$Res>; - @override - @useResult - $Res call({VeilidStateAttachment attachment}); - - @override - $VeilidStateAttachmentCopyWith<$Res> get attachment; -} - -/// @nodoc -class __$$ConnectionStateImplCopyWithImpl<$Res> - extends _$ConnectionStateCopyWithImpl<$Res, _$ConnectionStateImpl> - implements _$$ConnectionStateImplCopyWith<$Res> { - __$$ConnectionStateImplCopyWithImpl( - _$ConnectionStateImpl _value, $Res Function(_$ConnectionStateImpl) _then) - : super(_value, _then); - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? attachment = null, - }) { - return _then(_$ConnectionStateImpl( - attachment: null == attachment - ? _value.attachment - : attachment // ignore: cast_nullable_to_non_nullable - as VeilidStateAttachment, - )); - } -} - -/// @nodoc - -class _$ConnectionStateImpl extends _ConnectionState { - const _$ConnectionStateImpl({required this.attachment}) : super._(); - - @override - final VeilidStateAttachment attachment; - - @override - String toString() { - return 'ConnectionState(attachment: $attachment)'; - } - - @override - bool operator ==(dynamic other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$ConnectionStateImpl && - (identical(other.attachment, attachment) || - other.attachment == attachment)); - } - - @override - int get hashCode => Object.hash(runtimeType, attachment); - - @JsonKey(ignore: true) - @override - @pragma('vm:prefer-inline') - _$$ConnectionStateImplCopyWith<_$ConnectionStateImpl> get copyWith => - __$$ConnectionStateImplCopyWithImpl<_$ConnectionStateImpl>( - this, _$identity); -} - -abstract class _ConnectionState extends ConnectionState { - const factory _ConnectionState( - {required final VeilidStateAttachment attachment}) = - _$ConnectionStateImpl; - const _ConnectionState._() : super._(); - - @override - VeilidStateAttachment get attachment; - @override - @JsonKey(ignore: true) - _$$ConnectionStateImplCopyWith<_$ConnectionStateImpl> get copyWith => - throw _privateConstructorUsedError; -} diff --git a/lib/providers/contact.dart b/lib/providers/contact.dart deleted file mode 100644 index d935e73..0000000 --- a/lib/providers/contact.dart +++ /dev/null @@ -1,133 +0,0 @@ -import 'dart:convert'; - -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -import '../proto/proto.dart' as proto; -import '../proto/proto.dart' show Contact; - -import '../veilid_support/veilid_support.dart'; -import '../tools/tools.dart'; -import 'account.dart'; -import 'chat.dart'; - -part 'contact.g.dart'; - -Future createContact({ - required ActiveAccountInfo activeAccountInfo, - required proto.Profile profile, - required IdentityMaster remoteIdentity, - required TypedKey remoteConversationRecordKey, - required TypedKey localConversationRecordKey, -}) async { - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; - - // Create Contact - final contact = Contact() - ..editedProfile = profile - ..remoteProfile = profile - ..identityMasterJson = jsonEncode(remoteIdentity.toJson()) - ..identityPublicKey = TypedKey( - kind: remoteIdentity.identityRecordKey.kind, - value: remoteIdentity.identityPublicKey) - .toProto() - ..remoteConversationRecordKey = remoteConversationRecordKey.toProto() - ..localConversationRecordKey = localConversationRecordKey.toProto() - ..showAvailability = false; - - // Add Contact to account's list - // if this fails, don't keep retrying, user can try again later - await (await DHTShortArray.openOwned( - proto.OwnedDHTRecordPointerProto.fromProto( - activeAccountInfo.account.contactList), - parent: accountRecordKey)) - .scope((contactList) async { - if (await contactList.tryAddItem(contact.writeToBuffer()) == false) { - throw Exception('Failed to add contact'); - } - }); -} - -Future deleteContact( - {required ActiveAccountInfo activeAccountInfo, - required Contact contact}) async { - final pool = await DHTRecordPool.instance(); - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; - final localConversationKey = - proto.TypedKeyProto.fromProto(contact.localConversationRecordKey); - final remoteConversationKey = - proto.TypedKeyProto.fromProto(contact.remoteConversationRecordKey); - - // Remove any chats for this contact - await deleteChat( - activeAccountInfo: activeAccountInfo, - remoteConversationRecordKey: remoteConversationKey); - - // Remove Contact from account's list - await (await DHTShortArray.openOwned( - proto.OwnedDHTRecordPointerProto.fromProto( - activeAccountInfo.account.contactList), - parent: accountRecordKey)) - .scope((contactList) async { - for (var i = 0; i < contactList.length; i++) { - final item = - await contactList.getItemProtobuf(proto.Contact.fromBuffer, i); - if (item == null) { - throw Exception('Failed to get contact'); - } - if (item.remoteConversationRecordKey == - contact.remoteConversationRecordKey) { - await contactList.tryRemoveItem(i); - break; - } - } - try { - await (await pool.openRead(localConversationKey, - parent: accountRecordKey)) - .delete(); - } on Exception catch (e) { - log.debug('error removing local conversation record key: $e', e); - } - try { - if (localConversationKey != remoteConversationKey) { - await (await pool.openRead(remoteConversationKey, - parent: accountRecordKey)) - .delete(); - } - } on Exception catch (e) { - log.debug('error removing remote conversation record key: $e', e); - } - }); -} - -/// Get the active account contact list -@riverpod -Future?> fetchContactList(FetchContactListRef ref) async { - // See if we've logged into this account or if it is locked - final activeAccountInfo = await ref.watch(fetchActiveAccountProvider.future); - if (activeAccountInfo == null) { - return null; - } - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; - - // Decode the contact list from the DHT - IList out = const IListConst([]); - await (await DHTShortArray.openOwned( - proto.OwnedDHTRecordPointerProto.fromProto( - activeAccountInfo.account.contactList), - parent: accountRecordKey)) - .scope((cList) async { - for (var i = 0; i < cList.length; i++) { - final cir = await cList.getItem(i); - if (cir == null) { - throw Exception('Failed to get contact'); - } - out = out.add(Contact.fromBuffer(cir)); - } - }); - - return out; -} diff --git a/lib/providers/contact.g.dart b/lib/providers/contact.g.dart deleted file mode 100644 index 823f594..0000000 --- a/lib/providers/contact.g.dart +++ /dev/null @@ -1,28 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'contact.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$fetchContactListHash() => r'f75cb33fbc664404bba122f1e128e437e0f0b2da'; - -/// Get the active account contact list -/// -/// Copied from [fetchContactList]. -@ProviderFor(fetchContactList) -final fetchContactListProvider = - AutoDisposeFutureProvider?>.internal( - fetchContactList, - name: r'fetchContactListProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$fetchContactListHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef FetchContactListRef = AutoDisposeFutureProviderRef?>; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/providers/contact_invite.dart b/lib/providers/contact_invite.dart deleted file mode 100644 index 3f7bd72..0000000 --- a/lib/providers/contact_invite.dart +++ /dev/null @@ -1,565 +0,0 @@ -import 'dart:typed_data'; - -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:fixnum/fixnum.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -import '../entities/local_account.dart'; -import '../proto/proto.dart' as proto; -import '../proto/proto.dart' - show - ContactInvitation, - ContactInvitationRecord, - ContactRequest, - ContactRequestPrivate, - ContactResponse, - SignedContactInvitation, - SignedContactResponse; -import '../tools/tools.dart'; -import '../veilid_support/veilid_support.dart'; -import 'account.dart'; -import 'conversation.dart'; - -part 'contact_invite.g.dart'; - -class ContactInviteInvalidKeyException implements Exception { - const ContactInviteInvalidKeyException(this.type) : super(); - final EncryptionKeyType type; -} - -class AcceptedContact { - AcceptedContact({ - required this.profile, - required this.remoteIdentity, - required this.remoteConversationRecordKey, - required this.localConversationRecordKey, - }); - - proto.Profile profile; - IdentityMaster remoteIdentity; - TypedKey remoteConversationRecordKey; - TypedKey localConversationRecordKey; -} - -class AcceptedOrRejectedContact { - AcceptedOrRejectedContact({required this.acceptedContact}); - AcceptedContact? acceptedContact; -} - -Future checkAcceptRejectContact( - {required ActiveAccountInfo activeAccountInfo, - required ContactInvitationRecord contactInvitationRecord}) async { - // Open the contact request inbox - try { - final pool = await DHTRecordPool.instance(); - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; - final writerKey = - proto.CryptoKeyProto.fromProto(contactInvitationRecord.writerKey); - final writerSecret = - proto.CryptoKeyProto.fromProto(contactInvitationRecord.writerSecret); - final recordKey = proto.TypedKeyProto.fromProto( - contactInvitationRecord.contactRequestInbox.recordKey); - final writer = TypedKeyPair( - kind: recordKey.kind, key: writerKey, secret: writerSecret); - final acceptReject = await (await pool.openRead(recordKey, - crypto: await DHTRecordCryptoPrivate.fromTypedKeyPair(writer), - parent: accountRecordKey, - defaultSubkey: 1)) - .scope((contactRequestInbox) async { - // - final signedContactResponse = await contactRequestInbox - .getProtobuf(SignedContactResponse.fromBuffer, forceRefresh: true); - if (signedContactResponse == null) { - return null; - } - - final contactResponseBytes = - Uint8List.fromList(signedContactResponse.contactResponse); - final contactResponse = ContactResponse.fromBuffer(contactResponseBytes); - final contactIdentityMasterRecordKey = proto.TypedKeyProto.fromProto( - contactResponse.identityMasterRecordKey); - final cs = await pool.veilid.getCryptoSystem(recordKey.kind); - - // Fetch the remote contact's account master - final contactIdentityMaster = await openIdentityMaster( - identityMasterRecordKey: contactIdentityMasterRecordKey); - - // Verify - final signature = proto.SignatureProto.fromProto( - signedContactResponse.identitySignature); - await cs.verify(contactIdentityMaster.identityPublicKey, - contactResponseBytes, signature); - - // Check for rejection - if (!contactResponse.accept) { - return AcceptedOrRejectedContact(acceptedContact: null); - } - - // Pull profile from remote conversation key - final remoteConversationRecordKey = proto.TypedKeyProto.fromProto( - contactResponse.remoteConversationRecordKey); - final remoteConversation = await readRemoteConversation( - activeAccountInfo: activeAccountInfo, - remoteIdentityPublicKey: - contactIdentityMaster.identityPublicTypedKey(), - remoteConversationRecordKey: remoteConversationRecordKey); - if (remoteConversation == null) { - log.info('Remote conversation could not be read. Waiting...'); - return null; - } - // Complete the local conversation now that we have the remote profile - final localConversationRecordKey = proto.TypedKeyProto.fromProto( - contactInvitationRecord.localConversationRecordKey); - return createConversation( - activeAccountInfo: activeAccountInfo, - remoteIdentityPublicKey: - contactIdentityMaster.identityPublicTypedKey(), - existingConversationRecordKey: localConversationRecordKey, - // ignore: prefer_expression_function_bodies - callback: (localConversation) async { - return AcceptedOrRejectedContact( - acceptedContact: AcceptedContact( - profile: remoteConversation.profile, - remoteIdentity: contactIdentityMaster, - remoteConversationRecordKey: remoteConversationRecordKey, - localConversationRecordKey: localConversationRecordKey)); - }); - }); - - if (acceptReject == null) { - return null; - } - - // Delete invitation and return the accepted or rejected contact - await deleteContactInvitation( - accepted: acceptReject.acceptedContact != null, - activeAccountInfo: activeAccountInfo, - contactInvitationRecord: contactInvitationRecord); - - return acceptReject; - } on Exception catch (e) { - log.error('Exception in checkAcceptRejectContact: $e', e); - - // Attempt to clean up. All this needs better lifetime management - await deleteContactInvitation( - accepted: false, - activeAccountInfo: activeAccountInfo, - contactInvitationRecord: contactInvitationRecord); - - rethrow; - } -} - -Future deleteContactInvitation( - {required bool accepted, - required ActiveAccountInfo activeAccountInfo, - required ContactInvitationRecord contactInvitationRecord}) async { - final pool = await DHTRecordPool.instance(); - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; - - // Remove ContactInvitationRecord from account's list - await (await DHTShortArray.openOwned( - proto.OwnedDHTRecordPointerProto.fromProto( - activeAccountInfo.account.contactInvitationRecords), - parent: accountRecordKey)) - .scope((cirList) async { - for (var i = 0; i < cirList.length; i++) { - final item = await cirList.getItemProtobuf( - proto.ContactInvitationRecord.fromBuffer, i); - if (item == null) { - throw Exception('Failed to get contact invitation record'); - } - if (item.contactRequestInbox.recordKey == - contactInvitationRecord.contactRequestInbox.recordKey) { - await cirList.tryRemoveItem(i); - break; - } - } - await (await pool.openOwned( - proto.OwnedDHTRecordPointerProto.fromProto( - contactInvitationRecord.contactRequestInbox), - parent: accountRecordKey)) - .scope((contactRequestInbox) async { - // Wipe out old invitation so it shows up as invalid - await contactRequestInbox.tryWriteBytes(Uint8List(0)); - await contactRequestInbox.delete(); - }); - if (!accepted) { - await (await pool.openRead( - proto.TypedKeyProto.fromProto( - contactInvitationRecord.localConversationRecordKey), - parent: accountRecordKey)) - .delete(); - } - }); -} - -Future createContactInvitation( - {required ActiveAccountInfo activeAccountInfo, - required EncryptionKeyType encryptionKeyType, - required String encryptionKey, - required String message, - required Timestamp? expiration}) async { - final pool = await DHTRecordPool.instance(); - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; - final identityKey = - activeAccountInfo.localAccount.identityMaster.identityPublicKey; - final identitySecret = activeAccountInfo.userLogin.identitySecret.value; - - // Generate writer keypair to share with new contact - final cs = await pool.veilid.bestCryptoSystem(); - final contactRequestWriter = await cs.generateKeyPair(); - final conversationWriter = - getConversationWriter(activeAccountInfo: activeAccountInfo); - - // Encrypt the writer secret with the encryption key - final encryptedSecret = await encryptSecretToBytes( - secret: contactRequestWriter.secret, - cryptoKind: cs.kind(), - encryptionKey: encryptionKey, - encryptionKeyType: encryptionKeyType); - - // Create local chat DHT record with the account record key as its parent - // Do not set the encryption of this key yet as it will not yet be written - // to and it will be eventually encrypted with the DH of the contact's - // identity key - late final Uint8List signedContactInvitationBytes; - await (await pool.create( - parent: accountRecordKey, - schema: DHTSchema.smpl(oCnt: 0, members: [ - DHTSchemaMember(mKey: conversationWriter.key, mCnt: 1) - ]))) - .deleteScope((localConversation) async { - // dont bother reopening localConversation with writer - // Make ContactRequestPrivate and encrypt with the writer secret - final crpriv = ContactRequestPrivate() - ..writerKey = contactRequestWriter.key.toProto() - ..profile = activeAccountInfo.account.profile - ..identityMasterRecordKey = - activeAccountInfo.userLogin.accountMasterRecordKey.toProto() - ..chatRecordKey = localConversation.key.toProto() - ..expiration = expiration?.toInt64() ?? Int64.ZERO; - final crprivbytes = crpriv.writeToBuffer(); - final encryptedContactRequestPrivate = - await cs.encryptAeadWithNonce(crprivbytes, contactRequestWriter.secret); - - // Create ContactRequest and embed contactrequestprivate - final creq = ContactRequest() - ..encryptionKeyType = encryptionKeyType.toProto() - ..private = encryptedContactRequestPrivate; - - // Create DHT unicast inbox for ContactRequest - await (await pool.create( - parent: accountRecordKey, - schema: DHTSchema.smpl(oCnt: 1, members: [ - DHTSchemaMember(mCnt: 1, mKey: contactRequestWriter.key) - ]), - crypto: const DHTRecordCryptoPublic())) - .deleteScope((contactRequestInbox) async { - // Store ContactRequest in owner subkey - await contactRequestInbox.eventualWriteProtobuf(creq); - - // Create ContactInvitation and SignedContactInvitation - final cinv = ContactInvitation() - ..contactRequestInboxKey = contactRequestInbox.key.toProto() - ..writerSecret = encryptedSecret; - final cinvbytes = cinv.writeToBuffer(); - final scinv = SignedContactInvitation() - ..contactInvitation = cinvbytes - ..identitySignature = - (await cs.sign(identityKey, identitySecret, cinvbytes)).toProto(); - signedContactInvitationBytes = scinv.writeToBuffer(); - - // Create ContactInvitationRecord - final cinvrec = ContactInvitationRecord() - ..contactRequestInbox = - contactRequestInbox.ownedDHTRecordPointer.toProto() - ..writerKey = contactRequestWriter.key.toProto() - ..writerSecret = contactRequestWriter.secret.toProto() - ..localConversationRecordKey = localConversation.key.toProto() - ..expiration = expiration?.toInt64() ?? Int64.ZERO - ..invitation = signedContactInvitationBytes - ..message = message; - - // Add ContactInvitationRecord to account's list - // if this fails, don't keep retrying, user can try again later - await (await DHTShortArray.openOwned( - proto.OwnedDHTRecordPointerProto.fromProto( - activeAccountInfo.account.contactInvitationRecords), - parent: accountRecordKey)) - .scope((cirList) async { - if (await cirList.tryAddItem(cinvrec.writeToBuffer()) == false) { - throw Exception('Failed to add contact invitation record'); - } - }); - }); - }); - - return signedContactInvitationBytes; -} - -class ValidContactInvitation { - ValidContactInvitation( - {required this.signedContactInvitation, - required this.contactInvitation, - required this.contactRequestInboxKey, - required this.contactRequest, - required this.contactRequestPrivate, - required this.contactIdentityMaster, - required this.writer}); - - SignedContactInvitation signedContactInvitation; - ContactInvitation contactInvitation; - TypedKey contactRequestInboxKey; - ContactRequest contactRequest; - ContactRequestPrivate contactRequestPrivate; - IdentityMaster contactIdentityMaster; - KeyPair writer; -} - -typedef GetEncryptionKeyCallback = Future Function( - VeilidCryptoSystem cs, - EncryptionKeyType encryptionKeyType, - Uint8List encryptedSecret); - -Future validateContactInvitation( - {required ActiveAccountInfo activeAccountInfo, - required IList? contactInvitationRecords, - required Uint8List inviteData, - required GetEncryptionKeyCallback getEncryptionKeyCallback}) async { - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; - - final signedContactInvitation = - proto.SignedContactInvitation.fromBuffer(inviteData); - - final contactInvitationBytes = - Uint8List.fromList(signedContactInvitation.contactInvitation); - final contactInvitation = - proto.ContactInvitation.fromBuffer(contactInvitationBytes); - - final contactRequestInboxKey = - proto.TypedKeyProto.fromProto(contactInvitation.contactRequestInboxKey); - - ValidContactInvitation? out; - - final pool = await DHTRecordPool.instance(); - final cs = await pool.veilid.getCryptoSystem(contactRequestInboxKey.kind); - - // See if we're chatting to ourselves, if so, don't delete it here - final isSelf = contactInvitationRecords?.indexWhere((cir) => - proto.TypedKeyProto.fromProto(cir.contactRequestInbox.recordKey) == - contactRequestInboxKey) != - -1; - - await (await pool.openRead(contactRequestInboxKey, parent: accountRecordKey)) - .maybeDeleteScope(!isSelf, (contactRequestInbox) async { - // - final contactRequest = - await contactRequestInbox.getProtobuf(proto.ContactRequest.fromBuffer); - - // Decrypt contact request private - final encryptionKeyType = - EncryptionKeyType.fromProto(contactRequest!.encryptionKeyType); - late final SharedSecret? writerSecret; - try { - writerSecret = await getEncryptionKeyCallback(cs, encryptionKeyType, - Uint8List.fromList(contactInvitation.writerSecret)); - } on Exception catch (_) { - throw ContactInviteInvalidKeyException(encryptionKeyType); - } - if (writerSecret == null) { - return null; - } - - final contactRequestPrivateBytes = await cs.decryptAeadWithNonce( - Uint8List.fromList(contactRequest.private), writerSecret); - - final contactRequestPrivate = - proto.ContactRequestPrivate.fromBuffer(contactRequestPrivateBytes); - final contactIdentityMasterRecordKey = proto.TypedKeyProto.fromProto( - contactRequestPrivate.identityMasterRecordKey); - - // Fetch the account master - final contactIdentityMaster = await openIdentityMaster( - identityMasterRecordKey: contactIdentityMasterRecordKey); - - // Verify - final signature = proto.SignatureProto.fromProto( - signedContactInvitation.identitySignature); - await cs.verify(contactIdentityMaster.identityPublicKey, - contactInvitationBytes, signature); - - final writer = KeyPair( - key: proto.CryptoKeyProto.fromProto(contactRequestPrivate.writerKey), - secret: writerSecret); - - out = ValidContactInvitation( - signedContactInvitation: signedContactInvitation, - contactInvitation: contactInvitation, - contactRequestInboxKey: contactRequestInboxKey, - contactRequest: contactRequest, - contactRequestPrivate: contactRequestPrivate, - contactIdentityMaster: contactIdentityMaster, - writer: writer); - }); - - return out; -} - -Future acceptContactInvitation( - ActiveAccountInfo activeAccountInfo, - ValidContactInvitation validContactInvitation) async { - final pool = await DHTRecordPool.instance(); - try { - // Ensure we don't delete this if we're trying to chat to self - final isSelf = - validContactInvitation.contactIdentityMaster.identityPublicKey == - activeAccountInfo.localAccount.identityMaster.identityPublicKey; - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; - - return (await pool.openWrite(validContactInvitation.contactRequestInboxKey, - validContactInvitation.writer, - parent: accountRecordKey)) - // ignore: prefer_expression_function_bodies - .maybeDeleteScope(!isSelf, (contactRequestInbox) async { - // Create local conversation key for this - // contact and send via contact response - return createConversation( - activeAccountInfo: activeAccountInfo, - remoteIdentityPublicKey: validContactInvitation.contactIdentityMaster - .identityPublicTypedKey(), - callback: (localConversation) async { - final contactResponse = ContactResponse() - ..accept = true - ..remoteConversationRecordKey = localConversation.key.toProto() - ..identityMasterRecordKey = activeAccountInfo - .localAccount.identityMaster.masterRecordKey - .toProto(); - final contactResponseBytes = contactResponse.writeToBuffer(); - - final cs = await pool.veilid.getCryptoSystem( - validContactInvitation.contactRequestInboxKey.kind); - - final identitySignature = await cs.sign( - activeAccountInfo.localAccount.identityMaster.identityPublicKey, - activeAccountInfo.userLogin.identitySecret.value, - contactResponseBytes); - - final signedContactResponse = SignedContactResponse() - ..contactResponse = contactResponseBytes - ..identitySignature = identitySignature.toProto(); - - // Write the acceptance to the inbox - if (await contactRequestInbox.tryWriteProtobuf( - SignedContactResponse.fromBuffer, signedContactResponse, - subkey: 1) != - null) { - throw Exception('failed to accept contact invitation'); - } - return AcceptedContact( - profile: validContactInvitation.contactRequestPrivate.profile, - remoteIdentity: validContactInvitation.contactIdentityMaster, - remoteConversationRecordKey: proto.TypedKeyProto.fromProto( - validContactInvitation.contactRequestPrivate.chatRecordKey), - localConversationRecordKey: localConversation.key, - ); - }); - }); - } on Exception catch (e) { - log.debug('exception: $e', e); - return null; - } -} - -Future rejectContactInvitation(ActiveAccountInfo activeAccountInfo, - ValidContactInvitation validContactInvitation) async { - final pool = await DHTRecordPool.instance(); - - // Ensure we don't delete this if we're trying to chat to self - final isSelf = - validContactInvitation.contactIdentityMaster.identityPublicKey == - activeAccountInfo.localAccount.identityMaster.identityPublicKey; - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; - - return (await pool.openWrite(validContactInvitation.contactRequestInboxKey, - validContactInvitation.writer, - parent: accountRecordKey)) - .maybeDeleteScope(!isSelf, (contactRequestInbox) async { - final cs = await pool.veilid - .getCryptoSystem(validContactInvitation.contactRequestInboxKey.kind); - - final contactResponse = ContactResponse() - ..accept = false - ..identityMasterRecordKey = activeAccountInfo - .localAccount.identityMaster.masterRecordKey - .toProto(); - final contactResponseBytes = contactResponse.writeToBuffer(); - - final identitySignature = await cs.sign( - activeAccountInfo.localAccount.identityMaster.identityPublicKey, - activeAccountInfo.userLogin.identitySecret.value, - contactResponseBytes); - - final signedContactResponse = SignedContactResponse() - ..contactResponse = contactResponseBytes - ..identitySignature = identitySignature.toProto(); - - // Write the rejection to the inbox - if (await contactRequestInbox.tryWriteProtobuf( - SignedContactResponse.fromBuffer, signedContactResponse, - subkey: 1) != - null) { - log.error('failed to reject contact invitation'); - return false; - } - return true; - }); -} - -/// Get the active account contact invitation list -@riverpod -Future?> fetchContactInvitationRecords( - FetchContactInvitationRecordsRef ref) async { - // See if we've logged into this account or if it is locked - final activeAccountInfo = await ref.watch(fetchActiveAccountProvider.future); - if (activeAccountInfo == null) { - return null; - } - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; - - // Decode the contact invitation list from the DHT - IList out = const IListConst([]); - - try { - await (await DHTShortArray.openOwned( - proto.OwnedDHTRecordPointerProto.fromProto( - activeAccountInfo.account.contactInvitationRecords), - parent: accountRecordKey)) - .scope((cirList) async { - for (var i = 0; i < cirList.length; i++) { - final cir = await cirList.getItem(i); - if (cir == null) { - throw Exception('Failed to get contact invitation record'); - } - out = out.add(ContactInvitationRecord.fromBuffer(cir)); - } - }); - } on VeilidAPIExceptionTryAgain catch (_) { - // Try again later - ref.invalidateSelf(); - return null; - } on Exception catch (_) { - // Try again later - ref.invalidateSelf(); - rethrow; - } - - return out; -} diff --git a/lib/providers/contact_invite.g.dart b/lib/providers/contact_invite.g.dart deleted file mode 100644 index b6cf257..0000000 --- a/lib/providers/contact_invite.g.dart +++ /dev/null @@ -1,30 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'contact_invite.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$fetchContactInvitationRecordsHash() => - r'365d563c5e66f45679f597502ea9e4b8296ff8af'; - -/// Get the active account contact invitation list -/// -/// Copied from [fetchContactInvitationRecords]. -@ProviderFor(fetchContactInvitationRecords) -final fetchContactInvitationRecordsProvider = - AutoDisposeFutureProvider?>.internal( - fetchContactInvitationRecords, - name: r'fetchContactInvitationRecordsProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$fetchContactInvitationRecordsHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef FetchContactInvitationRecordsRef - = AutoDisposeFutureProviderRef?>; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/providers/conversation.dart b/lib/providers/conversation.dart deleted file mode 100644 index 7e1c56b..0000000 --- a/lib/providers/conversation.dart +++ /dev/null @@ -1,374 +0,0 @@ -import 'dart:convert'; - -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -import '../proto/proto.dart' as proto; -import '../proto/proto.dart' show Conversation, Message; - -import '../tools/tools.dart'; -import '../veilid_init.dart'; -import '../veilid_support/veilid_support.dart'; -import 'account.dart'; -import 'chat.dart'; -import 'contact.dart'; - -part 'conversation.g.dart'; - -Future getConversationCrypto({ - required ActiveAccountInfo activeAccountInfo, - required TypedKey remoteIdentityPublicKey, -}) async { - final veilid = await eventualVeilid.future; - final identitySecret = activeAccountInfo.userLogin.identitySecret; - final cs = await veilid.getCryptoSystem(identitySecret.kind); - final sharedSecret = - await cs.cachedDH(remoteIdentityPublicKey.value, identitySecret.value); - return DHTRecordCryptoPrivate.fromSecret(identitySecret.kind, sharedSecret); -} - -KeyPair getConversationWriter({ - required ActiveAccountInfo activeAccountInfo, -}) { - final identityKey = - activeAccountInfo.localAccount.identityMaster.identityPublicKey; - final identitySecret = activeAccountInfo.userLogin.identitySecret; - return KeyPair(key: identityKey, secret: identitySecret.value); -} - -// Create a conversation -// If we were the initiator of the conversation there may be an -// incomplete 'existingConversationRecord' that we need to fill -// in now that we have the remote identity key -Future createConversation( - {required ActiveAccountInfo activeAccountInfo, - required TypedKey remoteIdentityPublicKey, - required FutureOr Function(DHTRecord) callback, - TypedKey? existingConversationRecordKey}) async { - final pool = await DHTRecordPool.instance(); - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; - - final crypto = await getConversationCrypto( - activeAccountInfo: activeAccountInfo, - remoteIdentityPublicKey: remoteIdentityPublicKey); - final writer = getConversationWriter(activeAccountInfo: activeAccountInfo); - - // Open with SMPL scheme for identity writer - late final DHTRecord localConversationRecord; - if (existingConversationRecordKey != null) { - localConversationRecord = await pool.openWrite( - existingConversationRecordKey, writer, - parent: accountRecordKey, crypto: crypto); - } else { - final localConversationRecordCreate = await pool.create( - parent: accountRecordKey, - crypto: crypto, - schema: DHTSchema.smpl( - oCnt: 0, members: [DHTSchemaMember(mKey: writer.key, mCnt: 1)])); - await localConversationRecordCreate.close(); - localConversationRecord = await pool.openWrite( - localConversationRecordCreate.key, writer, - parent: accountRecordKey, crypto: crypto); - } - return localConversationRecord - // ignore: prefer_expression_function_bodies - .deleteScope((localConversation) async { - // Make messages log - return (await DHTShortArray.create( - parent: localConversation.key, crypto: crypto, smplWriter: writer)) - .deleteScope((messages) async { - // Write local conversation key - final conversation = Conversation() - ..profile = activeAccountInfo.account.profile - ..identityMasterJson = - jsonEncode(activeAccountInfo.localAccount.identityMaster.toJson()) - ..messages = messages.record.key.toProto(); - - // - final update = await localConversation.tryWriteProtobuf( - Conversation.fromBuffer, conversation); - if (update != null) { - throw Exception('Failed to write local conversation'); - } - return await callback(localConversation); - }); - }); -} - -Future readRemoteConversation({ - required ActiveAccountInfo activeAccountInfo, - required TypedKey remoteConversationRecordKey, - required TypedKey remoteIdentityPublicKey, -}) async { - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; - final pool = await DHTRecordPool.instance(); - - final crypto = await getConversationCrypto( - activeAccountInfo: activeAccountInfo, - remoteIdentityPublicKey: remoteIdentityPublicKey); - return (await pool.openRead(remoteConversationRecordKey, - parent: accountRecordKey, crypto: crypto)) - .scope((remoteConversation) async { - // - final conversation = - await remoteConversation.getProtobuf(Conversation.fromBuffer); - return conversation; - }); -} - -Future writeLocalConversation({ - required ActiveAccountInfo activeAccountInfo, - required TypedKey localConversationRecordKey, - required TypedKey remoteIdentityPublicKey, - required Conversation conversation, -}) async { - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; - final pool = await DHTRecordPool.instance(); - - final crypto = await getConversationCrypto( - activeAccountInfo: activeAccountInfo, - remoteIdentityPublicKey: remoteIdentityPublicKey); - final writer = getConversationWriter(activeAccountInfo: activeAccountInfo); - - return (await pool.openWrite(localConversationRecordKey, writer, - parent: accountRecordKey, crypto: crypto)) - .scope((localConversation) async { - // - final update = await localConversation.tryWriteProtobuf( - Conversation.fromBuffer, conversation); - if (update != null) { - return update; - } - return null; - }); -} - -Future readLocalConversation({ - required ActiveAccountInfo activeAccountInfo, - required TypedKey localConversationRecordKey, - required TypedKey remoteIdentityPublicKey, -}) async { - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; - final pool = await DHTRecordPool.instance(); - - final crypto = await getConversationCrypto( - activeAccountInfo: activeAccountInfo, - remoteIdentityPublicKey: remoteIdentityPublicKey); - - return (await pool.openRead(localConversationRecordKey, - parent: accountRecordKey, crypto: crypto)) - .scope((localConversation) async { - // - final update = await localConversation.getProtobuf(Conversation.fromBuffer); - if (update != null) { - return update; - } - return null; - }); -} - -Future addLocalConversationMessage( - {required ActiveAccountInfo activeAccountInfo, - required TypedKey localConversationRecordKey, - required TypedKey remoteIdentityPublicKey, - required Message message}) async { - final conversation = await readLocalConversation( - activeAccountInfo: activeAccountInfo, - localConversationRecordKey: localConversationRecordKey, - remoteIdentityPublicKey: remoteIdentityPublicKey); - if (conversation == null) { - return; - } - final messagesRecordKey = - proto.TypedKeyProto.fromProto(conversation.messages); - final crypto = await getConversationCrypto( - activeAccountInfo: activeAccountInfo, - remoteIdentityPublicKey: remoteIdentityPublicKey); - final writer = getConversationWriter(activeAccountInfo: activeAccountInfo); - - await (await DHTShortArray.openWrite(messagesRecordKey, writer, - parent: localConversationRecordKey, crypto: crypto)) - .scope((messages) async { - await messages.tryAddItem(message.writeToBuffer()); - }); -} - -Future mergeLocalConversationMessages( - {required ActiveAccountInfo activeAccountInfo, - required TypedKey localConversationRecordKey, - required TypedKey remoteIdentityPublicKey, - required IList newMessages}) async { - final conversation = await readLocalConversation( - activeAccountInfo: activeAccountInfo, - localConversationRecordKey: localConversationRecordKey, - remoteIdentityPublicKey: remoteIdentityPublicKey); - if (conversation == null) { - return false; - } - var changed = false; - final messagesRecordKey = - proto.TypedKeyProto.fromProto(conversation.messages); - final crypto = await getConversationCrypto( - activeAccountInfo: activeAccountInfo, - remoteIdentityPublicKey: remoteIdentityPublicKey); - final writer = getConversationWriter(activeAccountInfo: activeAccountInfo); - - newMessages = newMessages.sort((a, b) => Timestamp.fromInt64(a.timestamp) - .compareTo(Timestamp.fromInt64(b.timestamp))); - - await (await DHTShortArray.openWrite(messagesRecordKey, writer, - parent: localConversationRecordKey, crypto: crypto)) - .scope((messages) async { - // Ensure newMessages is sorted by timestamp - newMessages = - newMessages.sort((a, b) => a.timestamp.compareTo(b.timestamp)); - - // Existing messages will always be sorted by timestamp so merging is easy - var pos = 0; - outer: - for (final newMessage in newMessages) { - var skip = false; - while (pos < messages.length) { - final m = await messages.getItemProtobuf(proto.Message.fromBuffer, pos); - if (m == null) { - log.error('unable to get message #$pos'); - break outer; - } - - // If timestamp to insert is less than - // the current position, insert it here - final newTs = Timestamp.fromInt64(newMessage.timestamp); - final curTs = Timestamp.fromInt64(m.timestamp); - final cmp = newTs.compareTo(curTs); - if (cmp < 0) { - break; - } else if (cmp == 0) { - skip = true; - break; - } - pos++; - } - // Insert at this position - if (!skip) { - await messages.tryInsertItem(pos, newMessage.writeToBuffer()); - changed = true; - } - } - }); - return changed; -} - -Future?> getLocalConversationMessages({ - required ActiveAccountInfo activeAccountInfo, - required TypedKey localConversationRecordKey, - required TypedKey remoteIdentityPublicKey, -}) async { - final conversation = await readLocalConversation( - activeAccountInfo: activeAccountInfo, - localConversationRecordKey: localConversationRecordKey, - remoteIdentityPublicKey: remoteIdentityPublicKey); - if (conversation == null) { - return null; - } - final messagesRecordKey = - proto.TypedKeyProto.fromProto(conversation.messages); - final crypto = await getConversationCrypto( - activeAccountInfo: activeAccountInfo, - remoteIdentityPublicKey: remoteIdentityPublicKey); - - return (await DHTShortArray.openRead(messagesRecordKey, - parent: localConversationRecordKey, crypto: crypto)) - .scope((messages) async { - var out = IList(); - for (var i = 0; i < messages.length; i++) { - final msg = await messages.getItemProtobuf(Message.fromBuffer, i); - if (msg == null) { - throw Exception('Failed to get message'); - } - out = out.add(msg); - } - return out; - }); -} - -Future?> getRemoteConversationMessages({ - required ActiveAccountInfo activeAccountInfo, - required TypedKey remoteConversationRecordKey, - required TypedKey remoteIdentityPublicKey, -}) async { - final conversation = await readRemoteConversation( - activeAccountInfo: activeAccountInfo, - remoteConversationRecordKey: remoteConversationRecordKey, - remoteIdentityPublicKey: remoteIdentityPublicKey); - if (conversation == null) { - return null; - } - final messagesRecordKey = - proto.TypedKeyProto.fromProto(conversation.messages); - final crypto = await getConversationCrypto( - activeAccountInfo: activeAccountInfo, - remoteIdentityPublicKey: remoteIdentityPublicKey); - - return (await DHTShortArray.openRead(messagesRecordKey, - parent: remoteConversationRecordKey, crypto: crypto)) - .scope((messages) async { - var out = IList(); - for (var i = 0; i < messages.length; i++) { - final msg = await messages.getItemProtobuf(Message.fromBuffer, i); - if (msg == null) { - throw Exception('Failed to get message'); - } - out = out.add(msg); - } - return out; - }); -} - -@riverpod -class ActiveConversationMessages extends _$ActiveConversationMessages { - /// Get message for active conversation - @override - FutureOr?> build() async { - await eventualVeilid.future; - - final activeChat = ref.watch(activeChatStateProvider); - if (activeChat == null) { - return null; - } - - final activeAccountInfo = - await ref.watch(fetchActiveAccountProvider.future); - if (activeAccountInfo == null) { - return null; - } - - final contactList = ref.watch(fetchContactListProvider).asData?.value ?? - const IListConst([]); - - final activeChatContactIdx = contactList.indexWhere( - (c) => - proto.TypedKeyProto.fromProto(c.remoteConversationRecordKey) == - activeChat, - ); - if (activeChatContactIdx == -1) { - return null; - } - final activeChatContact = contactList[activeChatContactIdx]; - final remoteIdentityPublicKey = - proto.TypedKeyProto.fromProto(activeChatContact.identityPublicKey); - // final remoteConversationRecordKey = proto.TypedKeyProto.fromProto( - // activeChatContact.remoteConversationRecordKey); - final localConversationRecordKey = proto.TypedKeyProto.fromProto( - activeChatContact.localConversationRecordKey); - - return await getLocalConversationMessages( - activeAccountInfo: activeAccountInfo, - localConversationRecordKey: localConversationRecordKey, - remoteIdentityPublicKey: remoteIdentityPublicKey, - ); - } -} diff --git a/lib/providers/conversation.g.dart b/lib/providers/conversation.g.dart deleted file mode 100644 index fcf007c..0000000 --- a/lib/providers/conversation.g.dart +++ /dev/null @@ -1,28 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'conversation.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$activeConversationMessagesHash() => - r'61c9e16f1304c7929a971ec7711d2b6c7cadc5ea'; - -/// See also [ActiveConversationMessages]. -@ProviderFor(ActiveConversationMessages) -final activeConversationMessagesProvider = AutoDisposeAsyncNotifierProvider< - ActiveConversationMessages, IList?>.internal( - ActiveConversationMessages.new, - name: r'activeConversationMessagesProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$activeConversationMessagesHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$ActiveConversationMessages - = AutoDisposeAsyncNotifier?>; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/providers/local_accounts.dart b/lib/providers/local_accounts.dart deleted file mode 100644 index c7c793d..0000000 --- a/lib/providers/local_accounts.dart +++ /dev/null @@ -1,167 +0,0 @@ -import 'dart:async'; - -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -import '../entities/entities.dart'; -import '../proto/proto.dart' as proto; -import '../tools/tools.dart'; -import '../veilid_init.dart'; -import '../veilid_support/veilid_support.dart'; -import 'logins.dart'; - -part 'local_accounts.g.dart'; - -const String veilidChatAccountKey = 'com.veilid.veilidchat'; - -// Local account manager -@riverpod -class LocalAccounts extends _$LocalAccounts - with AsyncTableDBBacked> { - ////////////////////////////////////////////////////////////// - /// AsyncTableDBBacked - @override - String tableName() => 'local_account_manager'; - @override - String tableKeyName() => 'local_accounts'; - @override - IList valueFromJson(Object? obj) => obj != null - ? IList.fromJson( - obj, genericFromJson(LocalAccount.fromJson)) - : IList(); - @override - Object? valueToJson(IList val) => - val.toJson((la) => la.toJson()); - - /// Get all local account information - @override - FutureOr> build() async { - try { - await eventualVeilid.future; - return await load(); - } on Exception catch (e) { - log.error('Failed to load LocalAccounts table: $e', e); - return const IListConst([]); - } - } - - ////////////////////////////////////////////////////////////// - /// Mutators and Selectors - - /// Reorder accounts - Future reorderAccount(int oldIndex, int newIndex) async { - final localAccounts = state.requireValue; - final removedItem = Output(); - final updated = localAccounts - .removeAt(oldIndex, removedItem) - .insert(newIndex, removedItem.value!); - await store(updated); - state = AsyncValue.data(updated); - } - - /// Creates a new Account associated with master identity - /// Adds a logged-out LocalAccount to track its existence on this device - Future newLocalAccount( - {required IdentityMaster identityMaster, - required SecretKey identitySecret, - required String name, - required String pronouns, - EncryptionKeyType encryptionKeyType = EncryptionKeyType.none, - String encryptionKey = ''}) async { - final localAccounts = state.requireValue; - - // Add account with profile to DHT - await identityMaster.addAccountToIdentity( - identitySecret: identitySecret, - accountKey: veilidChatAccountKey, - createAccountCallback: (parent) async { - // Make empty contact list - final contactList = await (await DHTShortArray.create(parent: parent)) - .scope((r) async => r.record.ownedDHTRecordPointer); - - // Make empty contact invitation record list - final contactInvitationRecords = - await (await DHTShortArray.create(parent: parent)) - .scope((r) async => r.record.ownedDHTRecordPointer); - - // Make empty chat record list - final chatRecords = await (await DHTShortArray.create(parent: parent)) - .scope((r) async => r.record.ownedDHTRecordPointer); - - // Make account object - final account = proto.Account() - ..profile = (proto.Profile() - ..name = name - ..pronouns = pronouns) - ..contactList = contactList.toProto() - ..contactInvitationRecords = contactInvitationRecords.toProto() - ..chatList = chatRecords.toProto(); - return account; - }); - - // Encrypt identitySecret with key - final identitySecretBytes = await encryptSecretToBytes( - secret: identitySecret, - cryptoKind: identityMaster.identityRecordKey.kind, - encryptionKey: encryptionKey, - encryptionKeyType: encryptionKeyType); - - // Create local account object - // Does not contain the account key or its secret - // as that is not to be persisted, and only pulled from the identity key - // and optionally decrypted with the unlock password - final localAccount = LocalAccount( - identityMaster: identityMaster, - identitySecretBytes: identitySecretBytes, - encryptionKeyType: encryptionKeyType, - biometricsEnabled: false, - hiddenAccount: false, - name: name, - ); - - // Add local account object to internal store - final newLocalAccounts = localAccounts.add(localAccount); - await store(newLocalAccounts); - state = AsyncValue.data(newLocalAccounts); - - // Return local account object - return localAccount; - } - - /// Remove an account and wipe the messages for this account from this device - Future deleteLocalAccount(TypedKey accountMasterRecordKey) async { - final logins = ref.read(loginsProvider.notifier); - await logins.logout(accountMasterRecordKey); - - final localAccounts = state.requireValue; - final updated = localAccounts.removeWhere( - (la) => la.identityMaster.masterRecordKey == accountMasterRecordKey); - await store(updated); - state = AsyncValue.data(updated); - - // TO DO: wipe messages - - return true; - } - - /// Import an account from another VeilidChat instance - - /// Recover an account with the master identity secret - - /// Delete an account from all devices -} - -@riverpod -Future fetchLocalAccount(FetchLocalAccountRef ref, - {required TypedKey accountMasterRecordKey}) async { - final localAccounts = await ref.watch(localAccountsProvider.future); - try { - return localAccounts.firstWhere( - (e) => e.identityMaster.masterRecordKey == accountMasterRecordKey); - } on Exception catch (e) { - if (e is StateError) { - return null; - } - rethrow; - } -} diff --git a/lib/providers/local_accounts.g.dart b/lib/providers/local_accounts.g.dart deleted file mode 100644 index 026ddcc..0000000 --- a/lib/providers/local_accounts.g.dart +++ /dev/null @@ -1,179 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'local_accounts.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$fetchLocalAccountHash() => r'e9f8ea0dd15031cc8145532e9cac73ab7f0f81be'; - -/// Copied from Dart SDK -class _SystemHash { - _SystemHash._(); - - static int combine(int hash, int value) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + value); - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); - return hash ^ (hash >> 6); - } - - static int finish(int hash) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); - // ignore: parameter_assignments - hash = hash ^ (hash >> 11); - return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); - } -} - -/// See also [fetchLocalAccount]. -@ProviderFor(fetchLocalAccount) -const fetchLocalAccountProvider = FetchLocalAccountFamily(); - -/// See also [fetchLocalAccount]. -class FetchLocalAccountFamily extends Family> { - /// See also [fetchLocalAccount]. - const FetchLocalAccountFamily(); - - /// See also [fetchLocalAccount]. - FetchLocalAccountProvider call({ - required Typed accountMasterRecordKey, - }) { - return FetchLocalAccountProvider( - accountMasterRecordKey: accountMasterRecordKey, - ); - } - - @override - FetchLocalAccountProvider getProviderOverride( - covariant FetchLocalAccountProvider provider, - ) { - return call( - accountMasterRecordKey: provider.accountMasterRecordKey, - ); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'fetchLocalAccountProvider'; -} - -/// See also [fetchLocalAccount]. -class FetchLocalAccountProvider - extends AutoDisposeFutureProvider { - /// See also [fetchLocalAccount]. - FetchLocalAccountProvider({ - required Typed accountMasterRecordKey, - }) : this._internal( - (ref) => fetchLocalAccount( - ref as FetchLocalAccountRef, - accountMasterRecordKey: accountMasterRecordKey, - ), - from: fetchLocalAccountProvider, - name: r'fetchLocalAccountProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') - ? null - : _$fetchLocalAccountHash, - dependencies: FetchLocalAccountFamily._dependencies, - allTransitiveDependencies: - FetchLocalAccountFamily._allTransitiveDependencies, - accountMasterRecordKey: accountMasterRecordKey, - ); - - FetchLocalAccountProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.accountMasterRecordKey, - }) : super.internal(); - - final Typed accountMasterRecordKey; - - @override - Override overrideWith( - FutureOr Function(FetchLocalAccountRef provider) create, - ) { - return ProviderOverride( - origin: this, - override: FetchLocalAccountProvider._internal( - (ref) => create(ref as FetchLocalAccountRef), - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - accountMasterRecordKey: accountMasterRecordKey, - ), - ); - } - - @override - AutoDisposeFutureProviderElement createElement() { - return _FetchLocalAccountProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is FetchLocalAccountProvider && - other.accountMasterRecordKey == accountMasterRecordKey; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, accountMasterRecordKey.hashCode); - - return _SystemHash.finish(hash); - } -} - -mixin FetchLocalAccountRef on AutoDisposeFutureProviderRef { - /// The parameter `accountMasterRecordKey` of this provider. - Typed get accountMasterRecordKey; -} - -class _FetchLocalAccountProviderElement - extends AutoDisposeFutureProviderElement - with FetchLocalAccountRef { - _FetchLocalAccountProviderElement(super.provider); - - @override - Typed get accountMasterRecordKey => - (origin as FetchLocalAccountProvider).accountMasterRecordKey; -} - -String _$localAccountsHash() => r'f19ec560b585d353219be82bc383b2c091660c53'; - -/// See also [LocalAccounts]. -@ProviderFor(LocalAccounts) -final localAccountsProvider = AutoDisposeAsyncNotifierProvider>.internal( - LocalAccounts.new, - name: r'localAccountsProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$localAccountsHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$LocalAccounts = AutoDisposeAsyncNotifier>; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/providers/logins.dart b/lib/providers/logins.dart deleted file mode 100644 index 2617d04..0000000 --- a/lib/providers/logins.dart +++ /dev/null @@ -1,150 +0,0 @@ -import 'dart:async'; - -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -import '../entities/entities.dart'; -import '../tools/tools.dart'; -import '../veilid_init.dart'; -import '../veilid_support/veilid_support.dart'; -import 'local_accounts.dart'; - -part 'logins.g.dart'; - -// Local account manager -@riverpod -class Logins extends _$Logins with AsyncTableDBBacked { - ////////////////////////////////////////////////////////////// - /// AsyncTableDBBacked - @override - String tableName() => 'local_account_manager'; - @override - String tableKeyName() => 'active_logins'; - @override - ActiveLogins valueFromJson(Object? obj) => obj != null - ? ActiveLogins.fromJson(obj as Map) - : ActiveLogins.empty(); - @override - Object? valueToJson(ActiveLogins val) => val.toJson(); - - /// Get all local account information - @override - FutureOr build() async { - try { - await eventualVeilid.future; - return await load(); - } on Exception catch (e) { - log.error('Failed to load ActiveLogins table: $e', e); - return const ActiveLogins(userLogins: IListConst([])); - } - } - - ////////////////////////////////////////////////////////////// - /// Mutators and Selectors - - Future switchToAccount(TypedKey? accountMasterRecordKey) async { - final current = state.requireValue; - if (accountMasterRecordKey != null) { - // Assert the specified record key can be found, will throw if not - final _ = current.userLogins.firstWhere( - (ul) => ul.accountMasterRecordKey == accountMasterRecordKey); - } - final updated = current.copyWith(activeUserLogin: accountMasterRecordKey); - await store(updated); - state = AsyncValue.data(updated); - } - - Future _decryptedLogin( - IdentityMaster identityMaster, SecretKey identitySecret) async { - final veilid = await eventualVeilid.future; - final cs = - await veilid.getCryptoSystem(identityMaster.identityRecordKey.kind); - final keyOk = await cs.validateKeyPair( - identityMaster.identityPublicKey, identitySecret); - if (!keyOk) { - throw Exception('Identity is corrupted'); - } - - // Read the identity key to get the account keys - final accountRecordInfo = await identityMaster.readAccountFromIdentity( - identitySecret: identitySecret, accountKey: veilidChatAccountKey); - - // Add to user logins and select it - final current = state.requireValue; - final now = veilid.now(); - final updated = current.copyWith( - userLogins: current.userLogins.replaceFirstWhere( - (ul) => ul.accountMasterRecordKey == identityMaster.masterRecordKey, - (ul) => ul != null - ? ul.copyWith(lastActive: now) - : UserLogin( - accountMasterRecordKey: identityMaster.masterRecordKey, - identitySecret: - TypedSecret(kind: cs.kind(), value: identitySecret), - accountRecordInfo: accountRecordInfo, - lastActive: now), - addIfNotFound: true), - activeUserLogin: identityMaster.masterRecordKey); - await store(updated); - state = AsyncValue.data(updated); - - return true; - } - - Future login(TypedKey accountMasterRecordKey, - EncryptionKeyType encryptionKeyType, String encryptionKey) async { - final localAccounts = ref.read(localAccountsProvider).requireValue; - - // Get account, throws if not found - final localAccount = localAccounts.firstWhere( - (la) => la.identityMaster.masterRecordKey == accountMasterRecordKey); - - // Log in with this local account - - // Derive key from password - if (localAccount.encryptionKeyType != encryptionKeyType) { - throw Exception('Wrong authentication type'); - } - - final identitySecret = await decryptSecretFromBytes( - secretBytes: localAccount.identitySecretBytes, - cryptoKind: localAccount.identityMaster.identityRecordKey.kind, - encryptionKeyType: localAccount.encryptionKeyType, - encryptionKey: encryptionKey, - ); - - // Validate this secret with the identity public key and log in - return _decryptedLogin(localAccount.identityMaster, identitySecret); - } - - Future logout(TypedKey? accountMasterRecordKey) async { - final current = state.requireValue; - final logoutUser = accountMasterRecordKey ?? current.activeUserLogin; - if (logoutUser == null) { - return; - } - final updated = current.copyWith( - activeUserLogin: current.activeUserLogin == logoutUser - ? null - : current.activeUserLogin, - userLogins: current.userLogins - .removeWhere((ul) => ul.accountMasterRecordKey == logoutUser)); - await store(updated); - state = AsyncValue.data(updated); - } -} - -@riverpod -Future fetchLogin(FetchLoginRef ref, - {required TypedKey accountMasterRecordKey}) async { - final activeLogins = await ref.watch(loginsProvider.future); - try { - return activeLogins.userLogins - .firstWhere((e) => e.accountMasterRecordKey == accountMasterRecordKey); - } on Exception catch (e) { - if (e is StateError) { - return null; - } - rethrow; - } -} diff --git a/lib/providers/logins.g.dart b/lib/providers/logins.g.dart deleted file mode 100644 index e4eee2e..0000000 --- a/lib/providers/logins.g.dart +++ /dev/null @@ -1,176 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'logins.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$fetchLoginHash() => r'cfe13f5152f1275e6eccc698142abfd98170d9b9'; - -/// Copied from Dart SDK -class _SystemHash { - _SystemHash._(); - - static int combine(int hash, int value) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + value); - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); - return hash ^ (hash >> 6); - } - - static int finish(int hash) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); - // ignore: parameter_assignments - hash = hash ^ (hash >> 11); - return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); - } -} - -/// See also [fetchLogin]. -@ProviderFor(fetchLogin) -const fetchLoginProvider = FetchLoginFamily(); - -/// See also [fetchLogin]. -class FetchLoginFamily extends Family> { - /// See also [fetchLogin]. - const FetchLoginFamily(); - - /// See also [fetchLogin]. - FetchLoginProvider call({ - required Typed accountMasterRecordKey, - }) { - return FetchLoginProvider( - accountMasterRecordKey: accountMasterRecordKey, - ); - } - - @override - FetchLoginProvider getProviderOverride( - covariant FetchLoginProvider provider, - ) { - return call( - accountMasterRecordKey: provider.accountMasterRecordKey, - ); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'fetchLoginProvider'; -} - -/// See also [fetchLogin]. -class FetchLoginProvider extends AutoDisposeFutureProvider { - /// See also [fetchLogin]. - FetchLoginProvider({ - required Typed accountMasterRecordKey, - }) : this._internal( - (ref) => fetchLogin( - ref as FetchLoginRef, - accountMasterRecordKey: accountMasterRecordKey, - ), - from: fetchLoginProvider, - name: r'fetchLoginProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') - ? null - : _$fetchLoginHash, - dependencies: FetchLoginFamily._dependencies, - allTransitiveDependencies: - FetchLoginFamily._allTransitiveDependencies, - accountMasterRecordKey: accountMasterRecordKey, - ); - - FetchLoginProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.accountMasterRecordKey, - }) : super.internal(); - - final Typed accountMasterRecordKey; - - @override - Override overrideWith( - FutureOr Function(FetchLoginRef provider) create, - ) { - return ProviderOverride( - origin: this, - override: FetchLoginProvider._internal( - (ref) => create(ref as FetchLoginRef), - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - accountMasterRecordKey: accountMasterRecordKey, - ), - ); - } - - @override - AutoDisposeFutureProviderElement createElement() { - return _FetchLoginProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is FetchLoginProvider && - other.accountMasterRecordKey == accountMasterRecordKey; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, accountMasterRecordKey.hashCode); - - return _SystemHash.finish(hash); - } -} - -mixin FetchLoginRef on AutoDisposeFutureProviderRef { - /// The parameter `accountMasterRecordKey` of this provider. - Typed get accountMasterRecordKey; -} - -class _FetchLoginProviderElement - extends AutoDisposeFutureProviderElement with FetchLoginRef { - _FetchLoginProviderElement(super.provider); - - @override - Typed get accountMasterRecordKey => - (origin as FetchLoginProvider).accountMasterRecordKey; -} - -String _$loginsHash() => r'2660f71bb7903464187a93fba5c07e22041e8c40'; - -/// See also [Logins]. -@ProviderFor(Logins) -final loginsProvider = - AutoDisposeAsyncNotifierProvider.internal( - Logins.new, - name: r'loginsProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') ? null : _$loginsHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$Logins = AutoDisposeAsyncNotifier; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/providers/window_control.dart b/lib/providers/window_control.dart deleted file mode 100644 index 56116f1..0000000 --- a/lib/providers/window_control.dart +++ /dev/null @@ -1,83 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:window_manager/window_manager.dart'; - -import '../tools/responsive.dart'; - -export 'package:window_manager/window_manager.dart' show TitleBarStyle; - -part 'window_control.g.dart'; - -enum OrientationCapability { - normal, - portraitOnly, - landscapeOnly, -} - -// Window Control -@riverpod -class WindowControl extends _$WindowControl { - /// Change window control - @override - FutureOr build() async { - await _doWindowSetup(TitleBarStyle.hidden, OrientationCapability.normal); - return true; - } - - static Future initialize() async { - if (isDesktop) { - await windowManager.ensureInitialized(); - - const windowOptions = WindowOptions( - size: Size(768, 1024), - //minimumSize: Size(480, 480), - center: true, - backgroundColor: Colors.transparent, - skipTaskbar: false, - ); - await windowManager.waitUntilReadyToShow(windowOptions, () async { - await windowManager.show(); - await windowManager.focus(); - }); - } - } - - Future _doWindowSetup(TitleBarStyle titleBarStyle, - OrientationCapability orientationCapability) async { - if (isDesktop) { - await windowManager.setTitleBarStyle(titleBarStyle); - } else { - switch (orientationCapability) { - case OrientationCapability.normal: - await SystemChrome.setPreferredOrientations([ - DeviceOrientation.portraitUp, - DeviceOrientation.landscapeLeft, - DeviceOrientation.landscapeRight, - ]); - case OrientationCapability.portraitOnly: - await SystemChrome.setPreferredOrientations([ - DeviceOrientation.portraitUp, - ]); - case OrientationCapability.landscapeOnly: - await SystemChrome.setPreferredOrientations([ - DeviceOrientation.landscapeLeft, - DeviceOrientation.landscapeRight, - ]); - } - } - } - - ////////////////////////////////////////////////////////////// - /// Mutators and Selectors - - /// Reorder accounts - Future changeWindowSetup(TitleBarStyle titleBarStyle, - OrientationCapability orientationCapability) async { - state = const AsyncValue.loading(); - await _doWindowSetup(titleBarStyle, orientationCapability); - state = const AsyncValue.data(true); - } -} diff --git a/lib/providers/window_control.g.dart b/lib/providers/window_control.g.dart deleted file mode 100644 index d093cf4..0000000 --- a/lib/providers/window_control.g.dart +++ /dev/null @@ -1,26 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'window_control.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$windowControlHash() => r'c6afcbe1d4bfcfc580c30393aac60624c5ceabe0'; - -/// See also [WindowControl]. -@ProviderFor(WindowControl) -final windowControlProvider = - AutoDisposeAsyncNotifierProvider.internal( - WindowControl.new, - name: r'windowControlProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$windowControlHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$WindowControl = AutoDisposeAsyncNotifier; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/router/cubits/router_cubit.dart b/lib/router/cubits/router_cubit.dart new file mode 100644 index 0000000..d651611 --- /dev/null +++ b/lib/router/cubits/router_cubit.dart @@ -0,0 +1,180 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:go_router/go_router.dart'; +import 'package:stream_transform/stream_transform.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../../account_manager/account_manager.dart'; +import '../../layout/layout.dart'; +import '../../settings/settings.dart'; +import '../../theme/theme.dart'; +import '../../tools/tools.dart'; +import '../../veilid_processor/views/developer.dart'; +import '../views/router_shell.dart'; + +export 'package:go_router/go_router.dart'; + +part 'router_cubit.freezed.dart'; +part 'router_cubit.g.dart'; + +final _rootNavKey = GlobalKey(debugLabel: 'rootNavKey'); + +@freezed +sealed class RouterState with _$RouterState { + const factory RouterState({ + required bool hasAnyAccount, + }) = _RouterState; + + factory RouterState.fromJson(dynamic json) => + _$RouterStateFromJson(json as Map); +} + +class RouterCubit extends Cubit { + RouterCubit(AccountRepository accountRepository) + : super(RouterState( + hasAnyAccount: accountRepository.getLocalAccounts().isNotEmpty, + )) { + // Subscribe to repository streams + _accountRepositorySubscription = accountRepository.stream.listen((event) { + switch (event) { + case AccountRepositoryChange.localAccounts: + emit(state.copyWith( + hasAnyAccount: accountRepository.getLocalAccounts().isNotEmpty)); + case AccountRepositoryChange.userLogins: + case AccountRepositoryChange.activeLocalAccount: + } + }); + } + + @override + Future close() async { + await _accountRepositorySubscription.cancel(); + await super.close(); + } + + /// Our application routes + List get routes => [ + ShellRoute( + builder: (context, state, child) => RouterShell(child: child), + routes: [ + GoRoute( + path: '/', + builder: (context, state) => const HomeScreen(), + ), + GoRoute( + path: '/edit_account', + redirect: (_, state) { + final extra = state.extra; + if (extra == null || + extra is! List || + extra[0] is! TypedKey) { + return '/'; + } + return null; + }, + builder: (context, state) { + final extra = state.extra! as List; + return EditAccountPage( + superIdentityRecordKey: extra[0] as TypedKey, + initialValue: extra[1] as AccountSpec, + accountRecord: extra[2] as OwnedDHTRecordPointer, + ); + }, + ), + GoRoute( + path: '/new_account', + builder: (context, state) => const NewAccountPage(), + routes: [ + GoRoute( + path: 'recovery_key', + redirect: (_, state) { + final extra = state.extra; + if (extra == null || + extra is! List || + extra[0] is! WritableSuperIdentity || + extra[1] is! String || + extra[2] is! bool) { + return '/'; + } + return null; + }, + builder: (context, state) { + final extra = state.extra! as List; + + return PopControl( + dismissible: false, + child: ShowRecoveryKeyPage( + writableSuperIdentity: + extra[0] as WritableSuperIdentity, + name: extra[1] as String, + isFirstAccount: extra[2] as bool)); + }), + ]), + GoRoute( + path: '/settings', + builder: (context, state) => const SettingsPage(), + ), + GoRoute( + path: '/developer', + builder: (context, state) => const DeveloperPage(), + ) + ]) + ]; + + /// Redirects when our state changes + String? redirect(BuildContext context, GoRouterState goRouterState) { + switch (goRouterState.matchedLocation) { + // We can go to any of these routes without an account. + case '/new_account': + return null; + case '/new_account/recovery_key': + return null; + case '/settings': + return null; + case '/developer': + return null; + // Otherwise, if there's no account, + // we need to go to the new account page. + default: + return state.hasAnyAccount ? null : '/new_account'; + } + } + + /// Make a GoRouter instance that uses this cubit + GoRouter router() { + final r = _router; + if (r != null) { + return r; + } + return _router = GoRouter( + navigatorKey: _rootNavKey, + refreshListenable: StreamListenable(stream.startWith(state).distinct()), + debugLogDiagnostics: kIsDebugMode, + initialLocation: '/', + routes: routes, + redirect: redirect, + ); + } + + //////////////// + + late final StreamSubscription + _accountRepositorySubscription; + GoRouter? _router; +} + +extension GoRouterExtension on GoRouter { + String location() { + final lastMatch = routerDelegate.currentConfiguration.last; + final matchList = lastMatch is ImperativeRouteMatch + ? lastMatch.matches + : routerDelegate.currentConfiguration; + final location = matchList.uri.toString(); + return location; + } +} diff --git a/lib/router/cubits/router_cubit.freezed.dart b/lib/router/cubits/router_cubit.freezed.dart new file mode 100644 index 0000000..0f5b285 --- /dev/null +++ b/lib/router/cubits/router_cubit.freezed.dart @@ -0,0 +1,173 @@ +// dart format width=80 +// 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 'router_cubit.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$RouterState implements DiagnosticableTreeMixin { + bool get hasAnyAccount; + + /// Create a copy of RouterState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $RouterStateCopyWith get copyWith => + _$RouterStateCopyWithImpl(this as RouterState, _$identity); + + /// Serializes this RouterState to a JSON map. + Map toJson(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('type', 'RouterState')) + ..add(DiagnosticsProperty('hasAnyAccount', hasAnyAccount)); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is RouterState && + (identical(other.hasAnyAccount, hasAnyAccount) || + other.hasAnyAccount == hasAnyAccount)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, hasAnyAccount); + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + return 'RouterState(hasAnyAccount: $hasAnyAccount)'; + } +} + +/// @nodoc +abstract mixin class $RouterStateCopyWith<$Res> { + factory $RouterStateCopyWith( + RouterState value, $Res Function(RouterState) _then) = + _$RouterStateCopyWithImpl; + @useResult + $Res call({bool hasAnyAccount}); +} + +/// @nodoc +class _$RouterStateCopyWithImpl<$Res> implements $RouterStateCopyWith<$Res> { + _$RouterStateCopyWithImpl(this._self, this._then); + + final RouterState _self; + final $Res Function(RouterState) _then; + + /// Create a copy of RouterState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? hasAnyAccount = null, + }) { + return _then(_self.copyWith( + hasAnyAccount: null == hasAnyAccount + ? _self.hasAnyAccount + : hasAnyAccount // ignore: cast_nullable_to_non_nullable + as bool, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _RouterState with DiagnosticableTreeMixin implements RouterState { + const _RouterState({required this.hasAnyAccount}); + factory _RouterState.fromJson(Map json) => + _$RouterStateFromJson(json); + + @override + final bool hasAnyAccount; + + /// Create a copy of RouterState + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$RouterStateCopyWith<_RouterState> get copyWith => + __$RouterStateCopyWithImpl<_RouterState>(this, _$identity); + + @override + Map toJson() { + return _$RouterStateToJson( + this, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('type', 'RouterState')) + ..add(DiagnosticsProperty('hasAnyAccount', hasAnyAccount)); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _RouterState && + (identical(other.hasAnyAccount, hasAnyAccount) || + other.hasAnyAccount == hasAnyAccount)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, hasAnyAccount); + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + return 'RouterState(hasAnyAccount: $hasAnyAccount)'; + } +} + +/// @nodoc +abstract mixin class _$RouterStateCopyWith<$Res> + implements $RouterStateCopyWith<$Res> { + factory _$RouterStateCopyWith( + _RouterState value, $Res Function(_RouterState) _then) = + __$RouterStateCopyWithImpl; + @override + @useResult + $Res call({bool hasAnyAccount}); +} + +/// @nodoc +class __$RouterStateCopyWithImpl<$Res> implements _$RouterStateCopyWith<$Res> { + __$RouterStateCopyWithImpl(this._self, this._then); + + final _RouterState _self; + final $Res Function(_RouterState) _then; + + /// Create a copy of RouterState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? hasAnyAccount = null, + }) { + return _then(_RouterState( + hasAnyAccount: null == hasAnyAccount + ? _self.hasAnyAccount + : hasAnyAccount // ignore: cast_nullable_to_non_nullable + as bool, + )); + } +} + +// dart format on diff --git a/lib/router/cubits/router_cubit.g.dart b/lib/router/cubits/router_cubit.g.dart new file mode 100644 index 0000000..3623d0e --- /dev/null +++ b/lib/router/cubits/router_cubit.g.dart @@ -0,0 +1,16 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'router_cubit.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_RouterState _$RouterStateFromJson(Map json) => _RouterState( + hasAnyAccount: json['has_any_account'] as bool, + ); + +Map _$RouterStateToJson(_RouterState instance) => + { + 'has_any_account': instance.hasAnyAccount, + }; diff --git a/lib/router/router.dart b/lib/router/router.dart index d0f2cf5..4fd9dc5 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -1,23 +1,2 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -import 'router_notifier.dart'; - -part 'router.g.dart'; - -final _key = GlobalKey(debugLabel: 'routerKey'); - -/// This simple provider caches our GoRouter. -@riverpod -GoRouter router(RouterRef ref) { - final notifier = ref.watch(routerNotifierProvider.notifier); - return GoRouter( - navigatorKey: _key, - refreshListenable: notifier, - debugLogDiagnostics: true, - initialLocation: '/', - routes: notifier.routes, - redirect: notifier.redirect, - ); -} +export 'cubits/router_cubit.dart'; +export 'views/router_shell.dart'; diff --git a/lib/router/router.g.dart b/lib/router/router.g.dart deleted file mode 100644 index b015d4b..0000000 --- a/lib/router/router.g.dart +++ /dev/null @@ -1,26 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'router.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$routerHash() => r'86eecb1955be62ef8e6f6efcec0fa615289cb823'; - -/// This simple provider caches our GoRouter. -/// -/// Copied from [router]. -@ProviderFor(router) -final routerProvider = AutoDisposeProvider.internal( - router, - name: r'routerProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') ? null : _$routerHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef RouterRef = AutoDisposeProviderRef; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/router/router_notifier.dart b/lib/router/router_notifier.dart deleted file mode 100644 index a4f7bb6..0000000 --- a/lib/router/router_notifier.dart +++ /dev/null @@ -1,160 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -import '../pages/chat_only.dart'; -import '../pages/developer.dart'; -import '../pages/home.dart'; -import '../pages/index.dart'; -import '../pages/new_account.dart'; -import '../pages/settings.dart'; -import '../providers/chat.dart'; -import '../providers/local_accounts.dart'; -import '../tools/responsive.dart'; -import '../veilid_init.dart'; - -part 'router_notifier.g.dart'; - -@riverpod -class RouterNotifier extends _$RouterNotifier implements Listenable { - /// GoRouter listener - VoidCallback? routerListener; - - /// Do we need to make or import an account immediately? - bool hasAnyAccount = false; - bool hasActiveChat = false; - - /// AsyncNotifier build - @override - Future build() async { - hasAnyAccount = await ref.watch( - localAccountsProvider.selectAsync((data) => data.isNotEmpty), - ); - hasActiveChat = ref.watch(activeChatStateProvider) != null; - - // When this notifier's state changes, inform GoRouter - ref.listenSelf((_, __) { - if (state.isLoading) { - return; - } - routerListener?.call(); - }); - } - - /// Redirects when our state changes - String? redirect(BuildContext context, GoRouterState state) { - if (this.state.isLoading || this.state.hasError) { - return null; - } - - // No matter where we are, if there's not - switch (state.matchedLocation) { - case '/': - - // Wait for veilid to be initialized - if (!eventualVeilid.isCompleted) { - return null; - } - - return hasAnyAccount ? '/home' : '/new_account'; - case '/new_account': - return hasAnyAccount ? '/home' : null; - case '/home': - if (!hasAnyAccount) { - return '/new_account'; - } - if (responsiveVisibility( - context: context, - tablet: false, - tabletLandscape: false, - desktop: false)) { - if (hasActiveChat) { - return '/home/chat'; - } - } - return null; - case '/home/chat': - if (!hasAnyAccount) { - return '/new_account'; - } - if (responsiveVisibility( - context: context, - tablet: false, - tabletLandscape: false, - desktop: false)) { - if (!hasActiveChat) { - return '/home'; - } - } else { - return '/home'; - } - return null; - case '/home/settings': - case '/new_account/settings': - return null; - case '/developer': - return null; - default: - return hasAnyAccount ? null : '/new_account'; - } - } - - /// Our application routes - List get routes => [ - GoRoute( - path: '/', - builder: (context, state) => const IndexPage(), - ), - GoRoute( - path: '/home', - builder: (context, state) => const HomePage(), - routes: [ - GoRoute( - path: 'settings', - builder: (context, state) => const SettingsPage(), - ), - GoRoute( - path: 'chat', - builder: (context, state) => const ChatOnlyPage(), - ), - ], - ), - GoRoute( - path: '/new_account', - builder: (context, state) => const NewAccountPage(), - routes: [ - GoRoute( - path: 'settings', - builder: (context, state) => const SettingsPage(), - ), - ], - ), - GoRoute( - path: '/developer', - builder: (context, state) => const DeveloperPage(), - ) - ]; - - /////////////////////////////////////////////////////////////////////////// - /// Listenable - - /// Adds [GoRouter]'s listener as specified by its [Listenable]. - /// [GoRouteInformationProvider] uses this method on creation to handle its - /// internal [ChangeNotifier]. - /// Check out the internal implementation of [GoRouter] and - /// [GoRouteInformationProvider] to see this in action. - @override - void addListener(VoidCallback listener) { - routerListener = listener; - } - - /// Removes [GoRouter]'s listener as specified by its [Listenable]. - /// [GoRouteInformationProvider] uses this method when disposing, - /// so that it removes its callback when destroyed. - /// Check out the internal implementation of [GoRouter] and - /// [GoRouteInformationProvider] to see this in action. - @override - void removeListener(VoidCallback listener) { - routerListener = null; - } -} diff --git a/lib/router/router_notifier.g.dart b/lib/router/router_notifier.g.dart deleted file mode 100644 index fe12322..0000000 --- a/lib/router/router_notifier.g.dart +++ /dev/null @@ -1,26 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'router_notifier.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$routerNotifierHash() => r'6f52ed95f090f2d198d358e7526a91511c0a61e5'; - -/// See also [RouterNotifier]. -@ProviderFor(RouterNotifier) -final routerNotifierProvider = - AutoDisposeAsyncNotifierProvider.internal( - RouterNotifier.new, - name: r'routerNotifierProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$routerNotifierHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$RouterNotifier = AutoDisposeAsyncNotifier; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/router/views/router_shell.dart b/lib/router/views/router_shell.dart new file mode 100644 index 0000000..d22129f --- /dev/null +++ b/lib/router/views/router_shell.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; + +import '../../keyboard_shortcuts.dart'; +import '../../notifications/notifications.dart'; +import '../../settings/settings.dart'; +import '../../theme/theme.dart'; + +class RouterShell extends StatelessWidget { + const RouterShell({required Widget child, super.key}) : _child = child; + + @override + Widget build(BuildContext context) => PopControl( + dismissible: false, + child: AsyncBlocBuilder( + builder: (context, state) => MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: + TextScaler.linear(state.themePreference.displayScale)), + child: NotificationsWidget( + child: KeyboardShortcuts(child: _child))))); + + final Widget _child; +} diff --git a/lib/settings/models/models.dart b/lib/settings/models/models.dart new file mode 100644 index 0000000..b7a1cbf --- /dev/null +++ b/lib/settings/models/models.dart @@ -0,0 +1 @@ +export 'preferences.dart'; diff --git a/lib/settings/models/preferences.dart b/lib/settings/models/preferences.dart new file mode 100644 index 0000000..a7432d6 --- /dev/null +++ b/lib/settings/models/preferences.dart @@ -0,0 +1,53 @@ +import 'package:change_case/change_case.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../notifications/notifications.dart'; +import '../../theme/theme.dart'; + +part 'preferences.freezed.dart'; +part 'preferences.g.dart'; + +// Lock preference changes how frequently the messenger locks its +// interface and requires the identitySecretKey to be entered (pin/password/etc) +@freezed +sealed class LockPreference with _$LockPreference { + const factory LockPreference({ + @Default(0) int inactivityLockSecs, + @Default(false) bool lockWhenSwitching, + @Default(false) bool lockWithSystemLock, + }) = _LockPreference; + + factory LockPreference.fromJson(dynamic json) => + _$LockPreferenceFromJson(json as Map); + + static const defaults = LockPreference(); +} + +// Theme supports multiple translations +enum LanguagePreference { + englishUs; + + factory LanguagePreference.fromJson(dynamic j) => + LanguagePreference.values.byName((j as String).toCamelCase()); + String toJson() => name.toPascalCase(); + + static const LanguagePreference defaults = LanguagePreference.englishUs; +} + +// Preferences are stored in a table locally and globally affect all +// accounts imported/added and the app in general +@freezed +sealed class Preferences with _$Preferences { + const factory Preferences({ + @Default(ThemePreferences.defaults) ThemePreferences themePreference, + @Default(LanguagePreference.defaults) LanguagePreference languagePreference, + @Default(LockPreference.defaults) LockPreference lockPreference, + @Default(NotificationsPreference.defaults) + NotificationsPreference notificationsPreference, + }) = _Preferences; + + factory Preferences.fromJson(dynamic json) => + _$PreferencesFromJson(json as Map); + + static const defaults = Preferences(); +} diff --git a/lib/settings/models/preferences.freezed.dart b/lib/settings/models/preferences.freezed.dart new file mode 100644 index 0000000..9e090f5 --- /dev/null +++ b/lib/settings/models/preferences.freezed.dart @@ -0,0 +1,497 @@ +// dart format width=80 +// 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 'preferences.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$LockPreference { + int get inactivityLockSecs; + bool get lockWhenSwitching; + bool get lockWithSystemLock; + + /// Create a copy of LockPreference + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $LockPreferenceCopyWith get copyWith => + _$LockPreferenceCopyWithImpl( + this as LockPreference, _$identity); + + /// Serializes this LockPreference to a JSON map. + Map toJson(); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is LockPreference && + (identical(other.inactivityLockSecs, inactivityLockSecs) || + other.inactivityLockSecs == inactivityLockSecs) && + (identical(other.lockWhenSwitching, lockWhenSwitching) || + other.lockWhenSwitching == lockWhenSwitching) && + (identical(other.lockWithSystemLock, lockWithSystemLock) || + other.lockWithSystemLock == lockWithSystemLock)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, inactivityLockSecs, lockWhenSwitching, lockWithSystemLock); + + @override + String toString() { + return 'LockPreference(inactivityLockSecs: $inactivityLockSecs, lockWhenSwitching: $lockWhenSwitching, lockWithSystemLock: $lockWithSystemLock)'; + } +} + +/// @nodoc +abstract mixin class $LockPreferenceCopyWith<$Res> { + factory $LockPreferenceCopyWith( + LockPreference value, $Res Function(LockPreference) _then) = + _$LockPreferenceCopyWithImpl; + @useResult + $Res call( + {int inactivityLockSecs, + bool lockWhenSwitching, + bool lockWithSystemLock}); +} + +/// @nodoc +class _$LockPreferenceCopyWithImpl<$Res> + implements $LockPreferenceCopyWith<$Res> { + _$LockPreferenceCopyWithImpl(this._self, this._then); + + final LockPreference _self; + final $Res Function(LockPreference) _then; + + /// Create a copy of LockPreference + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? inactivityLockSecs = null, + Object? lockWhenSwitching = null, + Object? lockWithSystemLock = null, + }) { + return _then(_self.copyWith( + inactivityLockSecs: null == inactivityLockSecs + ? _self.inactivityLockSecs + : inactivityLockSecs // ignore: cast_nullable_to_non_nullable + as int, + lockWhenSwitching: null == lockWhenSwitching + ? _self.lockWhenSwitching + : lockWhenSwitching // ignore: cast_nullable_to_non_nullable + as bool, + lockWithSystemLock: null == lockWithSystemLock + ? _self.lockWithSystemLock + : lockWithSystemLock // ignore: cast_nullable_to_non_nullable + as bool, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _LockPreference implements LockPreference { + const _LockPreference( + {this.inactivityLockSecs = 0, + this.lockWhenSwitching = false, + this.lockWithSystemLock = false}); + factory _LockPreference.fromJson(Map json) => + _$LockPreferenceFromJson(json); + + @override + @JsonKey() + final int inactivityLockSecs; + @override + @JsonKey() + final bool lockWhenSwitching; + @override + @JsonKey() + final bool lockWithSystemLock; + + /// Create a copy of LockPreference + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$LockPreferenceCopyWith<_LockPreference> get copyWith => + __$LockPreferenceCopyWithImpl<_LockPreference>(this, _$identity); + + @override + Map toJson() { + return _$LockPreferenceToJson( + this, + ); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _LockPreference && + (identical(other.inactivityLockSecs, inactivityLockSecs) || + other.inactivityLockSecs == inactivityLockSecs) && + (identical(other.lockWhenSwitching, lockWhenSwitching) || + other.lockWhenSwitching == lockWhenSwitching) && + (identical(other.lockWithSystemLock, lockWithSystemLock) || + other.lockWithSystemLock == lockWithSystemLock)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, inactivityLockSecs, lockWhenSwitching, lockWithSystemLock); + + @override + String toString() { + return 'LockPreference(inactivityLockSecs: $inactivityLockSecs, lockWhenSwitching: $lockWhenSwitching, lockWithSystemLock: $lockWithSystemLock)'; + } +} + +/// @nodoc +abstract mixin class _$LockPreferenceCopyWith<$Res> + implements $LockPreferenceCopyWith<$Res> { + factory _$LockPreferenceCopyWith( + _LockPreference value, $Res Function(_LockPreference) _then) = + __$LockPreferenceCopyWithImpl; + @override + @useResult + $Res call( + {int inactivityLockSecs, + bool lockWhenSwitching, + bool lockWithSystemLock}); +} + +/// @nodoc +class __$LockPreferenceCopyWithImpl<$Res> + implements _$LockPreferenceCopyWith<$Res> { + __$LockPreferenceCopyWithImpl(this._self, this._then); + + final _LockPreference _self; + final $Res Function(_LockPreference) _then; + + /// Create a copy of LockPreference + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? inactivityLockSecs = null, + Object? lockWhenSwitching = null, + Object? lockWithSystemLock = null, + }) { + return _then(_LockPreference( + inactivityLockSecs: null == inactivityLockSecs + ? _self.inactivityLockSecs + : inactivityLockSecs // ignore: cast_nullable_to_non_nullable + as int, + lockWhenSwitching: null == lockWhenSwitching + ? _self.lockWhenSwitching + : lockWhenSwitching // ignore: cast_nullable_to_non_nullable + as bool, + lockWithSystemLock: null == lockWithSystemLock + ? _self.lockWithSystemLock + : lockWithSystemLock // ignore: cast_nullable_to_non_nullable + as bool, + )); + } +} + +/// @nodoc +mixin _$Preferences { + ThemePreferences get themePreference; + LanguagePreference get languagePreference; + LockPreference get lockPreference; + NotificationsPreference get notificationsPreference; + + /// Create a copy of Preferences + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $PreferencesCopyWith get copyWith => + _$PreferencesCopyWithImpl(this as Preferences, _$identity); + + /// Serializes this Preferences to a JSON map. + Map toJson(); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is Preferences && + (identical(other.themePreference, themePreference) || + other.themePreference == themePreference) && + (identical(other.languagePreference, languagePreference) || + other.languagePreference == languagePreference) && + (identical(other.lockPreference, lockPreference) || + other.lockPreference == lockPreference) && + (identical( + other.notificationsPreference, notificationsPreference) || + other.notificationsPreference == notificationsPreference)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, themePreference, + languagePreference, lockPreference, notificationsPreference); + + @override + String toString() { + return 'Preferences(themePreference: $themePreference, languagePreference: $languagePreference, lockPreference: $lockPreference, notificationsPreference: $notificationsPreference)'; + } +} + +/// @nodoc +abstract mixin class $PreferencesCopyWith<$Res> { + factory $PreferencesCopyWith( + Preferences value, $Res Function(Preferences) _then) = + _$PreferencesCopyWithImpl; + @useResult + $Res call( + {ThemePreferences themePreference, + LanguagePreference languagePreference, + LockPreference lockPreference, + NotificationsPreference notificationsPreference}); + + $ThemePreferencesCopyWith<$Res> get themePreference; + $LockPreferenceCopyWith<$Res> get lockPreference; + $NotificationsPreferenceCopyWith<$Res> get notificationsPreference; +} + +/// @nodoc +class _$PreferencesCopyWithImpl<$Res> implements $PreferencesCopyWith<$Res> { + _$PreferencesCopyWithImpl(this._self, this._then); + + final Preferences _self; + final $Res Function(Preferences) _then; + + /// Create a copy of Preferences + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? themePreference = null, + Object? languagePreference = null, + Object? lockPreference = null, + Object? notificationsPreference = null, + }) { + return _then(_self.copyWith( + themePreference: null == themePreference + ? _self.themePreference + : themePreference // ignore: cast_nullable_to_non_nullable + as ThemePreferences, + languagePreference: null == languagePreference + ? _self.languagePreference + : languagePreference // ignore: cast_nullable_to_non_nullable + as LanguagePreference, + lockPreference: null == lockPreference + ? _self.lockPreference + : lockPreference // ignore: cast_nullable_to_non_nullable + as LockPreference, + notificationsPreference: null == notificationsPreference + ? _self.notificationsPreference + : notificationsPreference // ignore: cast_nullable_to_non_nullable + as NotificationsPreference, + )); + } + + /// Create a copy of Preferences + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $ThemePreferencesCopyWith<$Res> get themePreference { + return $ThemePreferencesCopyWith<$Res>(_self.themePreference, (value) { + return _then(_self.copyWith(themePreference: value)); + }); + } + + /// Create a copy of Preferences + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $LockPreferenceCopyWith<$Res> get lockPreference { + return $LockPreferenceCopyWith<$Res>(_self.lockPreference, (value) { + return _then(_self.copyWith(lockPreference: value)); + }); + } + + /// Create a copy of Preferences + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $NotificationsPreferenceCopyWith<$Res> get notificationsPreference { + return $NotificationsPreferenceCopyWith<$Res>(_self.notificationsPreference, + (value) { + return _then(_self.copyWith(notificationsPreference: value)); + }); + } +} + +/// @nodoc +@JsonSerializable() +class _Preferences implements Preferences { + const _Preferences( + {this.themePreference = ThemePreferences.defaults, + this.languagePreference = LanguagePreference.defaults, + this.lockPreference = LockPreference.defaults, + this.notificationsPreference = NotificationsPreference.defaults}); + factory _Preferences.fromJson(Map json) => + _$PreferencesFromJson(json); + + @override + @JsonKey() + final ThemePreferences themePreference; + @override + @JsonKey() + final LanguagePreference languagePreference; + @override + @JsonKey() + final LockPreference lockPreference; + @override + @JsonKey() + final NotificationsPreference notificationsPreference; + + /// Create a copy of Preferences + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$PreferencesCopyWith<_Preferences> get copyWith => + __$PreferencesCopyWithImpl<_Preferences>(this, _$identity); + + @override + Map toJson() { + return _$PreferencesToJson( + this, + ); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _Preferences && + (identical(other.themePreference, themePreference) || + other.themePreference == themePreference) && + (identical(other.languagePreference, languagePreference) || + other.languagePreference == languagePreference) && + (identical(other.lockPreference, lockPreference) || + other.lockPreference == lockPreference) && + (identical( + other.notificationsPreference, notificationsPreference) || + other.notificationsPreference == notificationsPreference)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, themePreference, + languagePreference, lockPreference, notificationsPreference); + + @override + String toString() { + return 'Preferences(themePreference: $themePreference, languagePreference: $languagePreference, lockPreference: $lockPreference, notificationsPreference: $notificationsPreference)'; + } +} + +/// @nodoc +abstract mixin class _$PreferencesCopyWith<$Res> + implements $PreferencesCopyWith<$Res> { + factory _$PreferencesCopyWith( + _Preferences value, $Res Function(_Preferences) _then) = + __$PreferencesCopyWithImpl; + @override + @useResult + $Res call( + {ThemePreferences themePreference, + LanguagePreference languagePreference, + LockPreference lockPreference, + NotificationsPreference notificationsPreference}); + + @override + $ThemePreferencesCopyWith<$Res> get themePreference; + @override + $LockPreferenceCopyWith<$Res> get lockPreference; + @override + $NotificationsPreferenceCopyWith<$Res> get notificationsPreference; +} + +/// @nodoc +class __$PreferencesCopyWithImpl<$Res> implements _$PreferencesCopyWith<$Res> { + __$PreferencesCopyWithImpl(this._self, this._then); + + final _Preferences _self; + final $Res Function(_Preferences) _then; + + /// Create a copy of Preferences + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? themePreference = null, + Object? languagePreference = null, + Object? lockPreference = null, + Object? notificationsPreference = null, + }) { + return _then(_Preferences( + themePreference: null == themePreference + ? _self.themePreference + : themePreference // ignore: cast_nullable_to_non_nullable + as ThemePreferences, + languagePreference: null == languagePreference + ? _self.languagePreference + : languagePreference // ignore: cast_nullable_to_non_nullable + as LanguagePreference, + lockPreference: null == lockPreference + ? _self.lockPreference + : lockPreference // ignore: cast_nullable_to_non_nullable + as LockPreference, + notificationsPreference: null == notificationsPreference + ? _self.notificationsPreference + : notificationsPreference // ignore: cast_nullable_to_non_nullable + as NotificationsPreference, + )); + } + + /// Create a copy of Preferences + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $ThemePreferencesCopyWith<$Res> get themePreference { + return $ThemePreferencesCopyWith<$Res>(_self.themePreference, (value) { + return _then(_self.copyWith(themePreference: value)); + }); + } + + /// Create a copy of Preferences + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $LockPreferenceCopyWith<$Res> get lockPreference { + return $LockPreferenceCopyWith<$Res>(_self.lockPreference, (value) { + return _then(_self.copyWith(lockPreference: value)); + }); + } + + /// Create a copy of Preferences + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $NotificationsPreferenceCopyWith<$Res> get notificationsPreference { + return $NotificationsPreferenceCopyWith<$Res>(_self.notificationsPreference, + (value) { + return _then(_self.copyWith(notificationsPreference: value)); + }); + } +} + +// dart format on diff --git a/lib/settings/models/preferences.g.dart b/lib/settings/models/preferences.g.dart new file mode 100644 index 0000000..55f21a7 --- /dev/null +++ b/lib/settings/models/preferences.g.dart @@ -0,0 +1,44 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'preferences.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_LockPreference _$LockPreferenceFromJson(Map json) => + _LockPreference( + inactivityLockSecs: (json['inactivity_lock_secs'] as num?)?.toInt() ?? 0, + lockWhenSwitching: json['lock_when_switching'] as bool? ?? false, + lockWithSystemLock: json['lock_with_system_lock'] as bool? ?? false, + ); + +Map _$LockPreferenceToJson(_LockPreference instance) => + { + 'inactivity_lock_secs': instance.inactivityLockSecs, + 'lock_when_switching': instance.lockWhenSwitching, + 'lock_with_system_lock': instance.lockWithSystemLock, + }; + +_Preferences _$PreferencesFromJson(Map json) => _Preferences( + themePreference: json['theme_preference'] == null + ? ThemePreferences.defaults + : ThemePreferences.fromJson(json['theme_preference']), + languagePreference: json['language_preference'] == null + ? LanguagePreference.defaults + : LanguagePreference.fromJson(json['language_preference']), + lockPreference: json['lock_preference'] == null + ? LockPreference.defaults + : LockPreference.fromJson(json['lock_preference']), + notificationsPreference: json['notifications_preference'] == null + ? NotificationsPreference.defaults + : NotificationsPreference.fromJson(json['notifications_preference']), + ); + +Map _$PreferencesToJson(_Preferences instance) => + { + 'theme_preference': instance.themePreference.toJson(), + 'language_preference': instance.languagePreference.toJson(), + 'lock_preference': instance.lockPreference.toJson(), + 'notifications_preference': instance.notificationsPreference.toJson(), + }; diff --git a/lib/settings/preferences_cubit.dart b/lib/settings/preferences_cubit.dart new file mode 100644 index 0000000..f96d755 --- /dev/null +++ b/lib/settings/preferences_cubit.dart @@ -0,0 +1,8 @@ +import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; + +import 'settings.dart'; + +class PreferencesCubit extends StreamWrapperCubit { + PreferencesCubit(PreferencesRepository repository) + : super(repository.stream, defaultState: repository.value); +} diff --git a/lib/settings/preferences_repository.dart b/lib/settings/preferences_repository.dart new file mode 100644 index 0000000..03f73ba --- /dev/null +++ b/lib/settings/preferences_repository.dart @@ -0,0 +1,36 @@ +import 'dart:async'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import '../tools/tools.dart'; +import 'models/models.dart'; + +class PreferencesRepository { + PreferencesRepository._(); + + late final SharedPreferencesValue _data; + + Preferences get value => _data.requireValue; + Stream get stream => _data.stream; + + ////////////////////////////////////////////////////////////// + /// Singleton initialization + + static PreferencesRepository instance = PreferencesRepository._(); + + Future init() async { + final sharedPreferences = await SharedPreferences.getInstance(); + // ignore: do_not_use_environment + const namespace = String.fromEnvironment('NAMESPACE'); + _data = SharedPreferencesValue( + sharedPreferences: sharedPreferences, + keyName: namespace.isEmpty ? 'preferences' : 'preferences_$namespace', + valueFromJson: (obj) => + obj != null ? Preferences.fromJson(obj) : Preferences.defaults, + valueToJson: (val) => val.toJson()); + await _data.get(); + } + + Future set(Preferences value) => _data.set(value); + Future get() => _data.get(); +} diff --git a/lib/settings/settings.dart b/lib/settings/settings.dart new file mode 100644 index 0000000..b56c1a4 --- /dev/null +++ b/lib/settings/settings.dart @@ -0,0 +1,4 @@ +export 'models/models.dart'; +export 'preferences_cubit.dart'; +export 'preferences_repository.dart'; +export 'settings_page.dart'; diff --git a/lib/settings/settings_page.dart b/lib/settings/settings_page.dart new file mode 100644 index 0000000..bb6ee3e --- /dev/null +++ b/lib/settings/settings_page.dart @@ -0,0 +1,61 @@ +import 'package:animated_theme_switcher/animated_theme_switcher.dart'; +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:go_router/go_router.dart'; + +import '../layout/default_app_bar.dart'; +import '../notifications/notifications.dart'; +import '../theme/theme.dart'; +import '../veilid_processor/veilid_processor.dart'; +import 'settings.dart'; + +class SettingsPage extends StatelessWidget { + const SettingsPage({super.key}); + + @override + Widget build(BuildContext context) => + AsyncBlocBuilder( + builder: (context, state) => ThemeSwitcher.withTheme( + builder: (_, switcher, theme) => StyledScaffold( + appBar: DefaultAppBar( + context: context, + title: Text(translate('settings_page.titlebar')), + leading: IconButton( + iconSize: 24.scaled(context), + icon: const Icon(Icons.arrow_back), + onPressed: () => GoRouterHelper(context).pop(), + ), + actions: [ + const SignalStrengthMeterWidget() + .paddingLTRB(16, 0, 16, 0), + ]), + body: ListView( + padding: const EdgeInsets.all(8).scaled(context), + children: [ + buildSettingsPageColorPreferences( + context: context, + switcher: switcher, + ), + buildSettingsPageBrightnessPreferences( + context: context, + switcher: switcher, + ), + buildSettingsPageDisplayScalePreferences( + context: context, + switcher: switcher, + ), + buildSettingsPageWallpaperPreferences( + context: context, + switcher: switcher, + ), + buildSettingsPageNotificationPreferences( + context: context, + ), + ] + .map((x) => x.paddingLTRB(0, 0, 0, 8.scaled(context))) + .toList(), + ).paddingSymmetric(vertical: 4.scaled(context)), + ), + )); +} diff --git a/lib/theme/models/contrast_generator.dart b/lib/theme/models/contrast_generator.dart new file mode 100644 index 0000000..05c5f55 --- /dev/null +++ b/lib/theme/models/contrast_generator.dart @@ -0,0 +1,334 @@ +import 'package:flutter/material.dart'; + +import 'radix_generator.dart'; +import 'scale_theme/scale_theme.dart'; + +ScaleColor _contrastScaleColor( + {required Brightness brightness, + required Color frontColor, + required Color backColor}) { + final back = brightness == Brightness.light ? backColor : frontColor; + final front = brightness == Brightness.light ? frontColor : backColor; + + return ScaleColor( + appBackground: back, + subtleBackground: back, + elementBackground: back, + hoverElementBackground: back, + activeElementBackground: back, + subtleBorder: front, + border: front, + hoverBorder: front, + primary: back, + hoverPrimary: back, + subtleText: front, + appText: front, + primaryText: front, + borderText: back, + dialogBorder: front, + dialogBorderText: back, + calloutBackground: front, + calloutText: back, + ); +} + +const kMonoSpaceFontDisplay = 'Source Code Pro'; +const kMonoSpaceFontText = 'Source Code Pro'; + +TextTheme makeMonoSpaceTextTheme(Brightness brightness) => + (brightness == Brightness.light) + ? const TextTheme( + displayLarge: TextStyle( + debugLabel: 'blackMonoSpace displayLarge', + fontFamily: kMonoSpaceFontDisplay, + color: Colors.black54, + decoration: TextDecoration.none), + displayMedium: TextStyle( + debugLabel: 'blackMonoSpace displayMedium', + fontFamily: kMonoSpaceFontDisplay, + color: Colors.black54, + decoration: TextDecoration.none), + displaySmall: TextStyle( + debugLabel: 'blackMonoSpace displaySmall', + fontFamily: kMonoSpaceFontDisplay, + color: Colors.black54, + decoration: TextDecoration.none), + headlineLarge: TextStyle( + debugLabel: 'blackMonoSpace headlineLarge', + fontFamily: kMonoSpaceFontDisplay, + color: Colors.black54, + decoration: TextDecoration.none), + headlineMedium: TextStyle( + debugLabel: 'blackMonoSpace headlineMedium', + fontFamily: kMonoSpaceFontDisplay, + color: Colors.black54, + decoration: TextDecoration.none), + headlineSmall: TextStyle( + debugLabel: 'blackMonoSpace headlineSmall', + fontFamily: kMonoSpaceFontDisplay, + color: Colors.black87, + decoration: TextDecoration.none), + titleLarge: TextStyle( + debugLabel: 'blackMonoSpace titleLarge', + fontFamily: kMonoSpaceFontDisplay, + color: Colors.black87, + decoration: TextDecoration.none), + titleMedium: TextStyle( + debugLabel: 'blackMonoSpace titleMedium', + fontFamily: kMonoSpaceFontText, + color: Colors.black87, + decoration: TextDecoration.none), + titleSmall: TextStyle( + debugLabel: 'blackMonoSpace titleSmall', + fontFamily: kMonoSpaceFontText, + color: Colors.black, + decoration: TextDecoration.none), + bodyLarge: TextStyle( + debugLabel: 'blackMonoSpace bodyLarge', + fontFamily: kMonoSpaceFontText, + color: Colors.black87, + decoration: TextDecoration.none), + bodyMedium: TextStyle( + debugLabel: 'blackMonoSpace bodyMedium', + fontFamily: kMonoSpaceFontText, + color: Colors.black87, + decoration: TextDecoration.none), + bodySmall: TextStyle( + debugLabel: 'blackMonoSpace bodySmall', + fontFamily: kMonoSpaceFontText, + color: Colors.black54, + decoration: TextDecoration.none), + labelLarge: TextStyle( + debugLabel: 'blackMonoSpace labelLarge', + fontFamily: kMonoSpaceFontText, + color: Colors.black87, + decoration: TextDecoration.none), + labelMedium: TextStyle( + debugLabel: 'blackMonoSpace labelMedium', + fontFamily: kMonoSpaceFontText, + color: Colors.black, + decoration: TextDecoration.none), + labelSmall: TextStyle( + debugLabel: 'blackMonoSpace labelSmall', + fontFamily: kMonoSpaceFontText, + color: Colors.black, + decoration: TextDecoration.none), + ) + : const TextTheme( + displayLarge: TextStyle( + debugLabel: 'whiteMonoSpace displayLarge', + fontFamily: kMonoSpaceFontDisplay, + color: Colors.white70, + decoration: TextDecoration.none), + displayMedium: TextStyle( + debugLabel: 'whiteMonoSpace displayMedium', + fontFamily: kMonoSpaceFontDisplay, + color: Colors.white70, + decoration: TextDecoration.none), + displaySmall: TextStyle( + debugLabel: 'whiteMonoSpace displaySmall', + fontFamily: kMonoSpaceFontDisplay, + color: Colors.white70, + decoration: TextDecoration.none), + headlineLarge: TextStyle( + debugLabel: 'whiteMonoSpace headlineLarge', + fontFamily: kMonoSpaceFontDisplay, + color: Colors.white70, + decoration: TextDecoration.none), + headlineMedium: TextStyle( + debugLabel: 'whiteMonoSpace headlineMedium', + fontFamily: kMonoSpaceFontDisplay, + color: Colors.white70, + decoration: TextDecoration.none), + headlineSmall: TextStyle( + debugLabel: 'whiteMonoSpace headlineSmall', + fontFamily: kMonoSpaceFontDisplay, + color: Colors.white, + decoration: TextDecoration.none), + titleLarge: TextStyle( + debugLabel: 'whiteMonoSpace titleLarge', + fontFamily: kMonoSpaceFontDisplay, + color: Colors.white, + decoration: TextDecoration.none), + titleMedium: TextStyle( + debugLabel: 'whiteMonoSpace titleMedium', + fontFamily: kMonoSpaceFontText, + color: Colors.white, + decoration: TextDecoration.none), + titleSmall: TextStyle( + debugLabel: 'whiteMonoSpace titleSmall', + fontFamily: kMonoSpaceFontText, + color: Colors.white, + decoration: TextDecoration.none), + bodyLarge: TextStyle( + debugLabel: 'whiteMonoSpace bodyLarge', + fontFamily: kMonoSpaceFontText, + color: Colors.white, + decoration: TextDecoration.none), + bodyMedium: TextStyle( + debugLabel: 'whiteMonoSpace bodyMedium', + fontFamily: kMonoSpaceFontText, + color: Colors.white, + decoration: TextDecoration.none), + bodySmall: TextStyle( + debugLabel: 'whiteMonoSpace bodySmall', + fontFamily: kMonoSpaceFontText, + color: Colors.white70, + decoration: TextDecoration.none), + labelLarge: TextStyle( + debugLabel: 'whiteMonoSpace labelLarge', + fontFamily: kMonoSpaceFontText, + color: Colors.white, + decoration: TextDecoration.none), + labelMedium: TextStyle( + debugLabel: 'whiteMonoSpace labelMedium', + fontFamily: kMonoSpaceFontText, + color: Colors.white, + decoration: TextDecoration.none), + labelSmall: TextStyle( + debugLabel: 'whiteMonoSpace labelSmall', + fontFamily: kMonoSpaceFontText, + color: Colors.white, + decoration: TextDecoration.none), + ); + +ScaleScheme _contrastScaleScheme( + {required Brightness brightness, + required Color primaryFront, + required Color primaryBack, + required Color secondaryFront, + required Color secondaryBack, + required Color tertiaryFront, + required Color tertiaryBack, + required Color grayFront, + required Color grayBack, + required Color errorFront, + required Color errorBack}) => + ScaleScheme( + primaryScale: _contrastScaleColor( + brightness: brightness, + frontColor: primaryFront, + backColor: primaryBack), + primaryAlphaScale: _contrastScaleColor( + brightness: brightness, + frontColor: primaryFront, + backColor: primaryBack), + secondaryScale: _contrastScaleColor( + brightness: brightness, + frontColor: secondaryFront, + backColor: secondaryBack), + tertiaryScale: _contrastScaleColor( + brightness: brightness, + frontColor: tertiaryFront, + backColor: tertiaryBack), + grayScale: _contrastScaleColor( + brightness: brightness, frontColor: grayFront, backColor: grayBack), + errorScale: _contrastScaleColor( + brightness: brightness, + frontColor: errorFront, + backColor: errorBack)); + +ThemeData contrastGenerator({ + required Brightness brightness, + required ScaleConfig scaleConfig, + required Color primaryFront, + required Color primaryBack, + required Color secondaryFront, + required Color secondaryBack, + required Color tertiaryFront, + required Color tertiaryBack, + required Color grayFront, + required Color grayBack, + required Color errorFront, + required Color errorBack, + TextTheme? customTextTheme, +}) { + final textTheme = customTextTheme ?? makeRadixTextTheme(brightness); + final scheme = _contrastScaleScheme( + brightness: brightness, + primaryFront: primaryFront, + primaryBack: primaryBack, + secondaryFront: secondaryFront, + secondaryBack: secondaryBack, + tertiaryFront: tertiaryFront, + tertiaryBack: tertiaryBack, + grayFront: grayFront, + grayBack: grayBack, + errorFront: errorFront, + errorBack: errorBack, + ); + + final scaleTheme = + ScaleTheme(textTheme: textTheme, scheme: scheme, config: scaleConfig); + + final baseThemeData = scaleTheme.toThemeData(brightness); + + WidgetStateProperty elementBorderWidgetStateProperty() => + WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return BorderSide(color: scheme.grayScale.border.withAlpha(0x7F)); + } else if (states.contains(WidgetState.pressed)) { + return BorderSide( + color: scheme.primaryScale.border, + strokeAlign: BorderSide.strokeAlignOutside); + } else if (states.contains(WidgetState.hovered)) { + return BorderSide(color: scheme.primaryScale.hoverBorder, width: 2); + } else if (states.contains(WidgetState.focused)) { + return BorderSide(color: scheme.primaryScale.hoverBorder, width: 2); + } + return BorderSide(color: scheme.primaryScale.border); + }); + + WidgetStateProperty elementBackgroundWidgetStateProperty() => + WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return scheme.grayScale.elementBackground; + } else if (states.contains(WidgetState.pressed)) { + return scheme.primaryScale.activeElementBackground; + } else if (states.contains(WidgetState.hovered)) { + return scheme.primaryScale.hoverElementBackground; + } else if (states.contains(WidgetState.focused)) { + return scheme.primaryScale.activeElementBackground; + } + return scheme.primaryScale.elementBackground; + }); + + final elevatedButtonTheme = ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: scheme.primaryScale.elementBackground, + foregroundColor: scheme.primaryScale.appText, + disabledBackgroundColor: + scheme.grayScale.elementBackground.withAlpha(0x7F), + disabledForegroundColor: scheme.grayScale.appText.withAlpha(0x7F), + shape: RoundedRectangleBorder( + side: BorderSide(color: scheme.primaryScale.border), + borderRadius: + BorderRadius.circular(8 * scaleConfig.borderRadiusScale))) + .copyWith( + side: elementBorderWidgetStateProperty(), + backgroundColor: elementBackgroundWidgetStateProperty())); + + final sliderTheme = SliderThemeData.fromPrimaryColors( + primaryColor: scheme.primaryScale.borderText, + primaryColorDark: scheme.primaryScale.border, + primaryColorLight: scheme.primaryScale.border, + valueIndicatorTextStyle: textTheme.labelMedium! + .copyWith(color: scheme.primaryScale.borderText)); + + final themeData = baseThemeData.copyWith( + // chipTheme: baseThemeData.chipTheme.copyWith( + // backgroundColor: scaleScheme.primaryScale.elementBackground, + // selectedColor: scaleScheme.primaryScale.activeElementBackground, + // surfaceTintColor: scaleScheme.primaryScale.hoverElementBackground, + // checkmarkColor: scaleScheme.primaryScale.border, + // side: BorderSide(color: scaleScheme.primaryScale.border)), + elevatedButtonTheme: elevatedButtonTheme, + sliderTheme: sliderTheme, + textSelectionTheme: TextSelectionThemeData( + cursorColor: scheme.primaryScale.appText, + selectionColor: scheme.primaryScale.appText.withAlpha(0x7F), + selectionHandleColor: scheme.primaryScale.appText), + extensions: >[scheme, scaleConfig, scaleTheme]); + + return themeData; +} diff --git a/lib/theme/models/models.dart b/lib/theme/models/models.dart new file mode 100644 index 0000000..7806ede --- /dev/null +++ b/lib/theme/models/models.dart @@ -0,0 +1,3 @@ +export 'radix_generator.dart'; +export 'scale_theme/scale_theme.dart'; +export 'theme_preference.dart'; diff --git a/lib/tools/radix_generator.dart b/lib/theme/models/radix_generator.dart similarity index 53% rename from lib/tools/radix_generator.dart rename to lib/theme/models/radix_generator.dart index b805374..ce05769 100644 --- a/lib/tools/radix_generator.dart +++ b/lib/theme/models/radix_generator.dart @@ -1,12 +1,15 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_chat_ui/flutter_chat_ui.dart'; import 'package:radix_colors/radix_colors.dart'; -import 'theme_service.dart'; +import '../../tools/tools.dart'; +import 'scale_theme/scale_theme.dart'; enum RadixThemeColor { - scarlet, // tomato + red + violet - babydoll, // crimson + purple + pink + scarlet, // red + violet + tomato + babydoll, // crimson + pink + purple vapor, // pink + cyan + plum gold, // yellow + amber + orange garden, // grass + orange + brown @@ -15,7 +18,7 @@ enum RadixThemeColor { lapis, // blue + indigo + mint eggplant, // violet + purple + indigo lime, // lime + yellow + orange - grim, // mauve + slate + sage + grim, // grey + purple + brown } enum _RadixBaseColor { @@ -269,7 +272,7 @@ RadixColor _radixColorSteps( } extension ToScaleColor on RadixColor { - ScaleColor toScale() => ScaleColor( + ScaleColor toScale(RadixScaleExtra scaleExtra) => ScaleColor( appBackground: step1, subtleBackground: step2, elementBackground: step3, @@ -278,274 +281,370 @@ extension ToScaleColor on RadixColor { subtleBorder: step6, border: step7, hoverBorder: step8, - background: step9, - hoverBackground: step10, + primary: step9, + hoverPrimary: step10, subtleText: step11, - text: step12, + appText: step12, + primaryText: scaleExtra.foregroundText, + borderText: step12, + dialogBorder: step9, + dialogBorderText: scaleExtra.foregroundText, + calloutBackground: step9, + calloutText: scaleExtra.foregroundText, ); } -class RadixScheme { - RadixScheme( - {required this.primaryScale, - required this.primaryAlphaScale, - required this.secondaryScale, - required this.tertiaryScale, - required this.grayScale, - required this.errorScale}); +class RadixScaleExtra { + RadixScaleExtra({required this.foregroundText}); - RadixColor primaryScale; - RadixColor primaryAlphaScale; - RadixColor secondaryScale; - RadixColor tertiaryScale; - RadixColor grayScale; - RadixColor errorScale; + final Color foregroundText; +} + +class RadixScheme { + const RadixScheme({ + required this.primaryScale, + required this.primaryExtra, + required this.primaryAlphaScale, + required this.primaryAlphaExtra, + required this.secondaryScale, + required this.secondaryExtra, + required this.tertiaryScale, + required this.tertiaryExtra, + required this.grayScale, + required this.grayExtra, + required this.errorScale, + required this.errorExtra, + }); + + final RadixColor primaryScale; + final RadixScaleExtra primaryExtra; + final RadixColor primaryAlphaScale; + final RadixScaleExtra primaryAlphaExtra; + final RadixColor secondaryScale; + final RadixScaleExtra secondaryExtra; + final RadixColor tertiaryScale; + final RadixScaleExtra tertiaryExtra; + final RadixColor grayScale; + final RadixScaleExtra grayExtra; + final RadixColor errorScale; + final RadixScaleExtra errorExtra; ScaleScheme toScale() => ScaleScheme( - primaryScale: primaryScale.toScale(), - primaryAlphaScale: primaryAlphaScale.toScale(), - secondaryScale: secondaryScale.toScale(), - tertiaryScale: tertiaryScale.toScale(), - grayScale: grayScale.toScale(), - errorScale: errorScale.toScale(), + primaryScale: primaryScale.toScale(primaryExtra), + primaryAlphaScale: primaryAlphaScale.toScale(primaryAlphaExtra), + secondaryScale: secondaryScale.toScale(secondaryExtra), + tertiaryScale: tertiaryScale.toScale(tertiaryExtra), + grayScale: grayScale.toScale(grayExtra), + errorScale: errorScale.toScale(errorExtra), ); } RadixScheme _radixScheme(Brightness brightness, RadixThemeColor themeColor) { late RadixScheme radixScheme; switch (themeColor) { - // tomato + red + violet + // red + violet + tomato case RadixThemeColor.scarlet: radixScheme = RadixScheme( - primaryScale: - _radixColorSteps(brightness, false, _RadixBaseColor.tomato), - primaryAlphaScale: - _radixColorSteps(brightness, true, _RadixBaseColor.tomato), - secondaryScale: - _radixColorSteps(brightness, false, _RadixBaseColor.red), - tertiaryScale: - _radixColorSteps(brightness, false, _RadixBaseColor.violet), - grayScale: _radixGraySteps(brightness, false, _RadixBaseColor.tomato), - errorScale: - _radixColorSteps(brightness, false, _RadixBaseColor.yellow)); + primaryScale: _radixColorSteps(brightness, false, _RadixBaseColor.red), + primaryExtra: RadixScaleExtra(foregroundText: Colors.white), + primaryAlphaScale: + _radixColorSteps(brightness, true, _RadixBaseColor.red), + primaryAlphaExtra: RadixScaleExtra(foregroundText: Colors.white), + secondaryScale: + _radixColorSteps(brightness, false, _RadixBaseColor.violet), + secondaryExtra: RadixScaleExtra(foregroundText: Colors.white), + tertiaryScale: + _radixColorSteps(brightness, false, _RadixBaseColor.tomato), + tertiaryExtra: RadixScaleExtra(foregroundText: Colors.white), + grayScale: _radixGraySteps(brightness, false, _RadixBaseColor.red), + grayExtra: RadixScaleExtra(foregroundText: Colors.white), + errorScale: _radixColorSteps(brightness, false, _RadixBaseColor.yellow), + errorExtra: RadixScaleExtra(foregroundText: Colors.black), + ); - // crimson + purple + pink + // crimson + pink + purple case RadixThemeColor.babydoll: radixScheme = RadixScheme( primaryScale: _radixColorSteps(brightness, false, _RadixBaseColor.crimson), + primaryExtra: RadixScaleExtra(foregroundText: Colors.white), primaryAlphaScale: _radixColorSteps(brightness, true, _RadixBaseColor.crimson), + primaryAlphaExtra: RadixScaleExtra(foregroundText: Colors.white), secondaryScale: - _radixColorSteps(brightness, false, _RadixBaseColor.purple), - tertiaryScale: _radixColorSteps(brightness, false, _RadixBaseColor.pink), + secondaryExtra: RadixScaleExtra(foregroundText: Colors.white), + tertiaryScale: + _radixColorSteps(brightness, false, _RadixBaseColor.purple), + tertiaryExtra: RadixScaleExtra(foregroundText: Colors.white), grayScale: _radixGraySteps(brightness, false, _RadixBaseColor.crimson), + grayExtra: RadixScaleExtra(foregroundText: Colors.white), errorScale: - _radixColorSteps(brightness, false, _RadixBaseColor.orange)); + _radixColorSteps(brightness, false, _RadixBaseColor.orange), + errorExtra: RadixScaleExtra(foregroundText: Colors.white)); // pink + cyan + plum case RadixThemeColor.vapor: radixScheme = RadixScheme( primaryScale: _radixColorSteps(brightness, false, _RadixBaseColor.pink), + primaryExtra: RadixScaleExtra(foregroundText: Colors.white), primaryAlphaScale: _radixColorSteps(brightness, true, _RadixBaseColor.pink), + primaryAlphaExtra: RadixScaleExtra(foregroundText: Colors.white), secondaryScale: _radixColorSteps(brightness, false, _RadixBaseColor.cyan), + secondaryExtra: RadixScaleExtra(foregroundText: Colors.white), tertiaryScale: _radixColorSteps(brightness, false, _RadixBaseColor.plum), + tertiaryExtra: RadixScaleExtra(foregroundText: Colors.white), grayScale: _radixGraySteps(brightness, false, _RadixBaseColor.pink), - errorScale: _radixColorSteps(brightness, false, _RadixBaseColor.red)); + grayExtra: RadixScaleExtra(foregroundText: Colors.white), + errorScale: _radixColorSteps(brightness, false, _RadixBaseColor.red), + errorExtra: RadixScaleExtra(foregroundText: Colors.white)); // yellow + amber + orange case RadixThemeColor.gold: radixScheme = RadixScheme( primaryScale: _radixColorSteps(brightness, false, _RadixBaseColor.yellow), + primaryExtra: RadixScaleExtra(foregroundText: Colors.black), primaryAlphaScale: _radixColorSteps(brightness, true, _RadixBaseColor.yellow), + primaryAlphaExtra: RadixScaleExtra(foregroundText: Colors.black), secondaryScale: _radixColorSteps(brightness, false, _RadixBaseColor.amber), + secondaryExtra: RadixScaleExtra(foregroundText: Colors.black), tertiaryScale: _radixColorSteps(brightness, false, _RadixBaseColor.orange), + tertiaryExtra: RadixScaleExtra(foregroundText: Colors.black), grayScale: _radixGraySteps(brightness, false, _RadixBaseColor.yellow), - errorScale: _radixColorSteps(brightness, false, _RadixBaseColor.red)); + grayExtra: RadixScaleExtra(foregroundText: Colors.white), + errorScale: _radixColorSteps(brightness, false, _RadixBaseColor.red), + errorExtra: RadixScaleExtra(foregroundText: Colors.white)); // grass + orange + brown case RadixThemeColor.garden: radixScheme = RadixScheme( primaryScale: _radixColorSteps(brightness, false, _RadixBaseColor.grass), + primaryExtra: RadixScaleExtra(foregroundText: Colors.white), primaryAlphaScale: _radixColorSteps(brightness, true, _RadixBaseColor.grass), + primaryAlphaExtra: RadixScaleExtra(foregroundText: Colors.white), secondaryScale: _radixColorSteps(brightness, false, _RadixBaseColor.orange), + secondaryExtra: RadixScaleExtra(foregroundText: Colors.white), tertiaryScale: _radixColorSteps(brightness, false, _RadixBaseColor.brown), + tertiaryExtra: RadixScaleExtra(foregroundText: Colors.white), grayScale: _radixGraySteps(brightness, false, _RadixBaseColor.grass), + grayExtra: RadixScaleExtra(foregroundText: Colors.white), errorScale: - _radixColorSteps(brightness, false, _RadixBaseColor.tomato)); + _radixColorSteps(brightness, false, _RadixBaseColor.tomato), + errorExtra: RadixScaleExtra(foregroundText: Colors.white)); // green + brown + amber case RadixThemeColor.forest: radixScheme = RadixScheme( primaryScale: _radixColorSteps(brightness, false, _RadixBaseColor.green), + primaryExtra: RadixScaleExtra(foregroundText: Colors.white), primaryAlphaScale: _radixColorSteps(brightness, true, _RadixBaseColor.green), + primaryAlphaExtra: RadixScaleExtra(foregroundText: Colors.white), secondaryScale: _radixColorSteps(brightness, false, _RadixBaseColor.brown), + secondaryExtra: RadixScaleExtra(foregroundText: Colors.white), tertiaryScale: _radixColorSteps(brightness, false, _RadixBaseColor.amber), + tertiaryExtra: RadixScaleExtra(foregroundText: Colors.black), grayScale: _radixGraySteps(brightness, false, _RadixBaseColor.green), + grayExtra: RadixScaleExtra(foregroundText: Colors.white), errorScale: - _radixColorSteps(brightness, false, _RadixBaseColor.tomato)); + _radixColorSteps(brightness, false, _RadixBaseColor.tomato), + errorExtra: RadixScaleExtra(foregroundText: Colors.white)); + // sky + teal + violet case RadixThemeColor.arctic: radixScheme = RadixScheme( primaryScale: _radixColorSteps(brightness, false, _RadixBaseColor.sky), + primaryExtra: RadixScaleExtra(foregroundText: Colors.black), primaryAlphaScale: _radixColorSteps(brightness, true, _RadixBaseColor.sky), + primaryAlphaExtra: RadixScaleExtra(foregroundText: Colors.black), secondaryScale: _radixColorSteps(brightness, false, _RadixBaseColor.teal), + secondaryExtra: RadixScaleExtra(foregroundText: Colors.white), tertiaryScale: _radixColorSteps(brightness, false, _RadixBaseColor.violet), + tertiaryExtra: RadixScaleExtra(foregroundText: Colors.white), grayScale: _radixGraySteps(brightness, false, _RadixBaseColor.sky), + grayExtra: RadixScaleExtra(foregroundText: Colors.white), errorScale: - _radixColorSteps(brightness, false, _RadixBaseColor.crimson)); + _radixColorSteps(brightness, false, _RadixBaseColor.crimson), + errorExtra: RadixScaleExtra(foregroundText: Colors.white)); // blue + indigo + mint case RadixThemeColor.lapis: radixScheme = RadixScheme( - primaryScale: - _radixColorSteps(brightness, false, _RadixBaseColor.blue), - primaryAlphaScale: - _radixColorSteps(brightness, true, _RadixBaseColor.blue), - secondaryScale: - _radixColorSteps(brightness, false, _RadixBaseColor.indigo), - tertiaryScale: - _radixColorSteps(brightness, false, _RadixBaseColor.mint), - grayScale: _radixGraySteps(brightness, false, _RadixBaseColor.blue), - errorScale: - _radixColorSteps(brightness, false, _RadixBaseColor.crimson)); + primaryScale: _radixColorSteps(brightness, false, _RadixBaseColor.blue), + primaryExtra: RadixScaleExtra(foregroundText: Colors.white), + primaryAlphaScale: + _radixColorSteps(brightness, true, _RadixBaseColor.blue), + primaryAlphaExtra: RadixScaleExtra(foregroundText: Colors.white), + secondaryScale: + _radixColorSteps(brightness, false, _RadixBaseColor.indigo), + secondaryExtra: RadixScaleExtra(foregroundText: Colors.white), + tertiaryScale: + _radixColorSteps(brightness, false, _RadixBaseColor.mint), + tertiaryExtra: RadixScaleExtra(foregroundText: Colors.black), + grayScale: _radixGraySteps(brightness, false, _RadixBaseColor.blue), + grayExtra: RadixScaleExtra(foregroundText: Colors.white), + errorScale: + _radixColorSteps(brightness, false, _RadixBaseColor.crimson), + errorExtra: RadixScaleExtra(foregroundText: Colors.white), + ); // violet + purple + indigo case RadixThemeColor.eggplant: radixScheme = RadixScheme( - primaryScale: - _radixColorSteps(brightness, false, _RadixBaseColor.violet), - primaryAlphaScale: - _radixColorSteps(brightness, true, _RadixBaseColor.violet), - secondaryScale: - _radixColorSteps(brightness, false, _RadixBaseColor.purple), - tertiaryScale: - _radixColorSteps(brightness, false, _RadixBaseColor.indigo), - grayScale: _radixGraySteps(brightness, false, _RadixBaseColor.violet), - errorScale: - _radixColorSteps(brightness, false, _RadixBaseColor.crimson)); + primaryScale: + _radixColorSteps(brightness, false, _RadixBaseColor.violet), + primaryExtra: RadixScaleExtra(foregroundText: Colors.white), + primaryAlphaScale: + _radixColorSteps(brightness, true, _RadixBaseColor.violet), + primaryAlphaExtra: RadixScaleExtra(foregroundText: Colors.white), + secondaryScale: + _radixColorSteps(brightness, false, _RadixBaseColor.purple), + secondaryExtra: RadixScaleExtra(foregroundText: Colors.white), + tertiaryScale: + _radixColorSteps(brightness, false, _RadixBaseColor.indigo), + tertiaryExtra: RadixScaleExtra(foregroundText: Colors.white), + grayScale: _radixGraySteps(brightness, false, _RadixBaseColor.violet), + grayExtra: RadixScaleExtra(foregroundText: Colors.white), + errorScale: + _radixColorSteps(brightness, false, _RadixBaseColor.crimson), + errorExtra: RadixScaleExtra(foregroundText: Colors.white), + ); // lime + yellow + orange case RadixThemeColor.lime: radixScheme = RadixScheme( - primaryScale: - _radixColorSteps(brightness, false, _RadixBaseColor.lime), - primaryAlphaScale: - _radixColorSteps(brightness, true, _RadixBaseColor.lime), - secondaryScale: - _radixColorSteps(brightness, false, _RadixBaseColor.yellow), - tertiaryScale: - _radixColorSteps(brightness, false, _RadixBaseColor.orange), - grayScale: _radixGraySteps(brightness, false, _RadixBaseColor.lime), - errorScale: _radixColorSteps(brightness, false, _RadixBaseColor.red)); + primaryScale: _radixColorSteps(brightness, false, _RadixBaseColor.lime), + primaryExtra: RadixScaleExtra(foregroundText: Colors.black), + primaryAlphaScale: + _radixColorSteps(brightness, true, _RadixBaseColor.lime), + primaryAlphaExtra: RadixScaleExtra(foregroundText: Colors.black), + secondaryScale: + _radixColorSteps(brightness, false, _RadixBaseColor.yellow), + secondaryExtra: RadixScaleExtra(foregroundText: Colors.black), + tertiaryScale: + _radixColorSteps(brightness, false, _RadixBaseColor.orange), + tertiaryExtra: RadixScaleExtra(foregroundText: Colors.white), + grayScale: _radixGraySteps(brightness, false, _RadixBaseColor.lime), + grayExtra: RadixScaleExtra(foregroundText: Colors.white), + errorScale: _radixColorSteps(brightness, false, _RadixBaseColor.red), + errorExtra: RadixScaleExtra(foregroundText: Colors.white), + ); // mauve + slate + sage case RadixThemeColor.grim: radixScheme = RadixScheme( - primaryScale: - _radixGraySteps(brightness, false, _RadixBaseColor.tomato), - primaryAlphaScale: - _radixColorSteps(brightness, true, _RadixBaseColor.tomato), - secondaryScale: - _radixColorSteps(brightness, false, _RadixBaseColor.indigo), - tertiaryScale: - _radixColorSteps(brightness, false, _RadixBaseColor.teal), - grayScale: brightness == Brightness.dark - ? RadixColors.dark.gray - : RadixColors.gray, - errorScale: _radixColorSteps(brightness, false, _RadixBaseColor.red)); + primaryScale: + _radixGraySteps(brightness, false, _RadixBaseColor.tomato), + primaryExtra: RadixScaleExtra(foregroundText: Colors.white), + primaryAlphaScale: + _radixGraySteps(brightness, true, _RadixBaseColor.tomato), + primaryAlphaExtra: RadixScaleExtra(foregroundText: Colors.white), + secondaryScale: + _radixColorSteps(brightness, false, _RadixBaseColor.purple), + secondaryExtra: RadixScaleExtra(foregroundText: Colors.white), + tertiaryScale: + _radixColorSteps(brightness, false, _RadixBaseColor.brown), + tertiaryExtra: RadixScaleExtra(foregroundText: Colors.white), + grayScale: brightness == Brightness.dark + ? RadixColors.dark.gray + : RadixColors.gray, + grayExtra: RadixScaleExtra(foregroundText: Colors.white), + errorScale: _radixColorSteps(brightness, false, _RadixBaseColor.red), + errorExtra: RadixScaleExtra(foregroundText: Colors.white), + ); } return radixScheme; } -ColorScheme _radixColorScheme(Brightness brightness, RadixScheme radix) => - ColorScheme( - brightness: brightness, - primary: radix.primaryScale.step9, - onPrimary: radix.primaryScale.step12, - primaryContainer: radix.primaryScale.step4, - onPrimaryContainer: radix.primaryScale.step11, - secondary: radix.secondaryScale.step9, - onSecondary: radix.secondaryScale.step12, - secondaryContainer: radix.secondaryScale.step3, - onSecondaryContainer: radix.secondaryScale.step11, - tertiary: radix.tertiaryScale.step9, - onTertiary: radix.tertiaryScale.step12, - tertiaryContainer: radix.tertiaryScale.step3, - onTertiaryContainer: radix.tertiaryScale.step11, - error: radix.errorScale.step9, - onError: radix.errorScale.step12, - errorContainer: radix.errorScale.step3, - onErrorContainer: radix.errorScale.step11, - background: radix.grayScale.step1, - onBackground: radix.grayScale.step11, - surface: radix.primaryScale.step1, - onSurface: radix.primaryScale.step12, - surfaceVariant: radix.secondaryScale.step2, - onSurfaceVariant: radix.secondaryScale.step11, - outline: radix.primaryScale.step7, - outlineVariant: radix.primaryScale.step6, - shadow: RadixColors.dark.gray.step1, - scrim: radix.primaryScale.step9, - inverseSurface: radix.primaryScale.step11, - onInverseSurface: radix.primaryScale.step2, - inversePrimary: radix.primaryScale.step10, - surfaceTint: radix.primaryAlphaScale.step4, - ); +TextTheme makeRadixTextTheme(Brightness brightness) { + late final TextTheme textTheme; + if (kIsWeb) { + textTheme = (brightness == Brightness.light) + ? Typography.blackHelsinki + : Typography.whiteHelsinki; + } else if (Platform.isIOS) { + textTheme = (brightness == Brightness.light) + ? Typography.blackCupertino + : Typography.whiteCupertino; + } else if (Platform.isMacOS) { + textTheme = (brightness == Brightness.light) + ? Typography.blackRedwoodCity + : Typography.whiteRedwoodCity; + } else if (Platform.isAndroid || Platform.isFuchsia) { + textTheme = (brightness == Brightness.light) + ? Typography.blackMountainView + : Typography.whiteMountainView; + } else if (Platform.isLinux) { + textTheme = (brightness == Brightness.light) + ? Typography.blackHelsinki + : Typography.whiteHelsinki; + } else if (Platform.isWindows) { + textTheme = (brightness == Brightness.light) + ? Typography.blackRedmond + : Typography.whiteRedmond; + } else { + log.warning('unknown platform'); + textTheme = (brightness == Brightness.light) + ? Typography.blackHelsinki + : Typography.whiteHelsinki; + } + return textTheme; +} -ChatTheme makeChatTheme(ScaleScheme scale, TextTheme textTheme) => - DefaultChatTheme( - primaryColor: scale.primaryScale.background, - secondaryColor: scale.secondaryScale.background, - backgroundColor: scale.grayScale.subtleBackground, - inputBackgroundColor: Colors.blue, - inputBorderRadius: BorderRadius.zero, - inputTextDecoration: InputDecoration( - filled: true, - fillColor: scale.primaryScale.elementBackground, - isDense: true, - contentPadding: const EdgeInsets.fromLTRB(8, 12, 8, 12), - border: const OutlineInputBorder( - borderSide: BorderSide.none, - borderRadius: BorderRadius.all(Radius.circular(16))), - ), - inputContainerDecoration: BoxDecoration(color: scale.primaryScale.border), - inputPadding: const EdgeInsets.all(9), - inputTextColor: scale.primaryScale.text, - attachmentButtonIcon: const Icon(Icons.attach_file), - ); +double wallpaperAlpha(Brightness brightness, RadixThemeColor themeColor) { + switch (themeColor) { + case RadixThemeColor.scarlet: + return 64; + case RadixThemeColor.babydoll: + return 192; + case RadixThemeColor.vapor: + return 192; + case RadixThemeColor.gold: + return 192; + case RadixThemeColor.garden: + return brightness == Brightness.dark ? 192 : 128; + case RadixThemeColor.forest: + return 192; + case RadixThemeColor.arctic: + return brightness == Brightness.dark ? 208 : 180; + case RadixThemeColor.lapis: + return brightness == Brightness.dark ? 128 : 192; + case RadixThemeColor.eggplant: + return brightness == Brightness.dark ? 192 : 192; + case RadixThemeColor.lime: + return brightness == Brightness.dark ? 192 : 128; + case RadixThemeColor.grim: + return brightness == Brightness.dark ? 240 : 224; + } +} ThemeData radixGenerator(Brightness brightness, RadixThemeColor themeColor) { - final textTheme = (brightness == Brightness.light) - ? Typography.blackCupertino - : Typography.whiteCupertino; + final textTheme = makeRadixTextTheme(brightness); final radix = _radixScheme(brightness, themeColor); - final colorScheme = _radixColorScheme(brightness, radix); final scaleScheme = radix.toScale(); + final scaleConfig = ScaleConfig( + useVisualIndicators: false, + preferBorders: false, + borderRadiusScale: 1, + wallpaperOpacity: wallpaperAlpha(brightness, themeColor), + ); - final themeData = ThemeData.from( - colorScheme: colorScheme, textTheme: textTheme, useMaterial3: true); - return themeData.copyWith( - bottomSheetTheme: themeData.bottomSheetTheme.copyWith( - elevation: 0, - modalElevation: 0, - shape: - RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))), - extensions: >[ - scaleScheme, - ]); + final scaleTheme = ScaleTheme( + textTheme: textTheme, scheme: scaleScheme, config: scaleConfig); + + final themeData = scaleTheme.toThemeData(brightness); + + return themeData; } diff --git a/lib/theme/models/scale_theme/scale_app_bar_theme.dart b/lib/theme/models/scale_theme/scale_app_bar_theme.dart new file mode 100644 index 0000000..ea8e83e --- /dev/null +++ b/lib/theme/models/scale_theme/scale_app_bar_theme.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +import 'scale_theme.dart'; + +class ScaleAppBarTheme { + ScaleAppBarTheme({ + required this.textStyle, + required this.iconColor, + required this.backgroundColor, + }); + + final TextStyle textStyle; + final Color iconColor; + final Color backgroundColor; +} + +extension ScaleAppBarThemeExt on ScaleTheme { + ScaleAppBarTheme appBarTheme({ScaleKind scaleKind = ScaleKind.primary}) { + final scale = scheme.scale(scaleKind); + + final textStyle = textTheme.titleLarge!.copyWith(color: scale.borderText); + final iconColor = scale.borderText; + final backgroundColor = scale.border; + + return ScaleAppBarTheme( + textStyle: textStyle, + iconColor: iconColor, + backgroundColor: backgroundColor, + ); + } +} diff --git a/lib/theme/models/scale_theme/scale_chat_theme.dart b/lib/theme/models/scale_theme/scale_chat_theme.dart new file mode 100644 index 0000000..d1145cf --- /dev/null +++ b/lib/theme/models/scale_theme/scale_chat_theme.dart @@ -0,0 +1,372 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart' as core; + +import 'scale_theme.dart'; + +class ScaleChatTheme { + ScaleChatTheme({ + // Default chat theme + required this.chatTheme, + + // Customization fields (from v1 of flutter chat ui) + required this.attachmentButtonIcon, + // required this.attachmentButtonMargin, + required this.backgroundColor, + required this.bubbleBorderSide, + // required this.dateDividerMargin, + // required this.chatContentMargin, + required this.dateDividerTextStyle, + // required this.deliveredIcon, + // required this.documentIcon, + // required this.emptyChatPlaceholderTextStyle, + // required this.errorColor, + // required this.errorIcon, + required this.inputBackgroundColor, + // required this.inputSurfaceTintColor, + // required this.inputElevation, + required this.inputBorderRadius, + // required this.inputMargin, + required this.inputPadding, + required this.inputTextColor, + required this.inputTextStyle, + required this.messageBorderRadius, + required this.messageInsetsHorizontal, + required this.messageInsetsVertical, + // required this.messageMaxWidth, + required this.primaryColor, + required this.receivedEmojiMessageTextStyle, + required this.receivedMessageBodyTextStyle, + // required this.receivedMessageCaptionTextStyle, + // required this.receivedMessageDocumentIconColor, + // required this.receivedMessageLinkDescriptionTextStyle, + // required this.receivedMessageLinkTitleTextStyle, + required this.secondaryColor, + // required this.seenIcon, + required this.sendButtonIcon, + // required this.sendButtonMargin, + // required this.sendingIcon, + required this.onlyEmojiFontSize, + required this.timeStyle, + required this.sentMessageBodyTextStyle, + // required this.sentMessageCaptionTextStyle, + // required this.sentMessageDocumentIconColor, + // required this.sentMessageLinkDescriptionTextStyle, + // required this.sentMessageLinkTitleTextStyle, + // required this.statusIconPadding, + // required this.userAvatarImageBackgroundColor, + // required this.userAvatarNameColors, + // required this.userAvatarTextStyle, + // required this.userNameTextStyle, + // required this.bubbleMargin, + required this.inputContainerDecoration, + // required this.inputTextCursorColor, + // required this.receivedMessageBodyBoldTextStyle, + // required this.receivedMessageBodyCodeTextStyle, + // required this.receivedMessageBodyLinkTextStyle, + // required this.sentMessageBodyBoldTextStyle, + // required this.sentMessageBodyCodeTextStyle, + // required this.sentMessageBodyLinkTextStyle, + // required this.highlightMessageColor, + }); + + final core.ChatTheme chatTheme; + + /// Icon for select attachment button. + final Widget? attachmentButtonIcon; + + /// Margin of attachment button. + // final EdgeInsets? attachmentButtonMargin; + + /// Used as a background color of a chat widget. + final Color backgroundColor; + + // Margin around the message bubble. + // final EdgeInsetsGeometry? bubbleMargin; + + /// Border for chat bubbles + final BorderSide bubbleBorderSide; + + /// Margin around date dividers. + // final EdgeInsets dateDividerMargin; + + /// Margin inside chat area. + // final EdgeInsets chatContentMargin; + + /// Text style of the date dividers. + final TextStyle dateDividerTextStyle; + + /// Icon for message's `delivered` status. For the best look use size of 16. + // final Widget? deliveredIcon; + + /// Icon inside file message. + // final Widget? documentIcon; + + /// Text style of the empty chat placeholder. + // final TextStyle emptyChatPlaceholderTextStyle; + + /// Color to indicate something bad happened (usually - shades of red). + // final Color errorColor; + + /// Icon for message's `error` status. For the best look use size of 16. + // final Widget? errorIcon; + + /// Color of the bottom bar where text field is. + final Color inputBackgroundColor; + + /// Surface Tint Color of the bottom bar where text field is. + // final Color inputSurfaceTintColor; + + /// Elevation to use for input material + // final double inputElevation; + + /// Top border radius of the bottom bar where text field is. + final BorderRadius inputBorderRadius; + + /// Decoration of the container wrapping the text field. + final Decoration? inputContainerDecoration; + + /// Outer insets of the bottom bar where text field is. + // final EdgeInsets inputMargin; + + /// Inner insets of the bottom bar where text field is. + final EdgeInsets inputPadding; + + /// Color of the text field's text and attachment/send buttons. + final Color inputTextColor; + + /// Color of the text field's cursor. + // final Color? inputTextCursorColor; + + /// Text style of the message input. To change the color use [inputTextColor]. + final TextStyle inputTextStyle; + + /// Border radius of message container. + final double messageBorderRadius; + + /// Horizontal message bubble insets. + final double messageInsetsHorizontal; + + /// Vertical message bubble insets. + final double messageInsetsVertical; + + /// Message bubble max width. set to [double.infinity] adaptive screen. + // final double messageMaxWidth; + + /// Primary color of the chat used as a background of sent messages + /// and statuses. + final Color primaryColor; + + /// Text style used for displaying emojis on text messages. + final TextStyle receivedEmojiMessageTextStyle; + + /// Body text style used for displaying bold text on received text messages. + // Default to a bold version of [receivedMessageBodyTextStyle]. + // final TextStyle? receivedMessageBodyBoldTextStyle; + + /// Body text style used for displaying code text on received text messages. + // Defaults to a mono version of [receivedMessageBodyTextStyle]. + // final TextStyle? receivedMessageBodyCodeTextStyle; + + /// Text style used for displaying link text on received text messages. + // Defaults to [receivedMessageBodyTextStyle]. + // final TextStyle? receivedMessageBodyLinkTextStyle; + + /// Body text style used for displaying text on different types + /// of received messages. + final TextStyle receivedMessageBodyTextStyle; + + /// Caption text style used for displaying secondary info (e.g. file size) on + /// different types of received messages. + // final TextStyle receivedMessageCaptionTextStyle; + + /// Color of the document icon on received messages. Has no effect when + // [documentIcon] is used. + // final Color receivedMessageDocumentIconColor; + + /// Text style used for displaying link description on received messages. + // final TextStyle receivedMessageLinkDescriptionTextStyle; + + /// Text style used for displaying link title on received messages. + // final TextStyle receivedMessageLinkTitleTextStyle; + + /// Secondary color, used as a background of received messages. + final Color secondaryColor; + + /// Icon for message's `seen` status. For the best look use size of 16. + // final Widget? seenIcon; + + /// Icon for send button. + final Widget? sendButtonIcon; + + /// Margin of send button. + // final EdgeInsets? sendButtonMargin; + + /// Icon for message's `sending` status. For the best look use size of 10. + // final Widget? sendingIcon; + + /// Text size for displaying emojis on text messages. + final double onlyEmojiFontSize; + + /// Text style used for time and status + final TextStyle timeStyle; + + /// Body text style used for displaying bold text on sent text messages. + /// Defaults to a bold version of [sentMessageBodyTextStyle]. + // final TextStyle? sentMessageBodyBoldTextStyle; + + /// Body text style used for displaying code text on sent text messages. + /// Defaults to a mono version of [sentMessageBodyTextStyle]. + // final TextStyle? sentMessageBodyCodeTextStyle; + + /// Text style used for displaying link text on sent text messages. + /// Defaults to [sentMessageBodyTextStyle]. + // final TextStyle? sentMessageBodyLinkTextStyle; + + /// Body text style used for displaying text on different types + /// of sent messages. + final TextStyle sentMessageBodyTextStyle; + + /// Caption text style used for displaying secondary info (e.g. file size) on + /// different types of sent messages. + // final TextStyle sentMessageCaptionTextStyle; + + /// Color of the document icon on sent messages. Has no effect when + // [documentIcon] is used. + // final Color sentMessageDocumentIconColor; + + /// Text style used for displaying link description on sent messages. + // final TextStyle sentMessageLinkDescriptionTextStyle; + + /// Text style used for displaying link title on sent messages. + // final TextStyle sentMessageLinkTitleTextStyle; + + /// Padding around status icons. + // final EdgeInsets statusIconPadding; + + /// Color used as a background for user avatar if an image is provided. + /// Visible if the image has some transparent parts. + // final Color userAvatarImageBackgroundColor; + + /// Colors used as backgrounds for user avatars with no image and so, + /// corresponding user names. + /// Calculated based on a user ID, so unique across the whole app. + // final List userAvatarNameColors; + + /// Text style used for displaying initials on user avatar if no + /// image is provided. + // final TextStyle userAvatarTextStyle; + + /// User names text style. Color will be overwritten with + // [userAvatarNameColors]. + // final TextStyle userNameTextStyle; + + /// Color used as background of message row on highlight. + // final Color? highlightMessageColor; +} + +extension ScaleChatThemeExt on ScaleTheme { + ScaleChatTheme chatTheme() { + // 'brightness' is not actually used by ChatColors.fromThemeData, + // or ChatTypography.fromThemeData so just say 'light' here + final themeData = toThemeData(Brightness.light); + final typography = core.ChatTypography.fromThemeData(themeData); + + final surfaceContainer = config.preferBorders + ? scheme.secondaryScale.calloutText + : scheme.secondaryScale.calloutBackground; + + final colors = core.ChatColors( + // Primary color, often used for sent messages and accents. + primary: config.preferBorders + ? scheme.primaryScale.calloutText + : scheme.primaryScale.calloutBackground, + // Color for text and icons displayed on top of [primary]. + onPrimary: scheme.primaryScale.primaryText, + // The main background color of the chat screen. + surface: + scheme.grayScale.appBackground.withAlpha(config.wallpaperAlpha), + + // Color for text and icons displayed on top of [surface]. + onSurface: scheme.primaryScale.appText, + + // Background color for elements like received messages. + surfaceContainer: surfaceContainer, + + // A slightly lighter/darker variant of [surfaceContainer]. + surfaceContainerLow: surfaceContainer.darken(25), + + // A slightly lighter/darker variant of [surfaceContainer]. + surfaceContainerHigh: surfaceContainer.lighten(25)); + + final chatTheme = core.ChatTheme( + colors: colors, + typography: typography, + shape: + BorderRadius.all(Radius.circular(config.borderRadiusScale * 12))); + + return ScaleChatTheme( + chatTheme: chatTheme, + primaryColor: config.preferBorders + ? scheme.primaryScale.calloutText + : scheme.primaryScale.calloutBackground, + secondaryColor: config.preferBorders + ? scheme.secondaryScale.calloutText + : scheme.secondaryScale.calloutBackground, + backgroundColor: + scheme.grayScale.appBackground.withAlpha(config.wallpaperAlpha), + messageBorderRadius: config.borderRadiusScale * 12, + bubbleBorderSide: config.preferBorders + ? BorderSide( + color: scheme.primaryScale.calloutBackground, + width: 2, + ) + : BorderSide(width: 2, color: Colors.black.withAlpha(96)), + sendButtonIcon: Image.asset( + 'assets/icon-send.png', + color: config.preferBorders + ? scheme.primaryScale.border + : scheme.primaryScale.borderText, + package: 'flutter_chat_ui', + ), + inputBackgroundColor: Colors.blue, + inputBorderRadius: BorderRadius.zero, + inputTextStyle: textTheme.bodyLarge!, + inputContainerDecoration: BoxDecoration( + border: config.preferBorders + ? Border( + top: + BorderSide(color: scheme.primaryScale.border, width: 2)) + : null, + color: config.preferBorders + ? scheme.primaryScale.elementBackground + : scheme.primaryScale.border), + inputPadding: const EdgeInsets.all(6), + inputTextColor: !config.preferBorders + ? scheme.primaryScale.appText + : scheme.primaryScale.border, + messageInsetsHorizontal: 12, + messageInsetsVertical: 8, + attachmentButtonIcon: const Icon(Icons.attach_file), + sentMessageBodyTextStyle: textTheme.bodyLarge!.copyWith( + color: config.preferBorders + ? scheme.primaryScale.calloutBackground + : scheme.primaryScale.calloutText, + ), + onlyEmojiFontSize: 64, + timeStyle: textTheme.bodySmall!.copyWith(fontSize: 9).copyWith( + color: config.preferBorders || config.useVisualIndicators + ? scheme.primaryScale.calloutBackground + : scheme.primaryScale.borderText), + receivedMessageBodyTextStyle: textTheme.bodyLarge!.copyWith( + color: config.preferBorders + ? scheme.secondaryScale.calloutBackground + : scheme.secondaryScale.calloutText, + ), + receivedEmojiMessageTextStyle: const TextStyle( + color: Colors.white, + fontSize: 64, + ), + dateDividerTextStyle: textTheme.labelSmall!); + } +} diff --git a/lib/theme/models/scale_theme/scale_color.dart b/lib/theme/models/scale_theme/scale_color.dart new file mode 100644 index 0000000..f3c884f --- /dev/null +++ b/lib/theme/models/scale_theme/scale_color.dart @@ -0,0 +1,129 @@ +import 'dart:ui'; + +class ScaleColor { + ScaleColor({ + required this.appBackground, + required this.subtleBackground, + required this.elementBackground, + required this.hoverElementBackground, + required this.activeElementBackground, + required this.subtleBorder, + required this.border, + required this.hoverBorder, + required this.primary, + required this.hoverPrimary, + required this.subtleText, + required this.appText, + required this.primaryText, + required this.borderText, + required this.dialogBorder, + required this.dialogBorderText, + required this.calloutBackground, + required this.calloutText, + }); + + Color appBackground; + Color subtleBackground; + Color elementBackground; + Color hoverElementBackground; + Color activeElementBackground; + Color subtleBorder; + Color border; + Color hoverBorder; + Color primary; + Color hoverPrimary; + Color subtleText; + Color appText; + Color primaryText; + Color borderText; + Color dialogBorder; + Color dialogBorderText; + Color calloutBackground; + Color calloutText; + + ScaleColor copyWith({ + Color? appBackground, + Color? subtleBackground, + Color? elementBackground, + Color? hoverElementBackground, + Color? activeElementBackground, + Color? subtleBorder, + Color? border, + Color? hoverBorder, + Color? primary, + Color? hoverPrimary, + Color? subtleText, + Color? appText, + Color? primaryText, + Color? borderText, + Color? dialogBorder, + Color? dialogBorderText, + Color? calloutBackground, + Color? calloutText, + }) => + ScaleColor( + appBackground: appBackground ?? this.appBackground, + subtleBackground: subtleBackground ?? this.subtleBackground, + elementBackground: elementBackground ?? this.elementBackground, + hoverElementBackground: + hoverElementBackground ?? this.hoverElementBackground, + activeElementBackground: + activeElementBackground ?? this.activeElementBackground, + subtleBorder: subtleBorder ?? this.subtleBorder, + border: border ?? this.border, + hoverBorder: hoverBorder ?? this.hoverBorder, + primary: primary ?? this.primary, + hoverPrimary: hoverPrimary ?? this.hoverPrimary, + subtleText: subtleText ?? this.subtleText, + appText: appText ?? this.appText, + primaryText: primaryText ?? this.primaryText, + borderText: borderText ?? this.borderText, + dialogBorder: dialogBorder ?? this.dialogBorder, + dialogBorderText: dialogBorderText ?? this.dialogBorderText, + calloutBackground: calloutBackground ?? this.calloutBackground, + calloutText: calloutText ?? this.calloutText); + + // Use static method + // ignore: prefer_constructors_over_static_methods + static ScaleColor lerp(ScaleColor a, ScaleColor b, double t) => ScaleColor( + appBackground: Color.lerp(a.appBackground, b.appBackground, t) ?? + const Color(0x00000000), + subtleBackground: + Color.lerp(a.subtleBackground, b.subtleBackground, t) ?? + const Color(0x00000000), + elementBackground: + Color.lerp(a.elementBackground, b.elementBackground, t) ?? + const Color(0x00000000), + hoverElementBackground: + Color.lerp(a.hoverElementBackground, b.hoverElementBackground, t) ?? + const Color(0x00000000), + activeElementBackground: Color.lerp( + a.activeElementBackground, b.activeElementBackground, t) ?? + const Color(0x00000000), + subtleBorder: Color.lerp(a.subtleBorder, b.subtleBorder, t) ?? + const Color(0x00000000), + border: Color.lerp(a.border, b.border, t) ?? const Color(0x00000000), + hoverBorder: Color.lerp(a.hoverBorder, b.hoverBorder, t) ?? + const Color(0x00000000), + primary: Color.lerp(a.primary, b.primary, t) ?? const Color(0x00000000), + hoverPrimary: Color.lerp(a.hoverPrimary, b.hoverPrimary, t) ?? + const Color(0x00000000), + subtleText: Color.lerp(a.subtleText, b.subtleText, t) ?? + const Color(0x00000000), + appText: Color.lerp(a.appText, b.appText, t) ?? const Color(0x00000000), + primaryText: Color.lerp(a.primaryText, b.primaryText, t) ?? + const Color(0x00000000), + borderText: Color.lerp(a.borderText, b.borderText, t) ?? + const Color(0x00000000), + dialogBorder: Color.lerp(a.dialogBorder, b.dialogBorder, t) ?? + const Color(0x00000000), + dialogBorderText: + Color.lerp(a.dialogBorderText, b.dialogBorderText, t) ?? + const Color(0x00000000), + calloutBackground: + Color.lerp(a.calloutBackground, b.calloutBackground, t) ?? + const Color(0x00000000), + calloutText: Color.lerp(a.calloutText, b.calloutText, t) ?? + const Color(0x00000000), + ); +} diff --git a/lib/theme/models/scale_theme/scale_custom_dropdown_theme.dart b/lib/theme/models/scale_theme/scale_custom_dropdown_theme.dart new file mode 100644 index 0000000..2c5eb1c --- /dev/null +++ b/lib/theme/models/scale_theme/scale_custom_dropdown_theme.dart @@ -0,0 +1,92 @@ +import 'package:animated_custom_dropdown/custom_dropdown.dart'; +import 'package:flutter/material.dart'; + +import 'scale_theme.dart'; + +class ScaleCustomDropdownTheme { + ScaleCustomDropdownTheme({ + required this.decoration, + required this.closedHeaderPadding, + required this.expandedHeaderPadding, + required this.itemsListPadding, + required this.listItemPadding, + required this.disabledDecoration, + required this.textStyle, + }); + + final CustomDropdownDecoration decoration; + final EdgeInsets closedHeaderPadding; + final EdgeInsets expandedHeaderPadding; + final EdgeInsets itemsListPadding; + final EdgeInsets listItemPadding; + final CustomDropdownDisabledDecoration disabledDecoration; + final TextStyle textStyle; +} + +extension ScaleCustomDropdownThemeExt on ScaleTheme { + ScaleCustomDropdownTheme customDropdownTheme() { + final scale = scheme.primaryScale; + final borderColor = scale.borderText; + final fillColor = scale.subtleBorder; + + // final backgroundColor = config.useVisualIndicators && !selected + // ? tileColor.borderText + // : borderColor; + // final textColor = config.useVisualIndicators && !selected + // ? borderColor + // : tileColor.borderText; + + // final largeTextStyle = textTheme.labelMedium!.copyWith(color: textColor); + // final smallTextStyle = textTheme.labelSmall!.copyWith(color: textColor); + + final border = Border.fromBorderSide(config.useVisualIndicators + ? BorderSide(width: 2, color: borderColor, strokeAlign: 0) + : BorderSide.none); + final borderRadius = BorderRadius.circular(8 * config.borderRadiusScale); + + final decoration = CustomDropdownDecoration( + closedFillColor: fillColor, + expandedFillColor: fillColor, + closedShadow: [], + expandedShadow: [], + closedSuffixIcon: Icon(Icons.arrow_drop_down, color: borderColor), + expandedSuffixIcon: Icon(Icons.arrow_drop_up, color: borderColor), + prefixIcon: null, + closedBorder: border, + closedBorderRadius: borderRadius, + closedErrorBorder: null, + closedErrorBorderRadius: null, + expandedBorder: border, + expandedBorderRadius: borderRadius, + hintStyle: null, + headerStyle: null, + noResultFoundStyle: null, + errorStyle: null, + listItemStyle: null, + overlayScrollbarDecoration: null, + searchFieldDecoration: null, + listItemDecoration: null, + ); + + const disabledDecoration = CustomDropdownDisabledDecoration( + fillColor: null, + shadow: null, + suffixIcon: null, + prefixIcon: null, + border: null, + borderRadius: null, + headerStyle: null, + hintStyle: null, + ); + + return ScaleCustomDropdownTheme( + textStyle: textTheme.labelSmall!.copyWith(color: borderColor), + decoration: decoration, + closedHeaderPadding: const EdgeInsets.all(4), + expandedHeaderPadding: const EdgeInsets.all(4), + itemsListPadding: const EdgeInsets.all(4), + listItemPadding: const EdgeInsets.all(4), + disabledDecoration: disabledDecoration, + ); + } +} diff --git a/lib/theme/models/scale_theme/scale_input_decorator_theme.dart b/lib/theme/models/scale_theme/scale_input_decorator_theme.dart new file mode 100644 index 0000000..1fb26a4 --- /dev/null +++ b/lib/theme/models/scale_theme/scale_input_decorator_theme.dart @@ -0,0 +1,234 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'scale_theme.dart'; + +class ScaleInputDecoratorTheme extends InputDecorationTheme { + ScaleInputDecoratorTheme( + this._scaleScheme, ScaleConfig scaleConfig, this._textTheme) + : hintAlpha = scaleConfig.preferBorders ? 127 : 255, + super( + contentPadding: const EdgeInsets.all(8), + labelStyle: TextStyle(color: _scaleScheme.primaryScale.subtleText), + floatingLabelStyle: + TextStyle(color: _scaleScheme.primaryScale.subtleText), + border: OutlineInputBorder( + borderSide: BorderSide(color: _scaleScheme.primaryScale.border), + borderRadius: + BorderRadius.circular(8 * scaleConfig.borderRadiusScale)), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: _scaleScheme.primaryScale.border), + borderRadius: + BorderRadius.circular(8 * scaleConfig.borderRadiusScale)), + disabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: _scaleScheme.grayScale.border.withAlpha(0x7F)), + borderRadius: + BorderRadius.circular(8 * scaleConfig.borderRadiusScale)), + errorBorder: OutlineInputBorder( + borderSide: BorderSide(color: _scaleScheme.errorScale.border), + borderRadius: + BorderRadius.circular(8 * scaleConfig.borderRadiusScale)), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: _scaleScheme.primaryScale.hoverBorder, width: 2), + borderRadius: + BorderRadius.circular(8 * scaleConfig.borderRadiusScale)), + hoverColor: + _scaleScheme.primaryScale.hoverElementBackground.withAlpha(0x7F), + filled: true, + focusedErrorBorder: OutlineInputBorder( + borderSide: + BorderSide(color: _scaleScheme.errorScale.border, width: 2), + borderRadius: + BorderRadius.circular(8 * scaleConfig.borderRadiusScale)), + ); + + final ScaleScheme _scaleScheme; + final TextTheme _textTheme; + final int hintAlpha; + final int disabledAlpha = 127; + + @override + TextStyle? get hintStyle => WidgetStateTextStyle.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return TextStyle(color: _scaleScheme.grayScale.border); + } + return TextStyle( + color: _scaleScheme.primaryScale.border.withAlpha(hintAlpha)); + }); + + @override + Color? get fillColor => WidgetStateColor.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return _scaleScheme.grayScale.primary.withAlpha(10); + } + return _scaleScheme.primaryScale.primary.withAlpha(10); + }); + + @override + BorderSide? get activeIndicatorBorder => + WidgetStateBorderSide.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return BorderSide( + color: _scaleScheme.grayScale.border.withAlpha(disabledAlpha)); + } + if (states.contains(WidgetState.error)) { + if (states.contains(WidgetState.hovered)) { + return BorderSide(color: _scaleScheme.errorScale.hoverBorder); + } + if (states.contains(WidgetState.focused)) { + return BorderSide(color: _scaleScheme.errorScale.border, width: 2); + } + return BorderSide(color: _scaleScheme.errorScale.subtleBorder); + } + if (states.contains(WidgetState.hovered)) { + return BorderSide(color: _scaleScheme.secondaryScale.hoverBorder); + } + if (states.contains(WidgetState.focused)) { + return BorderSide( + color: _scaleScheme.secondaryScale.border, width: 2); + } + return BorderSide(color: _scaleScheme.secondaryScale.subtleBorder); + }); + + @override + BorderSide? get outlineBorder => WidgetStateBorderSide.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return BorderSide( + color: _scaleScheme.grayScale.border.withAlpha(disabledAlpha)); + } + if (states.contains(WidgetState.error)) { + if (states.contains(WidgetState.hovered)) { + return BorderSide(color: _scaleScheme.errorScale.hoverBorder); + } + if (states.contains(WidgetState.focused)) { + return BorderSide(color: _scaleScheme.errorScale.border, width: 2); + } + return BorderSide(color: _scaleScheme.errorScale.subtleBorder); + } + if (states.contains(WidgetState.hovered)) { + return BorderSide(color: _scaleScheme.primaryScale.hoverBorder); + } + if (states.contains(WidgetState.focused)) { + return BorderSide(color: _scaleScheme.primaryScale.border, width: 2); + } + return BorderSide(color: _scaleScheme.primaryScale.subtleBorder); + }); + + @override + Color? get iconColor => _scaleScheme.primaryScale.primary; + + @override + Color? get prefixIconColor => WidgetStateColor.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return _scaleScheme.primaryScale.primary.withAlpha(disabledAlpha); + } + if (states.contains(WidgetState.error)) { + return _scaleScheme.errorScale.primary; + } + return _scaleScheme.primaryScale.primary; + }); + + @override + Color? get suffixIconColor => WidgetStateColor.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return _scaleScheme.primaryScale.primary.withAlpha(disabledAlpha); + } + if (states.contains(WidgetState.error)) { + return _scaleScheme.errorScale.primary; + } + return _scaleScheme.primaryScale.primary; + }); + + @override + TextStyle? get labelStyle => WidgetStateTextStyle.resolveWith((states) { + final textStyle = _textTheme.bodyLarge ?? const TextStyle(); + if (states.contains(WidgetState.disabled)) { + return textStyle.copyWith( + color: _scaleScheme.grayScale.border.withAlpha(disabledAlpha)); + } + if (states.contains(WidgetState.error)) { + if (states.contains(WidgetState.hovered)) { + return textStyle.copyWith( + color: _scaleScheme.errorScale.hoverBorder); + } + if (states.contains(WidgetState.focused)) { + return textStyle.copyWith( + color: _scaleScheme.errorScale.hoverBorder); + } + return textStyle.copyWith( + color: _scaleScheme.errorScale.subtleBorder); + } + if (states.contains(WidgetState.hovered)) { + return textStyle.copyWith( + color: _scaleScheme.primaryScale.hoverBorder); + } + if (states.contains(WidgetState.focused)) { + return textStyle.copyWith( + color: _scaleScheme.primaryScale.hoverBorder); + } + return textStyle.copyWith( + color: _scaleScheme.primaryScale.border.withAlpha(hintAlpha)); + }); + + @override + TextStyle? get floatingLabelStyle => + WidgetStateTextStyle.resolveWith((states) { + final textStyle = _textTheme.bodyLarge ?? const TextStyle(); + if (states.contains(WidgetState.disabled)) { + return textStyle.copyWith( + color: _scaleScheme.grayScale.border.withAlpha(disabledAlpha)); + } + if (states.contains(WidgetState.error)) { + if (states.contains(WidgetState.hovered)) { + return textStyle.copyWith( + color: _scaleScheme.errorScale.hoverBorder); + } + if (states.contains(WidgetState.focused)) { + return textStyle.copyWith( + color: _scaleScheme.errorScale.hoverBorder); + } + return textStyle.copyWith( + color: _scaleScheme.errorScale.subtleBorder); + } + if (states.contains(WidgetState.hovered)) { + return textStyle.copyWith( + color: _scaleScheme.primaryScale.hoverBorder); + } + if (states.contains(WidgetState.focused)) { + return textStyle.copyWith( + color: _scaleScheme.primaryScale.hoverBorder); + } + return textStyle.copyWith(color: _scaleScheme.primaryScale.border); + }); + + @override + TextStyle? get helperStyle => WidgetStateTextStyle.resolveWith((states) { + final textStyle = _textTheme.bodySmall ?? const TextStyle(); + if (states.contains(WidgetState.disabled)) { + return textStyle.copyWith(color: _scaleScheme.grayScale.border); + } + return textStyle.copyWith( + color: _scaleScheme.primaryScale.border.withAlpha(hintAlpha)); + }); + + @override + TextStyle? get errorStyle => WidgetStateTextStyle.resolveWith((states) { + final textStyle = _textTheme.bodySmall ?? const TextStyle(); + return textStyle.copyWith(color: _scaleScheme.errorScale.primary); + }); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(IntProperty('disabledAlpha', disabledAlpha)) + ..add(IntProperty('hintAlpha', hintAlpha)); + } +} + +extension ScaleInputDecoratorThemeExt on ScaleTheme { + ScaleInputDecoratorTheme inputDecoratorTheme() => + ScaleInputDecoratorTheme(scheme, config, textTheme); +} diff --git a/lib/theme/models/scale_theme/scale_scheme.dart b/lib/theme/models/scale_theme/scale_scheme.dart new file mode 100644 index 0000000..8363476 --- /dev/null +++ b/lib/theme/models/scale_theme/scale_scheme.dart @@ -0,0 +1,151 @@ +import 'dart:ui'; + +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/material.dart'; + +import 'scale_color.dart'; + +enum ScaleKind { primary, primaryAlpha, secondary, tertiary, gray, error } + +class ScaleScheme extends ThemeExtension { + ScaleScheme({ + required this.primaryScale, + required this.primaryAlphaScale, + required this.secondaryScale, + required this.tertiaryScale, + required this.grayScale, + required this.errorScale, + }); + + final ScaleColor primaryScale; + final ScaleColor primaryAlphaScale; + final ScaleColor secondaryScale; + final ScaleColor tertiaryScale; + final ScaleColor grayScale; + final ScaleColor errorScale; + + ScaleColor scale(ScaleKind kind) { + switch (kind) { + case ScaleKind.primary: + return primaryScale; + case ScaleKind.primaryAlpha: + return primaryAlphaScale; + case ScaleKind.secondary: + return secondaryScale; + case ScaleKind.tertiary: + return tertiaryScale; + case ScaleKind.gray: + return grayScale; + case ScaleKind.error: + return errorScale; + } + } + + @override + ScaleScheme copyWith( + {ScaleColor? primaryScale, + ScaleColor? primaryAlphaScale, + ScaleColor? secondaryScale, + ScaleColor? tertiaryScale, + ScaleColor? grayScale, + ScaleColor? errorScale}) => + ScaleScheme( + primaryScale: primaryScale ?? this.primaryScale, + primaryAlphaScale: primaryAlphaScale ?? this.primaryAlphaScale, + secondaryScale: secondaryScale ?? this.secondaryScale, + tertiaryScale: tertiaryScale ?? this.tertiaryScale, + grayScale: grayScale ?? this.grayScale, + errorScale: errorScale ?? this.errorScale, + ); + + @override + ScaleScheme lerp(ScaleScheme? other, double t) { + if (other is! ScaleScheme) { + return this; + } + return ScaleScheme( + primaryScale: ScaleColor.lerp(primaryScale, other.primaryScale, t), + primaryAlphaScale: + ScaleColor.lerp(primaryAlphaScale, other.primaryAlphaScale, t), + secondaryScale: ScaleColor.lerp(secondaryScale, other.secondaryScale, t), + tertiaryScale: ScaleColor.lerp(tertiaryScale, other.tertiaryScale, t), + grayScale: ScaleColor.lerp(grayScale, other.grayScale, t), + errorScale: ScaleColor.lerp(errorScale, other.errorScale, t), + ); + } + + ColorScheme toColorScheme(Brightness brightness) => ColorScheme( + brightness: brightness, + primary: primaryScale.primary, + onPrimary: primaryScale.primaryText, + // primaryContainer: primaryScale.hoverElementBackground, + // onPrimaryContainer: primaryScale.subtleText, + secondary: secondaryScale.primary, + onSecondary: secondaryScale.primaryText, + // secondaryContainer: secondaryScale.hoverElementBackground, + // onSecondaryContainer: secondaryScale.subtleText, + tertiary: tertiaryScale.primary, + onTertiary: tertiaryScale.primaryText, + // tertiaryContainer: tertiaryScale.hoverElementBackground, + // onTertiaryContainer: tertiaryScale.subtleText, + error: errorScale.primary, + onError: errorScale.primaryText, + // errorContainer: errorScale.hoverElementBackground, + // onErrorContainer: errorScale.subtleText, + surface: primaryScale.appBackground, + onSurface: primaryScale.appText, + onSurfaceVariant: secondaryScale.appText, + outline: primaryScale.border, + outlineVariant: secondaryScale.border, + shadow: primaryScale.primary.darken(60), + //scrim: primaryScale.background, + // inverseSurface: primaryScale.subtleText, + // onInverseSurface: primaryScale.subtleBackground, + // inversePrimary: primaryScale.hoverBackground, + // surfaceTint: primaryAlphaScale.hoverElementBackground, + ); +} + +class ScaleConfig extends ThemeExtension { + ScaleConfig({ + required this.useVisualIndicators, + required this.preferBorders, + required this.borderRadiusScale, + required this.wallpaperOpacity, + }); + + final bool useVisualIndicators; + final bool preferBorders; + final double borderRadiusScale; + final double wallpaperOpacity; + + int get wallpaperAlpha => wallpaperOpacity.toInt(); + + @override + ScaleConfig copyWith( + {bool? useVisualIndicators, + bool? preferBorders, + double? borderRadiusScale, + double? wallpaperOpacity}) => + ScaleConfig( + useVisualIndicators: useVisualIndicators ?? this.useVisualIndicators, + preferBorders: preferBorders ?? this.preferBorders, + borderRadiusScale: borderRadiusScale ?? this.borderRadiusScale, + wallpaperOpacity: wallpaperOpacity ?? this.wallpaperOpacity, + ); + + @override + ScaleConfig lerp(ScaleConfig? other, double t) { + if (other is! ScaleConfig) { + return this; + } + return ScaleConfig( + useVisualIndicators: + t < .5 ? useVisualIndicators : other.useVisualIndicators, + preferBorders: t < .5 ? preferBorders : other.preferBorders, + borderRadiusScale: + lerpDouble(borderRadiusScale, other.borderRadiusScale, t) ?? 1, + wallpaperOpacity: + lerpDouble(wallpaperOpacity, other.wallpaperOpacity, t) ?? 1); + } +} diff --git a/lib/theme/models/scale_theme/scale_theme.dart b/lib/theme/models/scale_theme/scale_theme.dart new file mode 100644 index 0000000..c1d41b2 --- /dev/null +++ b/lib/theme/models/scale_theme/scale_theme.dart @@ -0,0 +1,205 @@ +import 'package:flutter/material.dart'; + +import 'scale_input_decorator_theme.dart'; +import 'scale_scheme.dart'; + +export 'scale_app_bar_theme.dart'; +export 'scale_chat_theme.dart'; +export 'scale_color.dart'; +export 'scale_input_decorator_theme.dart'; +export 'scale_scheme.dart'; +export 'scale_tile_theme.dart'; +export 'scale_toast_theme.dart'; + +class ScaleTheme extends ThemeExtension { + ScaleTheme({ + required this.textTheme, + required this.scheme, + required this.config, + }); + + final TextTheme textTheme; + final ScaleScheme scheme; + final ScaleConfig config; + + @override + ScaleTheme copyWith({ + TextTheme? textTheme, + ScaleScheme? scheme, + ScaleConfig? config, + }) => + ScaleTheme( + textTheme: textTheme ?? this.textTheme, + scheme: scheme ?? this.scheme, + config: config ?? this.config, + ); + + @override + ScaleTheme lerp(ScaleTheme? other, double t) { + if (other is! ScaleTheme) { + return this; + } + return ScaleTheme( + textTheme: TextTheme.lerp(textTheme, other.textTheme, t), + scheme: scheme.lerp(other.scheme, t), + config: config.lerp(other.config, t)); + } + + WidgetStateProperty elementBorderWidgetStateProperty() => + WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return BorderSide( + color: scheme.grayScale.border.withAlpha(0x7F), + strokeAlign: BorderSide.strokeAlignOutside); + } else if (states.contains(WidgetState.pressed)) { + return BorderSide( + color: scheme.primaryScale.border, + ); + } else if (states.contains(WidgetState.hovered)) { + return BorderSide( + color: scheme.primaryScale.hoverBorder, + strokeAlign: BorderSide.strokeAlignOutside); + } else if (states.contains(WidgetState.focused)) { + return BorderSide( + color: scheme.primaryScale.hoverBorder, + width: 2, + strokeAlign: BorderSide.strokeAlignOutside); + } + return BorderSide( + color: scheme.primaryScale.border, + strokeAlign: BorderSide.strokeAlignOutside); + }); + + WidgetStateProperty elementColorWidgetStateProperty() => + WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return scheme.grayScale.primary.withAlpha(0x7F); + } else if (states.contains(WidgetState.pressed)) { + return scheme.primaryScale.borderText; + } else if (states.contains(WidgetState.hovered)) { + return scheme.primaryScale.borderText; + } else if (states.contains(WidgetState.focused)) { + return scheme.primaryScale.borderText; + } + return Color.lerp( + scheme.primaryScale.borderText, scheme.primaryScale.primary, 0.25); + }); + + WidgetStateProperty checkboxFillColorWidgetStateProperty() => + WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + if (states.contains(WidgetState.disabled)) { + return scheme.grayScale.primary.withAlpha(0x7F); + } else if (states.contains(WidgetState.pressed)) { + return scheme.primaryScale.hoverBorder; + } else if (states.contains(WidgetState.hovered)) { + return scheme.primaryScale.hoverBorder; + } else if (states.contains(WidgetState.focused)) { + return scheme.primaryScale.border; + } + return scheme.primaryScale.border; + } else { + return Colors.transparent; + } + }); + + // WidgetStateProperty elementBackgroundWidgetStateProperty() { + // return null; + // } + + ThemeData toThemeData(Brightness brightness) { + final colorScheme = scheme.toColorScheme(brightness); + + final baseThemeData = ThemeData.from( + colorScheme: colorScheme, textTheme: textTheme, useMaterial3: true); + + final elevatedButtonTheme = ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + elevation: 0, + textStyle: textTheme.labelSmall, + backgroundColor: scheme.primaryScale.elementBackground, + disabledBackgroundColor: + scheme.grayScale.elementBackground.withAlpha(0x7F), + disabledForegroundColor: + scheme.grayScale.primary.withAlpha(0x7F), + shape: RoundedRectangleBorder( + side: BorderSide(color: scheme.primaryScale.border), + borderRadius: + BorderRadius.circular(8 * config.borderRadiusScale))) + .copyWith( + foregroundColor: elementColorWidgetStateProperty(), + side: elementBorderWidgetStateProperty(), + iconColor: elementColorWidgetStateProperty(), + )); + + final sliderTheme = SliderThemeData.fromPrimaryColors( + primaryColor: scheme.primaryScale.hoverBorder, + primaryColorDark: scheme.primaryScale.border, + primaryColorLight: scheme.primaryScale.border, + valueIndicatorTextStyle: textTheme.labelMedium! + .copyWith(color: scheme.primaryScale.borderText)); + + final themeData = baseThemeData.copyWith( + scrollbarTheme: baseThemeData.scrollbarTheme.copyWith( + thumbColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) { + return scheme.primaryScale.border; + } else if (states.contains(WidgetState.hovered)) { + return scheme.primaryScale.hoverBorder; + } + return scheme.primaryScale.subtleBorder; + }), trackColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) { + return scheme.primaryScale.activeElementBackground; + } else if (states.contains(WidgetState.hovered)) { + return scheme.primaryScale.hoverElementBackground; + } + return scheme.primaryScale.elementBackground; + }), trackBorderColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) { + return scheme.primaryScale.subtleBorder; + } else if (states.contains(WidgetState.hovered)) { + return scheme.primaryScale.subtleBorder; + } + return scheme.primaryScale.subtleBorder; + })), + appBarTheme: baseThemeData.appBarTheme.copyWith( + backgroundColor: scheme.primaryScale.border, + foregroundColor: scheme.primaryScale.borderText, + toolbarHeight: 48, + ), + bottomSheetTheme: baseThemeData.bottomSheetTheme.copyWith( + elevation: 0, + modalElevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16 * config.borderRadiusScale), + topRight: Radius.circular(16 * config.borderRadiusScale)))), + canvasColor: scheme.primaryScale.subtleBackground, + checkboxTheme: baseThemeData.checkboxTheme.copyWith( + side: BorderSide(color: scheme.primaryScale.border, width: 2), + checkColor: elementColorWidgetStateProperty(), + fillColor: checkboxFillColorWidgetStateProperty(), + ), + chipTheme: baseThemeData.chipTheme.copyWith( + backgroundColor: scheme.primaryScale.elementBackground, + selectedColor: scheme.primaryScale.activeElementBackground, + surfaceTintColor: scheme.primaryScale.hoverElementBackground, + checkmarkColor: scheme.primaryScale.primary, + side: BorderSide(color: scheme.primaryScale.border)), + elevatedButtonTheme: elevatedButtonTheme, + inputDecorationTheme: + ScaleInputDecoratorTheme(scheme, config, textTheme), + sliderTheme: sliderTheme, + popupMenuTheme: PopupMenuThemeData( + color: scheme.primaryScale.elementBackground, + shadowColor: Colors.transparent, + shape: RoundedRectangleBorder( + side: BorderSide(color: scheme.primaryScale.border, width: 2), + borderRadius: + BorderRadius.circular(8 * config.borderRadiusScale))), + extensions: >[scheme, config, this]); + + return themeData; + } +} diff --git a/lib/theme/models/scale_theme/scale_tile_theme.dart b/lib/theme/models/scale_theme/scale_tile_theme.dart new file mode 100644 index 0000000..d549157 --- /dev/null +++ b/lib/theme/models/scale_theme/scale_tile_theme.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; + +import 'scale_theme.dart'; + +class ScaleTileTheme { + ScaleTileTheme( + {required this.textColor, + required this.backgroundColor, + required this.borderColor, + required this.shapeBorder, + required this.largeTextStyle, + required this.smallTextStyle}); + + final Color textColor; + final Color backgroundColor; + final Color borderColor; + final ShapeBorder shapeBorder; + final TextStyle largeTextStyle; + final TextStyle smallTextStyle; +} + +extension ScaleTileThemeExt on ScaleTheme { + ScaleTileTheme tileTheme( + {bool disabled = false, + bool selected = false, + ScaleKind scaleKind = ScaleKind.primary}) { + final tileColor = scheme.scale(!disabled ? scaleKind : ScaleKind.gray); + + final borderColor = selected ? tileColor.hoverBorder : tileColor.border; + final backgroundColor = config.useVisualIndicators && !selected + ? tileColor.borderText + : borderColor; + final textColor = config.useVisualIndicators && !selected + ? borderColor + : tileColor.borderText; + + final largeTextStyle = textTheme.labelMedium!.copyWith(color: textColor); + final smallTextStyle = textTheme.labelSmall!.copyWith(color: textColor); + + final shapeBorder = RoundedRectangleBorder( + side: config.useVisualIndicators + ? BorderSide( + width: 2, + color: borderColor, + ) + : BorderSide.none, + borderRadius: BorderRadius.circular(8 * config.borderRadiusScale)); + + return ScaleTileTheme( + textColor: textColor, + backgroundColor: backgroundColor, + borderColor: borderColor, + shapeBorder: shapeBorder, + largeTextStyle: largeTextStyle, + smallTextStyle: smallTextStyle, + ); + } +} diff --git a/lib/theme/models/scale_theme/scale_toast_theme.dart b/lib/theme/models/scale_theme/scale_toast_theme.dart new file mode 100644 index 0000000..61f119d --- /dev/null +++ b/lib/theme/models/scale_theme/scale_toast_theme.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; + +import 'scale_theme.dart'; + +enum ScaleToastKind { + info, + error, +} + +class ScaleToastTheme { + ScaleToastTheme( + {required this.primaryColor, + required this.backgroundColor, + required this.foregroundColor, + required this.borderSide, + required this.borderRadius, + required this.padding, + required this.icon, + required this.titleTextStyle, + required this.descriptionTextStyle}); + + final Color primaryColor; + final Color backgroundColor; + final Color foregroundColor; + final BorderSide? borderSide; + final BorderRadiusGeometry borderRadius; + final EdgeInsetsGeometry padding; + final Icon icon; + final TextStyle titleTextStyle; + final TextStyle descriptionTextStyle; +} + +extension ScaleToastThemeExt on ScaleTheme { + ScaleToastTheme toastTheme(ScaleToastKind kind) { + final toastScaleColor = scheme.scale(ScaleKind.tertiary); + + final primaryColor = toastScaleColor.calloutText; + final borderColor = toastScaleColor.border; + final backgroundColor = config.useVisualIndicators + ? toastScaleColor.calloutText + : toastScaleColor.calloutBackground; + final textColor = config.useVisualIndicators + ? toastScaleColor.calloutBackground + : toastScaleColor.calloutText; + final titleColor = config.useVisualIndicators + ? toastScaleColor.calloutBackground + : toastScaleColor.calloutText; + Icon icon; + switch (kind) { + case ScaleToastKind.info: + icon = Icon(Icons.info, size: 32, color: primaryColor); + case ScaleToastKind.error: + icon = Icon(Icons.dangerous, size: 32, color: primaryColor); + } + + return ScaleToastTheme( + primaryColor: primaryColor, + backgroundColor: backgroundColor, + foregroundColor: textColor, + borderSide: (config.useVisualIndicators || config.preferBorders) + ? BorderSide(color: borderColor, width: 2) + : const BorderSide(color: Colors.transparent, width: 0), + borderRadius: BorderRadius.circular(12 * config.borderRadiusScale), + padding: const EdgeInsets.all(8), + icon: icon, + titleTextStyle: textTheme.labelMedium!.copyWith(color: titleColor), + descriptionTextStyle: + textTheme.labelMedium!.copyWith(color: textColor)); + } +} diff --git a/lib/theme/models/theme_preference.dart b/lib/theme/models/theme_preference.dart new file mode 100644 index 0000000..44d06d8 --- /dev/null +++ b/lib/theme/models/theme_preference.dart @@ -0,0 +1,183 @@ +import 'package:change_case/change_case.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../init.dart'; +import '../views/widget_helpers.dart'; +import 'contrast_generator.dart'; +import 'radix_generator.dart'; +import 'scale_theme/scale_scheme.dart'; + +part 'theme_preference.freezed.dart'; +part 'theme_preference.g.dart'; + +// Theme supports light and dark mode, optionally selected by the +// operating system +enum BrightnessPreference { + system, + light, + dark; + + factory BrightnessPreference.fromJson(dynamic j) => + BrightnessPreference.values.byName((j as String).toCamelCase()); + + String toJson() => name.toPascalCase(); +} + +// Theme supports multiple color variants based on 'Radix' +enum ColorPreference { + // Radix Colors + scarlet, + babydoll, + vapor, + gold, + garden, + forest, + arctic, + lapis, + eggplant, + lime, + grim, + // Accessible Colors + elite, + contrast; + + factory ColorPreference.fromJson(dynamic j) => + ColorPreference.values.byName((j as String).toCamelCase()); + String toJson() => name.toPascalCase(); +} + +@freezed +sealed class ThemePreferences with _$ThemePreferences { + const factory ThemePreferences({ + @Default(BrightnessPreference.system) + BrightnessPreference brightnessPreference, + @Default(ColorPreference.vapor) ColorPreference colorPreference, + @Default(1) double displayScale, + @Default(true) bool enableWallpaper, + }) = _ThemePreferences; + + factory ThemePreferences.fromJson(dynamic json) => + _$ThemePreferencesFromJson(json as Map); + + static const defaults = ThemePreferences(); +} + +extension ThemePreferencesExt on ThemePreferences { + /// Get wallpaper for existing theme + Widget? wallpaper() { + if (enableWallpaper) { + final assetName = 'assets/images/wallpaper/${colorPreference.name}.svg'; + if (rootAssets.contains(assetName)) { + return SvgPicture.asset(assetName, fit: BoxFit.cover); + } + } + return null; + } + + /// Get material 'ThemeData' for existing theme + ThemeData themeData() { + late final Brightness brightness; + switch (brightnessPreference) { + case BrightnessPreference.system: + if (isPlatformDark) { + brightness = Brightness.dark; + } else { + brightness = Brightness.light; + } + case BrightnessPreference.light: + brightness = Brightness.light; + case BrightnessPreference.dark: + brightness = Brightness.dark; + } + + late final ThemeData themeData; + switch (colorPreference) { + // Special cases + case ColorPreference.contrast: + themeData = contrastGenerator( + brightness: brightness, + scaleConfig: ScaleConfig( + useVisualIndicators: true, + preferBorders: false, + borderRadiusScale: 1, + wallpaperOpacity: 255), + primaryFront: Colors.black, + primaryBack: Colors.white, + secondaryFront: Colors.black, + secondaryBack: Colors.white, + tertiaryFront: Colors.black, + tertiaryBack: Colors.white, + grayFront: Colors.black, + grayBack: Colors.white, + errorFront: Colors.black, + errorBack: Colors.white, + ); + case ColorPreference.elite: + themeData = brightness == Brightness.light + ? contrastGenerator( + brightness: Brightness.light, + scaleConfig: ScaleConfig( + useVisualIndicators: true, + preferBorders: true, + borderRadiusScale: 0.2, + wallpaperOpacity: 208), + primaryFront: const Color(0xFF000000), + primaryBack: const Color(0xFF00FF00), + secondaryFront: const Color(0xFF000000), + secondaryBack: const Color(0xFF00FFFF), + tertiaryFront: const Color(0xFF000000), + tertiaryBack: const Color(0xFFFF00FF), + grayFront: const Color(0xFF000000), + grayBack: const Color(0xFFFFFFFF), + errorFront: const Color(0xFFC0C0C0), + errorBack: const Color(0xFF0000FF), + customTextTheme: makeMonoSpaceTextTheme(Brightness.light)) + : contrastGenerator( + brightness: Brightness.dark, + scaleConfig: ScaleConfig( + useVisualIndicators: true, + preferBorders: true, + borderRadiusScale: 0.2, + wallpaperOpacity: 192), + primaryFront: const Color(0xFF000000), + primaryBack: const Color(0xFF00FF00), + secondaryFront: const Color(0xFF000000), + secondaryBack: const Color(0xFF00FFFF), + tertiaryFront: const Color(0xFF000000), + tertiaryBack: const Color(0xFFFF00FF), + grayFront: const Color(0xFF000000), + grayBack: const Color(0xFFFFFFFF), + errorFront: const Color(0xFF0000FF), + errorBack: const Color(0xFFC0C0C0), + customTextTheme: makeMonoSpaceTextTheme(Brightness.dark), + ); + // Generate from Radix + case ColorPreference.scarlet: + themeData = radixGenerator(brightness, RadixThemeColor.scarlet); + case ColorPreference.babydoll: + themeData = radixGenerator(brightness, RadixThemeColor.babydoll); + case ColorPreference.vapor: + themeData = radixGenerator(brightness, RadixThemeColor.vapor); + case ColorPreference.gold: + themeData = radixGenerator(brightness, RadixThemeColor.gold); + case ColorPreference.garden: + themeData = radixGenerator(brightness, RadixThemeColor.garden); + case ColorPreference.forest: + themeData = radixGenerator(brightness, RadixThemeColor.forest); + case ColorPreference.arctic: + themeData = radixGenerator(brightness, RadixThemeColor.arctic); + case ColorPreference.lapis: + themeData = radixGenerator(brightness, RadixThemeColor.lapis); + case ColorPreference.eggplant: + themeData = radixGenerator(brightness, RadixThemeColor.eggplant); + case ColorPreference.lime: + themeData = radixGenerator(brightness, RadixThemeColor.lime); + case ColorPreference.grim: + themeData = radixGenerator(brightness, RadixThemeColor.grim); + } + + return themeData; + } +} diff --git a/lib/theme/models/theme_preference.freezed.dart b/lib/theme/models/theme_preference.freezed.dart new file mode 100644 index 0000000..c915bca --- /dev/null +++ b/lib/theme/models/theme_preference.freezed.dart @@ -0,0 +1,231 @@ +// dart format width=80 +// 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 'theme_preference.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$ThemePreferences { + BrightnessPreference get brightnessPreference; + ColorPreference get colorPreference; + double get displayScale; + bool get enableWallpaper; + + /// Create a copy of ThemePreferences + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $ThemePreferencesCopyWith get copyWith => + _$ThemePreferencesCopyWithImpl( + this as ThemePreferences, _$identity); + + /// Serializes this ThemePreferences to a JSON map. + Map toJson(); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is ThemePreferences && + (identical(other.brightnessPreference, brightnessPreference) || + other.brightnessPreference == brightnessPreference) && + (identical(other.colorPreference, colorPreference) || + other.colorPreference == colorPreference) && + (identical(other.displayScale, displayScale) || + other.displayScale == displayScale) && + (identical(other.enableWallpaper, enableWallpaper) || + other.enableWallpaper == enableWallpaper)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, brightnessPreference, + colorPreference, displayScale, enableWallpaper); + + @override + String toString() { + return 'ThemePreferences(brightnessPreference: $brightnessPreference, colorPreference: $colorPreference, displayScale: $displayScale, enableWallpaper: $enableWallpaper)'; + } +} + +/// @nodoc +abstract mixin class $ThemePreferencesCopyWith<$Res> { + factory $ThemePreferencesCopyWith( + ThemePreferences value, $Res Function(ThemePreferences) _then) = + _$ThemePreferencesCopyWithImpl; + @useResult + $Res call( + {BrightnessPreference brightnessPreference, + ColorPreference colorPreference, + double displayScale, + bool enableWallpaper}); +} + +/// @nodoc +class _$ThemePreferencesCopyWithImpl<$Res> + implements $ThemePreferencesCopyWith<$Res> { + _$ThemePreferencesCopyWithImpl(this._self, this._then); + + final ThemePreferences _self; + final $Res Function(ThemePreferences) _then; + + /// Create a copy of ThemePreferences + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? brightnessPreference = null, + Object? colorPreference = null, + Object? displayScale = null, + Object? enableWallpaper = null, + }) { + return _then(_self.copyWith( + brightnessPreference: null == brightnessPreference + ? _self.brightnessPreference + : brightnessPreference // ignore: cast_nullable_to_non_nullable + as BrightnessPreference, + colorPreference: null == colorPreference + ? _self.colorPreference + : colorPreference // ignore: cast_nullable_to_non_nullable + as ColorPreference, + displayScale: null == displayScale + ? _self.displayScale + : displayScale // ignore: cast_nullable_to_non_nullable + as double, + enableWallpaper: null == enableWallpaper + ? _self.enableWallpaper + : enableWallpaper // ignore: cast_nullable_to_non_nullable + as bool, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _ThemePreferences implements ThemePreferences { + const _ThemePreferences( + {this.brightnessPreference = BrightnessPreference.system, + this.colorPreference = ColorPreference.vapor, + this.displayScale = 1, + this.enableWallpaper = true}); + factory _ThemePreferences.fromJson(Map json) => + _$ThemePreferencesFromJson(json); + + @override + @JsonKey() + final BrightnessPreference brightnessPreference; + @override + @JsonKey() + final ColorPreference colorPreference; + @override + @JsonKey() + final double displayScale; + @override + @JsonKey() + final bool enableWallpaper; + + /// Create a copy of ThemePreferences + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$ThemePreferencesCopyWith<_ThemePreferences> get copyWith => + __$ThemePreferencesCopyWithImpl<_ThemePreferences>(this, _$identity); + + @override + Map toJson() { + return _$ThemePreferencesToJson( + this, + ); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _ThemePreferences && + (identical(other.brightnessPreference, brightnessPreference) || + other.brightnessPreference == brightnessPreference) && + (identical(other.colorPreference, colorPreference) || + other.colorPreference == colorPreference) && + (identical(other.displayScale, displayScale) || + other.displayScale == displayScale) && + (identical(other.enableWallpaper, enableWallpaper) || + other.enableWallpaper == enableWallpaper)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, brightnessPreference, + colorPreference, displayScale, enableWallpaper); + + @override + String toString() { + return 'ThemePreferences(brightnessPreference: $brightnessPreference, colorPreference: $colorPreference, displayScale: $displayScale, enableWallpaper: $enableWallpaper)'; + } +} + +/// @nodoc +abstract mixin class _$ThemePreferencesCopyWith<$Res> + implements $ThemePreferencesCopyWith<$Res> { + factory _$ThemePreferencesCopyWith( + _ThemePreferences value, $Res Function(_ThemePreferences) _then) = + __$ThemePreferencesCopyWithImpl; + @override + @useResult + $Res call( + {BrightnessPreference brightnessPreference, + ColorPreference colorPreference, + double displayScale, + bool enableWallpaper}); +} + +/// @nodoc +class __$ThemePreferencesCopyWithImpl<$Res> + implements _$ThemePreferencesCopyWith<$Res> { + __$ThemePreferencesCopyWithImpl(this._self, this._then); + + final _ThemePreferences _self; + final $Res Function(_ThemePreferences) _then; + + /// Create a copy of ThemePreferences + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? brightnessPreference = null, + Object? colorPreference = null, + Object? displayScale = null, + Object? enableWallpaper = null, + }) { + return _then(_ThemePreferences( + brightnessPreference: null == brightnessPreference + ? _self.brightnessPreference + : brightnessPreference // ignore: cast_nullable_to_non_nullable + as BrightnessPreference, + colorPreference: null == colorPreference + ? _self.colorPreference + : colorPreference // ignore: cast_nullable_to_non_nullable + as ColorPreference, + displayScale: null == displayScale + ? _self.displayScale + : displayScale // ignore: cast_nullable_to_non_nullable + as double, + enableWallpaper: null == enableWallpaper + ? _self.enableWallpaper + : enableWallpaper // ignore: cast_nullable_to_non_nullable + as bool, + )); + } +} + +// dart format on diff --git a/lib/theme/models/theme_preference.g.dart b/lib/theme/models/theme_preference.g.dart new file mode 100644 index 0000000..f052e2c --- /dev/null +++ b/lib/theme/models/theme_preference.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'theme_preference.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_ThemePreferences _$ThemePreferencesFromJson(Map json) => + _ThemePreferences( + brightnessPreference: json['brightness_preference'] == null + ? BrightnessPreference.system + : BrightnessPreference.fromJson(json['brightness_preference']), + colorPreference: json['color_preference'] == null + ? ColorPreference.vapor + : ColorPreference.fromJson(json['color_preference']), + displayScale: (json['display_scale'] as num?)?.toDouble() ?? 1, + enableWallpaper: json['enable_wallpaper'] as bool? ?? true, + ); + +Map _$ThemePreferencesToJson(_ThemePreferences instance) => + { + 'brightness_preference': instance.brightnessPreference.toJson(), + 'color_preference': instance.colorPreference.toJson(), + 'display_scale': instance.displayScale, + 'enable_wallpaper': instance.enableWallpaper, + }; diff --git a/lib/theme/theme.dart b/lib/theme/theme.dart new file mode 100644 index 0000000..3e9c176 --- /dev/null +++ b/lib/theme/theme.dart @@ -0,0 +1,2 @@ +export 'models/models.dart'; +export 'views/views.dart'; diff --git a/lib/components/enter_password.dart b/lib/theme/views/enter_password.dart similarity index 77% rename from lib/components/enter_password.dart rename to lib/theme/views/enter_password.dart index a1b06ab..f28b69e 100644 --- a/lib/components/enter_password.dart +++ b/lib/theme/views/enter_password.dart @@ -2,12 +2,11 @@ import 'package:awesome_extensions/awesome_extensions.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_translate/flutter_translate.dart'; -import '../tools/tools.dart'; +import '../theme.dart'; -class EnterPasswordDialog extends ConsumerStatefulWidget { +class EnterPasswordDialog extends StatefulWidget { const EnterPasswordDialog({ this.matchPass, this.description, @@ -18,7 +17,7 @@ class EnterPasswordDialog extends ConsumerStatefulWidget { final String? description; @override - EnterPasswordDialogState createState() => EnterPasswordDialogState(); + State createState() => _EnterPasswordDialogState(); @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { @@ -29,11 +28,11 @@ class EnterPasswordDialog extends ConsumerStatefulWidget { } } -class EnterPasswordDialogState extends ConsumerState { +class _EnterPasswordDialogState extends State { final passwordController = TextEditingController(); final focusNode = FocusNode(); final formKey = GlobalKey(); - bool _passwordVisible = false; + var _passwordVisible = false; @override void initState() { @@ -48,32 +47,27 @@ class EnterPasswordDialogState extends ConsumerState { } @override - // ignore: prefer_expression_function_bodies Widget build(BuildContext context) { final theme = Theme.of(context); final scale = theme.extension()!; - return Dialog( - backgroundColor: scale.grayScale.subtleBackground, + return StyledDialog( + title: widget.matchPass == null + ? translate('enter_password_dialog.enter_password') + : translate('enter_password_dialog.reenter_password'), child: Form( key: formKey, child: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ - Text( - widget.matchPass == null - ? translate('enter_password_dialog.enter_password') - : translate('enter_password_dialog.reenter_password'), - style: theme.textTheme.titleLarge, - ).paddingAll(16), TextField( controller: passwordController, focusNode: focusNode, autofocus: true, enableSuggestions: false, - obscureText: - !_passwordVisible, //This will obscure text dynamically + obscureText: !_passwordVisible, + obscuringCharacter: '*', inputFormatters: [ FilteringTextInputFormatter.singleLineFormatter ], @@ -88,14 +82,14 @@ class EnterPasswordDialogState extends ConsumerState { ? null : Icon(Icons.check_circle, color: passwordController.text == widget.matchPass - ? scale.primaryScale.background + ? scale.primaryScale.primary : scale.grayScale.subtleBackground), suffixIcon: IconButton( icon: Icon( _passwordVisible ? Icons.visibility : Icons.visibility_off, - color: scale.primaryScale.text, + color: scale.primaryScale.appText, ), onPressed: () { setState(() { diff --git a/lib/components/enter_pin.dart b/lib/theme/views/enter_pin.dart similarity index 85% rename from lib/components/enter_pin.dart rename to lib/theme/views/enter_pin.dart index a8def81..f4055c1 100644 --- a/lib/components/enter_pin.dart +++ b/lib/theme/views/enter_pin.dart @@ -2,13 +2,12 @@ import 'package:awesome_extensions/awesome_extensions.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_translate/flutter_translate.dart'; import 'package:pinput/pinput.dart'; -import '../tools/tools.dart'; +import '../theme.dart'; -class EnterPinDialog extends ConsumerStatefulWidget { +class EnterPinDialog extends StatefulWidget { const EnterPinDialog({ required this.reenter, required this.description, @@ -19,7 +18,7 @@ class EnterPinDialog extends ConsumerStatefulWidget { final String? description; @override - EnterPinDialogState createState() => EnterPinDialogState(); + State createState() => _EnterPinDialogState(); @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { @@ -30,7 +29,7 @@ class EnterPinDialog extends ConsumerStatefulWidget { } } -class EnterPinDialogState extends ConsumerState { +class _EnterPinDialogState extends State { final pinController = TextEditingController(); final focusNode = FocusNode(); final formKey = GlobalKey(); @@ -52,6 +51,7 @@ class EnterPinDialogState extends ConsumerState { Widget build(BuildContext context) { final theme = Theme.of(context); final scale = theme.extension()!; + final scaleConfig = theme.extension()!; final focusedBorderColor = scale.primaryScale.hoverBorder; final fillColor = scale.primaryScale.elementBackground; final borderColor = scale.primaryScale.border; @@ -59,29 +59,25 @@ class EnterPinDialogState extends ConsumerState { final defaultPinTheme = PinTheme( width: 56, height: 60, - textStyle: TextStyle(fontSize: 22, color: scale.primaryScale.text), + textStyle: TextStyle(fontSize: 22, color: scale.primaryScale.appText), decoration: BoxDecoration( color: fillColor, - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(8 * scaleConfig.borderRadiusScale), border: Border.all(color: borderColor), ), ); /// Optionally you can use form to validate the Pinput - return Dialog( - backgroundColor: scale.grayScale.subtleBackground, + return StyledDialog( + title: !widget.reenter + ? translate('enter_pin_dialog.enter_pin') + : translate('enter_pin_dialog.reenter_pin'), child: Form( key: formKey, child: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ - Text( - !widget.reenter - ? translate('enter_pin_dialog.enter_pin') - : translate('enter_pin_dialog.reenter_pin'), - style: theme.textTheme.titleLarge, - ).paddingAll(16), Directionality( // Specify direction if desired textDirection: TextDirection.ltr, diff --git a/lib/theme/views/pop_control.dart b/lib/theme/views/pop_control.dart new file mode 100644 index 0000000..d2e98f9 --- /dev/null +++ b/lib/theme/views/pop_control.dart @@ -0,0 +1,131 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class PopControl extends StatelessWidget { + const PopControl({ + required this.child, + required this.dismissible, + super.key, + }); + + void _doDismiss(BuildContext context) { + if (!dismissible) { + return; + } + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final route = ModalRoute.of(context); + if (route != null && route is PopControlDialogRoute) { + WidgetsBinding.instance.addPostFrameCallback((_) { + route.barrierDismissible = dismissible; + }); + } + + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (didPop) { + return; + } + _doDismiss(context); + return; + }, + child: child); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('dismissible', dismissible)); + } + + final bool dismissible; + final Widget child; +} + +class PopControlDialogRoute extends DialogRoute { + PopControlDialogRoute( + {required super.context, + required super.builder, + super.themes, + super.barrierColor = Colors.black54, + super.barrierDismissible, + super.barrierLabel, + super.useSafeArea, + super.settings, + super.anchorPoint, + super.traversalEdgeBehavior}) + : _barrierDismissible = barrierDismissible; + + @override + bool get barrierDismissible => _barrierDismissible; + + set barrierDismissible(bool d) { + _barrierDismissible = d; + changedInternalState(); + } + + bool _barrierDismissible; +} + +bool _debugIsActive(BuildContext context) { + if (context is Element && !context.debugIsActive) { + throw FlutterError.fromParts([ + ErrorSummary('This BuildContext is no longer valid.'), + ErrorDescription( + 'The showPopControlDialog function context parameter is a ' + 'BuildContext that is no longer valid.'), + ErrorHint( + 'This can commonly occur when the showPopControlDialog function is ' + 'called after awaiting a Future. ' + 'In this situation the BuildContext might refer to a widget that has ' + 'already been disposed during the await. ' + 'Consider using a parent context instead.', + ), + ]); + } + return true; +} + +Future showPopControlDialog({ + required BuildContext context, + required WidgetBuilder builder, + bool barrierDismissible = true, + Color? barrierColor, + String? barrierLabel, + bool useSafeArea = true, + bool useRootNavigator = true, + RouteSettings? routeSettings, + Offset? anchorPoint, + TraversalEdgeBehavior? traversalEdgeBehavior, +}) { + assert(_debugIsActive(context), 'debug is active check'); + assert(debugCheckHasMaterialLocalizations(context), + 'check has material localizations'); + + final themes = InheritedTheme.capture( + from: context, + to: Navigator.of( + context, + rootNavigator: useRootNavigator, + ).context, + ); + + return Navigator.of(context, rootNavigator: useRootNavigator) + .push(PopControlDialogRoute( + context: context, + builder: builder, + barrierColor: barrierColor ?? Colors.black54, + barrierDismissible: barrierDismissible, + barrierLabel: barrierLabel, + useSafeArea: useSafeArea, + settings: routeSettings, + themes: themes, + anchorPoint: anchorPoint, + traversalEdgeBehavior: + traversalEdgeBehavior ?? TraversalEdgeBehavior.closedLoop, + )); +} diff --git a/lib/theme/views/preferences/brightness_preferences.dart b/lib/theme/views/preferences/brightness_preferences.dart new file mode 100644 index 0000000..a149483 --- /dev/null +++ b/lib/theme/views/preferences/brightness_preferences.dart @@ -0,0 +1,40 @@ +import 'package:animated_theme_switcher/animated_theme_switcher.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_translate/flutter_translate.dart'; + +import '../../../settings/settings.dart'; +import '../../models/models.dart'; +import '../views.dart'; + +List> _getBrightnessDropdownItems() { + const brightnessPrefs = BrightnessPreference.values; + final brightnessNames = { + BrightnessPreference.system: translate('brightness.system'), + BrightnessPreference.light: translate('brightness.light'), + BrightnessPreference.dark: translate('brightness.dark') + }; + + return brightnessPrefs + .map((e) => DropdownMenuItem(value: e, child: Text(brightnessNames[e]!))) + .toList(); +} + +Widget buildSettingsPageBrightnessPreferences( + {required BuildContext context, required ThemeSwitcherState switcher}) { + final preferencesRepository = PreferencesRepository.instance; + final themePreferences = preferencesRepository.value.themePreference; + + return StyledDropdown( + items: _getBrightnessDropdownItems(), + value: themePreferences.brightnessPreference, + decoratorLabel: translate('settings_page.brightness_mode'), + onChanged: (value) async { + final newThemePrefs = + themePreferences.copyWith(brightnessPreference: value); + final newPrefs = preferencesRepository.value + .copyWith(themePreference: newThemePrefs); + + await preferencesRepository.set(newPrefs); + switcher.changeTheme(theme: newThemePrefs.themeData()); + }); +} diff --git a/lib/theme/views/preferences/color_preferences.dart b/lib/theme/views/preferences/color_preferences.dart new file mode 100644 index 0000000..2c14a93 --- /dev/null +++ b/lib/theme/views/preferences/color_preferences.dart @@ -0,0 +1,49 @@ +import 'package:animated_theme_switcher/animated_theme_switcher.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_translate/flutter_translate.dart'; + +import '../../../settings/settings.dart'; +import '../../models/models.dart'; +import '../views.dart'; + +List> _getThemeDropdownItems() { + const colorPrefs = ColorPreference.values; + final colorNames = { + ColorPreference.scarlet: translate('themes.scarlet'), + ColorPreference.vapor: translate('themes.vapor'), + ColorPreference.babydoll: translate('themes.babydoll'), + ColorPreference.gold: translate('themes.gold'), + ColorPreference.garden: translate('themes.garden'), + ColorPreference.forest: translate('themes.forest'), + ColorPreference.arctic: translate('themes.arctic'), + ColorPreference.lapis: translate('themes.lapis'), + ColorPreference.eggplant: translate('themes.eggplant'), + ColorPreference.lime: translate('themes.lime'), + ColorPreference.grim: translate('themes.grim'), + ColorPreference.elite: translate('themes.elite'), + ColorPreference.contrast: translate('themes.contrast') + }; + + return colorPrefs + .map((e) => DropdownMenuItem(value: e, child: Text(colorNames[e]!))) + .toList(); +} + +Widget buildSettingsPageColorPreferences( + {required BuildContext context, required ThemeSwitcherState switcher}) { + final preferencesRepository = PreferencesRepository.instance; + final themePreferences = preferencesRepository.value.themePreference; + + return StyledDropdown( + items: _getThemeDropdownItems(), + value: themePreferences.colorPreference, + decoratorLabel: translate('settings_page.color_theme'), + onChanged: (value) async { + final newThemePrefs = themePreferences.copyWith(colorPreference: value); + final newPrefs = preferencesRepository.value + .copyWith(themePreference: newThemePrefs); + + await preferencesRepository.set(newPrefs); + switcher.changeTheme(theme: newThemePrefs.themeData()); + }); +} diff --git a/lib/theme/views/preferences/display_scale_preferences.dart b/lib/theme/views/preferences/display_scale_preferences.dart new file mode 100644 index 0000000..c056280 --- /dev/null +++ b/lib/theme/views/preferences/display_scale_preferences.dart @@ -0,0 +1,127 @@ +import 'dart:math'; + +import 'package:animated_theme_switcher/animated_theme_switcher.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_translate/flutter_translate.dart'; + +import '../../../settings/settings.dart'; +import '../../models/models.dart'; +import '../views.dart'; + +const _scales = [ + 1 / (1 + 1 / 2), + 1 / (1 + 1 / 3), + 1 / (1 + 1 / 4), + 1, + 1 + (1 / 4), + 1 + (1 / 2), + 1 + (1 / 1), +]; +const _scaleNames = [ + '-3', + '-2', + '-1', + '0', + '1', + '2', + '3', +]; + +const _scaleNumMult = [ + 1 / (1 + 1 / 2), + 1 / (1 + 1 / 3), + 1 / (1 + 1 / 4), + 1, + 1 + 1 / 4, + 1 + 1 / 2, + 1 + 1 / 1, +]; + +const _scaleNumMultNoShrink = [ + 1, + 1, + 1, + 1, + 1 + 1 / 4, + 1 + 1 / 2, + 1 + 1 / 1, +]; + +int displayScaleToIndex(double displayScale) { + final idx = _scales.indexWhere((elem) => elem > displayScale); + final currentScaleIdx = idx == -1 ? _scales.length - 1 : max(0, idx - 1); + return currentScaleIdx; +} + +double indexToDisplayScale(int scaleIdx) { + final displayScale = + _scales[max(min(scaleIdx, _scales.length - 1), 0)].toDouble(); + return displayScale; +} + +String indexToDisplayScaleName(int scaleIdx) => + _scaleNames[max(min(scaleIdx, _scales.length - 1), 0)]; + +final maxDisplayScaleIndex = _scales.length - 1; + +Widget buildSettingsPageDisplayScalePreferences( + {required BuildContext context, required ThemeSwitcherState switcher}) { + final preferencesRepository = PreferencesRepository.instance; + final themePreferences = preferencesRepository.value.themePreference; + + final currentScaleIdx = displayScaleToIndex(themePreferences.displayScale); + final currentScaleName = indexToDisplayScaleName(currentScaleIdx); + + return StyledSlider( + value: currentScaleIdx.toDouble(), + label: currentScaleName, + decoratorLabel: translate('settings_page.display_scale'), + max: _scales.length - 1.toDouble(), + divisions: _scales.length - 1, + leftWidget: const Icon(Icons.text_decrease), + rightWidget: const Icon(Icons.text_increase), + onChanged: (value) async { + final scaleIdx = value.toInt(); + final displayScale = indexToDisplayScale(scaleIdx); + final newThemePrefs = + themePreferences.copyWith(displayScale: displayScale); + final newPrefs = preferencesRepository.value + .copyWith(themePreference: newThemePrefs); + + await preferencesRepository.set(newPrefs); + switcher.changeTheme(theme: newThemePrefs.themeData()); + }); +} + +extension DisplayScaledNum on num { + double scaled(BuildContext context) { + final prefs = context.watch().state.asData?.value ?? + PreferencesRepository.instance.value; + final currentScaleIdx = + displayScaleToIndex(prefs.themePreference.displayScale); + return this * _scaleNumMult[currentScaleIdx]; + } + + double scaledNoShrink(BuildContext context) { + final prefs = context.watch().state.asData?.value ?? + PreferencesRepository.instance.value; + final currentScaleIdx = + displayScaleToIndex(prefs.themePreference.displayScale); + return this * _scaleNumMultNoShrink[currentScaleIdx]; + } +} + +extension DisplayScaledEdgeInsets on EdgeInsets { + EdgeInsets scaled(BuildContext context) { + final prefs = context.watch().state.asData?.value ?? + PreferencesRepository.instance.value; + final currentScaleIdx = + displayScaleToIndex(prefs.themePreference.displayScale); + return EdgeInsets.fromLTRB( + left * _scaleNumMult[currentScaleIdx], + top * _scaleNumMult[currentScaleIdx], + right * _scaleNumMult[currentScaleIdx], + bottom * _scaleNumMult[currentScaleIdx]); + } +} diff --git a/lib/theme/views/preferences/preferences.dart b/lib/theme/views/preferences/preferences.dart new file mode 100644 index 0000000..ddac4c1 --- /dev/null +++ b/lib/theme/views/preferences/preferences.dart @@ -0,0 +1,4 @@ +export 'brightness_preferences.dart'; +export 'color_preferences.dart'; +export 'display_scale_preferences.dart'; +export 'wallpaper_preferences.dart'; diff --git a/lib/theme/views/preferences/wallpaper_preferences.dart b/lib/theme/views/preferences/wallpaper_preferences.dart new file mode 100644 index 0000000..050e294 --- /dev/null +++ b/lib/theme/views/preferences/wallpaper_preferences.dart @@ -0,0 +1,25 @@ +import 'package:animated_theme_switcher/animated_theme_switcher.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_translate/flutter_translate.dart'; + +import '../../../settings/settings.dart'; +import '../../models/models.dart'; +import '../views.dart'; + +Widget buildSettingsPageWallpaperPreferences( + {required BuildContext context, required ThemeSwitcherState switcher}) { + final preferencesRepository = PreferencesRepository.instance; + final themePreferences = preferencesRepository.value.themePreference; + + return StyledCheckbox( + value: themePreferences.enableWallpaper, + label: translate('settings_page.enable_wallpaper'), + onChanged: (value) async { + final newThemePrefs = themePreferences.copyWith(enableWallpaper: value); + final newPrefs = preferencesRepository.value + .copyWith(themePreference: newThemePrefs); + + await preferencesRepository.set(newPrefs); + switcher.changeTheme(theme: newThemePrefs.themeData()); + }); +} diff --git a/lib/theme/views/recovery_key_widget.dart b/lib/theme/views/recovery_key_widget.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/theme/views/responsive.dart b/lib/theme/views/responsive.dart new file mode 100644 index 0000000..4ce42d8 --- /dev/null +++ b/lib/theme/views/responsive.dart @@ -0,0 +1,53 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +final isAndroid = !kIsWeb && defaultTargetPlatform == TargetPlatform.android; +final isiOS = !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS; +final isMac = !kIsWeb && defaultTargetPlatform == TargetPlatform.macOS; +final isWindows = !kIsWeb && defaultTargetPlatform == TargetPlatform.windows; +final isLinux = !kIsWeb && defaultTargetPlatform == TargetPlatform.linux; + +final isMobile = !kIsWeb && + (defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.android); +final isDesktop = !kIsWeb && + !(defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.android); + +const isWeb = kIsWeb; +final isWebMobile = kIsWeb && + (defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.android); +final isWebDesktop = kIsWeb && + !(defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.android); + +final isAnyMobile = isMobile || isWebMobile; + +const kMobileWidthCutoff = 500.0; + +bool isMobileWidth(BuildContext context) => + MediaQuery.of(context).size.width < kMobileWidthCutoff; + +bool isMobileSize(BuildContext context) => + MediaQuery.of(context).size.width < kMobileWidthCutoff || + MediaQuery.of(context).size.height < kMobileWidthCutoff; + +bool responsiveVisibility({ + required BuildContext context, + bool phone = true, + bool tablet = true, + bool tabletLandscape = true, + bool desktop = true, +}) { + final width = MediaQuery.of(context).size.width; + if (width < kMobileWidthCutoff) { + return phone; + } else if (width < 767) { + return tablet; + } else if (width < 991) { + return tabletLandscape; + } else { + return desktop; + } +} diff --git a/lib/theme/views/styled_widgets/styled_alert.dart b/lib/theme/views/styled_widgets/styled_alert.dart new file mode 100644 index 0000000..4dec616 --- /dev/null +++ b/lib/theme/views/styled_widgets/styled_alert.dart @@ -0,0 +1,243 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:rflutter_alert/rflutter_alert.dart'; + +import '../../theme.dart'; + +AlertStyle _alertStyle(BuildContext context) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + + return AlertStyle( + animationType: AnimationType.grow, + isCloseButton: false, + //animationDuration: const Duration(milliseconds: 200), + alertBorder: RoundedRectangleBorder( + side: !scaleConfig.useVisualIndicators + ? BorderSide.none + : BorderSide( + strokeAlign: BorderSide.strokeAlignCenter, + color: scale.primaryScale.border, + width: 2), + borderRadius: BorderRadius.all( + Radius.circular(12 * scaleConfig.borderRadiusScale))), + // isButtonVisible: true, + // isCloseButton: true, + // isOverlayTapDismiss: true, + backgroundColor: scale.primaryScale.subtleBackground, + // overlayColor: Colors.black87, + titleStyle: theme.textTheme.titleMedium! + .copyWith(color: scale.primaryScale.appText), + // titleTextAlign: TextAlign.center, + descStyle: + theme.textTheme.bodyMedium!.copyWith(color: scale.primaryScale.appText), + // descTextAlign: TextAlign.center, + // buttonAreaPadding: const EdgeInsets.all(20.0), + // constraints: null, + // buttonsDirection: ButtonsDirection.row, + // alertElevation: null, + // alertPadding: defaultAlertPadding, + // alertAlignment: Alignment.center, + // isTitleSelectable: false, + // isDescSelectable: false, + // titlePadding: null, + //descPadding: const EdgeInsets.all(0.0), + ); +} + +Color _buttonColor(BuildContext context, bool highlight) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + + if (scaleConfig.useVisualIndicators && !scaleConfig.preferBorders) { + return scale.secondaryScale.border; + } + + return highlight + ? scale.secondaryScale.elementBackground + : scale.secondaryScale.hoverElementBackground; +} + +TextStyle _buttonTextStyle(BuildContext context) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + + if (scaleConfig.useVisualIndicators && !scaleConfig.preferBorders) { + return theme.textTheme.bodyMedium! + .copyWith(color: scale.secondaryScale.borderText); + } + + return theme.textTheme.bodyMedium! + .copyWith(color: scale.secondaryScale.appText); +} + +BoxBorder _buttonBorder(BuildContext context) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + + return Border.fromBorderSide(BorderSide( + color: scale.secondaryScale.border, + width: scaleConfig.preferBorders ? 2 : 0)); +} + +BorderRadius _buttonRadius(BuildContext context) { + final theme = Theme.of(context); + final scaleConfig = theme.extension()!; + + return BorderRadius.circular(8 * scaleConfig.borderRadiusScale); +} + +Future showErrorModal( + {required BuildContext context, + required String title, + required String text}) async { + await Alert( + context: context, + style: _alertStyle(context), + useRootNavigator: false, + type: AlertType.error, + title: title, + desc: text, + buttons: [ + DialogButton( + color: _buttonColor(context, false), + highlightColor: _buttonColor(context, true), + border: _buttonBorder(context), + radius: _buttonRadius(context), + width: 120, + onPressed: () { + Navigator.pop(context); + }, + child: Text( + translate('button.ok'), + style: _buttonTextStyle(context), + ), + ) + ], + ).show(); +} + +Future showErrorStacktraceModal( + {required BuildContext context, + required Object error, + StackTrace? stackTrace}) async { + await showErrorModal( + context: context, + title: translate('toast.error'), + text: 'Error: $error\n StackTrace: $stackTrace', + ); +} + +Future showAlertModal( + {required BuildContext context, + required String title, + required String text}) async { + await Alert( + context: context, + style: _alertStyle(context), + useRootNavigator: false, + type: AlertType.none, + title: title, + desc: text, + buttons: [ + DialogButton( + color: _buttonColor(context, false), + highlightColor: _buttonColor(context, true), + border: _buttonBorder(context), + radius: _buttonRadius(context), + width: 120, + onPressed: () { + Navigator.pop(context); + }, + child: Text( + translate('button.ok'), + style: _buttonTextStyle(context), + ), + ) + ], + ).show(); +} + +Future showAlertWidgetModal( + {required BuildContext context, + required String title, + required Widget child}) async { + await Alert( + context: context, + style: _alertStyle(context), + useRootNavigator: false, + type: AlertType.none, + title: title, + content: child, + buttons: [ + DialogButton( + color: _buttonColor(context, false), + highlightColor: _buttonColor(context, true), + border: _buttonBorder(context), + radius: _buttonRadius(context), + width: 120, + onPressed: () { + Navigator.pop(context); + }, + child: Text( + translate('button.ok'), + style: _buttonTextStyle(context), + softWrap: true, + ), + ) + ], + ).show(); +} + +Future showConfirmModal( + {required BuildContext context, + required String title, + required String text}) async { + var confirm = false; + + await Alert( + context: context, + style: _alertStyle(context), + useRootNavigator: false, + type: AlertType.none, + title: title, + desc: text, + buttons: [ + DialogButton( + color: _buttonColor(context, false), + highlightColor: _buttonColor(context, true), + border: _buttonBorder(context), + radius: _buttonRadius(context), + width: 120, + onPressed: () { + Navigator.pop(context); + }, + child: Text( + translate('button.no'), + style: _buttonTextStyle(context), + ), + ), + DialogButton( + color: _buttonColor(context, false), + highlightColor: _buttonColor(context, true), + border: _buttonBorder(context), + radius: _buttonRadius(context), + width: 120, + onPressed: () { + confirm = true; + Navigator.pop(context); + }, + child: Text( + translate('button.yes'), + style: _buttonTextStyle(context), + ), + ) + ], + ).show(); + + return confirm; +} diff --git a/lib/theme/views/styled_widgets/styled_avatar.dart b/lib/theme/views/styled_widgets/styled_avatar.dart new file mode 100644 index 0000000..dde39f2 --- /dev/null +++ b/lib/theme/views/styled_widgets/styled_avatar.dart @@ -0,0 +1,77 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; + +import '../../theme.dart'; + +class StyledAvatar extends StatelessWidget { + const StyledAvatar({ + required String name, + required double size, + bool enabled = true, + super.key, + ImageProvider? imageProvider, + }) : _name = name, + _size = size, + _imageProvider = imageProvider, + _enabled = enabled; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scaleTheme = Theme.of(context).extension()!; + + final borderColor = scaleTheme.config.useVisualIndicators + ? scaleTheme.scheme.primaryScale.primaryText + : scaleTheme.scheme.primaryScale.subtleBorder; + final foregroundColor = !_enabled + ? scaleTheme.scheme.grayScale.primaryText + : scaleTheme.scheme.primaryScale.calloutText; + final backgroundColor = !_enabled + ? scaleTheme.scheme.grayScale.primary + : scaleTheme.scheme.primaryScale.calloutBackground; + final scaleConfig = scaleTheme.config; + final textStyle = theme.textTheme.titleLarge!.copyWith(fontSize: _size / 2); + + final abbrev = _name.split(' ').map((s) => s.isEmpty ? '' : s[0]).join(); + late final String shortname; + if (abbrev.length >= 3) { + shortname = abbrev[0] + abbrev[1] + abbrev[abbrev.length - 1]; + } else { + shortname = abbrev; + } + + return Container( + height: _size, + width: _size, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: !scaleConfig.useVisualIndicators + ? null + : Border.all( + color: borderColor, + width: 1 * (_size ~/ 16 + 1), + strokeAlign: BorderSide.strokeAlignOutside)), + child: AvatarImage( + backgroundImage: _imageProvider, + backgroundColor: scaleConfig.useVisualIndicators + ? foregroundColor + : backgroundColor, + child: Text( + shortname.isNotEmpty ? shortname : '?', + softWrap: false, + textScaler: MediaQuery.of(context).textScaler, + style: textStyle.copyWith( + color: scaleConfig.useVisualIndicators + ? backgroundColor + : foregroundColor, + ), + ).paddingAll(4.scaled(context)).fit(fit: BoxFit.scaleDown))); + } + + //////////////////////////////////////////////////////////////////////////// + final String _name; + final double _size; + final ImageProvider? _imageProvider; + final bool _enabled; +} diff --git a/lib/theme/views/styled_widgets/styled_button_box.dart b/lib/theme/views/styled_widgets/styled_button_box.dart new file mode 100644 index 0000000..811e01c --- /dev/null +++ b/lib/theme/views/styled_widgets/styled_button_box.dart @@ -0,0 +1,59 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/material.dart'; + +import '../../theme.dart'; + +class StyledButtonBox extends StatelessWidget { + const StyledButtonBox( + {required String instructions, + required IconData buttonIcon, + required String buttonText, + required void Function() onClick, + super.key}) + : _instructions = instructions, + _buttonIcon = buttonIcon, + _buttonText = buttonText, + _onClick = onClick; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + + return Container( + constraints: const BoxConstraints(maxWidth: 400), + decoration: BoxDecoration( + color: scale.primaryScale.subtleBackground, + borderRadius: + BorderRadius.circular(8 * scaleConfig.borderRadiusScale), + border: Border.all(color: scale.primaryScale.border)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + style: theme.textTheme.labelMedium! + .copyWith(color: scale.primaryScale.appText), + softWrap: true, + textAlign: TextAlign.center, + _instructions), + ElevatedButton( + onPressed: _onClick, + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(_buttonIcon, + size: 24.scaled(context), + color: scale.primaryScale.appText) + .paddingLTRB(0, 8.scaled(context), + 8.scaled(context), 8.scaled(context)), + Text(textAlign: TextAlign.center, _buttonText) + ])).paddingLTRB(0, 12.scaled(context), 0, 0).toCenter() + ]).paddingAll(12.scaled(context))) + .paddingLTRB( + 24.scaled(context), 0, 24.scaled(context), 12.scaled(context)); + } + + final String _instructions; + final IconData _buttonIcon; + final String _buttonText; + final void Function() _onClick; +} diff --git a/lib/theme/views/styled_widgets/styled_checkbox.dart b/lib/theme/views/styled_widgets/styled_checkbox.dart new file mode 100644 index 0000000..0213db2 --- /dev/null +++ b/lib/theme/views/styled_widgets/styled_checkbox.dart @@ -0,0 +1,67 @@ +import 'package:async_tools/async_tools.dart'; +import 'package:awesome_extensions/awesome_extensions_flutter.dart'; +import 'package:flutter/material.dart'; + +import '../views.dart'; + +const _kStyledCheckboxChanged = 'kStyledCheckboxChanged'; + +class StyledCheckbox extends StatelessWidget { + const StyledCheckbox( + {required bool value, + required String label, + String? decoratorLabel, + Future Function(bool)? onChanged, + super.key}) + : _value = value, + _onChanged = onChanged, + _label = label, + _decoratorLabel = decoratorLabel; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + + var textStyle = textTheme.labelLarge!; + if (_onChanged == null) { + textStyle = textStyle.copyWith(color: textStyle.color!.withAlpha(127)); + } + + Widget ctrl = Row(children: [ + Transform.scale( + scale: 1.scaled(context), + child: Checkbox( + value: _value, + onChanged: _onChanged == null + ? null + : (value) { + if (value == null) { + return; + } + singleFuture((this, _kStyledCheckboxChanged), () async { + await _onChanged(value); + }); + })), + Text( + _label, + style: textStyle, + overflow: TextOverflow.clip, + ).paddingLTRB(4.scaled(context), 0, 0, 0).flexible(), + ]); + + if (_decoratorLabel != null) { + ctrl = ctrl + .paddingLTRB(4.scaled(context), 4.scaled(context), 4.scaled(context), + 4.scaled(context)) + .decoratorLabel(context, _decoratorLabel); + } + + return ctrl; + } + + final String _label; + final String? _decoratorLabel; + final Future Function(bool)? _onChanged; + final bool _value; +} diff --git a/lib/theme/views/styled_widgets/styled_dialog.dart b/lib/theme/views/styled_widgets/styled_dialog.dart new file mode 100644 index 0000000..54431b2 --- /dev/null +++ b/lib/theme/views/styled_widgets/styled_dialog.dart @@ -0,0 +1,69 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../../settings/settings.dart'; +import '../../theme.dart'; + +class StyledDialog extends StatelessWidget { + const StyledDialog({required this.title, required this.child, super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + final textTheme = theme.textTheme; + + return AlertDialog( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(16 * scaleConfig.borderRadiusScale)), + ), + contentPadding: const EdgeInsets.all(4), + backgroundColor: scale.primaryScale.border, + title: Text( + title, + style: textTheme.titleMedium! + .copyWith(color: scale.primaryScale.borderText), + textAlign: TextAlign.center, + ), + titlePadding: const EdgeInsets.fromLTRB(4, 4, 4, 4), + content: DecoratedBox( + decoration: ShapeDecoration( + color: scale.primaryScale.border, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + 16 * scaleConfig.borderRadiusScale))), + child: DecoratedBox( + decoration: ShapeDecoration( + color: scale.primaryScale.appBackground, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + 12 * scaleConfig.borderRadiusScale))), + child: child))); + } + + static Future show( + {required BuildContext context, + required String title, + required Widget child}) => + showDialog( + context: context, + useRootNavigator: false, + builder: (context) => AsyncBlocBuilder( + builder: (context, state) => MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: TextScaler.linear( + state.themePreference.displayScale)), + child: StyledDialog(title: title, child: child)))); + + final String title; + final Widget child; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(StringProperty('title', title)); + } +} diff --git a/lib/theme/views/styled_widgets/styled_dropdown.dart b/lib/theme/views/styled_widgets/styled_dropdown.dart new file mode 100644 index 0000000..3af6424 --- /dev/null +++ b/lib/theme/views/styled_widgets/styled_dropdown.dart @@ -0,0 +1,59 @@ +import 'package:async_tools/async_tools.dart'; +import 'package:awesome_extensions/awesome_extensions_flutter.dart'; +import 'package:flutter/material.dart'; + +import '../../models/models.dart'; +import '../views.dart'; + +const _kStyledDropdownChanged = 'kStyledDropdownChanged'; + +class StyledDropdown extends StatelessWidget { + const StyledDropdown( + {required List> items, + required T value, + String? decoratorLabel, + Future Function(T)? onChanged, + super.key}) + : _items = items, + _onChanged = onChanged, + _decoratorLabel = decoratorLabel, + _value = value; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.extension()!; + + Widget ctrl = DropdownButton( + isExpanded: true, + padding: const EdgeInsets.fromLTRB(4, 0, 4, 0).scaled(context), + focusColor: theme.focusColor, + dropdownColor: scheme.primaryScale.elementBackground, + iconEnabledColor: scheme.primaryScale.appText, + iconDisabledColor: scheme.primaryScale.appText.withAlpha(127), + items: _items, + value: _value, + style: theme.textTheme.labelLarge, + onChanged: _onChanged == null + ? null + : (value) { + if (value == null) { + return; + } + singleFuture((this, _kStyledDropdownChanged), () async { + await _onChanged(value); + }); + }); + if (_decoratorLabel != null) { + ctrl = ctrl + .paddingLTRB(0, 4.scaled(context), 0, 4.scaled(context)) + .decoratorLabel(context, _decoratorLabel); + } + return ctrl; + } + + final List> _items; + final String? _decoratorLabel; + final Future Function(T)? _onChanged; + final T _value; +} diff --git a/lib/theme/views/styled_widgets/styled_scaffold.dart b/lib/theme/views/styled_widgets/styled_scaffold.dart new file mode 100644 index 0000000..9a32640 --- /dev/null +++ b/lib/theme/views/styled_widgets/styled_scaffold.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +import '../../theme.dart'; + +class StyledScaffold extends StatelessWidget { + const StyledScaffold({required this.appBar, required this.body, super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scaleScheme = theme.extension()!; + final scaleConfig = theme.extension()!; + final scale = scaleScheme.scale(ScaleKind.primary); + + const enableBorder = false; //!isMobileSize(context); + + var scaffold = clipBorder( + clipEnabled: enableBorder, + borderEnabled: scaleConfig.useVisualIndicators, + borderRadius: 16 * scaleConfig.borderRadiusScale, + borderColor: scale.border, + child: Scaffold(appBar: appBar, body: body, key: key)); + + if (!scaleConfig.useVisualIndicators) { + scaffold = scaffold.withThemedShadow(scaleConfig, scale); + } + + return GestureDetector( + onTap: () => FocusManager.instance.primaryFocus?.unfocus(), + child: scaffold); + } + + //////////////////////////////////////////////////////////////////////////// + final PreferredSizeWidget? appBar; + final Widget? body; +} diff --git a/lib/theme/views/styled_widgets/styled_slide_tile.dart b/lib/theme/views/styled_widgets/styled_slide_tile.dart new file mode 100644 index 0000000..e4a0e27 --- /dev/null +++ b/lib/theme/views/styled_widgets/styled_slide_tile.dart @@ -0,0 +1,145 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; + +import '../../theme.dart'; + +class SlideTileAction { + const SlideTileAction({ + required this.actionScale, + required this.onPressed, + this.key, + this.icon, + this.label, + }); + + final Key? key; + final ScaleKind actionScale; + final String? label; + final IconData? icon; + final SlidableActionCallback? onPressed; +} + +class StyledSlideTile extends StatelessWidget { + const StyledSlideTile( + {required this.disabled, + required this.selected, + required this.tileScale, + required this.title, + this.subtitle = '', + this.endActions = const [], + this.startActions = const [], + this.onTap, + this.onDoubleTap, + this.leading, + this.trailing, + super.key}); + + final bool disabled; + final bool selected; + final ScaleKind tileScale; + final List endActions; + final List startActions; + final GestureTapCallback? onTap; + final GestureTapCallback? onDoubleTap; + final Widget? leading; + final Widget? trailing; + final String title; + final String subtitle; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('disabled', disabled)) + ..add(DiagnosticsProperty('selected', selected)) + ..add(DiagnosticsProperty('tileScale', tileScale)) + ..add(IterableProperty('endActions', endActions)) + ..add(IterableProperty('startActions', startActions)) + ..add(ObjectFlagProperty.has('onTap', onTap)) + ..add(DiagnosticsProperty('leading', leading)) + ..add(StringProperty('title', title)) + ..add(StringProperty('subtitle', subtitle)) + ..add(ObjectFlagProperty.has( + 'onDoubleTap', onDoubleTap)) + ..add(DiagnosticsProperty('trailing', trailing)); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scaleTheme = theme.extension()!; + final scaleTileTheme = scaleTheme.tileTheme( + disabled: disabled, selected: selected, scaleKind: tileScale); + + return Container( + clipBehavior: Clip.antiAlias, + decoration: ShapeDecoration( + color: scaleTileTheme.backgroundColor, + shape: scaleTileTheme.shapeBorder), + child: Slidable( + // Specify a key if the Slidable is dismissible. + key: key, + endActionPane: endActions.isEmpty + ? null + : ActionPane( + motion: const DrawerMotion(), + children: endActions.map((a) { + final scaleActionTheme = scaleTheme.tileTheme( + disabled: disabled, + selected: true, + scaleKind: a.actionScale); + return SlidableAction( + onPressed: disabled ? null : a.onPressed, + backgroundColor: scaleActionTheme.backgroundColor, + foregroundColor: scaleActionTheme.textColor, + icon: subtitle.isEmpty ? a.icon : null, + label: a.label, + padding: const EdgeInsets.all(2).scaled(context), + ); + }).toList()), + startActionPane: startActions.isEmpty + ? null + : ActionPane( + motion: const DrawerMotion(), + children: startActions.map((a) { + final scaleActionTheme = scaleTheme.tileTheme( + disabled: disabled, + selected: true, + scaleKind: a.actionScale); + + return SlidableAction( + onPressed: disabled ? null : a.onPressed, + backgroundColor: scaleActionTheme.backgroundColor, + foregroundColor: scaleActionTheme.textColor, + icon: subtitle.isEmpty ? a.icon : null, + label: a.label, + padding: const EdgeInsets.all(2).scaled(context), + ); + }).toList()), + child: Padding( + padding: scaleTheme.config.useVisualIndicators + ? EdgeInsets.zero + : const EdgeInsets.fromLTRB(0, 4, 0, 4).scaled(context), + child: GestureDetector( + onDoubleTap: onDoubleTap, + child: ListTile( + onTap: onTap, + dense: true, + title: Text( + title, + overflow: TextOverflow.fade, + softWrap: false, + ), + subtitle: subtitle.isNotEmpty ? Text(subtitle) : null, + contentPadding: const EdgeInsets.fromLTRB(8, 2, 8, 2) + .scaled(context), + iconColor: scaleTileTheme.textColor, + textColor: scaleTileTheme.textColor, + leading: + leading != null ? FittedBox(child: leading) : null, + trailing: trailing != null + ? FittedBox(child: trailing) + : null))))); + } +} diff --git a/lib/theme/views/styled_widgets/styled_slider.dart b/lib/theme/views/styled_widgets/styled_slider.dart new file mode 100644 index 0000000..a0c7259 --- /dev/null +++ b/lib/theme/views/styled_widgets/styled_slider.dart @@ -0,0 +1,79 @@ +import 'package:async_tools/async_tools.dart'; +import 'package:awesome_extensions/awesome_extensions_flutter.dart'; +import 'package:flutter/material.dart'; + +import '../../models/models.dart'; +import '../views.dart'; + +const _kStyledSliderChanged = 'kStyledSliderChanged'; + +class StyledSlider extends StatelessWidget { + const StyledSlider( + {required double value, + String? label, + String? decoratorLabel, + Future Function(double)? onChanged, + Widget? leftWidget, + Widget? rightWidget, + double min = 0, + double max = 1, + int? divisions, + super.key}) + : _value = value, + _onChanged = onChanged, + _leftWidget = leftWidget, + _rightWidget = rightWidget, + _min = min, + _max = max, + _divisions = divisions, + _label = label, + _decoratorLabel = decoratorLabel; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scale = theme.extension()!; + + Widget ctrl = Row(children: [ + if (_leftWidget != null) _leftWidget, + Slider( + activeColor: scale.scheme.primaryScale.border, + inactiveColor: scale.scheme.primaryScale.subtleBorder, + secondaryActiveColor: scale.scheme.secondaryScale.border, + value: _value, + min: _min, + max: _max, + divisions: _divisions, + label: _label, + thumbColor: scale.scheme.primaryScale.appText, + overlayColor: + WidgetStateColor.resolveWith((ws) => theme.focusColor), + onChanged: _onChanged == null + ? null + : (value) { + singleFuture((this, _kStyledSliderChanged), () async { + await _onChanged(value); + }); + }) + .expanded(), + if (_rightWidget != null) _rightWidget, + ]); + if (_decoratorLabel != null) { + ctrl = ctrl + .paddingLTRB(4.scaled(context), 4.scaled(context), 4.scaled(context), + 4.scaled(context)) + .decoratorLabel(context, _decoratorLabel); + } + return ctrl; + } + + final String? _label; + final String? _decoratorLabel; + final Future Function(double)? _onChanged; + final double _value; + final Widget? _leftWidget; + final Widget? _rightWidget; + final double _min; + final double _max; + final int? _divisions; +} diff --git a/lib/theme/views/styled_widgets/styled_widgets.dart b/lib/theme/views/styled_widgets/styled_widgets.dart new file mode 100644 index 0000000..ae45d59 --- /dev/null +++ b/lib/theme/views/styled_widgets/styled_widgets.dart @@ -0,0 +1,8 @@ +export 'styled_alert.dart'; +export 'styled_avatar.dart'; +export 'styled_checkbox.dart'; +export 'styled_dialog.dart'; +export 'styled_dropdown.dart'; +export 'styled_scaffold.dart'; +export 'styled_slide_tile.dart'; +export 'styled_slider.dart'; diff --git a/lib/theme/views/views.dart b/lib/theme/views/views.dart new file mode 100644 index 0000000..b62c4bc --- /dev/null +++ b/lib/theme/views/views.dart @@ -0,0 +1,9 @@ +export 'enter_password.dart'; +export 'enter_pin.dart'; +export 'pop_control.dart'; +export 'preferences/preferences.dart'; +export 'recovery_key_widget.dart'; +export 'responsive.dart'; +export 'styled_widgets/styled_button_box.dart'; +export 'styled_widgets/styled_widgets.dart'; +export 'widget_helpers.dart'; diff --git a/lib/theme/views/widget_helpers.dart b/lib/theme/views/widget_helpers.dart new file mode 100644 index 0000000..2d8d626 --- /dev/null +++ b/lib/theme/views/widget_helpers.dart @@ -0,0 +1,623 @@ +import 'dart:math'; + +import 'package:async_tools/async_tools.dart'; +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; +import 'package:blurry_modal_progress_hud/blurry_modal_progress_hud.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_spinkit/flutter_spinkit.dart'; +import 'package:flutter_sticky_header/flutter_sticky_header.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:sliver_expandable/sliver_expandable.dart'; + +import '../theme.dart'; + +extension BorderExt on Widget { + DecoratedBox debugBorder() => DecoratedBox( + decoration: BoxDecoration(border: Border.all(color: Colors.redAccent)), + child: this); +} + +extension ShadowExt on Widget { + Container withThemedShadow(ScaleConfig scaleConfig, ScaleColor scale) => + // ignore: use_decorated_box + Container( + decoration: BoxDecoration( + boxShadow: themedShadow(scaleConfig, scale), + ), + child: this, + ); +} + +List themedShadow(ScaleConfig scaleConfig, ScaleColor scale) => [ + if (scaleConfig.useVisualIndicators && !scaleConfig.preferBorders) + BoxShadow( + color: scale.primary.darken(60), + spreadRadius: 2, + ) + else if (scaleConfig.useVisualIndicators && scaleConfig.preferBorders) + BoxShadow( + color: scale.border, + spreadRadius: 2, + ) + else + BoxShadow( + color: scale.primary.darken(60).withAlpha(0x7F), + blurRadius: 16, + spreadRadius: 2, + offset: const Offset( + 0, + 2, + ), + ), + ]; + +extension SizeToFixExt on Widget { + FittedBox fit({BoxFit? fit, Key? key}) => FittedBox( + key: key, + fit: fit ?? BoxFit.scaleDown, + child: this, + ); +} + +extension FocusExt on Widget { + Focus focus( + {Key? key, + FocusNode? focusNode, + FocusNode? parentNode, + bool autofocus = false, + ValueChanged? onFocusChange, + FocusOnKeyEventCallback? onKeyEvent, + bool? canRequestFocus, + bool? skipTraversal, + bool? descendantsAreFocusable, + bool? descendantsAreTraversable, + bool includeSemantics = true, + String? debugLabel}) => + Focus( + key: key, + focusNode: focusNode, + parentNode: parentNode, + autofocus: autofocus, + onFocusChange: onFocusChange, + onKeyEvent: onKeyEvent, + canRequestFocus: canRequestFocus, + skipTraversal: skipTraversal, + descendantsAreFocusable: descendantsAreFocusable, + descendantsAreTraversable: descendantsAreTraversable, + includeSemantics: includeSemantics, + debugLabel: debugLabel, + child: this); + Focus onFocusChange(void Function(bool) onFocusChange) => + Focus(onFocusChange: onFocusChange, child: this); +} + +extension ModalProgressExt on Widget { + BlurryModalProgressHUD withModalHUD(BuildContext context, bool isLoading) { + final theme = Theme.of(context); + final scale = theme.extension()!; + + return BlurryModalProgressHUD( + inAsyncCall: isLoading, + blurEffectIntensity: 4, + progressIndicator: buildProgressIndicator(), + color: scale.tertiaryScale.appBackground.withAlpha(64), + child: this); + } +} + +extension LabelExt on Widget { + Widget decoratorLabel(BuildContext context, String label, + {ScaleColor? scale}) { + final theme = Theme.of(context); + final scaleScheme = theme.extension()!; + final scaleConfig = theme.extension()!; + scale = scale ?? scaleScheme.primaryScale; + + final border = scale.border; + final disabledBorder = scaleScheme.grayScale.border; + final hoverBorder = scale.hoverBorder; + final focusedErrorBorder = scaleScheme.errorScale.border; + final errorBorder = scaleScheme.errorScale.primary; + OutlineInputBorder makeBorder(Color color) => OutlineInputBorder( + borderRadius: + BorderRadius.circular(8 * scaleConfig.borderRadiusScale), + borderSide: BorderSide(color: color), + ); + OutlineInputBorder makeFocusedBorder(Color color) => OutlineInputBorder( + borderRadius: + BorderRadius.circular(8 * scaleConfig.borderRadiusScale), + borderSide: BorderSide(width: 2, color: color), + ); + return InputDecorator( + decoration: InputDecoration( + labelText: label, + floatingLabelStyle: TextStyle(color: hoverBorder), + border: makeBorder(border), + enabledBorder: makeBorder(border), + disabledBorder: makeBorder(disabledBorder), + focusedBorder: makeFocusedBorder(hoverBorder), + errorBorder: makeBorder(errorBorder), + focusedErrorBorder: makeFocusedBorder(focusedErrorBorder), + ), + child: this); + } + + Widget noEditDecoratorLabel(BuildContext context, String label, + {ScaleColor? scale}) { + final theme = Theme.of(context); + final scaleScheme = theme.extension()!; + scale = scale ?? scaleScheme.primaryScale; + + return Wrap(crossAxisAlignment: WrapCrossAlignment.end, children: [ + Text( + '$label:', + style: theme.textTheme.bodyLarge!.copyWith(color: scale.hoverBorder), + ).paddingLTRB(0, 0, 8, 0), + this + ]); + } +} + +Widget buildProgressIndicator() => Builder(builder: (context) { + final theme = Theme.of(context); + final scale = theme.extension()!; + return FittedBox( + fit: BoxFit.scaleDown, + child: SpinKitFoldingCube( + color: scale.tertiaryScale.border, + size: 80, + )); + }); + +Widget waitingPage({String? text, void Function()? onCancel}) => + Builder(builder: (context) { + final theme = Theme.of(context); + final scale = theme.extension()!; + return ColoredBox( + color: scale.tertiaryScale.appBackground, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + buildProgressIndicator().paddingAll(24), + if (text != null) + Text(text, + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall! + .copyWith(color: scale.tertiaryScale.appText)), + if (onCancel != null) + ElevatedButton( + onPressed: onCancel, + child: Text(translate('button.cancel'), + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall!.copyWith( + color: scale.tertiaryScale.appText))) + .alignAtCenter(), + ])); + }); + +Widget debugPage(String text) => Builder( + builder: (context) => ColoredBox( + color: Theme.of(context).colorScheme.error, + child: Center(child: Text(text)))); + +Widget errorPage(Object err, StackTrace? st) => Builder( + builder: (context) => ColoredBox( + color: Theme.of(context).colorScheme.error, + child: Center(child: ErrorWidget(err)))); + +Widget asyncValueBuilder( + AsyncValue av, Widget Function(BuildContext, T) builder) => + av.when( + loading: waitingPage, + error: errorPage, + data: (d) => Builder(builder: (context) => builder(context, d))); + +extension AsyncValueBuilderExt on AsyncValue { + Widget builder(Widget Function(BuildContext, T) builder) => + asyncValueBuilder(this, builder); + Widget buildNotData( + {Widget Function()? loading, + Widget Function(Object, StackTrace?)? error}) => + when( + loading: () => (loading ?? waitingPage)(), + error: (e, st) => (error ?? errorPage)(e, st), + data: (d) => debugPage('AsyncValue should not be data here')); +} + +extension BusyAsyncValueBuilderExt on BlocBusyState> { + Widget builder(Widget Function(BuildContext, T) builder) => + AbsorbPointer(absorbing: busy, child: state.builder(builder)); + Widget buildNotData( + {Widget Function()? loading, + Widget Function(Object, StackTrace?)? error}) => + AbsorbPointer( + absorbing: busy, + child: state.buildNotData(loading: loading, error: error)); +} + +class AsyncBlocBuilder>, S> + extends BlocBuilder> { + AsyncBlocBuilder({ + required BlocWidgetBuilder builder, + Widget Function()? loading, + Widget Function(Object, StackTrace?)? error, + super.key, + super.bloc, + super.buildWhen, + }) : super( + builder: (context, state) => state.when( + loading: () => (loading ?? waitingPage)(), + error: (e, st) => (error ?? errorPage)(e, st), + data: (d) => builder(context, d))); +} + +SliverAppBar styledSliverAppBar( + {required BuildContext context, required String title, Color? titleColor}) { + final theme = Theme.of(context); + final scale = theme.extension()!; + //final scaleConfig = theme.extension()!; + final textTheme = theme.textTheme; + + return SliverAppBar( + title: Text( + title, + style: textTheme.titleSmall! + .copyWith(color: titleColor ?? scale.primaryScale.borderText), + ), + pinned: true, + ); +} + +Widget styledHeaderSliver( + {required BuildContext context, + required String title, + required Widget sliver, + Color? borderColor, + Color? innerColor, + Color? titleColor, + Color? backgroundColor, + void Function()? onTap}) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + final textTheme = theme.textTheme; + + return SliverStickyHeader( + header: ColoredBox( + color: backgroundColor ?? Colors.transparent, + child: DecoratedBox( + decoration: ShapeDecoration( + color: borderColor ?? scale.primaryScale.border, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: + Radius.circular(12 * scaleConfig.borderRadiusScale), + topRight: Radius.circular( + 12 * scaleConfig.borderRadiusScale)))), + child: ListTile( + onTap: onTap, + title: Text(title, + textAlign: TextAlign.center, + style: textTheme.titleSmall!.copyWith( + color: titleColor ?? scale.primaryScale.borderText)), + ), + )), + sliver: DecoratedSliver( + decoration: ShapeDecoration( + color: borderColor ?? scale.primaryScale.border, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + bottomLeft: + Radius.circular(8 * scaleConfig.borderRadiusScale), + bottomRight: + Radius.circular(8 * scaleConfig.borderRadiusScale)))), + sliver: SliverPadding( + padding: const EdgeInsets.all(4), + sliver: DecoratedSliver( + decoration: ShapeDecoration( + color: innerColor ?? scale.primaryScale.subtleBackground, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + 8 * scaleConfig.borderRadiusScale))), + sliver: SliverPadding( + padding: const EdgeInsets.all(8), + sliver: sliver, + )))), + ); +} + +Widget styledExpandingSliver( + {required BuildContext context, + required String title, + required Widget sliver, + required bool expanded, + required Animation animation, + Color? borderColor, + Color? innerColor, + Color? titleColor, + Color? backgroundColor, + void Function()? onTap}) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + final textTheme = theme.textTheme; + + return SliverStickyHeader( + header: ColoredBox( + color: backgroundColor ?? Colors.transparent, + child: DecoratedBox( + decoration: ShapeDecoration( + color: borderColor ?? scale.primaryScale.border, + shape: RoundedRectangleBorder( + borderRadius: expanded + ? BorderRadius.only( + topLeft: Radius.circular( + 12 * scaleConfig.borderRadiusScale), + topRight: Radius.circular( + 12 * scaleConfig.borderRadiusScale)) + : BorderRadius.circular( + 12 * scaleConfig.borderRadiusScale))), + child: ListTile( + onTap: onTap, + title: Text(title, + textAlign: TextAlign.center, + style: textTheme.titleSmall!.copyWith( + color: titleColor ?? scale.primaryScale.borderText)), + trailing: AnimatedBuilder( + animation: animation, + builder: (context, child) => Transform.rotate( + angle: (animation.value - 0.5) * pi, + child: child, + ), + child: Icon(Icons.chevron_left, + color: borderColor ?? scale.primaryScale.borderText), + ), + ), + )), + sliver: SliverExpandable( + sliver: DecoratedSliver( + decoration: ShapeDecoration( + color: borderColor ?? scale.primaryScale.border, + shape: RoundedRectangleBorder( + borderRadius: expanded + ? BorderRadius.only( + bottomLeft: Radius.circular( + 8 * scaleConfig.borderRadiusScale), + bottomRight: Radius.circular( + 8 * scaleConfig.borderRadiusScale)) + : BorderRadius.circular( + 8 * scaleConfig.borderRadiusScale))), + sliver: SliverPadding( + padding: const EdgeInsets.all(4), + sliver: DecoratedSliver( + decoration: ShapeDecoration( + color: + innerColor ?? scale.primaryScale.subtleBackground, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + 8 * scaleConfig.borderRadiusScale))), + sliver: SliverPadding( + padding: const EdgeInsets.all(8), + sliver: sliver, + )))), + animation: animation, + )); +} + +Widget styledHeader({required BuildContext context, required Widget child}) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + // final textTheme = theme.textTheme; + + return DecoratedBox( + decoration: ShapeDecoration( + color: scale.primaryScale.border, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(12 * scaleConfig.borderRadiusScale), + topRight: + Radius.circular(12 * scaleConfig.borderRadiusScale)))), + child: child); +} + +Widget styledTitleContainer({ + required BuildContext context, + required String title, + required Widget child, + Color? borderColor, + Color? backgroundColor, + Color? titleColor, +}) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + final textTheme = theme.textTheme; + + return DecoratedBox( + decoration: ShapeDecoration( + color: borderColor ?? scale.primaryScale.border, + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(8 * scaleConfig.borderRadiusScale), + )), + child: Column(children: [ + Text( + title, + style: textTheme.titleSmall! + .copyWith(color: titleColor ?? scale.primaryScale.borderText), + ).paddingLTRB(8, 6, 8, 2), + DecoratedBox( + decoration: ShapeDecoration( + color: + backgroundColor ?? scale.primaryScale.subtleBackground, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + 8 * scaleConfig.borderRadiusScale), + )), + child: child) + .paddingAll(4) + .expanded() + ])); +} + +Widget styledContainer({ + required BuildContext context, + required Widget child, + Color? borderColor, + Color? backgroundColor, +}) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + + return DecoratedBox( + decoration: ShapeDecoration( + color: borderColor ?? scale.primaryScale.border, + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(8 * scaleConfig.borderRadiusScale), + )), + child: Column(children: [ + DecoratedBox( + decoration: ShapeDecoration( + color: + backgroundColor ?? scale.primaryScale.subtleBackground, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + 8 * scaleConfig.borderRadiusScale), + )), + child: child) + .paddingAll(4) + .expanded() + ])); +} + +Widget styledCard({ + required BuildContext context, + required Widget child, + Color? borderColor, + Color? backgroundColor, + Color? titleColor, +}) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + + return DecoratedBox( + decoration: ShapeDecoration( + color: backgroundColor ?? scale.primaryScale.elementBackground, + shape: RoundedRectangleBorder( + side: (scaleConfig.useVisualIndicators || scaleConfig.preferBorders) + ? BorderSide( + color: borderColor ?? scale.primaryScale.border, width: 2) + : BorderSide.none, + borderRadius: + BorderRadius.circular(12 * scaleConfig.borderRadiusScale), + )), + child: child.paddingAll(4)); +} + +Widget styledBottomSheet({ + required BuildContext context, + required String title, + required Widget child, + Color? borderColor, + Color? backgroundColor, + Color? titleColor, +}) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + final textTheme = theme.textTheme; + + return DecoratedBox( + decoration: ShapeDecoration( + color: borderColor ?? scale.primaryScale.dialogBorder, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16 * scaleConfig.borderRadiusScale), + topRight: + Radius.circular(16 * scaleConfig.borderRadiusScale)))), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Text( + title, + style: textTheme.titleMedium! + .copyWith(color: titleColor ?? scale.primaryScale.borderText), + ).paddingLTRB(8, 8, 8, 4), + DecoratedBox( + decoration: ShapeDecoration( + color: + backgroundColor ?? scale.primaryScale.subtleBackground, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular( + 16 * scaleConfig.borderRadiusScale), + topRight: Radius.circular( + 16 * scaleConfig.borderRadiusScale)))), + child: child) + .paddingLTRB(4, 4, 4, 0) + ])); +} + +bool get isPlatformDark => + WidgetsBinding.instance.platformDispatcher.platformBrightness == + Brightness.dark; + +const grayColorFilter = ColorFilter.matrix([ + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0, + 0, + 0, + 1, + 0, +]); + +const src96StencilFilter = + ColorFilter.mode(Color.fromARGB(96, 255, 255, 255), BlendMode.srcIn); + +const dst127StencilFilter = + ColorFilter.mode(Color.fromARGB(127, 255, 255, 255), BlendMode.dstIn); + +Container clipBorder({ + required bool clipEnabled, + required bool borderEnabled, + required double borderRadius, + required Color borderColor, + required Widget child, +}) => + // We want to return a container here + // ignore: avoid_unnecessary_containers, use_decorated_box + Container( + decoration: ShapeDecoration( + shape: RoundedRectangleBorder( + side: borderEnabled && clipEnabled + ? BorderSide(color: borderColor, width: 2) + : BorderSide.none, + borderRadius: clipEnabled + ? BorderRadius.circular(borderRadius) + : BorderRadius.zero, + )), + child: ClipRRect( + clipBehavior: Clip.antiAliasWithSaveLayer, + borderRadius: clipEnabled + ? BorderRadius.circular(borderRadius - 4) + : BorderRadius.zero, + child: child)); diff --git a/lib/tick.dart b/lib/tick.dart index 9f71a11..21c18a3 100644 --- a/lib/tick.dart +++ b/lib/tick.dart @@ -1,222 +1,50 @@ -// XXX Eliminate this when we have ValueChanged import 'dart:async'; -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:flutter/foundation.dart'; +import 'package:async_tools/async_tools.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:veilid_support/veilid_support.dart'; -import 'proto/proto.dart' as proto; -import 'providers/account.dart'; -import 'providers/chat.dart'; -import 'providers/connection_state.dart'; -import 'providers/contact.dart'; -import 'providers/contact_invite.dart'; -import 'providers/conversation.dart'; -import 'veilid_init.dart'; +import 'veilid_processor/veilid_processor.dart'; -const int ticksPerContactInvitationCheck = 5; -const int ticksPerNewMessageCheck = 5; +class BackgroundTicker extends StatefulWidget { + const BackgroundTicker({required this.child, super.key}); -class BackgroundTicker extends ConsumerStatefulWidget { - const BackgroundTicker({required this.builder, super.key}); - - final Widget Function(BuildContext) builder; + final Widget child; @override BackgroundTickerState createState() => BackgroundTickerState(); - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(ObjectFlagProperty.has( - 'builder', builder)); - } } -class BackgroundTickerState extends ConsumerState { +class BackgroundTickerState extends State { Timer? _tickTimer; - bool _inTick = false; - int _contactInvitationCheckTick = 0; - int _newMessageCheckTick = 0; - bool _hasRefreshedContactList = false; @override void initState() { super.initState(); _tickTimer = Timer.periodic(const Duration(seconds: 1), (timer) { - if (!_inTick) { - unawaited(_onTick()); - } + singleFuture(this, _onTick); }); } @override void dispose() { - final tickTimer = _tickTimer; - if (tickTimer != null) { - tickTimer.cancel(); - } - + _tickTimer?.cancel(); super.dispose(); } @override // ignore: prefer_expression_function_bodies Widget build(BuildContext context) { - return widget.builder(context); + return widget.child; } Future _onTick() async { - // Don't tick until veilid is started and attached - if (!eventualVeilid.isCompleted) { - return; - } - if (!connectionState.state.isAttached) { + if (!ProcessorRepository + .instance.processorConnectionState.isPublicInternetReady) { return; } - _inTick = true; - try { - final unord = >[]; - // If our contact list hasn't been refreshed yet, we need to - // refresh it. This happens every tick until it's non-empty. - // It will not happen until we are attached to Veilid. - if (_hasRefreshedContactList == false) { - unord.add(_doContactListRefresh()); - } - - // Check extant contact invitations once every N seconds - _contactInvitationCheckTick += 1; - if (_contactInvitationCheckTick >= ticksPerContactInvitationCheck) { - _contactInvitationCheckTick = 0; - unord.add(_doContactInvitationCheck()); - } - - // Check new messages once every N seconds - _newMessageCheckTick += 1; - if (_newMessageCheckTick >= ticksPerNewMessageCheck) { - _newMessageCheckTick = 0; - unord.add(_doNewMessageCheck()); - } - if (unord.isNotEmpty) { - await Future.wait(unord); - } - } finally { - _inTick = false; - } - } - - Future _doContactListRefresh() async { - // Don't refresh the contact list until we're connected to Veilid, because - // that's when we can actually communicate. - if (!connectionState.state.isAttached) { - return; - } - // Get the contact list, or an empty IList. - final contactList = ref.read(fetchContactListProvider).asData?.value ?? - const IListConst([]); - if (contactList.isEmpty) { - ref.invalidate(fetchContactListProvider); - } else { - // This happens on the tick after it refreshes, because invalidation - // and refresh happens only once per tick, and we won't know if it - // worked until it has. - _hasRefreshedContactList = true; - } - } - - Future _doContactInvitationCheck() async { - if (!connectionState.state.isPublicInternetReady) { - return; - } - final contactInvitationRecords = - await ref.read(fetchContactInvitationRecordsProvider.future); - if (contactInvitationRecords == null) { - return; - } - final activeAccountInfo = await ref.read(fetchActiveAccountProvider.future); - if (activeAccountInfo == null) { - return; - } - - final allChecks = >[]; - for (final contactInvitationRecord in contactInvitationRecords) { - allChecks.add(() async { - final acceptReject = await checkAcceptRejectContact( - activeAccountInfo: activeAccountInfo, - contactInvitationRecord: contactInvitationRecord); - if (acceptReject != null) { - final acceptedContact = acceptReject.acceptedContact; - if (acceptedContact != null) { - // Accept - await createContact( - activeAccountInfo: activeAccountInfo, - profile: acceptedContact.profile, - remoteIdentity: acceptedContact.remoteIdentity, - remoteConversationRecordKey: - acceptedContact.remoteConversationRecordKey, - localConversationRecordKey: - acceptedContact.localConversationRecordKey, - ); - ref - ..invalidate(fetchContactInvitationRecordsProvider) - ..invalidate(fetchContactListProvider); - } else { - // Reject - ref.invalidate(fetchContactInvitationRecordsProvider); - } - } - }()); - } - await Future.wait(allChecks); - } - - Future _doNewMessageCheck() async { - if (!connectionState.state.isPublicInternetReady) { - return; - } - - final activeChat = ref.read(activeChatStateProvider); - - if (activeChat == null) { - return; - } - final activeAccountInfo = await ref.read(fetchActiveAccountProvider.future); - if (activeAccountInfo == null) { - return; - } - - final contactList = ref.read(fetchContactListProvider).asData?.value ?? - const IListConst([]); - final activeChatContactIdx = contactList.indexWhere( - (c) => - proto.TypedKeyProto.fromProto(c.remoteConversationRecordKey) == - activeChat, - ); - if (activeChatContactIdx == -1) { - return; - } - final activeChatContact = contactList[activeChatContactIdx]; - final remoteIdentityPublicKey = - proto.TypedKeyProto.fromProto(activeChatContact.identityPublicKey); - final remoteConversationRecordKey = proto.TypedKeyProto.fromProto( - activeChatContact.remoteConversationRecordKey); - final localConversationRecordKey = proto.TypedKeyProto.fromProto( - activeChatContact.localConversationRecordKey); - - final newMessages = await getRemoteConversationMessages( - activeAccountInfo: activeAccountInfo, - remoteIdentityPublicKey: remoteIdentityPublicKey, - remoteConversationRecordKey: remoteConversationRecordKey); - if (newMessages != null && newMessages.isNotEmpty) { - final changed = await mergeLocalConversationMessages( - activeAccountInfo: activeAccountInfo, - localConversationRecordKey: localConversationRecordKey, - remoteIdentityPublicKey: remoteIdentityPublicKey, - newMessages: newMessages); - if (changed) { - ref.invalidate(activeConversationMessagesProvider); - } - } + // Tick DHT record pool + await DHTRecordPool.instance.tick(); } } diff --git a/lib/pages/edit_account.dart b/lib/tools/exceptions.dart similarity index 100% rename from lib/pages/edit_account.dart rename to lib/tools/exceptions.dart diff --git a/lib/tools/loggy.dart b/lib/tools/loggy.dart index fc07d3f..adc62d1 100644 --- a/lib/tools/loggy.dart +++ b/lib/tools/loggy.dart @@ -1,13 +1,16 @@ import 'dart:io' show Platform; import 'package:ansicolor/ansicolor.dart'; +import 'package:bloc/bloc.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_translate/flutter_translate.dart'; import 'package:intl/intl.dart'; import 'package:loggy/loggy.dart'; +import 'package:veilid_support/veilid_support.dart'; -import '../pages/developer.dart'; -import '../veilid_support/veilid_support.dart'; +import '../proto/proto.dart'; +import '../veilid_processor/views/developer.dart'; +import 'state_logger.dart'; String wrapWithLogColor(LogLevel? level, String text) { // XXX: https://github.com/flutter/flutter/issues/64491 @@ -108,12 +111,19 @@ class CallbackPrinter extends LoggyPrinter { @override void onLog(LogRecord record) { - final out = record.pretty(); - debugPrint(out); + final out = record.pretty().replaceAll('\uFFFD', ''); + + if (!kIsWeb && Platform.isAndroid) { + debugPrint(out); + } else { + debugPrintSynchronously(out); + } globalDebugTerminal.write('$out\n'.replaceAll('\n', '\r\n')); callback?.call(record); } + // Change callback function + // ignore: use_setters_to_change_properties void setCallback(void Function(LogRecord)? cb) { callback = cb; } @@ -139,14 +149,24 @@ void initLoggy() { logOptions: getLogOptions(null), ); + // Allow trace logging from the command line // ignore: do_not_use_environment const isTrace = String.fromEnvironment('LOG_TRACE') != ''; LogLevel logLevel; if (isTrace) { logLevel = traceLevel; } else { - logLevel = kDebugMode ? LogLevel.debug : LogLevel.info; + logLevel = kIsDebugMode ? LogLevel.debug : LogLevel.info; } Loggy('').level = getLogOptions(logLevel); + + // Create state logger + registerVeilidProtoToDebug(); + registerVeilidDHTProtoToDebug(); + registerVeilidchatProtoToDebug(); + + if (kIsDebugMode) { + Bloc.observer = const StateLogger(); + } } diff --git a/lib/tools/misc.dart b/lib/tools/misc.dart new file mode 100644 index 0000000..01dcbc0 --- /dev/null +++ b/lib/tools/misc.dart @@ -0,0 +1,18 @@ +extension StringExt on String { + (String, String?) splitOnce(Pattern p) { + final pos = indexOf(p); + if (pos == -1) { + return (this, null); + } + final rest = substring(pos); + var offset = 0; + while (true) { + final match = p.matchAsPrefix(rest, offset); + if (match == null) { + break; + } + offset = match.end; + } + return (substring(0, pos), rest.substring(offset)); + } +} diff --git a/lib/tools/package_info.dart b/lib/tools/package_info.dart new file mode 100644 index 0000000..7acd109 --- /dev/null +++ b/lib/tools/package_info.dart @@ -0,0 +1,14 @@ +import 'package:package_info_plus/package_info_plus.dart'; + +String packageInfoAppName = ''; +String packageInfoPackageName = ''; +String packageInfoVersion = ''; +String packageInfoBuildNumber = ''; + +Future initPackageInfo() async { + final packageInfo = await PackageInfo.fromPlatform(); + packageInfoAppName = packageInfo.appName; + packageInfoPackageName = packageInfo.packageName; + packageInfoVersion = packageInfo.version; + packageInfoBuildNumber = packageInfo.buildNumber; +} diff --git a/lib/tools/responsive.dart b/lib/tools/responsive.dart deleted file mode 100644 index 4ee206b..0000000 --- a/lib/tools/responsive.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -bool get isAndroid => !kIsWeb && Platform.isAndroid; -bool get isiOS => !kIsWeb && Platform.isIOS; -bool get isWeb => kIsWeb; -bool get isDesktop => - !isWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS); - -const kMobileWidthCutoff = 479.0; - -bool isMobileWidth(BuildContext context) => - MediaQuery.of(context).size.width < kMobileWidthCutoff; - -bool responsiveVisibility({ - required BuildContext context, - bool phone = true, - bool tablet = true, - bool tabletLandscape = true, - bool desktop = true, -}) { - final width = MediaQuery.of(context).size.width; - if (width < kMobileWidthCutoff) { - return phone; - } else if (width < 767) { - return tablet; - } else if (width < 991) { - return tabletLandscape; - } else { - return desktop; - } -} diff --git a/lib/tools/scanner_error_widget.dart b/lib/tools/scanner_error_widget.dart deleted file mode 100644 index 0926128..0000000 --- a/lib/tools/scanner_error_widget.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:mobile_scanner/mobile_scanner.dart'; - -class ScannerErrorWidget extends StatelessWidget { - const ScannerErrorWidget({required this.error, super.key}); - - final MobileScannerException error; - - @override - Widget build(BuildContext context) { - String errorMessage; - - switch (error.errorCode) { - case MobileScannerErrorCode.controllerUninitialized: - errorMessage = 'Controller not ready.'; - break; - case MobileScannerErrorCode.permissionDenied: - errorMessage = 'Permission denied'; - break; - case MobileScannerErrorCode.unsupported: - errorMessage = 'Scanning is unsupported on this device'; - break; - default: - errorMessage = 'Generic Error'; - break; - } - - return ColoredBox( - color: Colors.black, - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Padding( - padding: EdgeInsets.only(bottom: 16), - child: Icon(Icons.error, color: Colors.white), - ), - Text( - errorMessage, - style: const TextStyle(color: Colors.white), - ), - Text( - error.errorDetails?.message ?? '', - style: const TextStyle(color: Colors.white), - ), - ], - ), - ), - ); - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(DiagnosticsProperty('error', error)); - } -} diff --git a/lib/tools/secret_crypto.dart b/lib/tools/secret_crypto.dart deleted file mode 100644 index 873932d..0000000 --- a/lib/tools/secret_crypto.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'dart:typed_data'; -import '../entities/local_account.dart'; -import '../veilid_init.dart'; -import '../veilid_support/veilid_support.dart'; - -Future encryptSecretToBytes( - {required SecretKey secret, - required CryptoKind cryptoKind, - EncryptionKeyType encryptionKeyType = EncryptionKeyType.none, - String encryptionKey = ''}) async { - final veilid = await eventualVeilid.future; - - late final Uint8List secretBytes; - switch (encryptionKeyType) { - case EncryptionKeyType.none: - secretBytes = secret.decode(); - case EncryptionKeyType.pin: - case EncryptionKeyType.password: - final cs = await veilid.getCryptoSystem(cryptoKind); - - secretBytes = - await cs.encryptAeadWithPassword(secret.decode(), encryptionKey); - } - return secretBytes; -} - -Future decryptSecretFromBytes( - {required Uint8List secretBytes, - required CryptoKind cryptoKind, - EncryptionKeyType encryptionKeyType = EncryptionKeyType.none, - String encryptionKey = ''}) async { - final veilid = await eventualVeilid.future; - - late final SecretKey secret; - switch (encryptionKeyType) { - case EncryptionKeyType.none: - secret = SecretKey.fromBytes(secretBytes); - case EncryptionKeyType.pin: - case EncryptionKeyType.password: - final cs = await veilid.getCryptoSystem(cryptoKind); - - secret = SecretKey.fromBytes( - await cs.decryptAeadWithPassword(secretBytes, encryptionKey)); - } - return secret; -} diff --git a/lib/tools/shared_preferences.dart b/lib/tools/shared_preferences.dart new file mode 100644 index 0000000..ce1838d --- /dev/null +++ b/lib/tools/shared_preferences.dart @@ -0,0 +1,80 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +abstract mixin class SharedPreferencesBacked { + SharedPreferences get _sharedPreferences; + String keyName(); + T valueFromJson(Object? obj); + Object? valueToJson(T val); + + /// Load things from storage + Future load() async { + final valueJsonStr = _sharedPreferences.getString(keyName()); + final Object? valueJsonObj = + valueJsonStr != null ? jsonDecode(valueJsonStr) : null; + return valueFromJson(valueJsonObj); + } + + /// Store things to storage + Future store(T obj) async { + final valueJsonObj = valueToJson(obj); + if (valueJsonObj == null) { + await _sharedPreferences.remove(keyName()); + } else { + await _sharedPreferences.setString(keyName(), jsonEncode(valueJsonObj)); + } + return obj; + } +} + +class SharedPreferencesValue extends SharedPreferencesBacked { + SharedPreferencesValue({ + required SharedPreferences sharedPreferences, + required String keyName, + required T Function(Object? obj) valueFromJson, + required Object? Function(T obj) valueToJson, + }) : _sharedPreferencesInstance = sharedPreferences, + _valueFromJson = valueFromJson, + _valueToJson = valueToJson, + _keyName = keyName, + _streamController = StreamController.broadcast(); + + @override + SharedPreferences get _sharedPreferences => _sharedPreferencesInstance; + + T? get value => _value; + T get requireValue => _value!; + Stream get stream => _streamController.stream; + + Future get() async { + final val = _value; + if (val != null) { + return val; + } + final loadedValue = await load(); + return _value = loadedValue; + } + + Future set(T newVal) async { + _value = await store(newVal); + _streamController.add(newVal); + } + + T? _value; + final SharedPreferences _sharedPreferencesInstance; + final String _keyName; + final T Function(Object? obj) _valueFromJson; + final Object? Function(T obj) _valueToJson; + final StreamController _streamController; + + ////////////////////////////////////////////////////////////// + /// SharedPreferencesBacked + @override + String keyName() => _keyName; + @override + T valueFromJson(Object? obj) => _valueFromJson(obj); + @override + Object? valueToJson(T val) => _valueToJson(val); +} diff --git a/lib/tools/stack_trace.dart b/lib/tools/stack_trace.dart new file mode 100644 index 0000000..6cd7f55 --- /dev/null +++ b/lib/tools/stack_trace.dart @@ -0,0 +1,12 @@ +import 'package:stack_trace/stack_trace.dart'; + +/// Rethrows [error] with a stacktrace that is the combination of [stackTrace] +/// and [StackTrace.current]. +Never throwErrorWithCombinedStackTrace(Object error, StackTrace stackTrace) { + final chain = Chain([ + Trace.current(), + ...Chain.forTrace(stackTrace).traces, + ]); // .foldFrames((frame) => frame.package == 'xxx'); + + Error.throwWithStackTrace(error, chain.toTrace().vmTrace); +} diff --git a/lib/tools/state_logger.dart b/lib/tools/state_logger.dart index 973b5f9..8782662 100644 --- a/lib/tools/state_logger.dart +++ b/lib/tools/state_logger.dart @@ -1,22 +1,80 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'dart:convert'; + +import 'package:bloc/bloc.dart'; +import 'package:loggy/loggy.dart'; +import 'package:veilid_support/veilid_support.dart'; import 'loggy.dart'; -class StateLogger extends ProviderObserver { +const Map _blocChangeLogLevels = { + 'RouterCubit': LogLevel.debug, + 'PerAccountCollectionBlocMapCubit': LogLevel.debug, + 'PerAccountCollectionCubit': LogLevel.debug, + 'ActiveChatCubit': LogLevel.debug, + 'AccountRecordCubit': LogLevel.debug, + 'ContactListCubit': LogLevel.debug, + 'ContactInvitationListCubit': LogLevel.debug, + 'ChatListCubit': LogLevel.debug, + 'PreferencesCubit': LogLevel.debug, + 'ConversationCubit': LogLevel.debug, + 'DefaultDHTRecordCubit': LogLevel.debug, + 'WaitingInvitationCubit': LogLevel.debug, +}; + +const Map _blocCreateCloseLogLevels = {}; +const Map _blocErrorLogLevels = {}; + +/// [BlocObserver] for the VeilidChat application that +/// observes all state changes. +class StateLogger extends BlocObserver { + /// {@macro counter_observer} const StateLogger(); + + void _checkLogLevel( + Map blocLogLevels, + LogLevel defaultLogLevel, + BlocBase bloc, + void Function(LogLevel) closure) { + final logLevel = + blocLogLevels[bloc.runtimeType.toString()] ?? defaultLogLevel; + if (logLevel != LogLevel.off) { + closure(logLevel); + } + } + @override - void didUpdateProvider( - ProviderBase provider, - Object? previousValue, - Object? newValue, - ProviderContainer container, - ) { - log.debug(''' -{ - provider: ${provider.name ?? provider.runtimeType}, - oldValue: $previousValue, - newValue: $newValue -} -'''); - super.didUpdateProvider(provider, previousValue, newValue, container); + void onChange(BlocBase bloc, Change change) { + super.onChange(bloc, change); + _checkLogLevel(_blocChangeLogLevels, LogLevel.off, bloc, (logLevel) { + const encoder = JsonEncoder.withIndent(' ', DynamicDebug.toDebug); + log.log( + logLevel, + 'Change: ${bloc.runtimeType}\n' + 'currentState: ${encoder.convert(change.currentState)}\n' + 'nextState: ${encoder.convert(change.nextState)}\n'); + }); + } + + @override + void onCreate(BlocBase bloc) { + super.onCreate(bloc); + _checkLogLevel(_blocCreateCloseLogLevels, LogLevel.debug, bloc, (logLevel) { + log.log(logLevel, 'Create: ${bloc.runtimeType}'); + }); + } + + @override + void onClose(BlocBase bloc) { + super.onClose(bloc); + _checkLogLevel(_blocCreateCloseLogLevels, LogLevel.debug, bloc, (logLevel) { + log.log(logLevel, 'Close: ${bloc.runtimeType}'); + }); + } + + @override + void onError(BlocBase bloc, Object error, StackTrace stackTrace) { + super.onError(bloc, error, stackTrace); + _checkLogLevel(_blocErrorLogLevels, LogLevel.error, bloc, (logLevel) { + log.log(logLevel, 'Error: ${bloc.runtimeType} $error\n$stackTrace'); + }); } } diff --git a/lib/tools/stream_listenable.dart b/lib/tools/stream_listenable.dart new file mode 100644 index 0000000..f01ee04 --- /dev/null +++ b/lib/tools/stream_listenable.dart @@ -0,0 +1,34 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import 'loggy.dart'; + +/// Converts a [Stream] into a [Listenable] +/// +/// {@tool snippet} +/// Typical usage is as follows: +/// +/// ```dart +/// StreamListenable(stream) +/// ``` +/// {@end-tool} +class StreamListenable extends ChangeNotifier { + /// Creates a [StreamListenable]. + /// + /// Every time the [Stream] receives an event this [ChangeNotifier] will + /// notify its listeners. + StreamListenable(Stream stream) { + notifyListeners(); + _subscription = stream.asBroadcastStream().listen((_) => notifyListeners()); + } + + late final StreamSubscription _subscription; + + @override + void dispose() { + unawaited(_subscription.cancel().onError((error, stackTrace) => + log.error('StreamListenable cancel error: $error\n$stackTrace'))); + super.dispose(); + } +} diff --git a/lib/tools/theme_service.dart b/lib/tools/theme_service.dart deleted file mode 100644 index 41b664e..0000000 --- a/lib/tools/theme_service.dart +++ /dev/null @@ -1,255 +0,0 @@ -// ignore_for_file: always_put_required_named_parameters_first - -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -import '../entities/preferences.dart'; -import 'radix_generator.dart'; - -part 'theme_service.g.dart'; - -class ScaleColor { - ScaleColor({ - required this.appBackground, - required this.subtleBackground, - required this.elementBackground, - required this.hoverElementBackground, - required this.activeElementBackground, - required this.subtleBorder, - required this.border, - required this.hoverBorder, - required this.background, - required this.hoverBackground, - required this.subtleText, - required this.text, - }); - - Color appBackground; - Color subtleBackground; - Color elementBackground; - Color hoverElementBackground; - Color activeElementBackground; - Color subtleBorder; - Color border; - Color hoverBorder; - Color background; - Color hoverBackground; - Color subtleText; - Color text; - - ScaleColor copyWith( - {Color? appBackground, - Color? subtleBackground, - Color? elementBackground, - Color? hoverElementBackground, - Color? activeElementBackground, - Color? subtleBorder, - Color? border, - Color? hoverBorder, - Color? background, - Color? hoverBackground, - Color? subtleText, - Color? text}) => - ScaleColor( - appBackground: appBackground ?? this.appBackground, - subtleBackground: subtleBackground ?? this.subtleBackground, - elementBackground: elementBackground ?? this.elementBackground, - hoverElementBackground: - hoverElementBackground ?? this.hoverElementBackground, - activeElementBackground: - activeElementBackground ?? this.activeElementBackground, - subtleBorder: subtleBorder ?? this.subtleBorder, - border: border ?? this.border, - hoverBorder: hoverBorder ?? this.hoverBorder, - background: background ?? this.background, - hoverBackground: hoverBackground ?? this.hoverBackground, - subtleText: subtleText ?? this.subtleText, - text: text ?? this.text, - ); - - // ignore: prefer_constructors_over_static_methods - static ScaleColor lerp(ScaleColor a, ScaleColor b, double t) => ScaleColor( - appBackground: Color.lerp(a.appBackground, b.appBackground, t) ?? - const Color(0x00000000), - subtleBackground: - Color.lerp(a.subtleBackground, b.subtleBackground, t) ?? - const Color(0x00000000), - elementBackground: - Color.lerp(a.elementBackground, b.elementBackground, t) ?? - const Color(0x00000000), - hoverElementBackground: - Color.lerp(a.hoverElementBackground, b.hoverElementBackground, t) ?? - const Color(0x00000000), - activeElementBackground: Color.lerp( - a.activeElementBackground, b.activeElementBackground, t) ?? - const Color(0x00000000), - subtleBorder: Color.lerp(a.subtleBorder, b.subtleBorder, t) ?? - const Color(0x00000000), - border: Color.lerp(a.border, b.border, t) ?? const Color(0x00000000), - hoverBorder: Color.lerp(a.hoverBorder, b.hoverBorder, t) ?? - const Color(0x00000000), - background: Color.lerp(a.background, b.background, t) ?? - const Color(0x00000000), - hoverBackground: Color.lerp(a.hoverBackground, b.hoverBackground, t) ?? - const Color(0x00000000), - subtleText: Color.lerp(a.subtleText, b.subtleText, t) ?? - const Color(0x00000000), - text: Color.lerp(a.text, b.text, t) ?? const Color(0x00000000), - ); -} - -class ScaleScheme extends ThemeExtension { - ScaleScheme( - {required this.primaryScale, - required this.primaryAlphaScale, - required this.secondaryScale, - required this.tertiaryScale, - required this.grayScale, - required this.errorScale}); - - final ScaleColor primaryScale; - final ScaleColor primaryAlphaScale; - final ScaleColor secondaryScale; - final ScaleColor tertiaryScale; - final ScaleColor grayScale; - final ScaleColor errorScale; - - @override - ScaleScheme copyWith( - {ScaleColor? primaryScale, - ScaleColor? primaryAlphaScale, - ScaleColor? secondaryScale, - ScaleColor? tertiaryScale, - ScaleColor? grayScale, - ScaleColor? errorScale}) => - ScaleScheme( - primaryScale: primaryScale ?? this.primaryScale, - primaryAlphaScale: primaryAlphaScale ?? this.primaryAlphaScale, - secondaryScale: secondaryScale ?? this.secondaryScale, - tertiaryScale: tertiaryScale ?? this.tertiaryScale, - grayScale: grayScale ?? this.grayScale, - errorScale: errorScale ?? this.errorScale, - ); - - @override - ScaleScheme lerp(ScaleScheme? other, double t) { - if (other is! ScaleScheme) { - return this; - } - return ScaleScheme( - primaryScale: ScaleColor.lerp(primaryScale, other.primaryScale, t), - primaryAlphaScale: - ScaleColor.lerp(primaryAlphaScale, other.primaryAlphaScale, t), - secondaryScale: ScaleColor.lerp(secondaryScale, other.secondaryScale, t), - tertiaryScale: ScaleColor.lerp(tertiaryScale, other.tertiaryScale, t), - grayScale: ScaleColor.lerp(grayScale, other.grayScale, t), - errorScale: ScaleColor.lerp(errorScale, other.errorScale, t), - ); - } -} - -//////////////////////////////////////////////////////////////////////// - -class ThemeService { - ThemeService._(); - static late SharedPreferences prefs; - static ThemeService? _instance; - - static Future get instance async { - if (_instance == null) { - prefs = await SharedPreferences.getInstance(); - _instance = ThemeService._(); - } - return _instance!; - } - - static bool get isPlatformDark => - WidgetsBinding.instance.platformDispatcher.platformBrightness == - Brightness.dark; - - ThemeData get initial { - final themePreferences = load(); - return get(themePreferences); - } - - ThemePreferences load() { - final themePreferencesJson = prefs.getString('themePreferences'); - ThemePreferences? themePreferences; - if (themePreferencesJson != null) { - try { - themePreferences = - ThemePreferences.fromJson(jsonDecode(themePreferencesJson)); - // ignore: avoid_catches_without_on_clauses - } catch (_) { - // ignore - } - } - return themePreferences ?? - const ThemePreferences( - colorPreference: ColorPreference.vapor, - brightnessPreference: BrightnessPreference.system, - displayScale: 1, - ); - } - - Future save(ThemePreferences themePreferences) async { - await prefs.setString( - 'themePreferences', jsonEncode(themePreferences.toJson())); - } - - ThemeData get(ThemePreferences themePreferences) { - late final Brightness brightness; - switch (themePreferences.brightnessPreference) { - case BrightnessPreference.system: - if (isPlatformDark) { - brightness = Brightness.dark; - } else { - brightness = Brightness.light; - } - case BrightnessPreference.light: - brightness = Brightness.light; - case BrightnessPreference.dark: - brightness = Brightness.dark; - } - - late final ThemeData themeData; - switch (themePreferences.colorPreference) { - // Special cases - case ColorPreference.contrast: - // xxx do contrastGenerator - themeData = radixGenerator(brightness, RadixThemeColor.grim); - // Generate from Radix - case ColorPreference.scarlet: - themeData = radixGenerator(brightness, RadixThemeColor.scarlet); - case ColorPreference.babydoll: - themeData = radixGenerator(brightness, RadixThemeColor.babydoll); - case ColorPreference.vapor: - themeData = radixGenerator(brightness, RadixThemeColor.vapor); - case ColorPreference.gold: - themeData = radixGenerator(brightness, RadixThemeColor.gold); - case ColorPreference.garden: - themeData = radixGenerator(brightness, RadixThemeColor.garden); - case ColorPreference.forest: - themeData = radixGenerator(brightness, RadixThemeColor.forest); - case ColorPreference.arctic: - themeData = radixGenerator(brightness, RadixThemeColor.arctic); - case ColorPreference.lapis: - themeData = radixGenerator(brightness, RadixThemeColor.lapis); - case ColorPreference.eggplant: - themeData = radixGenerator(brightness, RadixThemeColor.eggplant); - case ColorPreference.lime: - themeData = radixGenerator(brightness, RadixThemeColor.lime); - case ColorPreference.grim: - themeData = radixGenerator(brightness, RadixThemeColor.grim); - } - - return themeData; - } -} - -@riverpod -FutureOr themeService(ThemeServiceRef ref) async => - await ThemeService.instance; diff --git a/lib/tools/theme_service.g.dart b/lib/tools/theme_service.g.dart deleted file mode 100644 index e146df9..0000000 --- a/lib/tools/theme_service.g.dart +++ /dev/null @@ -1,24 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'theme_service.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$themeServiceHash() => r'87dbacb9df4923f507fb01e486b91d73a3fcef9c'; - -/// See also [themeService]. -@ProviderFor(themeService) -final themeServiceProvider = AutoDisposeFutureProvider.internal( - themeService, - name: r'themeServiceProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') ? null : _$themeServiceHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef ThemeServiceRef = AutoDisposeFutureProviderRef; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/tools/tools.dart b/lib/tools/tools.dart index 11cb944..470b648 100644 --- a/lib/tools/tools.dart +++ b/lib/tools/tools.dart @@ -1,10 +1,10 @@ export 'animations.dart'; +export 'exceptions.dart'; export 'loggy.dart'; +export 'misc.dart'; +export 'package_info.dart'; export 'phono_byte.dart'; -export 'radix_generator.dart'; -export 'responsive.dart'; -export 'scanner_error_widget.dart'; -export 'secret_crypto.dart'; +export 'shared_preferences.dart'; export 'state_logger.dart'; -export 'theme_service.dart'; -export 'widget_helpers.dart'; +export 'stream_listenable.dart'; +export 'window_control.dart'; diff --git a/lib/tools/widget_helpers.dart b/lib/tools/widget_helpers.dart deleted file mode 100644 index 0d05d27..0000000 --- a/lib/tools/widget_helpers.dart +++ /dev/null @@ -1,137 +0,0 @@ -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:blurry_modal_progress_hud/blurry_modal_progress_hud.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_spinkit/flutter_spinkit.dart'; -import 'package:flutter_translate/flutter_translate.dart'; -import 'package:motion_toast/motion_toast.dart'; -import 'package:quickalert/quickalert.dart'; - -import 'theme_service.dart'; - -extension BorderExt on Widget { - DecoratedBox debugBorder() => DecoratedBox( - decoration: BoxDecoration(border: Border.all(color: Colors.redAccent)), - child: this); -} - -extension ModalProgressExt on Widget { - BlurryModalProgressHUD withModalHUD(BuildContext context, bool isLoading) { - final theme = Theme.of(context); - final scale = theme.extension()!; - - return BlurryModalProgressHUD( - inAsyncCall: isLoading, - blurEffectIntensity: 4, - progressIndicator: buildProgressIndicator(context), - color: scale.tertiaryScale.appBackground.withAlpha(64), - child: this); - } -} - -Widget buildProgressIndicator(BuildContext context) { - final theme = Theme.of(context); - final scale = theme.extension()!; - return SpinKitFoldingCube( - color: scale.tertiaryScale.background, - size: 80, - ); -} - -Widget waitingPage(BuildContext context) => ColoredBox( - color: Theme.of(context).scaffoldBackgroundColor, - child: Center(child: buildProgressIndicator(context))); - -Future showErrorModal( - BuildContext context, String title, String text) async { - await QuickAlert.show( - context: context, - type: QuickAlertType.error, - title: title, - text: text, - //backgroundColor: Colors.black, - //titleColor: Colors.white, - //textColor: Colors.white, - ); -} - -void showErrorToast(BuildContext context, String message) { - MotionToast.error( - title: Text(translate('toast.error')), - description: Text(message), - ).show(context); -} - -void showInfoToast(BuildContext context, String message) { - MotionToast.info( - title: Text(translate('toast.info')), - description: Text(message), - ).show(context); -} - -Widget styledTitleContainer( - {required BuildContext context, - required String title, - required Widget child}) { - final theme = Theme.of(context); - final scale = theme.extension()!; - final textTheme = theme.textTheme; - - return DecoratedBox( - decoration: ShapeDecoration( - color: scale.primaryScale.border, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - )), - child: Column(children: [ - Text( - title, - style: textTheme.titleMedium! - .copyWith(color: scale.primaryScale.subtleText), - ).paddingLTRB(8, 8, 8, 8), - DecoratedBox( - decoration: ShapeDecoration( - color: scale.primaryScale.subtleBackground, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - )), - child: child) - .paddingAll(4) - .expanded() - ])); -} - -Future showStyledDialog( - {required BuildContext context, - required String title, - required Widget child}) async { - final theme = Theme.of(context); - final scale = theme.extension()!; - final textTheme = theme.textTheme; - - return showDialog( - context: context, - builder: (context) => AlertDialog( - elevation: 0, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(16)), - ), - contentPadding: const EdgeInsets.all(4), - backgroundColor: scale.primaryScale.border, - title: Text( - title, - style: textTheme.titleMedium, - textAlign: TextAlign.center, - ), - titlePadding: const EdgeInsets.fromLTRB(4, 4, 4, 4), - content: DecoratedBox( - decoration: ShapeDecoration( - color: scale.primaryScale.border, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16))), - child: DecoratedBox( - decoration: ShapeDecoration( - color: scale.primaryScale.appBackground, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12))), - child: child.paddingAll(0))))); -} diff --git a/lib/tools/window_control.dart b/lib/tools/window_control.dart new file mode 100644 index 0000000..32c05ff --- /dev/null +++ b/lib/tools/window_control.dart @@ -0,0 +1,109 @@ +import 'dart:async'; + +import 'package:async_tools/async_tools.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:window_manager/window_manager.dart'; + +import '../theme/views/responsive.dart'; + +export 'package:window_manager/window_manager.dart' show TitleBarStyle; + +enum OrientationCapability { + normal, + portraitOnly, + landscapeOnly, +} + +// Window Control +Future initializeWindowControl() async { + if (isDesktop) { + await windowManager.ensureInitialized(); + + const windowOptions = WindowOptions( + size: Size(768, 1024), + minimumSize: Size(400, 500), + center: true, + backgroundColor: Colors.transparent, + skipTaskbar: false, + ); + await windowManager.waitUntilReadyToShow(windowOptions, () async { + await _asyncChangeWindowSetup( + TitleBarStyle.hidden, OrientationCapability.normal); + await windowManager.show(); + await windowManager.focus(); + }); + } +} + +const kWindowSetup = '__windowSetup'; + +Future _asyncChangeWindowSetup(TitleBarStyle titleBarStyle, + OrientationCapability orientationCapability) async { + if (isDesktop) { + await windowManager.setTitleBarStyle(titleBarStyle); + } else { + switch (orientationCapability) { + case OrientationCapability.normal: + await SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + case OrientationCapability.portraitOnly: + await SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + ]); + case OrientationCapability.landscapeOnly: + await SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + } + } +} + +void changeWindowSetup( + TitleBarStyle titleBarStyle, OrientationCapability orientationCapability) { + singleFuture( + kWindowSetup, + () async => + _asyncChangeWindowSetup(titleBarStyle, orientationCapability)); +} + +abstract class WindowSetupState extends State { + WindowSetupState( + {required this.titleBarStyle, required this.orientationCapability}); + + @override + void initState() { + changeWindowSetup(this.titleBarStyle, this.orientationCapability); + super.initState(); + } + + @override + void activate() { + changeWindowSetup(this.titleBarStyle, this.orientationCapability); + super.activate(); + } + + @override + void deactivate() { + changeWindowSetup(TitleBarStyle.normal, OrientationCapability.normal); + super.deactivate(); + } + + //////////////////////////////////////////////////////////////////////////// + final TitleBarStyle titleBarStyle; + final OrientationCapability orientationCapability; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(EnumProperty('titleBarStyle', titleBarStyle)) + ..add(EnumProperty( + 'orientationCapability', orientationCapability)); + } +} diff --git a/lib/veilid_init.dart b/lib/veilid_init.dart deleted file mode 100644 index 86c0900..0000000 --- a/lib/veilid_init.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -import 'processor.dart'; -import 'veilid_support/veilid_support.dart'; - -part 'veilid_init.g.dart'; - -Future getVeilidVersion() async { - String veilidVersion; - try { - veilidVersion = Veilid.instance.veilidVersionString(); - } on Exception { - veilidVersion = 'Failed to get veilid version.'; - } - return veilidVersion; -} - -// Initialize Veilid -// Call only once. -void _initVeilid() { - if (kIsWeb) { - const platformConfig = VeilidWASMConfig( - logging: VeilidWASMConfigLogging( - performance: VeilidWASMConfigLoggingPerformance( - enabled: true, - level: VeilidConfigLogLevel.debug, - logsInTimings: true, - logsInConsole: false), - api: VeilidWASMConfigLoggingApi( - enabled: true, level: VeilidConfigLogLevel.info))); - Veilid.instance.initializeVeilidCore(platformConfig.toJson()); - } else { - const platformConfig = VeilidFFIConfig( - logging: VeilidFFIConfigLogging( - terminal: VeilidFFIConfigLoggingTerminal( - enabled: false, - level: VeilidConfigLogLevel.debug, - ), - otlp: VeilidFFIConfigLoggingOtlp( - enabled: false, - level: VeilidConfigLogLevel.trace, - grpcEndpoint: '127.0.0.1:4317', - serviceName: 'VeilidChat'), - api: VeilidFFIConfigLoggingApi( - enabled: true, level: VeilidConfigLogLevel.info))); - Veilid.instance.initializeVeilidCore(platformConfig.toJson()); - } -} - -Completer eventualVeilid = Completer(); -Processor processor = Processor(); - -Future initializeVeilid() async { - // Ensure this runs only once - if (eventualVeilid.isCompleted) { - return; - } - - // Init Veilid - _initVeilid(); - - // Veilid logging - initVeilidLog(); - - // Startup Veilid - await processor.startup(); - - // Share the initialized veilid instance to the rest of the app - eventualVeilid.complete(Veilid.instance); -} - -// Expose the Veilid instance as a FutureProvider -@riverpod -FutureOr veilidInstance(VeilidInstanceRef ref) async => - await eventualVeilid.future; diff --git a/lib/veilid_init.g.dart b/lib/veilid_init.g.dart deleted file mode 100644 index ab235f9..0000000 --- a/lib/veilid_init.g.dart +++ /dev/null @@ -1,25 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'veilid_init.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$veilidInstanceHash() => r'cca5cf288bafc4a051a1713e285f4c1d3ef4b680'; - -/// See also [veilidInstance]. -@ProviderFor(veilidInstance) -final veilidInstanceProvider = AutoDisposeFutureProvider.internal( - veilidInstance, - name: r'veilidInstanceProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$veilidInstanceHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef VeilidInstanceRef = AutoDisposeFutureProviderRef; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/veilid_processor/cubit/connection_state_cubit.dart b/lib/veilid_processor/cubit/connection_state_cubit.dart new file mode 100644 index 0000000..0bbfdc2 --- /dev/null +++ b/lib/veilid_processor/cubit/connection_state_cubit.dart @@ -0,0 +1,13 @@ +import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; + +import '../models/models.dart'; +import '../repository/processor_repository.dart'; + +export '../models/processor_connection_state.dart'; + +class ConnectionStateCubit + extends StreamWrapperCubit { + ConnectionStateCubit(ProcessorRepository processorRepository) + : super(processorRepository.streamProcessorConnectionState(), + defaultState: processorRepository.processorConnectionState); +} diff --git a/lib/veilid_processor/models/models.dart b/lib/veilid_processor/models/models.dart new file mode 100644 index 0000000..4dd8061 --- /dev/null +++ b/lib/veilid_processor/models/models.dart @@ -0,0 +1 @@ +export 'processor_connection_state.dart'; diff --git a/lib/veilid_processor/models/processor_connection_state.dart b/lib/veilid_processor/models/processor_connection_state.dart new file mode 100644 index 0000000..6b68a8e --- /dev/null +++ b/lib/veilid_processor/models/processor_connection_state.dart @@ -0,0 +1,21 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:veilid_support/veilid_support.dart'; + +part 'processor_connection_state.freezed.dart'; + +@freezed +sealed class ProcessorConnectionState with _$ProcessorConnectionState { + const factory ProcessorConnectionState({ + required VeilidStateAttachment attachment, + required VeilidStateNetwork network, + }) = _ProcessorConnectionState; + const ProcessorConnectionState._(); + + bool get isAttached => !(attachment.state == AttachmentState.detached || + attachment.state == AttachmentState.detaching || + attachment.state == AttachmentState.attaching); + + bool get isDetached => attachment.state == AttachmentState.detached; + + bool get isPublicInternetReady => attachment.publicInternetReady; +} diff --git a/lib/veilid_processor/models/processor_connection_state.freezed.dart b/lib/veilid_processor/models/processor_connection_state.freezed.dart new file mode 100644 index 0000000..c7c5288 --- /dev/null +++ b/lib/veilid_processor/models/processor_connection_state.freezed.dart @@ -0,0 +1,214 @@ +// dart format width=80 +// 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 'processor_connection_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$ProcessorConnectionState { + VeilidStateAttachment get attachment; + VeilidStateNetwork get network; + + /// Create a copy of ProcessorConnectionState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $ProcessorConnectionStateCopyWith get copyWith => + _$ProcessorConnectionStateCopyWithImpl( + this as ProcessorConnectionState, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is ProcessorConnectionState && + (identical(other.attachment, attachment) || + other.attachment == attachment) && + (identical(other.network, network) || other.network == network)); + } + + @override + int get hashCode => Object.hash(runtimeType, attachment, network); + + @override + String toString() { + return 'ProcessorConnectionState(attachment: $attachment, network: $network)'; + } +} + +/// @nodoc +abstract mixin class $ProcessorConnectionStateCopyWith<$Res> { + factory $ProcessorConnectionStateCopyWith(ProcessorConnectionState value, + $Res Function(ProcessorConnectionState) _then) = + _$ProcessorConnectionStateCopyWithImpl; + @useResult + $Res call({VeilidStateAttachment attachment, VeilidStateNetwork network}); + + $VeilidStateAttachmentCopyWith<$Res> get attachment; + $VeilidStateNetworkCopyWith<$Res> get network; +} + +/// @nodoc +class _$ProcessorConnectionStateCopyWithImpl<$Res> + implements $ProcessorConnectionStateCopyWith<$Res> { + _$ProcessorConnectionStateCopyWithImpl(this._self, this._then); + + final ProcessorConnectionState _self; + final $Res Function(ProcessorConnectionState) _then; + + /// Create a copy of ProcessorConnectionState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? attachment = null, + Object? network = null, + }) { + return _then(_self.copyWith( + attachment: null == attachment + ? _self.attachment + : attachment // ignore: cast_nullable_to_non_nullable + as VeilidStateAttachment, + network: null == network + ? _self.network + : network // ignore: cast_nullable_to_non_nullable + as VeilidStateNetwork, + )); + } + + /// Create a copy of ProcessorConnectionState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $VeilidStateAttachmentCopyWith<$Res> get attachment { + return $VeilidStateAttachmentCopyWith<$Res>(_self.attachment, (value) { + return _then(_self.copyWith(attachment: value)); + }); + } + + /// Create a copy of ProcessorConnectionState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $VeilidStateNetworkCopyWith<$Res> get network { + return $VeilidStateNetworkCopyWith<$Res>(_self.network, (value) { + return _then(_self.copyWith(network: value)); + }); + } +} + +/// @nodoc + +class _ProcessorConnectionState extends ProcessorConnectionState { + const _ProcessorConnectionState( + {required this.attachment, required this.network}) + : super._(); + + @override + final VeilidStateAttachment attachment; + @override + final VeilidStateNetwork network; + + /// Create a copy of ProcessorConnectionState + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$ProcessorConnectionStateCopyWith<_ProcessorConnectionState> get copyWith => + __$ProcessorConnectionStateCopyWithImpl<_ProcessorConnectionState>( + this, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _ProcessorConnectionState && + (identical(other.attachment, attachment) || + other.attachment == attachment) && + (identical(other.network, network) || other.network == network)); + } + + @override + int get hashCode => Object.hash(runtimeType, attachment, network); + + @override + String toString() { + return 'ProcessorConnectionState(attachment: $attachment, network: $network)'; + } +} + +/// @nodoc +abstract mixin class _$ProcessorConnectionStateCopyWith<$Res> + implements $ProcessorConnectionStateCopyWith<$Res> { + factory _$ProcessorConnectionStateCopyWith(_ProcessorConnectionState value, + $Res Function(_ProcessorConnectionState) _then) = + __$ProcessorConnectionStateCopyWithImpl; + @override + @useResult + $Res call({VeilidStateAttachment attachment, VeilidStateNetwork network}); + + @override + $VeilidStateAttachmentCopyWith<$Res> get attachment; + @override + $VeilidStateNetworkCopyWith<$Res> get network; +} + +/// @nodoc +class __$ProcessorConnectionStateCopyWithImpl<$Res> + implements _$ProcessorConnectionStateCopyWith<$Res> { + __$ProcessorConnectionStateCopyWithImpl(this._self, this._then); + + final _ProcessorConnectionState _self; + final $Res Function(_ProcessorConnectionState) _then; + + /// Create a copy of ProcessorConnectionState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? attachment = null, + Object? network = null, + }) { + return _then(_ProcessorConnectionState( + attachment: null == attachment + ? _self.attachment + : attachment // ignore: cast_nullable_to_non_nullable + as VeilidStateAttachment, + network: null == network + ? _self.network + : network // ignore: cast_nullable_to_non_nullable + as VeilidStateNetwork, + )); + } + + /// Create a copy of ProcessorConnectionState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $VeilidStateAttachmentCopyWith<$Res> get attachment { + return $VeilidStateAttachmentCopyWith<$Res>(_self.attachment, (value) { + return _then(_self.copyWith(attachment: value)); + }); + } + + /// Create a copy of ProcessorConnectionState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $VeilidStateNetworkCopyWith<$Res> get network { + return $VeilidStateNetworkCopyWith<$Res>(_self.network, (value) { + return _then(_self.copyWith(network: value)); + }); + } +} + +// dart format on diff --git a/lib/veilid_processor/repository/processor_repository.dart b/lib/veilid_processor/repository/processor_repository.dart new file mode 100644 index 0000000..a92347c --- /dev/null +++ b/lib/veilid_processor/repository/processor_repository.dart @@ -0,0 +1,144 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../app.dart'; +import '../../tools/tools.dart'; +import '../models/models.dart'; + +class ProcessorRepository { + ProcessorRepository._() + : startedUp = false, + _controllerConnectionState = StreamController.broadcast(sync: true), + processorConnectionState = ProcessorConnectionState( + attachment: VeilidStateAttachment( + state: AttachmentState.detached, + publicInternetReady: false, + localNetworkReady: false, + uptime: TimestampDuration(value: BigInt.zero), + attachedUptime: null), + network: VeilidStateNetwork( + started: false, + bpsDown: BigInt.zero, + bpsUp: BigInt.zero, + peers: [])); + + ////////////////////////////////////////////////////////////// + /// Singleton initialization + + static ProcessorRepository instance = ProcessorRepository._(); + + Future startup() async { + if (startedUp) { + return; + } + + var veilidVersion = ''; + + try { + veilidVersion = Veilid.instance.veilidVersionString(); + } on Exception { + veilidVersion = 'Failed to get veilid version.'; + } + + log.info('Veilid version: $veilidVersion'); + + Stream updateStream; + + try { + log.debug('Starting VeilidCore'); + updateStream = await Veilid.instance + .startupVeilidCore(await getVeilidConfig(kIsWeb, VeilidChatApp.name)); + } on VeilidAPIExceptionAlreadyInitialized catch (_) { + log.debug( + 'VeilidCore is already started, shutting down and restarting...'); + startedUp = true; + await shutdown(); + updateStream = await Veilid.instance + .startupVeilidCore(await getVeilidConfig(kIsWeb, VeilidChatApp.name)); + } + + _updateSubscription = updateStream.listen((update) { + if (update is VeilidLog) { + processLog(update); + } else if (update is VeilidUpdateAttachment) { + processUpdateAttachment(update); + } else if (update is VeilidUpdateConfig) { + processUpdateConfig(update); + } else if (update is VeilidUpdateNetwork) { + processUpdateNetwork(update); + } else if (update is VeilidAppMessage) { + processAppMessage(update); + } else if (update is VeilidAppCall) { + log.info('AppCall: ${update.toJson()}'); + } else if (update is VeilidUpdateValueChange) { + processUpdateValueChange(update); + } else { + log.trace('Update: ${update.toJson()}'); + } + }); + + startedUp = true; + + await Veilid.instance.attach(); + } + + Future shutdown() async { + if (!startedUp) { + return; + } + await Veilid.instance.shutdownVeilidCore(); + await _updateSubscription?.cancel(); + _updateSubscription = null; + + startedUp = false; + } + + Stream streamProcessorConnectionState() => + _controllerConnectionState.stream; + + void processUpdateAttachment(VeilidUpdateAttachment updateAttachment) { + // Set connection meter and ui state for connection state + processorConnectionState = processorConnectionState.copyWith( + attachment: VeilidStateAttachment( + state: updateAttachment.state, + publicInternetReady: updateAttachment.publicInternetReady, + localNetworkReady: updateAttachment.localNetworkReady, + uptime: updateAttachment.uptime, + attachedUptime: updateAttachment.attachedUptime)); + } + + void processUpdateConfig(VeilidUpdateConfig updateConfig) { + log.debug('VeilidUpdateConfig: ${updateConfig.toJson()}'); + } + + void processUpdateNetwork(VeilidUpdateNetwork updateNetwork) { + // Set connection meter and ui state for connection state + processorConnectionState = processorConnectionState.copyWith( + network: VeilidStateNetwork( + started: updateNetwork.started, + bpsDown: updateNetwork.bpsDown, + bpsUp: updateNetwork.bpsUp, + peers: updateNetwork.peers)); + _controllerConnectionState.add(processorConnectionState); + } + + void processAppMessage(VeilidAppMessage appMessage) { + log.debug('VeilidAppMessage: ${appMessage.toJson()}'); + } + + void processUpdateValueChange(VeilidUpdateValueChange updateValueChange) { + log.debug('UpdateValueChange: ${updateValueChange.toJson()}'); + + // Send value updates to DHTRecordPool + DHTRecordPool.instance.processRemoteValueChange(updateValueChange); + } + + //////////////////////////////////////////// + + StreamSubscription? _updateSubscription; + final StreamController _controllerConnectionState; + bool startedUp; + ProcessorConnectionState processorConnectionState; +} diff --git a/lib/veilid_processor/veilid_processor.dart b/lib/veilid_processor/veilid_processor.dart new file mode 100644 index 0000000..12d36bd --- /dev/null +++ b/lib/veilid_processor/veilid_processor.dart @@ -0,0 +1,4 @@ +export 'cubit/connection_state_cubit.dart'; +export 'models/models.dart'; +export 'repository/processor_repository.dart'; +export 'views/views.dart'; diff --git a/lib/veilid_processor/views/developer.dart b/lib/veilid_processor/views/developer.dart new file mode 100644 index 0000000..c03b6bf --- /dev/null +++ b/lib/veilid_processor/views/developer.dart @@ -0,0 +1,395 @@ +import 'dart:async'; + +import 'package:animated_custom_dropdown/custom_dropdown.dart'; +import 'package:ansicolor/ansicolor.dart'; +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:go_router/go_router.dart'; +import 'package:loggy/loggy.dart'; +import 'package:veilid_support/veilid_support.dart'; +import 'package:xterm/xterm.dart'; + +import '../../layout/layout.dart'; +import '../../notifications/notifications.dart'; +import '../../theme/models/scale_theme/scale_custom_dropdown_theme.dart'; +import '../../theme/theme.dart'; +import '../../tools/tools.dart'; +import 'history_text_editing_controller.dart'; + +final globalDebugTerminal = Terminal( + maxLines: 10000, +); + +const kDefaultTerminalStyle = TerminalStyle( + fontSize: 11, + // height: 1.2, + fontFamily: 'Source Code Pro'); + +class LogLevelDropdownItem { + const LogLevelDropdownItem( + {required this.label, required this.icon, required this.value}); + + final String label; + final Widget icon; + final LogLevel value; +} + +class DeveloperPage extends StatefulWidget { + const DeveloperPage({super.key}); + + @override + State createState() => _DeveloperPageState(); +} + +class _DeveloperPageState extends State { + @override + void initState() { + super.initState(); + + _historyController = HistoryTextEditingController(setState: setState); + + _terminalController.addListener(() { + setState(() {}); + }); + + for (var i = 0; i < logLevels.length; i++) { + _logLevelDropdownItems.add(LogLevelDropdownItem( + label: logLevelName(logLevels[i]), + icon: Text(logLevelEmoji(logLevels[i])), + value: logLevels[i])); + } + } + + void _debugOut(String out) { + final sanitizedOut = out.replaceAll('\uFFFD', ''); + final pen = AnsiPen()..cyan(bold: true); + final colorOut = pen(sanitizedOut); + debugPrint(colorOut); + globalDebugTerminal.write(colorOut.replaceAll('\n', '\r\n')); + } + + Future _sendDebugCommand(String debugCommand) async { + try { + setState(() { + _busy = true; + }); + + if (debugCommand == 'pool allocations') { + try { + DHTRecordPool.instance.debugPrintAllocations(); + } on Exception catch (e, st) { + _debugOut('<<< ERROR\n$e\n<<< STACK\n$st'); + return false; + } + return true; + } + + if (debugCommand == 'pool opened') { + try { + DHTRecordPool.instance.debugPrintOpened(); + } on Exception catch (e, st) { + _debugOut('<<< ERROR\n$e\n<<< STACK\n$st'); + return false; + } + return true; + } + + if (debugCommand == 'pool stats') { + try { + DHTRecordPool.instance.debugPrintStats(); + } on Exception catch (e, st) { + _debugOut('<<< ERROR\n$e\n<<< STACK\n$st'); + return false; + } + return true; + } + + if (debugCommand.startsWith('change_log_ignore ')) { + final args = debugCommand.split(' '); + if (args.length < 3) { + _debugOut('Incorrect number of arguments'); + return false; + } + final layer = args[1]; + final changes = args[2].split(','); + try { + Veilid.instance.changeLogIgnore(layer, changes); + } on Exception catch (e, st) { + _debugOut('<<< ERROR\n$e\n<<< STACK\n$st'); + return false; + } + + return true; + } + + if (debugCommand == 'ellet') { + setState(() { + _showEllet = !_showEllet; + }); + return true; + } + + _debugOut('DEBUG >>>\n$debugCommand\n'); + try { + var out = await Veilid.instance.debug(debugCommand); + + if (debugCommand == 'help') { + out = 'VeilidChat Commands:\n' + ' pool \n' + ' allocations - List DHTRecordPool allocations\n' + ' opened - List opened DHTRecord instances\n' + ' stats - Dump DHTRecordPool statistics\n' + ' change_log_ignore change the log' + ' target ignore list for a tracing layer\n' + ' targets to add to the ignore list can be separated by' + ' a comma.\n' + ' to remove a target from the ignore list, prepend it' + ' with a minus.\n\n$out'; + } + + _debugOut('<<< DEBUG\n$out\n'); + } on Exception catch (e, st) { + _debugOut('<<< ERROR\n$e\n<<< STACK\n$st'); + return false; + } + + return true; + } finally { + setState(() { + _busy = false; + }); + } + } + + Future clear(BuildContext context) async { + globalDebugTerminal.buffer.clear(); + if (context.mounted) { + context + .read() + .info(text: translate('developer.cleared')); + } + } + + Future copySelection(BuildContext context) async { + final selection = _terminalController.selection; + if (selection != null) { + final text = globalDebugTerminal.buffer.getText(selection); + _terminalController.clearSelection(); + await Clipboard.setData(ClipboardData(text: text)); + if (context.mounted) { + context + .read() + .info(text: translate('developer.copied')); + } + } + } + + Future copyAll(BuildContext context) async { + final text = globalDebugTerminal.buffer.getText(); + await Clipboard.setData(ClipboardData(text: text)); + if (context.mounted) { + context + .read() + .info(text: translate('developer.copied_all')); + } + } + + Future _onSubmitCommand(String debugCommand) async { + final ok = await _sendDebugCommand(debugCommand); + if (ok) { + setState(() { + _historyController.submit(debugCommand); + }); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleTheme = theme.extension()!; + final dropdownTheme = scaleTheme.customDropdownTheme(); + final scaleConfig = theme.extension()!; + + final hintColor = scaleConfig.useVisualIndicators + ? scale.primaryScale.primaryText + : scale.primaryScale.primary; + + return Scaffold( + backgroundColor: scale.primaryScale.border, + appBar: DefaultAppBar( + context: context, + title: Text(translate('developer.title')), + leading: IconButton( + iconSize: 24.scaled(context), + icon: Icon(Icons.arrow_back, color: scale.primaryScale.borderText), + onPressed: () => GoRouterHelper(context).pop(), + ), + actions: [ + IconButton( + iconSize: 24.scaled(context), + icon: const Icon(Icons.copy), + color: scale.primaryScale.borderText, + disabledColor: scale.primaryScale.borderText.withAlpha(0x3F), + onPressed: _terminalController.selection == null + ? null + : () async { + await copySelection(context); + }), + IconButton( + iconSize: 24.scaled(context), + icon: const Icon(Icons.copy_all), + color: scale.primaryScale.borderText, + disabledColor: scale.primaryScale.borderText.withAlpha(0x3F), + onPressed: () async { + await copyAll(context); + }), + IconButton( + iconSize: 24.scaled(context), + icon: const Icon(Icons.clear_all), + color: scale.primaryScale.borderText, + disabledColor: scale.primaryScale.borderText.withAlpha(0x3F), + onPressed: () async { + final confirm = await showConfirmModal( + context: context, + title: translate('confirmation.confirm'), + text: translate('developer.are_you_sure_clear'), + ); + if (confirm && context.mounted) { + await clear(context); + } + }), + SizedBox.fromSize( + size: Size(140.scaled(context), 48), + child: CustomDropdown( + items: _logLevelDropdownItems, + initialItem: _logLevelDropdownItems + .singleWhere((x) => x.value == _logLevelDropDown), + onChanged: (item) { + if (item != null) { + setState(() { + _logLevelDropDown = item.value; + Loggy('').level = getLogOptions(item.value); + setVeilidLogLevel(item.value); + }); + } + }, + headerBuilder: (context, item, enabled) => Row(children: [ + item.icon, + const Spacer(), + Text(item.label).copyWith(style: dropdownTheme.textStyle) + ]), + listItemBuilder: (context, item, enabled, onItemSelect) => + Row(children: [ + item.icon, + const Spacer(), + Text(item.label).copyWith(style: dropdownTheme.textStyle) + ]), + decoration: dropdownTheme.decoration, + disabledDecoration: dropdownTheme.disabledDecoration, + listItemPadding: dropdownTheme.listItemPadding, + itemsListPadding: dropdownTheme.itemsListPadding, + expandedHeaderPadding: dropdownTheme.expandedHeaderPadding, + closedHeaderPadding: dropdownTheme.closedHeaderPadding, + )).paddingLTRB(0, 4, 8, 4), + ], + ), + body: GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: Column(children: [ + Stack(alignment: AlignmentDirectional.center, children: [ + Image.asset('assets/images/ellet.png'), + TerminalView(globalDebugTerminal, + textStyle: kDefaultTerminalStyle, + textScaler: TextScaler.noScaling, + controller: _terminalController, + keyboardType: TextInputType.none, + backgroundOpacity: _showEllet ? 0.75 : 1.0, + onSecondaryTapDown: (details, offset) async { + await copySelection(context); + }) + ]).expanded(), + TextFormField( + enabled: !_busy, + autofocus: true, + controller: _historyController.controller, + focusNode: _historyController.focusNode, + textInputAction: TextInputAction.send, + onTapOutside: (event) { + FocusManager.instance.primaryFocus?.unfocus(); + }, + decoration: InputDecoration( + filled: true, + contentPadding: const EdgeInsets.fromLTRB(8, 2, 8, 2), + enabledBorder: + const OutlineInputBorder(borderSide: BorderSide.none), + border: + const OutlineInputBorder(borderSide: BorderSide.none), + focusedBorder: + const OutlineInputBorder(borderSide: BorderSide.none), + fillColor: scale.primaryScale.elementBackground, + hoverColor: scale.primaryScale.elementBackground, + hintStyle: scaleTheme.textTheme.labelMedium!.copyWith( + color: scaleConfig.useVisualIndicators + ? hintColor.withAlpha(0x7F) + : hintColor), + hintText: translate('developer.command'), + suffixIcon: IconButton( + icon: Icon(Icons.send, + color: _historyController.controller.text.isEmpty + ? hintColor.withAlpha(0x7F) + : hintColor), + onPressed: + (_historyController.controller.text.isEmpty || _busy) + ? null + : () async { + final debugCommand = + _historyController.controller.text; + _historyController.controller.clear(); + await _onSubmitCommand(debugCommand); + }, + )), + onChanged: (_) { + setState(() => {}); + }, + onEditingComplete: () { + // part of the default action if onEditingComplete is null + _historyController.controller.clearComposing(); + // don't give up focus though + }, + onFieldSubmitted: (debugCommand) async { + if (debugCommand.isEmpty) { + return; + } + await _onSubmitCommand(debugCommand); + _historyController.focusNode.requestFocus(); + }, + ).paddingAll(4) + ]))); + } + + //////////////////////////////////////////////////////////////////////////// + + final _terminalController = TerminalController(); + late final HistoryTextEditingController _historyController; + + final List _logLevelDropdownItems = []; + var _logLevelDropDown = log.level.logLevel; + + var _showEllet = false; + var _busy = false; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty( + 'terminalController', _terminalController)) + ..add( + DiagnosticsProperty('logLevelDropDown', _logLevelDropDown)); + } +} diff --git a/lib/veilid_processor/views/history_text_editing_controller.dart b/lib/veilid_processor/views/history_text_editing_controller.dart new file mode 100644 index 0000000..f9646e5 --- /dev/null +++ b/lib/veilid_processor/views/history_text_editing_controller.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// TextField History Controller +class HistoryTextEditingController { + HistoryTextEditingController( + {required this.setState, TextEditingController? controller}) + : _controller = controller ?? TextEditingController() { + _historyFocusNode = FocusNode(onKeyEvent: (_node, event) { + if (event.runtimeType == KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.arrowUp) { + if (_historyPosition > 0) { + if (_historyPosition == _history.length) { + _historyCurrentEdit = _controller.text; + } + _historyPosition -= 1; + setState(() { + _controller.text = _history[_historyPosition]; + }); + } + return KeyEventResult.handled; + } else if (event.runtimeType == KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.arrowDown) { + if (_historyPosition < _history.length) { + _historyPosition += 1; + setState(() { + if (_historyPosition == _history.length) { + _controller.text = _historyCurrentEdit; + } else { + _controller.text = _history[_historyPosition]; + } + }); + } + return KeyEventResult.handled; + } else if (event.runtimeType == KeyDownEvent) { + _historyPosition = _history.length; + _historyCurrentEdit = _controller.text; + } + return KeyEventResult.ignored; + }); + } + + void submit(String v) { + // add to history + if (_history.isEmpty || _history.last != v) { + _history.add(v); + if (_history.length > 100) { + _history.removeAt(0); + } + } + _historyPosition = _history.length; + setState(() { + _controller.text = ''; + }); + } + + FocusNode get focusNode => _historyFocusNode; + TextEditingController get controller => _controller; + + //////////////////////////////////////////////////////////////////////////// + + late void Function(void Function()) setState; + final TextEditingController _controller; + late final FocusNode _historyFocusNode; + + final List _history = []; + int _historyPosition = 0; + String _historyCurrentEdit = ''; +} diff --git a/lib/veilid_processor/views/signal_strength_meter.dart b/lib/veilid_processor/views/signal_strength_meter.dart new file mode 100644 index 0000000..44e3cb6 --- /dev/null +++ b/lib/veilid_processor/views/signal_strength_meter.dart @@ -0,0 +1,100 @@ +import 'package:async_tools/async_tools.dart'; +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:signal_strength_indicator/signal_strength_indicator.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../theme/theme.dart'; +import '../cubit/connection_state_cubit.dart'; + +class SignalStrengthMeterWidget extends StatelessWidget { + const SignalStrengthMeterWidget({super.key, this.color, this.inactiveColor}); + + @override + // ignore: prefer_expression_function_bodies + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scale = theme.extension()!; + + final iconSize = 16.0.scaled(context); + + return BlocBuilder>(builder: (context, state) { + late final Widget iconWidget; + state.when( + data: (connectionState) { + late final double value; + late final Color color; + late final Color inactiveColor; + + switch (connectionState.attachment.state) { + case AttachmentState.detached: + iconWidget = Icon(Icons.signal_cellular_nodata, + size: iconSize, + color: this.color ?? scale.primaryScale.borderText); + return; + case AttachmentState.detaching: + iconWidget = Icon(Icons.signal_cellular_off, + size: iconSize, + color: this.color ?? scale.primaryScale.borderText); + return; + case AttachmentState.attaching: + value = 0; + color = this.color ?? scale.primaryScale.borderText; + case AttachmentState.attachedWeak: + value = 1; + color = this.color ?? scale.primaryScale.borderText; + case AttachmentState.attachedStrong: + value = 2; + color = this.color ?? scale.primaryScale.borderText; + case AttachmentState.attachedGood: + value = 3; + color = this.color ?? scale.primaryScale.borderText; + case AttachmentState.fullyAttached: + value = 4; + color = this.color ?? scale.primaryScale.borderText; + case AttachmentState.overAttached: + value = 4; + color = this.color ?? scale.primaryScale.borderText; + } + inactiveColor = this.inactiveColor ?? scale.grayScale.borderText; + + iconWidget = SignalStrengthIndicator.bars( + value: value, + activeColor: color, + inactiveColor: inactiveColor, + size: iconSize, + barCount: 4, + spacing: 2); + }, + loading: () => {iconWidget = const Icon(Icons.warning)}, + error: (e, st) => { + iconWidget = const Icon(Icons.error).onTap( + () async => showErrorStacktraceModal( + context: context, error: e, stackTrace: st), + ) + }); + + return GestureDetector( + onLongPress: () async { + await GoRouterHelper(context).push('/developer'); + }, + child: iconWidget); + }); + } + + //////////////////////////////////////////////////////////////////////////// + final Color? color; + final Color? inactiveColor; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(ColorProperty('color', color)) + ..add(ColorProperty('inactiveColor', inactiveColor)); + } +} diff --git a/lib/veilid_processor/views/views.dart b/lib/veilid_processor/views/views.dart new file mode 100644 index 0000000..3d70862 --- /dev/null +++ b/lib/veilid_processor/views/views.dart @@ -0,0 +1,2 @@ +export 'developer.dart'; +export 'signal_strength_meter.dart'; diff --git a/lib/veilid_support/dht_support/dht_support.dart b/lib/veilid_support/dht_support/dht_support.dart deleted file mode 100644 index d4f0b09..0000000 --- a/lib/veilid_support/dht_support/dht_support.dart +++ /dev/null @@ -1,8 +0,0 @@ -/// Support functions for Veilid DHT data structures - -library dht_support; - -export 'src/dht_record.dart'; -export 'src/dht_record_crypto.dart'; -export 'src/dht_record_pool.dart'; -export 'src/dht_short_array.dart'; diff --git a/lib/veilid_support/dht_support/proto/proto.dart b/lib/veilid_support/dht_support/proto/proto.dart deleted file mode 100644 index f4244c7..0000000 --- a/lib/veilid_support/dht_support/proto/proto.dart +++ /dev/null @@ -1,25 +0,0 @@ -import '../../../proto/dht.pb.dart' as dhtproto; -import '../../proto/proto.dart' as veilidproto; -import '../dht_support.dart'; - -export '../../../proto/dht.pb.dart'; -export '../../../proto/dht.pbenum.dart'; -export '../../../proto/dht.pbjson.dart'; -export '../../../proto/dht.pbserver.dart'; -export '../../proto/proto.dart'; - -/// OwnedDHTRecordPointer protobuf marshaling -/// -extension OwnedDHTRecordPointerProto on OwnedDHTRecordPointer { - dhtproto.OwnedDHTRecordPointer toProto() { - final out = dhtproto.OwnedDHTRecordPointer() - ..recordKey = recordKey.toProto() - ..owner = owner.toProto(); - return out; - } - - static OwnedDHTRecordPointer fromProto(dhtproto.OwnedDHTRecordPointer p) => - OwnedDHTRecordPointer( - recordKey: veilidproto.TypedKeyProto.fromProto(p.recordKey), - owner: veilidproto.KeyPairProto.fromProto(p.owner)); -} diff --git a/lib/veilid_support/dht_support/src/dht_record.dart b/lib/veilid_support/dht_support/src/dht_record.dart deleted file mode 100644 index 3722027..0000000 --- a/lib/veilid_support/dht_support/src/dht_record.dart +++ /dev/null @@ -1,256 +0,0 @@ -import 'dart:async'; -import 'dart:typed_data'; - -import 'package:protobuf/protobuf.dart'; - -import '../../veilid_support.dart'; - -class DHTRecord { - DHTRecord( - {required VeilidRoutingContext routingContext, - required DHTRecordDescriptor recordDescriptor, - int defaultSubkey = 0, - KeyPair? writer, - DHTRecordCrypto crypto = const DHTRecordCryptoPublic()}) - : _crypto = crypto, - _routingContext = routingContext, - _recordDescriptor = recordDescriptor, - _defaultSubkey = defaultSubkey, - _writer = writer, - _open = true, - _valid = true, - _subkeySeqCache = {}; - final VeilidRoutingContext _routingContext; - final DHTRecordDescriptor _recordDescriptor; - final int _defaultSubkey; - final KeyPair? _writer; - final Map _subkeySeqCache; - final DHTRecordCrypto _crypto; - bool _open; - bool _valid; - - int subkeyOrDefault(int subkey) => (subkey == -1) ? _defaultSubkey : subkey; - - VeilidRoutingContext get routingContext => _routingContext; - TypedKey get key => _recordDescriptor.key; - PublicKey get owner => _recordDescriptor.owner; - KeyPair? get ownerKeyPair => _recordDescriptor.ownerKeyPair(); - DHTSchema get schema => _recordDescriptor.schema; - KeyPair? get writer => _writer; - OwnedDHTRecordPointer get ownedDHTRecordPointer => - OwnedDHTRecordPointer(recordKey: key, owner: ownerKeyPair!); - - Future close() async { - if (!_valid) { - throw StateError('already deleted'); - } - if (!_open) { - return; - } - final pool = await DHTRecordPool.instance(); - await _routingContext.closeDHTRecord(_recordDescriptor.key); - pool.recordClosed(_recordDescriptor.key); - _open = false; - } - - Future delete() async { - if (!_valid) { - throw StateError('already deleted'); - } - if (_open) { - await close(); - } - final pool = await DHTRecordPool.instance(); - await pool.deleteDeep(key); - _valid = false; - } - - Future scope(Future Function(DHTRecord) scopeFunction) async { - try { - return await scopeFunction(this); - } finally { - if (_valid) { - await close(); - } - } - } - - Future deleteScope(Future Function(DHTRecord) scopeFunction) async { - try { - final out = await scopeFunction(this); - if (_valid && _open) { - await close(); - } - return out; - } on Exception catch (_) { - if (_valid) { - await delete(); - } - rethrow; - } - } - - Future maybeDeleteScope( - bool delete, Future Function(DHTRecord) scopeFunction) async { - if (delete) { - return deleteScope(scopeFunction); - } else { - return scope(scopeFunction); - } - } - - Future get( - {int subkey = -1, - bool forceRefresh = false, - bool onlyUpdates = false}) async { - subkey = subkeyOrDefault(subkey); - final valueData = await _routingContext.getDHTValue( - _recordDescriptor.key, subkey, forceRefresh); - if (valueData == null) { - return null; - } - final lastSeq = _subkeySeqCache[subkey]; - if (onlyUpdates && lastSeq != null && valueData.seq <= lastSeq) { - return null; - } - final out = _crypto.decrypt(valueData.data, subkey); - _subkeySeqCache[subkey] = valueData.seq; - return out; - } - - Future getJson(T Function(dynamic) fromJson, - {int subkey = -1, - bool forceRefresh = false, - bool onlyUpdates = false}) async { - final data = await get( - subkey: subkey, forceRefresh: forceRefresh, onlyUpdates: onlyUpdates); - if (data == null) { - return null; - } - return jsonDecodeBytes(fromJson, data); - } - - Future getProtobuf( - T Function(List i) fromBuffer, - {int subkey = -1, - bool forceRefresh = false, - bool onlyUpdates = false}) async { - final data = await get( - subkey: subkey, forceRefresh: forceRefresh, onlyUpdates: onlyUpdates); - if (data == null) { - return null; - } - return fromBuffer(data.toList()); - } - - Future tryWriteBytes(Uint8List newValue, - {int subkey = -1}) async { - subkey = subkeyOrDefault(subkey); - newValue = await _crypto.encrypt(newValue, subkey); - - // Set the new data if possible - var valueData = await _routingContext.setDHTValue( - _recordDescriptor.key, subkey, newValue); - if (valueData == null) { - // Get the data to check its sequence number - valueData = await _routingContext.getDHTValue( - _recordDescriptor.key, subkey, false); - assert(valueData != null, "can't get value that was just set"); - _subkeySeqCache[subkey] = valueData!.seq; - return null; - } - _subkeySeqCache[subkey] = valueData.seq; - return valueData.data; - } - - Future eventualWriteBytes(Uint8List newValue, {int subkey = -1}) async { - subkey = subkeyOrDefault(subkey); - newValue = await _crypto.encrypt(newValue, subkey); - - ValueData? valueData; - do { - // Set the new data - valueData = await _routingContext.setDHTValue( - _recordDescriptor.key, subkey, newValue); - - // Repeat if newer data on the network was found - } while (valueData != null); - - // Get the data to check its sequence number - valueData = - await _routingContext.getDHTValue(_recordDescriptor.key, subkey, false); - assert(valueData != null, "can't get value that was just set"); - _subkeySeqCache[subkey] = valueData!.seq; - } - - Future eventualUpdateBytes( - Future Function(Uint8List oldValue) update, - {int subkey = -1}) async { - subkey = subkeyOrDefault(subkey); - // Get existing identity key, do not allow force refresh here - // because if we need a refresh the setDHTValue will fail anyway - var valueData = - await _routingContext.getDHTValue(_recordDescriptor.key, subkey, false); - // Ensure it exists already - if (valueData == null) { - throw const FormatException('value does not exist'); - } - do { - // Update cache - _subkeySeqCache[subkey] = valueData!.seq; - - // Update the data - final oldData = await _crypto.decrypt(valueData.data, subkey); - final updatedData = await update(oldData); - final newData = await _crypto.encrypt(updatedData, subkey); - - // Set it back - valueData = await _routingContext.setDHTValue( - _recordDescriptor.key, subkey, newData); - - // Repeat if newer data on the network was found - } while (valueData != null); - - // Get the data to check its sequence number - valueData = - await _routingContext.getDHTValue(_recordDescriptor.key, subkey, false); - assert(valueData != null, "can't get value that was just set"); - _subkeySeqCache[subkey] = valueData!.seq; - } - - Future tryWriteJson(T Function(dynamic) fromJson, T newValue, - {int subkey = -1}) => - tryWriteBytes(jsonEncodeBytes(newValue), subkey: subkey).then((out) { - if (out == null) { - return null; - } - return jsonDecodeBytes(fromJson, out); - }); - - Future tryWriteProtobuf( - T Function(List) fromBuffer, T newValue, - {int subkey = -1}) => - tryWriteBytes(newValue.writeToBuffer(), subkey: subkey).then((out) { - if (out == null) { - return null; - } - return fromBuffer(out); - }); - - Future eventualWriteJson(T newValue, {int subkey = -1}) => - eventualWriteBytes(jsonEncodeBytes(newValue), subkey: subkey); - - Future eventualWriteProtobuf(T newValue, - {int subkey = -1}) => - eventualWriteBytes(newValue.writeToBuffer(), subkey: subkey); - - Future eventualUpdateJson( - T Function(dynamic) fromJson, Future Function(T) update, - {int subkey = -1}) => - eventualUpdateBytes(jsonUpdate(fromJson, update), subkey: subkey); - - Future eventualUpdateProtobuf( - T Function(List) fromBuffer, Future Function(T) update, - {int subkey = -1}) => - eventualUpdateBytes(protobufUpdate(fromBuffer, update), subkey: subkey); -} diff --git a/lib/veilid_support/dht_support/src/dht_record_crypto.dart b/lib/veilid_support/dht_support/src/dht_record_crypto.dart deleted file mode 100644 index 41a8949..0000000 --- a/lib/veilid_support/dht_support/src/dht_record_crypto.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'dart:async'; -import 'dart:typed_data'; -import '../../veilid_support.dart'; - -abstract class DHTRecordCrypto { - FutureOr encrypt(Uint8List data, int subkey); - FutureOr decrypt(Uint8List data, int subkey); -} - -//////////////////////////////////// -/// Private DHT Record: Encrypted for a specific symmetric key -class DHTRecordCryptoPrivate implements DHTRecordCrypto { - DHTRecordCryptoPrivate._( - VeilidCryptoSystem cryptoSystem, SharedSecret secretKey) - : _cryptoSystem = cryptoSystem, - _secretKey = secretKey; - final VeilidCryptoSystem _cryptoSystem; - final SharedSecret _secretKey; - - static Future fromTypedKeyPair( - TypedKeyPair typedKeyPair) async { - final cryptoSystem = - await Veilid.instance.getCryptoSystem(typedKeyPair.kind); - final secretKey = typedKeyPair.secret; - return DHTRecordCryptoPrivate._(cryptoSystem, secretKey); - } - - static Future fromSecret( - CryptoKind kind, SharedSecret secretKey) async { - final cryptoSystem = await Veilid.instance.getCryptoSystem(kind); - return DHTRecordCryptoPrivate._(cryptoSystem, secretKey); - } - - @override - FutureOr encrypt(Uint8List data, int subkey) => - _cryptoSystem.encryptNoAuthWithNonce(data, _secretKey); - - @override - FutureOr decrypt(Uint8List data, int subkey) => - _cryptoSystem.decryptNoAuthWithNonce(data, _secretKey); -} - -//////////////////////////////////// -/// Public DHT Record: No encryption -class DHTRecordCryptoPublic implements DHTRecordCrypto { - const DHTRecordCryptoPublic(); - - @override - FutureOr encrypt(Uint8List data, int subkey) => data; - - @override - FutureOr decrypt(Uint8List data, int subkey) => data; -} diff --git a/lib/veilid_support/dht_support/src/dht_record_pool.dart b/lib/veilid_support/dht_support/src/dht_record_pool.dart deleted file mode 100644 index cbd879e..0000000 --- a/lib/veilid_support/dht_support/src/dht_record_pool.dart +++ /dev/null @@ -1,327 +0,0 @@ -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:mutex/mutex.dart'; - -import '../../veilid_support.dart'; - -part 'dht_record_pool.freezed.dart'; -part 'dht_record_pool.g.dart'; - -/// Record pool that managed DHTRecords and allows for tagged deletion -@freezed -class DHTRecordPoolAllocations with _$DHTRecordPoolAllocations { - const factory DHTRecordPoolAllocations({ - required IMap> - childrenByParent, // String key due to IMap<> json unsupported in key - required IMap - parentByChild, // String key due to IMap<> json unsupported in key - required ISet rootRecords, - }) = _DHTRecordPoolAllocations; - - factory DHTRecordPoolAllocations.fromJson(dynamic json) => - _$DHTRecordPoolAllocationsFromJson(json as Map); -} - -/// Pointer to an owned record, with key, owner key and owner secret -/// Ensure that these are only serialized encrypted -@freezed -class OwnedDHTRecordPointer with _$OwnedDHTRecordPointer { - const factory OwnedDHTRecordPointer({ - required TypedKey recordKey, - required KeyPair owner, - }) = _OwnedDHTRecordPointer; - - factory OwnedDHTRecordPointer.fromJson(dynamic json) => - _$OwnedDHTRecordPointerFromJson(json as Map); -} - -class DHTRecordPool with AsyncTableDBBacked { - DHTRecordPool._(Veilid veilid, VeilidRoutingContext routingContext) - : _state = DHTRecordPoolAllocations( - childrenByParent: IMap(), - parentByChild: IMap(), - rootRecords: ISet()), - _opened = {}, - _routingContext = routingContext, - _veilid = veilid; - - // Persistent DHT record list - DHTRecordPoolAllocations _state; - // Which DHT records are currently open - final Map _opened; - // Default routing context to use for new keys - final VeilidRoutingContext _routingContext; - // Convenience accessor - final Veilid _veilid; - - static DHTRecordPool? _singleton; - - ////////////////////////////////////////////////////////////// - /// AsyncTableDBBacked - @override - String tableName() => 'dht_record_pool'; - @override - String tableKeyName() => 'pool_allocations'; - @override - DHTRecordPoolAllocations valueFromJson(Object? obj) => obj != null - ? DHTRecordPoolAllocations.fromJson(obj) - : DHTRecordPoolAllocations( - childrenByParent: IMap(), parentByChild: IMap(), rootRecords: ISet()); - @override - Object? valueToJson(DHTRecordPoolAllocations val) => val.toJson(); - - ////////////////////////////////////////////////////////////// - static Mutex instanceSetupMutex = Mutex(); - - // ignore: prefer_expression_function_bodies - static Future instance() async { - return instanceSetupMutex.protect(() async { - if (_singleton == null) { - final routingContext = await Veilid.instance.routingContext(); - final globalPool = DHTRecordPool._(Veilid.instance, routingContext); - globalPool._state = await globalPool.load(); - _singleton = globalPool; - } - return _singleton!; - }); - } - - Veilid get veilid => _veilid; - - Future _recordOpened(TypedKey key) async { - // no race because dart is single threaded until async breaks - final m = _opened[key] ?? Mutex(); - _opened[key] = m; - await m.acquire(); - _opened[key] = m; - } - - void recordClosed(TypedKey key) { - final m = _opened.remove(key); - if (m == null) { - throw StateError('record already closed'); - } - m.release(); - } - - Future deleteDeep(TypedKey parent) async { - // Collect all dependencies - final allDeps = []; - final currentDeps = [parent]; - while (currentDeps.isNotEmpty) { - final nextDep = currentDeps.removeLast(); - - // Ensure we get the exclusive lock on this record - await _recordOpened(nextDep); - - // Remove this child from its parent - await _removeDependency(nextDep); - - allDeps.add(nextDep); - final childDeps = - _state.childrenByParent[nextDep.toJson()]?.toList() ?? []; - currentDeps.addAll(childDeps); - } - - // Delete all records - final allFutures = >[]; - for (final dep in allDeps) { - allFutures.add(_routingContext.deleteDHTRecord(dep)); - recordClosed(dep); - } - await Future.wait(allFutures); - } - - void _validateParent(TypedKey? parent, TypedKey child) { - final childJson = child.toJson(); - final existingParent = _state.parentByChild[childJson]; - if (parent == null) { - if (existingParent != null) { - throw StateError('Child is already parented: $child'); - } - } else { - if (_state.rootRecords.contains(child)) { - throw StateError('Child already added as root: $child'); - } - if (existingParent != null && existingParent != parent) { - throw StateError('Child has two parents: $child <- $parent'); - } - } - } - - Future _addDependency(TypedKey? parent, TypedKey child) async { - if (parent == null) { - if (_state.rootRecords.contains(child)) { - // Dependency already added - return; - } - _state = await store( - _state.copyWith(rootRecords: _state.rootRecords.add(child))); - } else { - final childrenOfParent = - _state.childrenByParent[parent.toJson()] ?? ISet(); - if (childrenOfParent.contains(child)) { - // Dependency already added (consecutive opens, etc) - return; - } - _state = await store(_state.copyWith( - childrenByParent: _state.childrenByParent - .add(parent.toJson(), childrenOfParent.add(child)), - parentByChild: _state.parentByChild.add(child.toJson(), parent))); - } - } - - Future _removeDependency(TypedKey child) async { - if (_state.rootRecords.contains(child)) { - _state = await store( - _state.copyWith(rootRecords: _state.rootRecords.remove(child))); - } else { - final parent = _state.parentByChild[child.toJson()]; - if (parent == null) { - return; - } - final children = _state.childrenByParent[parent.toJson()]!.remove(child); - late final DHTRecordPoolAllocations newState; - if (children.isEmpty) { - newState = _state.copyWith( - childrenByParent: _state.childrenByParent.remove(parent.toJson()), - parentByChild: _state.parentByChild.remove(child.toJson())); - } else { - newState = _state.copyWith( - childrenByParent: - _state.childrenByParent.add(parent.toJson(), children), - parentByChild: _state.parentByChild.remove(child.toJson())); - } - _state = await store(newState); - } - } - - /////////////////////////////////////////////////////////////////////// - - /// Create a root DHTRecord that has no dependent records - Future create({ - VeilidRoutingContext? routingContext, - TypedKey? parent, - DHTSchema schema = const DHTSchema.dflt(oCnt: 1), - int defaultSubkey = 0, - DHTRecordCrypto? crypto, - KeyPair? writer, - }) async { - final dhtctx = routingContext ?? _routingContext; - final recordDescriptor = await dhtctx.createDHTRecord(schema); - - final rec = DHTRecord( - routingContext: dhtctx, - recordDescriptor: recordDescriptor, - defaultSubkey: defaultSubkey, - writer: writer ?? recordDescriptor.ownerKeyPair(), - crypto: crypto ?? - await DHTRecordCryptoPrivate.fromTypedKeyPair( - recordDescriptor.ownerTypedKeyPair()!)); - - await _addDependency(parent, rec.key); - await _recordOpened(rec.key); - - return rec; - } - - /// Open a DHTRecord readonly - Future openRead(TypedKey recordKey, - {VeilidRoutingContext? routingContext, - TypedKey? parent, - int defaultSubkey = 0, - DHTRecordCrypto? crypto}) async { - await _recordOpened(recordKey); - - late final DHTRecord rec; - try { - // If we are opening a key that already exists - // make sure we are using the same parent if one was specified - _validateParent(parent, recordKey); - - // Open from the veilid api - final dhtctx = routingContext ?? _routingContext; - final recordDescriptor = await dhtctx.openDHTRecord(recordKey, null); - rec = DHTRecord( - routingContext: dhtctx, - recordDescriptor: recordDescriptor, - defaultSubkey: defaultSubkey, - crypto: crypto ?? const DHTRecordCryptoPublic()); - - // Register the dependency - await _addDependency(parent, rec.key); - } on Exception catch (_) { - recordClosed(recordKey); - rethrow; - } - - return rec; - } - - /// Open a DHTRecord writable - Future openWrite( - TypedKey recordKey, - KeyPair writer, { - VeilidRoutingContext? routingContext, - TypedKey? parent, - int defaultSubkey = 0, - DHTRecordCrypto? crypto, - }) async { - await _recordOpened(recordKey); - - late final DHTRecord rec; - try { - // If we are opening a key that already exists - // make sure we are using the same parent if one was specified - _validateParent(parent, recordKey); - - // Open from the veilid api - final dhtctx = routingContext ?? _routingContext; - final recordDescriptor = await dhtctx.openDHTRecord(recordKey, writer); - rec = DHTRecord( - routingContext: dhtctx, - recordDescriptor: recordDescriptor, - defaultSubkey: defaultSubkey, - writer: writer, - crypto: crypto ?? - await DHTRecordCryptoPrivate.fromTypedKeyPair( - TypedKeyPair.fromKeyPair(recordKey.kind, writer))); - - // Register the dependency if specified - await _addDependency(parent, rec.key); - } on Exception catch (_) { - recordClosed(recordKey); - rethrow; - } - - return rec; - } - - /// Open a DHTRecord owned - /// This is the same as writable but uses an OwnedDHTRecordPointer - /// for convenience and uses symmetric encryption on the key - /// This is primarily used for backing up private content on to the DHT - /// to synchronizing it between devices. Because it is 'owned', the correct - /// parent must be specified. - Future openOwned( - OwnedDHTRecordPointer ownedDHTRecordPointer, { - required TypedKey parent, - VeilidRoutingContext? routingContext, - int defaultSubkey = 0, - DHTRecordCrypto? crypto, - }) => - openWrite( - ownedDHTRecordPointer.recordKey, - ownedDHTRecordPointer.owner, - routingContext: routingContext, - parent: parent, - defaultSubkey: defaultSubkey, - crypto: crypto, - ); - - /// Get the parent of a DHTRecord key if it exists - TypedKey? getParentRecord(TypedKey child) { - final childJson = child.toJson(); - return _state.parentByChild[childJson]; - } -} diff --git a/lib/veilid_support/dht_support/src/dht_record_pool.freezed.dart b/lib/veilid_support/dht_support/src/dht_record_pool.freezed.dart deleted file mode 100644 index a90b480..0000000 --- a/lib/veilid_support/dht_support/src/dht_record_pool.freezed.dart +++ /dev/null @@ -1,374 +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 'dht_record_pool.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'); - -DHTRecordPoolAllocations _$DHTRecordPoolAllocationsFromJson( - Map json) { - return _DHTRecordPoolAllocations.fromJson(json); -} - -/// @nodoc -mixin _$DHTRecordPoolAllocations { - IMap>> get childrenByParent => - throw _privateConstructorUsedError; // String key due to IMap<> json unsupported in key - IMap> get parentByChild => - throw _privateConstructorUsedError; // String key due to IMap<> json unsupported in key - ISet> get rootRecords => - throw _privateConstructorUsedError; - - Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) - $DHTRecordPoolAllocationsCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $DHTRecordPoolAllocationsCopyWith<$Res> { - factory $DHTRecordPoolAllocationsCopyWith(DHTRecordPoolAllocations value, - $Res Function(DHTRecordPoolAllocations) then) = - _$DHTRecordPoolAllocationsCopyWithImpl<$Res, DHTRecordPoolAllocations>; - @useResult - $Res call( - {IMap>> childrenByParent, - IMap> parentByChild, - ISet> rootRecords}); -} - -/// @nodoc -class _$DHTRecordPoolAllocationsCopyWithImpl<$Res, - $Val extends DHTRecordPoolAllocations> - implements $DHTRecordPoolAllocationsCopyWith<$Res> { - _$DHTRecordPoolAllocationsCopyWithImpl(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? childrenByParent = null, - Object? parentByChild = null, - Object? rootRecords = null, - }) { - return _then(_value.copyWith( - childrenByParent: null == childrenByParent - ? _value.childrenByParent - : childrenByParent // ignore: cast_nullable_to_non_nullable - as IMap>>, - parentByChild: null == parentByChild - ? _value.parentByChild - : parentByChild // ignore: cast_nullable_to_non_nullable - as IMap>, - rootRecords: null == rootRecords - ? _value.rootRecords - : rootRecords // ignore: cast_nullable_to_non_nullable - as ISet>, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$DHTRecordPoolAllocationsImplCopyWith<$Res> - implements $DHTRecordPoolAllocationsCopyWith<$Res> { - factory _$$DHTRecordPoolAllocationsImplCopyWith( - _$DHTRecordPoolAllocationsImpl value, - $Res Function(_$DHTRecordPoolAllocationsImpl) then) = - __$$DHTRecordPoolAllocationsImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {IMap>> childrenByParent, - IMap> parentByChild, - ISet> rootRecords}); -} - -/// @nodoc -class __$$DHTRecordPoolAllocationsImplCopyWithImpl<$Res> - extends _$DHTRecordPoolAllocationsCopyWithImpl<$Res, - _$DHTRecordPoolAllocationsImpl> - implements _$$DHTRecordPoolAllocationsImplCopyWith<$Res> { - __$$DHTRecordPoolAllocationsImplCopyWithImpl( - _$DHTRecordPoolAllocationsImpl _value, - $Res Function(_$DHTRecordPoolAllocationsImpl) _then) - : super(_value, _then); - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? childrenByParent = null, - Object? parentByChild = null, - Object? rootRecords = null, - }) { - return _then(_$DHTRecordPoolAllocationsImpl( - childrenByParent: null == childrenByParent - ? _value.childrenByParent - : childrenByParent // ignore: cast_nullable_to_non_nullable - as IMap>>, - parentByChild: null == parentByChild - ? _value.parentByChild - : parentByChild // ignore: cast_nullable_to_non_nullable - as IMap>, - rootRecords: null == rootRecords - ? _value.rootRecords - : rootRecords // ignore: cast_nullable_to_non_nullable - as ISet>, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$DHTRecordPoolAllocationsImpl implements _DHTRecordPoolAllocations { - const _$DHTRecordPoolAllocationsImpl( - {required this.childrenByParent, - required this.parentByChild, - required this.rootRecords}); - - factory _$DHTRecordPoolAllocationsImpl.fromJson(Map json) => - _$$DHTRecordPoolAllocationsImplFromJson(json); - - @override - final IMap>> childrenByParent; -// String key due to IMap<> json unsupported in key - @override - final IMap> parentByChild; -// String key due to IMap<> json unsupported in key - @override - final ISet> rootRecords; - - @override - String toString() { - return 'DHTRecordPoolAllocations(childrenByParent: $childrenByParent, parentByChild: $parentByChild, rootRecords: $rootRecords)'; - } - - @override - bool operator ==(dynamic other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$DHTRecordPoolAllocationsImpl && - (identical(other.childrenByParent, childrenByParent) || - other.childrenByParent == childrenByParent) && - (identical(other.parentByChild, parentByChild) || - other.parentByChild == parentByChild) && - const DeepCollectionEquality() - .equals(other.rootRecords, rootRecords)); - } - - @JsonKey(ignore: true) - @override - int get hashCode => Object.hash(runtimeType, childrenByParent, parentByChild, - const DeepCollectionEquality().hash(rootRecords)); - - @JsonKey(ignore: true) - @override - @pragma('vm:prefer-inline') - _$$DHTRecordPoolAllocationsImplCopyWith<_$DHTRecordPoolAllocationsImpl> - get copyWith => __$$DHTRecordPoolAllocationsImplCopyWithImpl< - _$DHTRecordPoolAllocationsImpl>(this, _$identity); - - @override - Map toJson() { - return _$$DHTRecordPoolAllocationsImplToJson( - this, - ); - } -} - -abstract class _DHTRecordPoolAllocations implements DHTRecordPoolAllocations { - const factory _DHTRecordPoolAllocations( - {required final IMap>> - childrenByParent, - required final IMap> parentByChild, - required final ISet> - rootRecords}) = _$DHTRecordPoolAllocationsImpl; - - factory _DHTRecordPoolAllocations.fromJson(Map json) = - _$DHTRecordPoolAllocationsImpl.fromJson; - - @override - IMap>> get childrenByParent; - @override // String key due to IMap<> json unsupported in key - IMap> get parentByChild; - @override // String key due to IMap<> json unsupported in key - ISet> get rootRecords; - @override - @JsonKey(ignore: true) - _$$DHTRecordPoolAllocationsImplCopyWith<_$DHTRecordPoolAllocationsImpl> - get copyWith => throw _privateConstructorUsedError; -} - -OwnedDHTRecordPointer _$OwnedDHTRecordPointerFromJson( - Map json) { - return _OwnedDHTRecordPointer.fromJson(json); -} - -/// @nodoc -mixin _$OwnedDHTRecordPointer { - Typed get recordKey => - throw _privateConstructorUsedError; - KeyPair get owner => throw _privateConstructorUsedError; - - Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) - $OwnedDHTRecordPointerCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $OwnedDHTRecordPointerCopyWith<$Res> { - factory $OwnedDHTRecordPointerCopyWith(OwnedDHTRecordPointer value, - $Res Function(OwnedDHTRecordPointer) then) = - _$OwnedDHTRecordPointerCopyWithImpl<$Res, OwnedDHTRecordPointer>; - @useResult - $Res call({Typed recordKey, KeyPair owner}); -} - -/// @nodoc -class _$OwnedDHTRecordPointerCopyWithImpl<$Res, - $Val extends OwnedDHTRecordPointer> - implements $OwnedDHTRecordPointerCopyWith<$Res> { - _$OwnedDHTRecordPointerCopyWithImpl(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? recordKey = null, - Object? owner = null, - }) { - return _then(_value.copyWith( - recordKey: null == recordKey - ? _value.recordKey - : recordKey // ignore: cast_nullable_to_non_nullable - as Typed, - owner: null == owner - ? _value.owner - : owner // ignore: cast_nullable_to_non_nullable - as KeyPair, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$OwnedDHTRecordPointerImplCopyWith<$Res> - implements $OwnedDHTRecordPointerCopyWith<$Res> { - factory _$$OwnedDHTRecordPointerImplCopyWith( - _$OwnedDHTRecordPointerImpl value, - $Res Function(_$OwnedDHTRecordPointerImpl) then) = - __$$OwnedDHTRecordPointerImplCopyWithImpl<$Res>; - @override - @useResult - $Res call({Typed recordKey, KeyPair owner}); -} - -/// @nodoc -class __$$OwnedDHTRecordPointerImplCopyWithImpl<$Res> - extends _$OwnedDHTRecordPointerCopyWithImpl<$Res, - _$OwnedDHTRecordPointerImpl> - implements _$$OwnedDHTRecordPointerImplCopyWith<$Res> { - __$$OwnedDHTRecordPointerImplCopyWithImpl(_$OwnedDHTRecordPointerImpl _value, - $Res Function(_$OwnedDHTRecordPointerImpl) _then) - : super(_value, _then); - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? recordKey = null, - Object? owner = null, - }) { - return _then(_$OwnedDHTRecordPointerImpl( - recordKey: null == recordKey - ? _value.recordKey - : recordKey // ignore: cast_nullable_to_non_nullable - as Typed, - owner: null == owner - ? _value.owner - : owner // ignore: cast_nullable_to_non_nullable - as KeyPair, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$OwnedDHTRecordPointerImpl implements _OwnedDHTRecordPointer { - const _$OwnedDHTRecordPointerImpl( - {required this.recordKey, required this.owner}); - - factory _$OwnedDHTRecordPointerImpl.fromJson(Map json) => - _$$OwnedDHTRecordPointerImplFromJson(json); - - @override - final Typed recordKey; - @override - final KeyPair owner; - - @override - String toString() { - return 'OwnedDHTRecordPointer(recordKey: $recordKey, owner: $owner)'; - } - - @override - bool operator ==(dynamic other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$OwnedDHTRecordPointerImpl && - (identical(other.recordKey, recordKey) || - other.recordKey == recordKey) && - (identical(other.owner, owner) || other.owner == owner)); - } - - @JsonKey(ignore: true) - @override - int get hashCode => Object.hash(runtimeType, recordKey, owner); - - @JsonKey(ignore: true) - @override - @pragma('vm:prefer-inline') - _$$OwnedDHTRecordPointerImplCopyWith<_$OwnedDHTRecordPointerImpl> - get copyWith => __$$OwnedDHTRecordPointerImplCopyWithImpl< - _$OwnedDHTRecordPointerImpl>(this, _$identity); - - @override - Map toJson() { - return _$$OwnedDHTRecordPointerImplToJson( - this, - ); - } -} - -abstract class _OwnedDHTRecordPointer implements OwnedDHTRecordPointer { - const factory _OwnedDHTRecordPointer( - {required final Typed recordKey, - required final KeyPair owner}) = _$OwnedDHTRecordPointerImpl; - - factory _OwnedDHTRecordPointer.fromJson(Map json) = - _$OwnedDHTRecordPointerImpl.fromJson; - - @override - Typed get recordKey; - @override - KeyPair get owner; - @override - @JsonKey(ignore: true) - _$$OwnedDHTRecordPointerImplCopyWith<_$OwnedDHTRecordPointerImpl> - get copyWith => throw _privateConstructorUsedError; -} diff --git a/lib/veilid_support/dht_support/src/dht_record_pool.g.dart b/lib/veilid_support/dht_support/src/dht_record_pool.g.dart deleted file mode 100644 index b7bb9c2..0000000 --- a/lib/veilid_support/dht_support/src/dht_record_pool.g.dart +++ /dev/null @@ -1,57 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'dht_record_pool.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -_$DHTRecordPoolAllocationsImpl _$$DHTRecordPoolAllocationsImplFromJson( - Map json) => - _$DHTRecordPoolAllocationsImpl( - childrenByParent: - IMap>>.fromJson( - json['children_by_parent'] as Map, - (value) => value as String, - (value) => ISet>.fromJson(value, - (value) => Typed.fromJson(value))), - parentByChild: IMap>.fromJson( - json['parent_by_child'] as Map, - (value) => value as String, - (value) => Typed.fromJson(value)), - rootRecords: ISet>.fromJson( - json['root_records'], - (value) => Typed.fromJson(value)), - ); - -Map _$$DHTRecordPoolAllocationsImplToJson( - _$DHTRecordPoolAllocationsImpl instance) => - { - 'children_by_parent': instance.childrenByParent.toJson( - (value) => value, - (value) => value.toJson( - (value) => value.toJson(), - ), - ), - 'parent_by_child': instance.parentByChild.toJson( - (value) => value, - (value) => value.toJson(), - ), - 'root_records': instance.rootRecords.toJson( - (value) => value.toJson(), - ), - }; - -_$OwnedDHTRecordPointerImpl _$$OwnedDHTRecordPointerImplFromJson( - Map json) => - _$OwnedDHTRecordPointerImpl( - recordKey: Typed.fromJson(json['record_key']), - owner: KeyPair.fromJson(json['owner']), - ); - -Map _$$OwnedDHTRecordPointerImplToJson( - _$OwnedDHTRecordPointerImpl instance) => - { - 'record_key': instance.recordKey.toJson(), - 'owner': instance.owner.toJson(), - }; diff --git a/lib/veilid_support/dht_support/src/dht_short_array.dart b/lib/veilid_support/dht_support/src/dht_short_array.dart deleted file mode 100644 index 82b701f..0000000 --- a/lib/veilid_support/dht_support/src/dht_short_array.dart +++ /dev/null @@ -1,615 +0,0 @@ -import 'dart:async'; -import 'dart:typed_data'; - -import 'package:protobuf/protobuf.dart'; - -import '../../veilid_support.dart'; -import '../proto/proto.dart' as proto; - -class _DHTShortArrayCache { - _DHTShortArrayCache() - : linkedRecords = List.empty(growable: true), - index = List.empty(growable: true), - free = List.empty(growable: true); - _DHTShortArrayCache.from(_DHTShortArrayCache other) - : linkedRecords = List.of(other.linkedRecords), - index = List.of(other.index), - free = List.of(other.free); - - final List linkedRecords; - final List index; - final List free; - - proto.DHTShortArray toProto() { - final head = proto.DHTShortArray(); - head.keys.addAll(linkedRecords.map((lr) => lr.key.toProto())); - head.index = head.index..addAll(index); - // Do not serialize free list, it gets recreated - return head; - } -} - -class DHTShortArray { - DHTShortArray._({required DHTRecord headRecord}) - : _headRecord = headRecord, - _head = _DHTShortArrayCache() { - late final int stride; - switch (headRecord.schema) { - case DHTSchemaDFLT(oCnt: final oCnt): - if (oCnt <= 1) { - throw StateError('Invalid DFLT schema in DHTShortArray'); - } - stride = oCnt - 1; - case DHTSchemaSMPL(oCnt: final oCnt, members: final members): - if (oCnt != 0 || members.length != 1 || members[0].mCnt <= 1) { - throw StateError('Invalid SMPL schema in DHTShortArray'); - } - stride = members[0].mCnt - 1; - } - assert(stride <= maxElements, 'stride too long'); - _stride = stride; - } - - static const maxElements = 256; - - // Head DHT record - final DHTRecord _headRecord; - late final int _stride; - - // Cached representation refreshed from head record - _DHTShortArrayCache _head; - - // Create a DHTShortArray - // if smplWriter is specified, uses a SMPL schema with a single writer - // rather than the key owner - static Future create( - {int stride = maxElements, - VeilidRoutingContext? routingContext, - TypedKey? parent, - DHTRecordCrypto? crypto, - KeyPair? smplWriter}) async { - assert(stride <= maxElements, 'stride too long'); - final pool = await DHTRecordPool.instance(); - - late final DHTRecord dhtRecord; - if (smplWriter != null) { - final schema = DHTSchema.smpl( - oCnt: 0, - members: [DHTSchemaMember(mKey: smplWriter.key, mCnt: stride + 1)]); - final dhtCreateRecord = await pool.create( - parent: parent, - routingContext: routingContext, - schema: schema, - crypto: crypto, - writer: smplWriter); - // Reopen with SMPL writer - await dhtCreateRecord.close(); - dhtRecord = await pool.openWrite(dhtCreateRecord.key, smplWriter, - parent: parent, routingContext: routingContext, crypto: crypto); - } else { - final schema = DHTSchema.dflt(oCnt: stride + 1); - dhtRecord = await pool.create( - parent: parent, - routingContext: routingContext, - schema: schema, - crypto: crypto); - } - - try { - final dhtShortArray = DHTShortArray._(headRecord: dhtRecord); - if (!await dhtShortArray._tryWriteHead()) { - throw StateError('Failed to write head at this time'); - } - return dhtShortArray; - } on Exception catch (_) { - await dhtRecord.delete(); - rethrow; - } - } - - static Future openRead(TypedKey headRecordKey, - {VeilidRoutingContext? routingContext, - TypedKey? parent, - DHTRecordCrypto? crypto}) async { - final pool = await DHTRecordPool.instance(); - - final dhtRecord = await pool.openRead(headRecordKey, - parent: parent, routingContext: routingContext, crypto: crypto); - try { - final dhtShortArray = DHTShortArray._(headRecord: dhtRecord); - await dhtShortArray._refreshHead(); - return dhtShortArray; - } on Exception catch (_) { - await dhtRecord.close(); - rethrow; - } - } - - static Future openWrite( - TypedKey headRecordKey, - KeyPair writer, { - VeilidRoutingContext? routingContext, - TypedKey? parent, - DHTRecordCrypto? crypto, - }) async { - final pool = await DHTRecordPool.instance(); - final dhtRecord = await pool.openWrite(headRecordKey, writer, - parent: parent, routingContext: routingContext, crypto: crypto); - try { - final dhtShortArray = DHTShortArray._(headRecord: dhtRecord); - await dhtShortArray._refreshHead(); - return dhtShortArray; - } on Exception catch (_) { - await dhtRecord.close(); - rethrow; - } - } - - static Future openOwned( - OwnedDHTRecordPointer ownedDHTRecordPointer, { - required TypedKey parent, - VeilidRoutingContext? routingContext, - DHTRecordCrypto? crypto, - }) => - openWrite( - ownedDHTRecordPointer.recordKey, - ownedDHTRecordPointer.owner, - routingContext: routingContext, - parent: parent, - crypto: crypto, - ); - - DHTRecord get record => _headRecord; - - //////////////////////////////////////////////////////////////// - - /// Serialize and write out the current head record, possibly updating it - /// if a newer copy is available online. Returns true if the write was - /// successful - Future _tryWriteHead() async { - final head = _head.toProto(); - final headBuffer = head.writeToBuffer(); - - final existingData = await _headRecord.tryWriteBytes(headBuffer); - if (existingData != null) { - // Head write failed, incorporate update - await _newHead(proto.DHTShortArray.fromBuffer(existingData)); - return false; - } - - return true; - } - - /// Validate the head from the DHT is properly formatted - /// and calculate the free list from it while we're here - List _validateHeadCacheData( - List> linkedKeys, List index) { - // Ensure nothing is duplicated in the linked keys set - final newKeys = linkedKeys.toSet(); - assert(newKeys.length <= (maxElements + (_stride - 1)) ~/ _stride, - 'too many keys'); - assert(newKeys.length == linkedKeys.length, 'duplicated linked keys'); - final newIndex = index.toSet(); - assert(newIndex.length <= maxElements, 'too many indexes'); - assert(newIndex.length == index.length, 'duplicated index locations'); - // Ensure all the index keys fit into the existing records - final indexCapacity = (linkedKeys.length + 1) * _stride; - int? maxIndex; - for (final idx in newIndex) { - assert(idx >= 0 || idx < indexCapacity, 'index out of range'); - if (maxIndex == null || idx > maxIndex) { - maxIndex = idx; - } - } - final free = []; - if (maxIndex != null) { - for (var i = 0; i < maxIndex; i++) { - if (!newIndex.contains(i)) { - free.add(i); - } - } - } - return free; - } - - /// Open a linked record for reading or writing, same as the head record - Future _openLinkedRecord(TypedKey recordKey) async { - final pool = await DHTRecordPool.instance(); - - final writer = _headRecord.writer; - return (writer != null) - ? await pool.openWrite( - recordKey, - writer, - parent: _headRecord.key, - routingContext: _headRecord.routingContext, - ) - : await pool.openRead( - recordKey, - parent: _headRecord.key, - routingContext: _headRecord.routingContext, - ); - } - - /// Validate a new head record - Future _newHead(proto.DHTShortArray head) async { - // Get the set of new linked keys and validate it - final linkedKeys = - head.keys.map(proto.TypedKeyProto.fromProto).toList(); - final index = head.index; - final free = _validateHeadCacheData(linkedKeys, index); - - // See which records are actually new - final oldRecords = Map.fromEntries( - _head.linkedRecords.map((lr) => MapEntry(lr.key, lr))); - final newRecords = {}; - final sameRecords = {}; - try { - for (var n = 0; n < linkedKeys.length; n++) { - final newKey = linkedKeys[n]; - final oldRecord = oldRecords[newKey]; - if (oldRecord == null) { - // Open the new record - final newRecord = await _openLinkedRecord(newKey); - newRecords[newKey] = newRecord; - } else { - sameRecords[newKey] = oldRecord; - } - } - } on Exception catch (_) { - // On any exception close the records we have opened - await Future.wait(newRecords.entries.map((e) => e.value.close())); - rethrow; - } - - // From this point forward we should not throw an exception or everything - // is possibly invalid. Just pass the exception up it happens and the caller - // will have to delete this short array and reopen it if it can - await Future.wait(oldRecords.entries - .where((e) => !sameRecords.containsKey(e.key)) - .map((e) => e.value.close())); - - // Figure out which indices are free - - // Make the new head cache - _head = _DHTShortArrayCache() - ..linkedRecords.addAll( - linkedKeys.map((key) => (sameRecords[key] ?? newRecords[key])!)) - ..index.addAll(index) - ..free.addAll(free); - } - - /// Pull the latest or updated copy of the head record from the network - Future _refreshHead( - {bool forceRefresh = true, bool onlyUpdates = false}) async { - // Get an updated head record copy if one exists - final head = await _headRecord.getProtobuf(proto.DHTShortArray.fromBuffer, - forceRefresh: forceRefresh, onlyUpdates: onlyUpdates); - if (head == null) { - if (onlyUpdates) { - // No update - return false; - } - throw StateError('head missing during refresh'); - } - - await _newHead(head); - - return true; - } - - //////////////////////////////////////////////////////////////// - - Future close() async { - final futures = >[_headRecord.close()]; - for (final lr in _head.linkedRecords) { - futures.add(lr.close()); - } - await Future.wait(futures); - } - - Future delete() async { - final futures = >[_headRecord.close()]; - for (final lr in _head.linkedRecords) { - futures.add(lr.delete()); - } - await Future.wait(futures); - } - - Future scope(Future Function(DHTShortArray) scopeFunction) async { - try { - return await scopeFunction(this); - } finally { - await close(); - } - } - - Future deleteScope( - Future Function(DHTShortArray) scopeFunction) async { - try { - final out = await scopeFunction(this); - await close(); - return out; - } on Exception catch (_) { - await delete(); - rethrow; - } - } - - DHTRecord? _getRecord(int recordNumber) { - if (recordNumber == 0) { - return _headRecord; - } - recordNumber--; - if (recordNumber >= _head.linkedRecords.length) { - return null; - } - return _head.linkedRecords[recordNumber]; - } - - int _emptyIndex() { - if (_head.free.isNotEmpty) { - return _head.free.removeLast(); - } - if (_head.index.length == maxElements) { - throw StateError('too many elements'); - } - return _head.index.length; - } - - void _freeIndex(int idx) { - _head.free.add(idx); - // xxx: free list optimization here? - } - - int get length => _head.index.length; - - Future getItem(int pos, {bool forceRefresh = false}) async { - await _refreshHead(forceRefresh: forceRefresh, onlyUpdates: true); - - if (pos < 0 || pos >= _head.index.length) { - throw IndexError.withLength(pos, _head.index.length); - } - final index = _head.index[pos]; - final recordNumber = index ~/ _stride; - final record = _getRecord(recordNumber); - assert(record != null, 'Record does not exist'); - - final recordSubkey = (index % _stride) + ((recordNumber == 0) ? 1 : 0); - return record!.get(subkey: recordSubkey, forceRefresh: forceRefresh); - } - - Future getItemJson(T Function(dynamic) fromJson, int pos, - {bool forceRefresh = false}) => - getItem(pos, forceRefresh: forceRefresh) - .then((out) => jsonDecodeOptBytes(fromJson, out)); - - Future getItemProtobuf( - T Function(List) fromBuffer, int pos, - {bool forceRefresh = false}) => - getItem(pos, forceRefresh: forceRefresh) - .then((out) => (out == null) ? null : fromBuffer(out)); - - Future tryAddItem(Uint8List value) async { - await _refreshHead(onlyUpdates: true); - - final oldHead = _DHTShortArrayCache.from(_head); - late final int pos; - try { - // Allocate empty index - final idx = _emptyIndex(); - // Add new index - pos = _head.index.length; - _head.index.add(idx); - - // Write new head - if (!await _tryWriteHead()) { - // Failed to write head means head got overwritten - return false; - } - } on Exception catch (_) { - // Exception on write means state needs to be reverted - _head = oldHead; - return false; - } - - // Head write succeeded, now write item - await eventualWriteItem(pos, value); - return true; - } - - Future tryInsertItem(int pos, Uint8List value) async { - await _refreshHead(onlyUpdates: true); - - final oldHead = _DHTShortArrayCache.from(_head); - try { - // Allocate empty index - final idx = _emptyIndex(); - // Add new index - _head.index.insert(pos, idx); - - // Write new head - if (!await _tryWriteHead()) { - // Failed to write head means head got overwritten - return false; - } - } on Exception catch (_) { - // Exception on write means state needs to be reverted - _head = oldHead; - return false; - } - - // Head write succeeded, now write item - await eventualWriteItem(pos, value); - return true; - } - - Future trySwapItem(int aPos, int bPos) async { - await _refreshHead(onlyUpdates: true); - - final oldHead = _DHTShortArrayCache.from(_head); - try { - // Add new index - final aIdx = _head.index[aPos]; - final bIdx = _head.index[bPos]; - _head.index[aPos] = bIdx; - _head.index[bPos] = aIdx; - - // Write new head - if (!await _tryWriteHead()) { - // Failed to write head means head got overwritten - return false; - } - } on Exception catch (_) { - // Exception on write means state needs to be reverted - _head = oldHead; - return false; - } - return true; - } - - Future tryRemoveItem(int pos) async { - await _refreshHead(onlyUpdates: true); - - final oldHead = _DHTShortArrayCache.from(_head); - try { - final removedIdx = _head.index.removeAt(pos); - _freeIndex(removedIdx); - final recordNumber = removedIdx ~/ _stride; - final record = _getRecord(recordNumber); - assert(record != null, 'Record does not exist'); - final recordSubkey = - (removedIdx % _stride) + ((recordNumber == 0) ? 1 : 0); - - // Write new head - if (!await _tryWriteHead()) { - // Failed to write head means head got overwritten - return null; - } - - return record!.get(subkey: recordSubkey); - } on Exception catch (_) { - // Exception on write means state needs to be reverted - _head = oldHead; - return null; - } - } - - Future tryRemoveItemJson( - T Function(dynamic) fromJson, - int pos, - ) => - tryRemoveItem(pos).then((out) => jsonDecodeOptBytes(fromJson, out)); - - Future tryRemoveItemProtobuf( - T Function(List) fromBuffer, int pos) => - getItem(pos).then((out) => (out == null) ? null : fromBuffer(out)); - - Future tryClear() async { - await _refreshHead(onlyUpdates: true); - - final oldHead = _DHTShortArrayCache.from(_head); - try { - _head.index.clear(); - _head.free.clear(); - - // Write new head - if (!await _tryWriteHead()) { - // Failed to write head means head got overwritten - return false; - } - } on Exception catch (_) { - // Exception on write means state needs to be reverted - _head = oldHead; - return false; - } - return true; - } - - Future tryWriteItem(int pos, Uint8List newValue) async { - if (await _refreshHead(onlyUpdates: true)) { - throw StateError('structure changed'); - } - if (pos < 0 || pos >= _head.index.length) { - throw IndexError.withLength(pos, _head.index.length); - } - final index = _head.index[pos]; - - final recordNumber = index ~/ _stride; - final record = _getRecord(recordNumber); - assert(record != null, 'Record does not exist'); - - final recordSubkey = (index % _stride) + ((recordNumber == 0) ? 1 : 0); - return record!.tryWriteBytes(newValue, subkey: recordSubkey); - } - - Future eventualWriteItem(int pos, Uint8List newValue) async { - Uint8List? oldData; - do { - // Set it back - oldData = await tryWriteItem(pos, newValue); - - // Repeat if newer data on the network was found - } while (oldData != null); - } - - Future eventualUpdateItem( - int pos, Future Function(Uint8List oldValue) update) async { - var oldData = await getItem(pos); - // Ensure it exists already - if (oldData == null) { - throw const FormatException('value does not exist'); - } - do { - // Update the data - final updatedData = await update(oldData!); - - // Set it back - oldData = await tryWriteItem(pos, updatedData); - - // Repeat if newer data on the network was found - } while (oldData != null); - } - - Future tryWriteItemJson( - T Function(dynamic) fromJson, - int pos, - T newValue, - ) => - tryWriteItem(pos, jsonEncodeBytes(newValue)) - .then((out) => jsonDecodeOptBytes(fromJson, out)); - - Future tryWriteItemProtobuf( - T Function(List) fromBuffer, - int pos, - T newValue, - ) => - tryWriteItem(pos, newValue.writeToBuffer()).then((out) { - if (out == null) { - return null; - } - return fromBuffer(out); - }); - - Future eventualWriteItemJson(int pos, T newValue) => - eventualWriteItem(pos, jsonEncodeBytes(newValue)); - - Future eventualWriteItemProtobuf( - int pos, T newValue, - {int subkey = -1}) => - eventualWriteItem(pos, newValue.writeToBuffer()); - - Future eventualUpdateItemJson( - T Function(dynamic) fromJson, - int pos, - Future Function(T) update, - ) => - eventualUpdateItem(pos, jsonUpdate(fromJson, update)); - - Future eventualUpdateItemProtobuf( - T Function(List) fromBuffer, - int pos, - Future Function(T) update, - ) => - eventualUpdateItem(pos, protobufUpdate(fromBuffer, update)); -} diff --git a/lib/veilid_support/proto/proto.dart b/lib/veilid_support/proto/proto.dart deleted file mode 100644 index 941c2af..0000000 --- a/lib/veilid_support/proto/proto.dart +++ /dev/null @@ -1,143 +0,0 @@ -import 'dart:typed_data'; - -import '../../proto/veilid.pb.dart' as proto; -import '../veilid_support.dart'; - -export '../../proto/veilid.pb.dart'; -export '../../proto/veilid.pbenum.dart'; -export '../../proto/veilid.pbjson.dart'; -export '../../proto/veilid.pbserver.dart'; - -/// CryptoKey protobuf marshaling -/// -extension CryptoKeyProto on CryptoKey { - proto.CryptoKey toProto() { - final b = decode().buffer.asByteData(); - final out = proto.CryptoKey() - ..u0 = b.getUint32(0 * 4) - ..u1 = b.getUint32(1 * 4) - ..u2 = b.getUint32(2 * 4) - ..u3 = b.getUint32(3 * 4) - ..u4 = b.getUint32(4 * 4) - ..u5 = b.getUint32(5 * 4) - ..u6 = b.getUint32(6 * 4) - ..u7 = b.getUint32(7 * 4); - return out; - } - - static CryptoKey fromProto(proto.CryptoKey p) { - final b = ByteData(32) - ..setUint32(0 * 4, p.u0) - ..setUint32(1 * 4, p.u1) - ..setUint32(2 * 4, p.u2) - ..setUint32(3 * 4, p.u3) - ..setUint32(4 * 4, p.u4) - ..setUint32(5 * 4, p.u5) - ..setUint32(6 * 4, p.u6) - ..setUint32(7 * 4, p.u7); - return CryptoKey.fromBytes(Uint8List.view(b.buffer)); - } -} - -/// Signature protobuf marshaling -/// -extension SignatureProto on Signature { - proto.Signature toProto() { - final b = decode().buffer.asByteData(); - final out = proto.Signature() - ..u0 = b.getUint32(0 * 4) - ..u1 = b.getUint32(1 * 4) - ..u2 = b.getUint32(2 * 4) - ..u3 = b.getUint32(3 * 4) - ..u4 = b.getUint32(4 * 4) - ..u5 = b.getUint32(5 * 4) - ..u6 = b.getUint32(6 * 4) - ..u7 = b.getUint32(7 * 4) - ..u8 = b.getUint32(8 * 4) - ..u9 = b.getUint32(9 * 4) - ..u10 = b.getUint32(10 * 4) - ..u11 = b.getUint32(11 * 4) - ..u12 = b.getUint32(12 * 4) - ..u13 = b.getUint32(13 * 4) - ..u14 = b.getUint32(14 * 4) - ..u15 = b.getUint32(15 * 4); - return out; - } - - static Signature fromProto(proto.Signature p) { - final b = ByteData(64) - ..setUint32(0 * 4, p.u0) - ..setUint32(1 * 4, p.u1) - ..setUint32(2 * 4, p.u2) - ..setUint32(3 * 4, p.u3) - ..setUint32(4 * 4, p.u4) - ..setUint32(5 * 4, p.u5) - ..setUint32(6 * 4, p.u6) - ..setUint32(7 * 4, p.u7) - ..setUint32(8 * 4, p.u8) - ..setUint32(9 * 4, p.u9) - ..setUint32(10 * 4, p.u10) - ..setUint32(11 * 4, p.u11) - ..setUint32(12 * 4, p.u12) - ..setUint32(13 * 4, p.u13) - ..setUint32(14 * 4, p.u14) - ..setUint32(15 * 4, p.u15); - return Signature.fromBytes(Uint8List.view(b.buffer)); - } -} - -/// Nonce protobuf marshaling -/// -extension NonceProto on Nonce { - proto.Nonce toProto() { - final b = decode().buffer.asByteData(); - final out = proto.Nonce() - ..u0 = b.getUint32(0 * 4) - ..u1 = b.getUint32(1 * 4) - ..u2 = b.getUint32(2 * 4) - ..u3 = b.getUint32(3 * 4) - ..u4 = b.getUint32(4 * 4) - ..u5 = b.getUint32(5 * 4); - return out; - } - - static Nonce fromProto(proto.Nonce p) { - final b = ByteData(24) - ..setUint32(0 * 4, p.u0) - ..setUint32(1 * 4, p.u1) - ..setUint32(2 * 4, p.u2) - ..setUint32(3 * 4, p.u3) - ..setUint32(4 * 4, p.u4) - ..setUint32(5 * 4, p.u5); - return Nonce.fromBytes(Uint8List.view(b.buffer)); - } -} - -/// TypedKey protobuf marshaling -/// -extension TypedKeyProto on TypedKey { - proto.TypedKey toProto() { - final out = proto.TypedKey() - ..kind = kind - ..value = value.toProto(); - return out; - } - - static TypedKey fromProto(proto.TypedKey p) => - TypedKey(kind: p.kind, value: CryptoKeyProto.fromProto(p.value)); -} - -/// KeyPair protobuf marshaling -/// -extension KeyPairProto on KeyPair { - proto.KeyPair toProto() { - final out = proto.KeyPair() - ..key = key.toProto() - ..secret = secret.toProto(); - return out; - } - - static KeyPair fromProto(proto.KeyPair p) => KeyPair( - key: CryptoKeyProto.fromProto(p.key), - secret: CryptoKeyProto.fromProto(p.secret)); -} diff --git a/lib/veilid_support/src/config.dart b/lib/veilid_support/src/config.dart deleted file mode 100644 index 3ffa1ca..0000000 --- a/lib/veilid_support/src/config.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:veilid/veilid.dart'; - -Future getVeilidChatConfig() async { - var config = await getDefaultVeilidConfig('VeilidChat'); - // ignore: do_not_use_environment - if (const String.fromEnvironment('DELETE_TABLE_STORE') == '1') { - config = - config.copyWith(tableStore: config.tableStore.copyWith(delete: true)); - } - // ignore: do_not_use_environment - if (const String.fromEnvironment('DELETE_PROTECTED_STORE') == '1') { - config = config.copyWith( - protectedStore: config.protectedStore.copyWith(delete: true)); - } - // ignore: do_not_use_environment - if (const String.fromEnvironment('DELETE_BLOCK_STORE') == '1') { - config = - config.copyWith(blockStore: config.blockStore.copyWith(delete: true)); - } - - return config.copyWith( - capabilities: const VeilidConfigCapabilities(disable: ['DHTV', 'TUNL']), - protectedStore: config.protectedStore.copyWith(allowInsecureFallback: true), - // network: config.network.copyWith( - // dht: config.network.dht.copyWith( - // getValueCount: 3, - // getValueFanout: 8, - // getValueTimeoutMs: 5000, - // setValueCount: 4, - // setValueFanout: 10, - // setValueTimeoutMs: 5000)) - ); -} diff --git a/lib/veilid_support/src/identity.dart b/lib/veilid_support/src/identity.dart deleted file mode 100644 index 0baf34b..0000000 --- a/lib/veilid_support/src/identity.dart +++ /dev/null @@ -1,281 +0,0 @@ -import 'dart:typed_data'; - -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:protobuf/protobuf.dart'; - -import '../veilid_support.dart'; - -part 'identity.freezed.dart'; -part 'identity.g.dart'; - -// AccountOwnerInfo is the key and owner info for the account dht key that is -// stored in the identity key -@freezed -class AccountRecordInfo with _$AccountRecordInfo { - const factory AccountRecordInfo({ - // Top level account keys and secrets - required OwnedDHTRecordPointer accountRecord, - }) = _AccountRecordInfo; - - factory AccountRecordInfo.fromJson(dynamic json) => - _$AccountRecordInfoFromJson(json as Map); -} - -// Identity Key points to accounts associated with this identity -// accounts field has a map of bundle id or uuid to account key pairs -// DHT Schema: DFLT(1) -// DHT Key (Private): identityRecordKey -// DHT Owner Key: identityPublicKey -// DHT Secret: identitySecretKey (stored encrypted -// with unlock code in local table store) -@freezed -class Identity with _$Identity { - const factory Identity({ - // Top level account keys and secrets - required IMap> accountRecords, - }) = _Identity; - - factory Identity.fromJson(dynamic json) => - _$IdentityFromJson(json as Map); -} - -// Identity Master key structure for created account -// Master key allows for regeneration of identity DHT record -// Bidirectional Master<->Identity signature allows for -// chain of identity ownership for account recovery process -// -// Backed by a DHT key at masterRecordKey, the secret is kept -// completely offline and only written to upon account recovery -// -// DHT Schema: DFLT(1) -// DHT Record Key (Public): masterRecordKey -// DHT Owner Key: masterPublicKey -// DHT Owner Secret: masterSecretKey (kept offline) -// Encryption: None -@freezed -class IdentityMaster with _$IdentityMaster { - const factory IdentityMaster( - { - // Private DHT record storing identity account mapping - required TypedKey identityRecordKey, - // Public key of identity - required PublicKey identityPublicKey, - // Public DHT record storing this structure for account recovery - required TypedKey masterRecordKey, - // Public key of master identity used to sign identity keys for recovery - required PublicKey masterPublicKey, - // Signature of identityRecordKey and identityPublicKey by masterPublicKey - required Signature identitySignature, - // Signature of masterRecordKey and masterPublicKey by identityPublicKey - required Signature masterSignature}) = _IdentityMaster; - - factory IdentityMaster.fromJson(dynamic json) => - _$IdentityMasterFromJson(json as Map); -} - -extension IdentityMasterExtension on IdentityMaster { - /// Deletes a master identity and the identity record under it - Future delete() async { - final pool = await DHTRecordPool.instance(); - await (await pool.openRead(masterRecordKey)).delete(); - } - - KeyPair identityWriter(SecretKey secret) => - KeyPair(key: identityPublicKey, secret: secret); - - KeyPair masterWriter(SecretKey secret) => - KeyPair(key: masterPublicKey, secret: secret); - - TypedKey identityPublicTypedKey() => - TypedKey(kind: identityRecordKey.kind, value: identityPublicKey); - - Future readAccountFromIdentity( - {required SharedSecret identitySecret, - required String accountKey}) async { - // Read the identity key to get the account keys - final pool = await DHTRecordPool.instance(); - - final identityRecordCrypto = await DHTRecordCryptoPrivate.fromSecret( - identityRecordKey.kind, identitySecret); - - late final AccountRecordInfo accountRecordInfo; - await (await pool.openRead(identityRecordKey, - parent: masterRecordKey, crypto: identityRecordCrypto)) - .scope((identityRec) async { - final identity = await identityRec.getJson(Identity.fromJson); - if (identity == null) { - // Identity could not be read or decrypted from DHT - throw StateError('identity could not be read'); - } - final accountRecords = IMapOfSets.from(identity.accountRecords); - final vcAccounts = accountRecords.get(accountKey); - if (vcAccounts.length != 1) { - // No account, or multiple accounts somehow associated with identity - throw StateError('no single account record info'); - } - - accountRecordInfo = vcAccounts.first; - }); - - return accountRecordInfo; - } - - /// Creates a new Account associated with master identity and store it in the - /// identity key. - Future addAccountToIdentity({ - required SharedSecret identitySecret, - required String accountKey, - required Future Function(TypedKey parent) createAccountCallback, - }) async { - final pool = await DHTRecordPool.instance(); - - /////// Add account with profile to DHT - - // Open identity key for writing - return (await pool.openWrite( - identityRecordKey, identityWriter(identitySecret), - parent: masterRecordKey)) - .scope((identityRec) async => - // Create new account to insert into identity - (await pool.create(parent: identityRec.key)) - .deleteScope((accountRec) async { - final account = await createAccountCallback(accountRec.key); - // Write account key - await accountRec.eventualWriteProtobuf(account); - - // Update identity key to include account - final newAccountRecordInfo = AccountRecordInfo( - accountRecord: OwnedDHTRecordPointer( - recordKey: accountRec.key, - owner: accountRec.ownerKeyPair!)); - - await identityRec.eventualUpdateJson(Identity.fromJson, - (oldIdentity) async { - final oldAccountRecords = - IMapOfSets.from(oldIdentity.accountRecords); - // Only allow one account per identity for veilidchat - if (oldAccountRecords.get(accountKey).isNotEmpty) { - throw StateError('Only one account per key in identity'); - } - final accountRecords = oldAccountRecords - .add(accountKey, newAccountRecordInfo) - .asIMap(); - return oldIdentity.copyWith(accountRecords: accountRecords); - }); - - return newAccountRecordInfo; - })); - } -} - -// Identity Master with secrets -// Not freezed because we never persist this class in its entirety -class IdentityMasterWithSecrets { - IdentityMasterWithSecrets._( - {required this.identityMaster, - required this.masterSecret, - required this.identitySecret}); - IdentityMaster identityMaster; - SecretKey masterSecret; - SecretKey identitySecret; - - /// Delete a master identity with secrets - Future delete() async => identityMaster.delete(); - - /// Creates a new master identity and returns it with its secrets - static Future create() async { - final pool = await DHTRecordPool.instance(); - - // IdentityMaster DHT record is public/unencrypted - return (await pool.create(crypto: const DHTRecordCryptoPublic())) - .deleteScope((masterRec) async => - // Identity record is private - (await pool.create(parent: masterRec.key)) - .scope((identityRec) async { - // Make IdentityMaster - final masterRecordKey = masterRec.key; - final masterOwner = masterRec.ownerKeyPair!; - final masterSigBuf = BytesBuilder() - ..add(masterRecordKey.decode()) - ..add(masterOwner.key.decode()); - - final identityRecordKey = identityRec.key; - final identityOwner = identityRec.ownerKeyPair!; - final identitySigBuf = BytesBuilder() - ..add(identityRecordKey.decode()) - ..add(identityOwner.key.decode()); - - assert(masterRecordKey.kind == identityRecordKey.kind, - 'new master and identity should have same cryptosystem'); - final crypto = - await pool.veilid.getCryptoSystem(masterRecordKey.kind); - - final identitySignature = await crypto.signWithKeyPair( - masterOwner, identitySigBuf.toBytes()); - final masterSignature = await crypto.signWithKeyPair( - identityOwner, masterSigBuf.toBytes()); - - final identityMaster = IdentityMaster( - identityRecordKey: identityRecordKey, - identityPublicKey: identityOwner.key, - masterRecordKey: masterRecordKey, - masterPublicKey: masterOwner.key, - identitySignature: identitySignature, - masterSignature: masterSignature); - - // Write identity master to master dht key - await masterRec.eventualWriteJson(identityMaster); - - // Make empty identity - const identity = Identity(accountRecords: IMapConst({})); - - // Write empty identity to identity dht key - await identityRec.eventualWriteJson(identity); - - return IdentityMasterWithSecrets._( - identityMaster: identityMaster, - masterSecret: masterOwner.secret, - identitySecret: identityOwner.secret); - })); - } -} - -/// Opens an existing master identity and validates it -Future openIdentityMaster( - {required TypedKey identityMasterRecordKey}) async { - final pool = await DHTRecordPool.instance(); - - // IdentityMaster DHT record is public/unencrypted - return (await pool.openRead(identityMasterRecordKey)) - .deleteScope((masterRec) async { - final identityMaster = - (await masterRec.getJson(IdentityMaster.fromJson, forceRefresh: true))!; - - // Validate IdentityMaster - final masterRecordKey = masterRec.key; - final masterOwnerKey = masterRec.owner; - final masterSigBuf = BytesBuilder() - ..add(masterRecordKey.decode()) - ..add(masterOwnerKey.decode()); - final masterSignature = identityMaster.masterSignature; - - final identityRecordKey = identityMaster.identityRecordKey; - final identityOwnerKey = identityMaster.identityPublicKey; - final identitySigBuf = BytesBuilder() - ..add(identityRecordKey.decode()) - ..add(identityOwnerKey.decode()); - final identitySignature = identityMaster.identitySignature; - - assert(masterRecordKey.kind == identityRecordKey.kind, - 'new master and identity should have same cryptosystem'); - final crypto = await pool.veilid.getCryptoSystem(masterRecordKey.kind); - - await crypto.verify( - masterOwnerKey, identitySigBuf.toBytes(), identitySignature); - await crypto.verify( - identityOwnerKey, masterSigBuf.toBytes(), masterSignature); - - return identityMaster; - }); -} diff --git a/lib/veilid_support/src/identity.freezed.dart b/lib/veilid_support/src/identity.freezed.dart deleted file mode 100644 index d8626df..0000000 --- a/lib/veilid_support/src/identity.freezed.dart +++ /dev/null @@ -1,579 +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 'identity.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'); - -AccountRecordInfo _$AccountRecordInfoFromJson(Map json) { - return _AccountRecordInfo.fromJson(json); -} - -/// @nodoc -mixin _$AccountRecordInfo { -// Top level account keys and secrets - OwnedDHTRecordPointer get accountRecord => throw _privateConstructorUsedError; - - Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) - $AccountRecordInfoCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $AccountRecordInfoCopyWith<$Res> { - factory $AccountRecordInfoCopyWith( - AccountRecordInfo value, $Res Function(AccountRecordInfo) then) = - _$AccountRecordInfoCopyWithImpl<$Res, AccountRecordInfo>; - @useResult - $Res call({OwnedDHTRecordPointer accountRecord}); - - $OwnedDHTRecordPointerCopyWith<$Res> get accountRecord; -} - -/// @nodoc -class _$AccountRecordInfoCopyWithImpl<$Res, $Val extends AccountRecordInfo> - implements $AccountRecordInfoCopyWith<$Res> { - _$AccountRecordInfoCopyWithImpl(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? accountRecord = null, - }) { - return _then(_value.copyWith( - accountRecord: null == accountRecord - ? _value.accountRecord - : accountRecord // ignore: cast_nullable_to_non_nullable - as OwnedDHTRecordPointer, - ) as $Val); - } - - @override - @pragma('vm:prefer-inline') - $OwnedDHTRecordPointerCopyWith<$Res> get accountRecord { - return $OwnedDHTRecordPointerCopyWith<$Res>(_value.accountRecord, (value) { - return _then(_value.copyWith(accountRecord: value) as $Val); - }); - } -} - -/// @nodoc -abstract class _$$AccountRecordInfoImplCopyWith<$Res> - implements $AccountRecordInfoCopyWith<$Res> { - factory _$$AccountRecordInfoImplCopyWith(_$AccountRecordInfoImpl value, - $Res Function(_$AccountRecordInfoImpl) then) = - __$$AccountRecordInfoImplCopyWithImpl<$Res>; - @override - @useResult - $Res call({OwnedDHTRecordPointer accountRecord}); - - @override - $OwnedDHTRecordPointerCopyWith<$Res> get accountRecord; -} - -/// @nodoc -class __$$AccountRecordInfoImplCopyWithImpl<$Res> - extends _$AccountRecordInfoCopyWithImpl<$Res, _$AccountRecordInfoImpl> - implements _$$AccountRecordInfoImplCopyWith<$Res> { - __$$AccountRecordInfoImplCopyWithImpl(_$AccountRecordInfoImpl _value, - $Res Function(_$AccountRecordInfoImpl) _then) - : super(_value, _then); - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? accountRecord = null, - }) { - return _then(_$AccountRecordInfoImpl( - accountRecord: null == accountRecord - ? _value.accountRecord - : accountRecord // ignore: cast_nullable_to_non_nullable - as OwnedDHTRecordPointer, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$AccountRecordInfoImpl implements _AccountRecordInfo { - const _$AccountRecordInfoImpl({required this.accountRecord}); - - factory _$AccountRecordInfoImpl.fromJson(Map json) => - _$$AccountRecordInfoImplFromJson(json); - -// Top level account keys and secrets - @override - final OwnedDHTRecordPointer accountRecord; - - @override - String toString() { - return 'AccountRecordInfo(accountRecord: $accountRecord)'; - } - - @override - bool operator ==(dynamic other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$AccountRecordInfoImpl && - (identical(other.accountRecord, accountRecord) || - other.accountRecord == accountRecord)); - } - - @JsonKey(ignore: true) - @override - int get hashCode => Object.hash(runtimeType, accountRecord); - - @JsonKey(ignore: true) - @override - @pragma('vm:prefer-inline') - _$$AccountRecordInfoImplCopyWith<_$AccountRecordInfoImpl> get copyWith => - __$$AccountRecordInfoImplCopyWithImpl<_$AccountRecordInfoImpl>( - this, _$identity); - - @override - Map toJson() { - return _$$AccountRecordInfoImplToJson( - this, - ); - } -} - -abstract class _AccountRecordInfo implements AccountRecordInfo { - const factory _AccountRecordInfo( - {required final OwnedDHTRecordPointer accountRecord}) = - _$AccountRecordInfoImpl; - - factory _AccountRecordInfo.fromJson(Map json) = - _$AccountRecordInfoImpl.fromJson; - - @override // Top level account keys and secrets - OwnedDHTRecordPointer get accountRecord; - @override - @JsonKey(ignore: true) - _$$AccountRecordInfoImplCopyWith<_$AccountRecordInfoImpl> get copyWith => - throw _privateConstructorUsedError; -} - -Identity _$IdentityFromJson(Map json) { - return _Identity.fromJson(json); -} - -/// @nodoc -mixin _$Identity { -// Top level account keys and secrets - IMap> get accountRecords => - throw _privateConstructorUsedError; - - Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) - $IdentityCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $IdentityCopyWith<$Res> { - factory $IdentityCopyWith(Identity value, $Res Function(Identity) then) = - _$IdentityCopyWithImpl<$Res, Identity>; - @useResult - $Res call({IMap> accountRecords}); -} - -/// @nodoc -class _$IdentityCopyWithImpl<$Res, $Val extends Identity> - implements $IdentityCopyWith<$Res> { - _$IdentityCopyWithImpl(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? accountRecords = null, - }) { - return _then(_value.copyWith( - accountRecords: null == accountRecords - ? _value.accountRecords - : accountRecords // ignore: cast_nullable_to_non_nullable - as IMap>, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$IdentityImplCopyWith<$Res> - implements $IdentityCopyWith<$Res> { - factory _$$IdentityImplCopyWith( - _$IdentityImpl value, $Res Function(_$IdentityImpl) then) = - __$$IdentityImplCopyWithImpl<$Res>; - @override - @useResult - $Res call({IMap> accountRecords}); -} - -/// @nodoc -class __$$IdentityImplCopyWithImpl<$Res> - extends _$IdentityCopyWithImpl<$Res, _$IdentityImpl> - implements _$$IdentityImplCopyWith<$Res> { - __$$IdentityImplCopyWithImpl( - _$IdentityImpl _value, $Res Function(_$IdentityImpl) _then) - : super(_value, _then); - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? accountRecords = null, - }) { - return _then(_$IdentityImpl( - accountRecords: null == accountRecords - ? _value.accountRecords - : accountRecords // ignore: cast_nullable_to_non_nullable - as IMap>, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$IdentityImpl implements _Identity { - const _$IdentityImpl({required this.accountRecords}); - - factory _$IdentityImpl.fromJson(Map json) => - _$$IdentityImplFromJson(json); - -// Top level account keys and secrets - @override - final IMap> accountRecords; - - @override - String toString() { - return 'Identity(accountRecords: $accountRecords)'; - } - - @override - bool operator ==(dynamic other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$IdentityImpl && - (identical(other.accountRecords, accountRecords) || - other.accountRecords == accountRecords)); - } - - @JsonKey(ignore: true) - @override - int get hashCode => Object.hash(runtimeType, accountRecords); - - @JsonKey(ignore: true) - @override - @pragma('vm:prefer-inline') - _$$IdentityImplCopyWith<_$IdentityImpl> get copyWith => - __$$IdentityImplCopyWithImpl<_$IdentityImpl>(this, _$identity); - - @override - Map toJson() { - return _$$IdentityImplToJson( - this, - ); - } -} - -abstract class _Identity implements Identity { - const factory _Identity( - {required final IMap> - accountRecords}) = _$IdentityImpl; - - factory _Identity.fromJson(Map json) = - _$IdentityImpl.fromJson; - - @override // Top level account keys and secrets - IMap> get accountRecords; - @override - @JsonKey(ignore: true) - _$$IdentityImplCopyWith<_$IdentityImpl> get copyWith => - throw _privateConstructorUsedError; -} - -IdentityMaster _$IdentityMasterFromJson(Map json) { - return _IdentityMaster.fromJson(json); -} - -/// @nodoc -mixin _$IdentityMaster { -// Private DHT record storing identity account mapping - Typed get identityRecordKey => - throw _privateConstructorUsedError; // Public key of identity - FixedEncodedString43 get identityPublicKey => - throw _privateConstructorUsedError; // Public DHT record storing this structure for account recovery - Typed get masterRecordKey => - throw _privateConstructorUsedError; // Public key of master identity used to sign identity keys for recovery - FixedEncodedString43 get masterPublicKey => - throw _privateConstructorUsedError; // Signature of identityRecordKey and identityPublicKey by masterPublicKey - FixedEncodedString86 get identitySignature => - throw _privateConstructorUsedError; // Signature of masterRecordKey and masterPublicKey by identityPublicKey - FixedEncodedString86 get masterSignature => - throw _privateConstructorUsedError; - - Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) - $IdentityMasterCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $IdentityMasterCopyWith<$Res> { - factory $IdentityMasterCopyWith( - IdentityMaster value, $Res Function(IdentityMaster) then) = - _$IdentityMasterCopyWithImpl<$Res, IdentityMaster>; - @useResult - $Res call( - {Typed identityRecordKey, - FixedEncodedString43 identityPublicKey, - Typed masterRecordKey, - FixedEncodedString43 masterPublicKey, - FixedEncodedString86 identitySignature, - FixedEncodedString86 masterSignature}); -} - -/// @nodoc -class _$IdentityMasterCopyWithImpl<$Res, $Val extends IdentityMaster> - implements $IdentityMasterCopyWith<$Res> { - _$IdentityMasterCopyWithImpl(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? identityRecordKey = null, - Object? identityPublicKey = null, - Object? masterRecordKey = null, - Object? masterPublicKey = null, - Object? identitySignature = null, - Object? masterSignature = null, - }) { - return _then(_value.copyWith( - identityRecordKey: null == identityRecordKey - ? _value.identityRecordKey - : identityRecordKey // ignore: cast_nullable_to_non_nullable - as Typed, - identityPublicKey: null == identityPublicKey - ? _value.identityPublicKey - : identityPublicKey // ignore: cast_nullable_to_non_nullable - as FixedEncodedString43, - masterRecordKey: null == masterRecordKey - ? _value.masterRecordKey - : masterRecordKey // ignore: cast_nullable_to_non_nullable - as Typed, - masterPublicKey: null == masterPublicKey - ? _value.masterPublicKey - : masterPublicKey // ignore: cast_nullable_to_non_nullable - as FixedEncodedString43, - identitySignature: null == identitySignature - ? _value.identitySignature - : identitySignature // ignore: cast_nullable_to_non_nullable - as FixedEncodedString86, - masterSignature: null == masterSignature - ? _value.masterSignature - : masterSignature // ignore: cast_nullable_to_non_nullable - as FixedEncodedString86, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$IdentityMasterImplCopyWith<$Res> - implements $IdentityMasterCopyWith<$Res> { - factory _$$IdentityMasterImplCopyWith(_$IdentityMasterImpl value, - $Res Function(_$IdentityMasterImpl) then) = - __$$IdentityMasterImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {Typed identityRecordKey, - FixedEncodedString43 identityPublicKey, - Typed masterRecordKey, - FixedEncodedString43 masterPublicKey, - FixedEncodedString86 identitySignature, - FixedEncodedString86 masterSignature}); -} - -/// @nodoc -class __$$IdentityMasterImplCopyWithImpl<$Res> - extends _$IdentityMasterCopyWithImpl<$Res, _$IdentityMasterImpl> - implements _$$IdentityMasterImplCopyWith<$Res> { - __$$IdentityMasterImplCopyWithImpl( - _$IdentityMasterImpl _value, $Res Function(_$IdentityMasterImpl) _then) - : super(_value, _then); - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? identityRecordKey = null, - Object? identityPublicKey = null, - Object? masterRecordKey = null, - Object? masterPublicKey = null, - Object? identitySignature = null, - Object? masterSignature = null, - }) { - return _then(_$IdentityMasterImpl( - identityRecordKey: null == identityRecordKey - ? _value.identityRecordKey - : identityRecordKey // ignore: cast_nullable_to_non_nullable - as Typed, - identityPublicKey: null == identityPublicKey - ? _value.identityPublicKey - : identityPublicKey // ignore: cast_nullable_to_non_nullable - as FixedEncodedString43, - masterRecordKey: null == masterRecordKey - ? _value.masterRecordKey - : masterRecordKey // ignore: cast_nullable_to_non_nullable - as Typed, - masterPublicKey: null == masterPublicKey - ? _value.masterPublicKey - : masterPublicKey // ignore: cast_nullable_to_non_nullable - as FixedEncodedString43, - identitySignature: null == identitySignature - ? _value.identitySignature - : identitySignature // ignore: cast_nullable_to_non_nullable - as FixedEncodedString86, - masterSignature: null == masterSignature - ? _value.masterSignature - : masterSignature // ignore: cast_nullable_to_non_nullable - as FixedEncodedString86, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$IdentityMasterImpl implements _IdentityMaster { - const _$IdentityMasterImpl( - {required this.identityRecordKey, - required this.identityPublicKey, - required this.masterRecordKey, - required this.masterPublicKey, - required this.identitySignature, - required this.masterSignature}); - - factory _$IdentityMasterImpl.fromJson(Map json) => - _$$IdentityMasterImplFromJson(json); - -// Private DHT record storing identity account mapping - @override - final Typed identityRecordKey; -// Public key of identity - @override - final FixedEncodedString43 identityPublicKey; -// Public DHT record storing this structure for account recovery - @override - final Typed masterRecordKey; -// Public key of master identity used to sign identity keys for recovery - @override - final FixedEncodedString43 masterPublicKey; -// Signature of identityRecordKey and identityPublicKey by masterPublicKey - @override - final FixedEncodedString86 identitySignature; -// Signature of masterRecordKey and masterPublicKey by identityPublicKey - @override - final FixedEncodedString86 masterSignature; - - @override - String toString() { - return 'IdentityMaster(identityRecordKey: $identityRecordKey, identityPublicKey: $identityPublicKey, masterRecordKey: $masterRecordKey, masterPublicKey: $masterPublicKey, identitySignature: $identitySignature, masterSignature: $masterSignature)'; - } - - @override - bool operator ==(dynamic other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$IdentityMasterImpl && - (identical(other.identityRecordKey, identityRecordKey) || - other.identityRecordKey == identityRecordKey) && - (identical(other.identityPublicKey, identityPublicKey) || - other.identityPublicKey == identityPublicKey) && - (identical(other.masterRecordKey, masterRecordKey) || - other.masterRecordKey == masterRecordKey) && - (identical(other.masterPublicKey, masterPublicKey) || - other.masterPublicKey == masterPublicKey) && - (identical(other.identitySignature, identitySignature) || - other.identitySignature == identitySignature) && - (identical(other.masterSignature, masterSignature) || - other.masterSignature == masterSignature)); - } - - @JsonKey(ignore: true) - @override - int get hashCode => Object.hash( - runtimeType, - identityRecordKey, - identityPublicKey, - masterRecordKey, - masterPublicKey, - identitySignature, - masterSignature); - - @JsonKey(ignore: true) - @override - @pragma('vm:prefer-inline') - _$$IdentityMasterImplCopyWith<_$IdentityMasterImpl> get copyWith => - __$$IdentityMasterImplCopyWithImpl<_$IdentityMasterImpl>( - this, _$identity); - - @override - Map toJson() { - return _$$IdentityMasterImplToJson( - this, - ); - } -} - -abstract class _IdentityMaster implements IdentityMaster { - const factory _IdentityMaster( - {required final Typed identityRecordKey, - required final FixedEncodedString43 identityPublicKey, - required final Typed masterRecordKey, - required final FixedEncodedString43 masterPublicKey, - required final FixedEncodedString86 identitySignature, - required final FixedEncodedString86 masterSignature}) = - _$IdentityMasterImpl; - - factory _IdentityMaster.fromJson(Map json) = - _$IdentityMasterImpl.fromJson; - - @override // Private DHT record storing identity account mapping - Typed get identityRecordKey; - @override // Public key of identity - FixedEncodedString43 get identityPublicKey; - @override // Public DHT record storing this structure for account recovery - Typed get masterRecordKey; - @override // Public key of master identity used to sign identity keys for recovery - FixedEncodedString43 get masterPublicKey; - @override // Signature of identityRecordKey and identityPublicKey by masterPublicKey - FixedEncodedString86 get identitySignature; - @override // Signature of masterRecordKey and masterPublicKey by identityPublicKey - FixedEncodedString86 get masterSignature; - @override - @JsonKey(ignore: true) - _$$IdentityMasterImplCopyWith<_$IdentityMasterImpl> get copyWith => - throw _privateConstructorUsedError; -} diff --git a/lib/veilid_support/src/identity.g.dart b/lib/veilid_support/src/identity.g.dart deleted file mode 100644 index 616477a..0000000 --- a/lib/veilid_support/src/identity.g.dart +++ /dev/null @@ -1,63 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'identity.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -_$AccountRecordInfoImpl _$$AccountRecordInfoImplFromJson( - Map json) => - _$AccountRecordInfoImpl( - accountRecord: OwnedDHTRecordPointer.fromJson(json['account_record']), - ); - -Map _$$AccountRecordInfoImplToJson( - _$AccountRecordInfoImpl instance) => - { - 'account_record': instance.accountRecord.toJson(), - }; - -_$IdentityImpl _$$IdentityImplFromJson(Map json) => - _$IdentityImpl( - accountRecords: IMap>.fromJson( - json['account_records'] as Map, - (value) => value as String, - (value) => ISet.fromJson( - value, (value) => AccountRecordInfo.fromJson(value))), - ); - -Map _$$IdentityImplToJson(_$IdentityImpl instance) => - { - 'account_records': instance.accountRecords.toJson( - (value) => value, - (value) => value.toJson( - (value) => value.toJson(), - ), - ), - }; - -_$IdentityMasterImpl _$$IdentityMasterImplFromJson(Map json) => - _$IdentityMasterImpl( - identityRecordKey: - Typed.fromJson(json['identity_record_key']), - identityPublicKey: - FixedEncodedString43.fromJson(json['identity_public_key']), - masterRecordKey: - Typed.fromJson(json['master_record_key']), - masterPublicKey: FixedEncodedString43.fromJson(json['master_public_key']), - identitySignature: - FixedEncodedString86.fromJson(json['identity_signature']), - masterSignature: FixedEncodedString86.fromJson(json['master_signature']), - ); - -Map _$$IdentityMasterImplToJson( - _$IdentityMasterImpl instance) => - { - 'identity_record_key': instance.identityRecordKey.toJson(), - 'identity_public_key': instance.identityPublicKey.toJson(), - 'master_record_key': instance.masterRecordKey.toJson(), - 'master_public_key': instance.masterPublicKey.toJson(), - 'identity_signature': instance.identitySignature.toJson(), - 'master_signature': instance.masterSignature.toJson(), - }; diff --git a/lib/veilid_support/src/protobuf_tools.dart b/lib/veilid_support/src/protobuf_tools.dart deleted file mode 100644 index c24302c..0000000 --- a/lib/veilid_support/src/protobuf_tools.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'dart:typed_data'; - -import 'package:protobuf/protobuf.dart'; - -Future protobufUpdateBytes( - T Function(List) fromBuffer, - Uint8List oldBytes, - Future Function(T) update) async { - final oldObj = fromBuffer(oldBytes); - final newObj = await update(oldObj); - return Uint8List.fromList(newObj.writeToBuffer()); -} - -Future Function(Uint8List) - protobufUpdate( - T Function(List) fromBuffer, Future Function(T) update) => - (oldBytes) => protobufUpdateBytes(fromBuffer, oldBytes, update); diff --git a/lib/veilid_support/src/table_db.dart b/lib/veilid_support/src/table_db.dart deleted file mode 100644 index a20b4be..0000000 --- a/lib/veilid_support/src/table_db.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'package:veilid/veilid.dart'; - -Future tableScope( - String name, Future Function(VeilidTableDB tdb) callback, - {int columnCount = 1}) async { - final tableDB = await Veilid.instance.openTableDB(name, columnCount); - try { - return await callback(tableDB); - } finally { - tableDB.close(); - } -} - -Future transactionScope( - VeilidTableDB tdb, - Future Function(VeilidTableDBTransaction tdbt) callback, -) async { - final tdbt = tdb.transact(); - try { - final ret = await callback(tdbt); - if (!tdbt.isDone()) { - await tdbt.commit(); - } - return ret; - } finally { - if (!tdbt.isDone()) { - await tdbt.rollback(); - } - } -} - -abstract mixin class AsyncTableDBBacked { - String tableName(); - String tableKeyName(); - T valueFromJson(Object? obj); - Object? valueToJson(T val); - - /// Load things from storage - Future load() async { - final obj = await tableScope(tableName(), (tdb) async { - final objJson = await tdb.loadStringJson(0, tableKeyName()); - return valueFromJson(objJson); - }); - return obj; - } - - /// Store things to storage - Future store(T obj) async { - await tableScope(tableName(), (tdb) async { - await tdb.storeStringJson(0, tableKeyName(), valueToJson(obj)); - }); - return obj; - } -} diff --git a/lib/veilid_support/veilid_support.dart b/lib/veilid_support/veilid_support.dart deleted file mode 100644 index f873397..0000000 --- a/lib/veilid_support/veilid_support.dart +++ /dev/null @@ -1,14 +0,0 @@ -/// Dart Veilid Support Library -/// Common functionality for interfacing with Veilid - -library veilid_support; - -export 'package:veilid/veilid.dart'; - -export 'dht_support/dht_support.dart'; -export 'src/config.dart'; -export 'src/identity.dart'; -export 'src/json_tools.dart'; -export 'src/protobuf_tools.dart'; -export 'src/table_db.dart'; -export 'src/veilid_log.dart'; diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index d2ecb95..0ac222b 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,23 +6,27 @@ #include "generated_plugin_registrant.h" +#include #include -#include -#include +#include +#include #include #include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_saver_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSaverPlugin"); + file_saver_plugin_register_with_registrar(file_saver_registrar); g_autoptr(FlPluginRegistrar) pasteboard_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "PasteboardPlugin"); pasteboard_plugin_register_with_registrar(pasteboard_registrar); - g_autoptr(FlPluginRegistrar) screen_retriever_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverPlugin"); - screen_retriever_plugin_register_with_registrar(screen_retriever_registrar); - g_autoptr(FlPluginRegistrar) smart_auth_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "SmartAuthPlugin"); - smart_auth_plugin_register_with_registrar(smart_auth_registrar); + g_autoptr(FlPluginRegistrar) printing_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "PrintingPlugin"); + printing_plugin_register_with_registrar(printing_registrar); + g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin"); + screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 10f8b07..a48f10f 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,9 +3,10 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_saver pasteboard - screen_retriever - smart_auth + printing + screen_retriever_linux url_launcher_linux veilid window_manager diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index fa5dd07..7e4a42f 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,26 +5,28 @@ import FlutterMacOS import Foundation -import mobile_scanner +import file_saver +import package_info_plus import pasteboard import path_provider_foundation -import screen_retriever +import printing +import screen_retriever_macos import share_plus import shared_preferences_foundation -import smart_auth -import sqflite +import sqflite_darwin import url_launcher_macos import veilid import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) + FileSaverPlugin.register(with: registry.registrar(forPlugin: "FileSaverPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PasteboardPlugin.register(with: registry.registrar(forPlugin: "PasteboardPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) - ScreenRetrieverPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverPlugin")) + PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin")) + ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) - SmartAuthPlugin.register(with: registry.registrar(forPlugin: "SmartAuthPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) VeilidPlugin.register(with: registry.registrar(forPlugin: "VeilidPlugin")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index e321ce3..bd9fccf 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,27 +1,26 @@ PODS: + - file_saver (0.0.1): + - FlutterMacOS - FlutterMacOS (1.0.0) - - FMDB (2.7.5): - - FMDB/standard (= 2.7.5) - - FMDB/standard (2.7.5) - - mobile_scanner (3.0.0): + - package_info_plus (0.0.1): - FlutterMacOS - pasteboard (0.0.1): - FlutterMacOS - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - - screen_retriever (0.0.1): + - printing (1.0.0): + - FlutterMacOS + - screen_retriever_macos (0.0.1): - FlutterMacOS - share_plus (0.0.1): - FlutterMacOS - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS - - smart_auth (0.0.1): + - sqflite_darwin (0.0.4): + - Flutter - FlutterMacOS - - sqflite (0.0.2): - - FlutterMacOS - - FMDB (>= 2.7.5) - url_launcher_macos (0.0.1): - FlutterMacOS - veilid (0.0.1): @@ -30,42 +29,41 @@ PODS: - FlutterMacOS DEPENDENCIES: + - file_saver (from `Flutter/ephemeral/.symlinks/plugins/file_saver/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - - mobile_scanner (from `Flutter/ephemeral/.symlinks/plugins/mobile_scanner/macos`) + - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) - pasteboard (from `Flutter/ephemeral/.symlinks/plugins/pasteboard/macos`) - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - - screen_retriever (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos`) + - printing (from `Flutter/ephemeral/.symlinks/plugins/printing/macos`) + - screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`) - share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - - smart_auth (from `Flutter/ephemeral/.symlinks/plugins/smart_auth/macos`) - - sqflite (from `Flutter/ephemeral/.symlinks/plugins/sqflite/macos`) + - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) - veilid (from `Flutter/ephemeral/.symlinks/plugins/veilid/macos`) - window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`) -SPEC REPOS: - trunk: - - FMDB - EXTERNAL SOURCES: + file_saver: + :path: Flutter/ephemeral/.symlinks/plugins/file_saver/macos FlutterMacOS: :path: Flutter/ephemeral - mobile_scanner: - :path: Flutter/ephemeral/.symlinks/plugins/mobile_scanner/macos + package_info_plus: + :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos pasteboard: :path: Flutter/ephemeral/.symlinks/plugins/pasteboard/macos path_provider_foundation: :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin - screen_retriever: - :path: Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos + printing: + :path: Flutter/ephemeral/.symlinks/plugins/printing/macos + screen_retriever_macos: + :path: Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos share_plus: :path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos shared_preferences_foundation: :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin - smart_auth: - :path: Flutter/ephemeral/.symlinks/plugins/smart_auth/macos - sqflite: - :path: Flutter/ephemeral/.symlinks/plugins/sqflite/macos + sqflite_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin url_launcher_macos: :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos veilid: @@ -74,20 +72,20 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos SPEC CHECKSUMS: + file_saver: e35bd97de451dde55ff8c38862ed7ad0f3418d0f FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 - FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a - mobile_scanner: ed7618fb749adc6574563e053f3b8e5002c13994 - pasteboard: 9b69dba6fedbb04866be632205d532fe2f6b1d99 - path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 - screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38 - share_plus: 76dd39142738f7a68dd57b05093b5e8193f220f7 - shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 - smart_auth: b38e3ab4bfe089eacb1e233aca1a2340f96c28e9 - sqflite: a5789cceda41d54d23f31d6de539d65bb14100ea - url_launcher_macos: d2691c7dd33ed713bf3544850a623080ec693d95 - veilid: a54f57b7bcf0e4e072fe99272d76ca126b2026d0 - window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8 + package_info_plus: f0052d280d17aa382b932f399edf32507174e870 + pasteboard: 278d8100149f940fb795d6b3a74f0720c890ecb7 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + printing: c4cf83c78fd684f9bc318e6aadc18972aa48f617 + screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f + share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc + shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 + veilid: 319e2e78836d7b3d08203596d0b4a0e244b68d29 + window_manager: 1d01fa7ac65a6e6f83b965471b1a7fdd3f06166c PODFILE CHECKSUM: ff0a9a3ce75ee73f200ca7e2f47745698c917ef9 -COCOAPODS: 1.12.1 +COCOAPODS: 1.16.2 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index ff30884..7130dd5 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -206,7 +206,7 @@ isa = PBXProject; attributes = { LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 1430; + LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 33CC10EC2044A3C60003C045 = { @@ -424,7 +424,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_TEAM = XP5LBLT7M7; @@ -562,7 +562,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_TEAM = XP5LBLT7M7; @@ -594,7 +594,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_TEAM = XP5LBLT7M7; diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 1a4fb81..408e781 100644 --- a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift index d53ef64..b3c1761 100644 --- a/macos/Runner/AppDelegate.swift +++ b/macos/Runner/AppDelegate.swift @@ -1,9 +1,13 @@ import Cocoa import FlutterMacOS -@NSApplicationMain +@main class AppDelegate: FlutterAppDelegate { override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } } diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index cff5a4b..fe015f3 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -6,11 +6,17 @@ com.apple.security.cs.allow-jit - com.apple.security.network.server + com.apple.security.files.user-selected.read-write com.apple.security.network.client - com.apple.security.files.user-selected.read-write + com.apple.security.network.server + com.apple.security.print + + keychain-access-groups + + $(AppIdentifierPrefix)com.veilid.veilidchat + diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index f822844..8d195f4 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -4,11 +4,17 @@ com.apple.security.app-sandbox - com.apple.security.network.server + com.apple.security.files.user-selected.read-write com.apple.security.network.client - com.apple.security.files.user-selected.read-write + com.apple.security.network.server + com.apple.security.print + + keychain-access-groups + + $(AppIdentifierPrefix)com.veilid.veilidchat + diff --git a/packages/veilid_support/.gitignore b/packages/veilid_support/.gitignore new file mode 100644 index 0000000..df26946 --- /dev/null +++ b/packages/veilid_support/.gitignore @@ -0,0 +1,56 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# Flutter generated files +# Not doing this at this time: https://stackoverflow.com/questions/56110386/should-i-commit-generated-code-in-flutter-dart-to-vcs +# *.g.dart +# *.freezed.dart +# *.pb.dart +# *.pbenum.dart +# *.pbjson.dart +# *.pbserver.dart + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# WASM +/web/wasm/ diff --git a/packages/veilid_support/analysis_options.yaml b/packages/veilid_support/analysis_options.yaml new file mode 100644 index 0000000..e1620f7 --- /dev/null +++ b/packages/veilid_support/analysis_options.yaml @@ -0,0 +1,15 @@ +include: package:lint_hard/all.yaml +analyzer: + errors: + invalid_annotation_target: ignore + exclude: + - '**/*.g.dart' + - '**/*.freezed.dart' + - '**/*.pb.dart' + - '**/*.pbenum.dart' + - '**/*.pbjson.dart' + - '**/*.pbserver.dart' +linter: + rules: + unawaited_futures: true + avoid_positional_boolean_parameters: false \ No newline at end of file diff --git a/packages/veilid_support/build.bat b/packages/veilid_support/build.bat new file mode 100644 index 0000000..88d2bb0 --- /dev/null +++ b/packages/veilid_support/build.bat @@ -0,0 +1,7 @@ +@echo off +dart run build_runner build --delete-conflicting-outputs + +pushd lib +protoc --dart_out=proto -I proto -I dht_support\proto dht.proto +protoc --dart_out=proto -I proto veilid.proto +popd diff --git a/packages/veilid_support/build.sh b/packages/veilid_support/build.sh new file mode 100755 index 0000000..0c43bc6 --- /dev/null +++ b/packages/veilid_support/build.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -e +dart run build_runner build --delete-conflicting-outputs + +pushd lib > /dev/null +protoc --dart_out=proto -I proto -I dht_support/proto dht.proto +protoc --dart_out=proto -I proto veilid.proto +popd > /dev/null diff --git a/packages/veilid_support/build.yaml b/packages/veilid_support/build.yaml new file mode 100644 index 0000000..84fde8c --- /dev/null +++ b/packages/veilid_support/build.yaml @@ -0,0 +1,10 @@ +targets: + $default: + sources: + exclude: + - example/** + builders: + json_serializable: + options: + explicit_to_json: true + field_rename: snake diff --git a/packages/veilid_support/debug_integration_tests.sh b/packages/veilid_support/debug_integration_tests.sh new file mode 100755 index 0000000..c689e64 --- /dev/null +++ b/packages/veilid_support/debug_integration_tests.sh @@ -0,0 +1,4 @@ +#!/bin/bash +pushd example 2>/dev/null +flutter run integration_test/app_test.dart $@ +popd 2>/dev/null diff --git a/packages/veilid_support/example/.gitignore b/packages/veilid_support/example/.gitignore new file mode 100644 index 0000000..79c113f --- /dev/null +++ b/packages/veilid_support/example/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/packages/veilid_support/example/.metadata b/packages/veilid_support/example/.metadata new file mode 100644 index 0000000..d2765fc --- /dev/null +++ b/packages/veilid_support/example/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "54e66469a933b60ddf175f858f82eaeb97e48c8d" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 54e66469a933b60ddf175f858f82eaeb97e48c8d + base_revision: 54e66469a933b60ddf175f858f82eaeb97e48c8d + - platform: android + create_revision: 54e66469a933b60ddf175f858f82eaeb97e48c8d + base_revision: 54e66469a933b60ddf175f858f82eaeb97e48c8d + - platform: ios + create_revision: 54e66469a933b60ddf175f858f82eaeb97e48c8d + base_revision: 54e66469a933b60ddf175f858f82eaeb97e48c8d + - platform: linux + create_revision: 54e66469a933b60ddf175f858f82eaeb97e48c8d + base_revision: 54e66469a933b60ddf175f858f82eaeb97e48c8d + - platform: macos + create_revision: 54e66469a933b60ddf175f858f82eaeb97e48c8d + base_revision: 54e66469a933b60ddf175f858f82eaeb97e48c8d + - platform: web + create_revision: 54e66469a933b60ddf175f858f82eaeb97e48c8d + base_revision: 54e66469a933b60ddf175f858f82eaeb97e48c8d + - platform: windows + create_revision: 54e66469a933b60ddf175f858f82eaeb97e48c8d + base_revision: 54e66469a933b60ddf175f858f82eaeb97e48c8d + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/packages/veilid_support/example/README.md b/packages/veilid_support/example/README.md new file mode 100644 index 0000000..2b3fce4 --- /dev/null +++ b/packages/veilid_support/example/README.md @@ -0,0 +1,16 @@ +# example + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/packages/veilid_support/example/analysis_options.yaml b/packages/veilid_support/example/analysis_options.yaml new file mode 100644 index 0000000..04953d6 --- /dev/null +++ b/packages/veilid_support/example/analysis_options.yaml @@ -0,0 +1,15 @@ +include: package:lint_hard/all.yaml +analyzer: + errors: + invalid_annotation_target: ignore + exclude: + - '**/*.g.dart' + - '**/*.freezed.dart' + - '**/*.pb.dart' + - '**/*.pbenum.dart' + - '**/*.pbjson.dart' + - '**/*.pbserver.dart' +linter: + rules: + unawaited_futures: true + avoid_positional_boolean_parameters: false diff --git a/packages/veilid_support/example/android/.gitignore b/packages/veilid_support/example/android/.gitignore new file mode 100644 index 0000000..6f56801 --- /dev/null +++ b/packages/veilid_support/example/android/.gitignore @@ -0,0 +1,13 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +key.properties +**/*.keystore +**/*.jks diff --git a/packages/veilid_support/example/android/app/build.gradle b/packages/veilid_support/example/android/app/build.gradle new file mode 100644 index 0000000..2ba6503 --- /dev/null +++ b/packages/veilid_support/example/android/app/build.gradle @@ -0,0 +1,69 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" +} + +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +android { + ndkVersion '27.0.12077973' + ndkVersion '27.0.12077973' + namespace "com.example.example" + compileSdk flutter.compileSdkVersion + ndkVersion flutter.ndkVersion + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = '17' + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId "com.example.example" + // You can update the following values to match your application needs. + // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. + minSdkVersion Math.max(flutter.minSdkVersion, 24) + targetSdkVersion flutter.targetSdkVersion + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig signingConfigs.debug + } + } +} + +flutter { + source '../..' +} + +dependencies {} diff --git a/packages/veilid_support/example/android/app/src/debug/AndroidManifest.xml b/packages/veilid_support/example/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/packages/veilid_support/example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/packages/veilid_support/example/android/app/src/main/AndroidManifest.xml b/packages/veilid_support/example/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..aff7dec --- /dev/null +++ b/packages/veilid_support/example/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/veilid_support/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt b/packages/veilid_support/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt new file mode 100644 index 0000000..70f8f08 --- /dev/null +++ b/packages/veilid_support/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.example + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() diff --git a/packages/veilid_support/example/android/app/src/main/res/drawable-v21/launch_background.xml b/packages/veilid_support/example/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/packages/veilid_support/example/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/packages/veilid_support/example/android/app/src/main/res/drawable/launch_background.xml b/packages/veilid_support/example/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/packages/veilid_support/example/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/packages/veilid_support/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/packages/veilid_support/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/packages/veilid_support/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/packages/veilid_support/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/packages/veilid_support/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/packages/veilid_support/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/packages/veilid_support/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/packages/veilid_support/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/packages/veilid_support/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/packages/veilid_support/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/packages/veilid_support/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/packages/veilid_support/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/packages/veilid_support/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/packages/veilid_support/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/packages/veilid_support/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/packages/veilid_support/example/android/app/src/main/res/values-night/styles.xml b/packages/veilid_support/example/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/packages/veilid_support/example/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/packages/veilid_support/example/android/app/src/main/res/values/styles.xml b/packages/veilid_support/example/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/packages/veilid_support/example/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/packages/veilid_support/example/android/app/src/profile/AndroidManifest.xml b/packages/veilid_support/example/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/packages/veilid_support/example/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/packages/veilid_support/example/android/build.gradle b/packages/veilid_support/example/android/build.gradle new file mode 100644 index 0000000..bc157bd --- /dev/null +++ b/packages/veilid_support/example/android/build.gradle @@ -0,0 +1,18 @@ +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/packages/veilid_support/example/android/gradle.properties b/packages/veilid_support/example/android/gradle.properties new file mode 100644 index 0000000..598d13f --- /dev/null +++ b/packages/veilid_support/example/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx4G +android.useAndroidX=true +android.enableJetifier=true diff --git a/packages/veilid_support/example/android/gradle/wrapper/gradle-wrapper.properties b/packages/veilid_support/example/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..6f8524c --- /dev/null +++ b/packages/veilid_support/example/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https://services.gradle.org/distributions/gradle-8.10.2-all.zip diff --git a/packages/veilid_support/example/android/settings.gradle b/packages/veilid_support/example/android/settings.gradle new file mode 100644 index 0000000..b1ae36a --- /dev/null +++ b/packages/veilid_support/example/android/settings.gradle @@ -0,0 +1,25 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return 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.8.0" apply false + id "org.jetbrains.kotlin.android" version "1.9.25" apply false +} + +include ":app" \ No newline at end of file diff --git a/packages/veilid_support/example/dev-setup/_script_common b/packages/veilid_support/example/dev-setup/_script_common new file mode 100644 index 0000000..c8aa85e --- /dev/null +++ b/packages/veilid_support/example/dev-setup/_script_common @@ -0,0 +1,16 @@ +set -eo pipefail + +get_abs_filename() { + # $1 : relative filename + echo "$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" +} + +# Veilid location +VEILIDDIR=$(get_abs_filename "$(git rev-parse --show-toplevel)/../veilid") +if [ ! -d "$VEILIDDIR" ]; then + echo 'Veilid git clone needs to be at $VEILIDDIR' + exit 1 +fi + +# App location +APPDIR=$(get_abs_filename "$SCRIPTDIR/..") diff --git a/packages/veilid_support/example/dev-setup/flutter_config.sh b/packages/veilid_support/example/dev-setup/flutter_config.sh new file mode 100755 index 0000000..a1b8c8d --- /dev/null +++ b/packages/veilid_support/example/dev-setup/flutter_config.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# +# Run this if you regenerate and need to reconfigure platform specific make system project files +# +SCRIPTDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" +source $SCRIPTDIR/_script_common + +# iOS: Set deployment target +sed -i '' 's/IPHONEOS_DEPLOYMENT_TARGET = [^;]*/IPHONEOS_DEPLOYMENT_TARGET = 12.4/g' $APPDIR/ios/Runner.xcodeproj/project.pbxproj +sed -i '' "s/platform :ios, '[^']*'/platform :ios, '12.4'/g" $APPDIR/ios/Podfile + +# MacOS: Set deployment target +sed -i '' 's/MACOSX_DEPLOYMENT_TARGET = [^;]*/MACOSX_DEPLOYMENT_TARGET = 10.14.6/g' $APPDIR/macos/Runner.xcodeproj/project.pbxproj +sed -i '' "s/platform :osx, '[^']*'/platform :osx, '10.14.6'/g" $APPDIR/macos/Podfile + +# Android: Set NDK version +if [[ "$TMPDIR" != "" ]]; then + ANDTMP=$TMPDIR/andtmp_$(date +%s) +else + ANDTMP=/tmp/andtmp_$(date +%s) +fi +cat < $ANDTMP + ndkVersion '27.0.12077973' +EOF +sed -i '' -e "/android {/r $ANDTMP" $APPDIR/android/app/build.gradle +rm -- $ANDTMP + +# Android: Set min sdk version +sed -i '' 's/minSdkVersion .*/minSdkVersion Math.max(flutter.minSdkVersion, 24)/g' $APPDIR/android/app/build.gradle + +# Android: Set gradle plugin version +sed -i '' "s/classpath \'com.android.tools.build:gradle:[^\']*\'/classpath 'com.android.tools.build:gradle:8.8.0'/g" $APPDIR/android/build.gradle + +# Android: Set gradle version +sed -i '' 's/distributionUrl=.*/distributionUrl=https:\/\/services.gradle.org\/distributions\/gradle-8.10.2-all.zip/g' $APPDIR/android/gradle/wrapper/gradle-wrapper.properties diff --git a/packages/veilid_support/example/dev-setup/wasm_update.sh b/packages/veilid_support/example/dev-setup/wasm_update.sh new file mode 100755 index 0000000..6dec701 --- /dev/null +++ b/packages/veilid_support/example/dev-setup/wasm_update.sh @@ -0,0 +1,25 @@ +#!/bin/bash +SCRIPTDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" +source $SCRIPTDIR/_script_common + +pushd $SCRIPTDIR >/dev/null + +# WASM output dir +WASMDIR=$APPDIR/web/wasm + +# Build veilid-wasm, passing any arguments here to the build script +pushd $VEILIDDIR/veilid-wasm >/dev/null +PKGDIR=$(./wasm_build.sh $@ | grep SUCCESS:OUTPUTDIR | cut -d= -f2) +popd >/dev/null + +# Copy wasm blob into place +echo Updating WASM from $PKGDIR to $WASMDIR +if [ -d $WASMDIR ]; then + rm -f $WASMDIR/* +fi +mkdir -p $WASMDIR +cp -f $PKGDIR/* $WASMDIR/ + +#### Done + +popd >/dev/null diff --git a/packages/veilid_support/example/integration_test/app_test.dart b/packages/veilid_support/example/integration_test/app_test.dart new file mode 100644 index 0000000..2d3d0e2 --- /dev/null +++ b/packages/veilid_support/example/integration_test/app_test.dart @@ -0,0 +1,209 @@ +import 'package:flutter/foundation.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:test/test.dart'; +import 'package:veilid_support/veilid_support.dart'; +import 'package:veilid_test/veilid_test.dart'; + +import 'fixtures/fixtures.dart'; +import 'test_dht_log.dart'; +import 'test_dht_record_pool.dart'; +import 'test_dht_short_array.dart'; +import 'test_persistent_queue.dart'; +import 'test_table_db_array.dart'; + +void main() { + final startTime = DateTime.now(); + + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final veilidFixture = + DefaultVeilidFixture(programName: 'veilid_support integration test'); + final updateProcessorFixture = + UpdateProcessorFixture(veilidFixture: veilidFixture); + final tickerFixture = + TickerFixture(updateProcessorFixture: updateProcessorFixture); + final dhtRecordPoolFixture = DHTRecordPoolFixture( + tickerFixture: tickerFixture, + updateProcessorFixture: updateProcessorFixture); + + group(timeout: const Timeout(Duration(seconds: 240)), 'Started Tests', () { + setUpAll(veilidFixture.setUp); + tearDownAll(veilidFixture.tearDown); + tearDownAll(() { + final endTime = DateTime.now(); + debugPrintSynchronously('Duration: ${endTime.difference(startTime)}'); + }); + + group('attached', () { + setUpAll(veilidFixture.attach); + tearDownAll(veilidFixture.detach); + + group('persistent_queue', () { + test('persistent_queue:open_close', testPersistentQueueOpenClose); + test('persistent_queue:add', testPersistentQueueAdd); + test('persistent_queue:add_sync', testPersistentQueueAddSync); + test('persistent_queue:add_persist', testPersistentQueueAddPersist); + test('persistent_queue:add_sync_persist', + testPersistentQueueAddSyncPersist); + }); + + group('dht_support', () { + setUpAll(updateProcessorFixture.setUp); + setUpAll(tickerFixture.setUp); + tearDownAll(tickerFixture.tearDown); + tearDownAll(updateProcessorFixture.tearDown); + + test('create_pool', testDHTRecordPoolCreate); + + group('dht_record_pool', () { + setUpAll(dhtRecordPoolFixture.setUp); + tearDownAll(dhtRecordPoolFixture.tearDown); + + test('dht_record_pool:create_delete', testDHTRecordCreateDelete); + test('dht_record_pool:scopes', testDHTRecordScopes); + test('dht_record_pool:deep_create_delete', + testDHTRecordDeepCreateDelete); + }); + + group('dht_short_array', () { + setUpAll(dhtRecordPoolFixture.setUp); + tearDownAll(dhtRecordPoolFixture.tearDown); + + for (final stride in [256, 16 /*64, 32, 16, 8, 4, 2, 1 */]) { + test('dht_short_array:create_stride_$stride', + makeTestDHTShortArrayCreateDelete(stride: stride)); + test('dht_short_array:add_stride_$stride', + makeTestDHTShortArrayAdd(stride: stride)); + } + }); + + group('dht_log', () { + setUpAll(dhtRecordPoolFixture.setUp); + tearDownAll(dhtRecordPoolFixture.tearDown); + + for (final stride in [256, 16 /*64, 32, 16, 8, 4, 2, 1 */]) { + test('dht_log:create_stride_$stride', + makeTestDHTLogCreateDelete(stride: stride)); + test( + timeout: const Timeout(Duration(seconds: 480)), + 'dht_log:add_truncate_stride_$stride', + makeTestDHTLogAddTruncate(stride: stride), + ); + } + }); + }); + + group('table_db', () { + group('table_db_array', () { + test('table_db_array:create_delete', testTableDBArrayCreateDelete); + + group('table_db_array:add_get', () { + for (final params in [ + // + (99, 3, 15), + (100, 4, 16), + (101, 5, 17), + // + (511, 3, 127), + (512, 4, 128), + (513, 5, 129), + // + (4095, 3, 1023), + (4096, 4, 1024), + (4097, 5, 1025), + // + (65535, 3, 16383), + (65536, 4, 16384), + (65537, 5, 16385), + ]) { + final count = params.$1; + final singles = params.$2; + final batchSize = params.$3; + + test( + timeout: const Timeout(Duration(seconds: 480)), + 'table_db_array:add_remove_count=${count}_batchSize=$batchSize', + makeTestTableDBArrayAddGetClear( + count: count, + singles: singles, + batchSize: batchSize, + crypto: const VeilidCryptoPublic()), + ); + } + }); + + group('table_db_array:insert', () { + for (final params in [ + // + (99, 3, 15), + (100, 4, 16), + (101, 5, 17), + // + (511, 3, 127), + (512, 4, 128), + (513, 5, 129), + // + (4095, 3, 1023), + (4096, 4, 1024), + (4097, 5, 1025), + // + (65535, 3, 16383), + (65536, 4, 16384), + (65537, 5, 16385), + ]) { + final count = params.$1; + final singles = params.$2; + final batchSize = params.$3; + + test( + timeout: const Timeout(Duration(seconds: 480)), + 'table_db_array:insert_count=${count}_' + 'singles=${singles}_batchSize=$batchSize', + makeTestTableDBArrayInsert( + count: count, + singles: singles, + batchSize: batchSize, + crypto: const VeilidCryptoPublic()), + ); + } + }); + + group('table_db_array:remove', () { + for (final params in [ + // + (99, 3, 15), + (100, 4, 16), + (101, 5, 17), + // + (511, 3, 127), + (512, 4, 128), + (513, 5, 129), + // + (4095, 3, 1023), + (4096, 4, 1024), + (4097, 5, 1025), + // + (16383, 3, 4095), + (16384, 4, 4096), + (16385, 5, 4097), + ]) { + final count = params.$1; + final singles = params.$2; + final batchSize = params.$3; + + test( + timeout: const Timeout(Duration(seconds: 480)), + 'table_db_array:remove_count=${count}_' + 'singles=${singles}_batchSize=$batchSize', + makeTestTableDBArrayRemove( + count: count, + singles: singles, + batchSize: batchSize, + crypto: const VeilidCryptoPublic()), + ); + } + }); + }); + }); + }); + }); +} diff --git a/packages/veilid_support/example/integration_test/fixtures/dht_record_pool_fixture.dart b/packages/veilid_support/example/integration_test/fixtures/dht_record_pool_fixture.dart new file mode 100644 index 0000000..d38181f --- /dev/null +++ b/packages/veilid_support/example/integration_test/fixtures/dht_record_pool_fixture.dart @@ -0,0 +1,45 @@ +import 'dart:async'; + +import 'package:async_tools/async_tools.dart'; +import 'package:flutter/foundation.dart'; +import 'package:veilid_support/veilid_support.dart'; +import 'package:veilid_test/veilid_test.dart'; + +class DHTRecordPoolFixture implements TickerFixtureTickable { + DHTRecordPoolFixture( + {required this.tickerFixture, required this.updateProcessorFixture}); + + static final _fixtureMutex = Mutex(); + UpdateProcessorFixture updateProcessorFixture; + TickerFixture tickerFixture; + + Future setUp({bool purge = true}) async { + await _fixtureMutex.acquire(); + if (purge) { + await Veilid.instance.debug('record purge local'); + await Veilid.instance.debug('record purge remote'); + } + await DHTRecordPool.init(logger: debugPrintSynchronously); + tickerFixture.register(this); + } + + Future tearDown() async { + assert(_fixtureMutex.isLocked, 'should not tearDown without setUp'); + tickerFixture.unregister(this); + await DHTRecordPool.close(); + + final recordList = await Veilid.instance.debug('record list local'); + debugPrintSynchronously('DHT Record List:\n$recordList'); + + _fixtureMutex.release(); + } + + @override + Future onTick() async { + if (!updateProcessorFixture + .processorConnectionState.isPublicInternetReady) { + return; + } + await DHTRecordPool.instance.tick(); + } +} diff --git a/packages/veilid_support/example/integration_test/fixtures/fixtures.dart b/packages/veilid_support/example/integration_test/fixtures/fixtures.dart new file mode 100644 index 0000000..95e89a0 --- /dev/null +++ b/packages/veilid_support/example/integration_test/fixtures/fixtures.dart @@ -0,0 +1 @@ +export 'dht_record_pool_fixture.dart'; diff --git a/packages/veilid_support/example/integration_test/test_dht_log.dart b/packages/veilid_support/example/integration_test/test_dht_log.dart new file mode 100644 index 0000000..3ca4eb9 --- /dev/null +++ b/packages/veilid_support/example/integration_test/test_dht_log.dart @@ -0,0 +1,124 @@ +import 'dart:convert'; + +import 'package:test/test.dart'; +import 'package:veilid_support/veilid_support.dart'; + +Future Function() makeTestDHTLogCreateDelete({required int stride}) => + () async { + // Close before delete + { + final dlog = await DHTLog.create( + debugName: 'log_create_delete 1 stride $stride', stride: stride); + expect(await dlog.operate((r) async => r.length), isZero); + expect(dlog.isOpen, isTrue); + await dlog.close(); + expect(dlog.isOpen, isFalse); + await dlog.delete(); + // Operate should fail + await expectLater(() async => dlog.operate((r) async => r.length), + throwsA(isA())); + } + + // Close after delete + { + final dlog = await DHTLog.create( + debugName: 'log_create_delete 2 stride $stride', stride: stride); + await dlog.delete(); + // Operate should still succeed because things aren't closed + expect(await dlog.operate((r) async => r.length), isZero); + await dlog.close(); + // Operate should fail + await expectLater(() async => dlog.operate((r) async => r.length), + throwsA(isA())); + } + + // Close after delete multiple + // Okay to request delete multiple times before close + { + final dlog = await DHTLog.create( + debugName: 'log_create_delete 3 stride $stride', stride: stride); + await dlog.delete(); + await dlog.delete(); + // Operate should still succeed because things aren't closed + expect(await dlog.operate((r) async => r.length), isZero); + await dlog.close(); + await expectLater(() async => dlog.close(), throwsA(isA())); + // Operate should fail + await expectLater(() async => dlog.operate((r) async => r.length), + throwsA(isA())); + } + }; + +Future Function() makeTestDHTLogAddTruncate({required int stride}) => + () async { + final dlog = await DHTLog.create( + debugName: 'log_add 1 stride $stride', stride: stride); + + final dataset = Iterable.generate(1000) + .map((n) => utf8.encode('elem $n')) + .toList(); + + print('adding\n'); + { + final res = await dlog.operateAppend((w) async { + const chunk = 25; + for (var n = 0; n < dataset.length; n += chunk) { + print('$n-${n + chunk - 1} '); + await w.addAll(dataset.sublist(n, n + chunk)); + } + }); + expect(res, isNull); + } + + print('get all\n'); + { + final dataset2 = await dlog.operate((r) async => r.getRange(0)); + expect(dataset2, equals(dataset)); + } + { + final dataset3 = + await dlog.operate((r) async => r.getRange(64, length: 128)); + expect(dataset3, equals(dataset.sublist(64, 64 + 128))); + } + { + final dataset4 = + await dlog.operate((r) async => r.getRange(0, length: 1000)); + expect(dataset4, equals(dataset.sublist(0, 1000))); + } + { + final dataset5 = + await dlog.operate((r) async => r.getRange(500, length: 499)); + expect(dataset5, equals(dataset.sublist(500, 999))); + } + print('truncate\n'); + { + await dlog.operateAppend((w) async => w.truncate(w.length - 5)); + } + { + final dataset6 = + await dlog.operate((r) async => r.getRange(500 - 5, length: 499)); + expect(dataset6, equals(dataset.sublist(500, 999))); + } + print('truncate 2\n'); + { + await dlog.operateAppend((w) async => w.truncate(w.length - 251)); + } + { + final dataset7 = + await dlog.operate((r) async => r.getRange(500 - 256, length: 499)); + expect(dataset7, equals(dataset.sublist(500, 999))); + } + print('clear\n'); + { + await dlog.operateAppend((w) async => w.clear()); + } + print('get all\n'); + { + final dataset8 = await dlog.operate((r) async => r.getRange(0)); + expect(dataset8, isEmpty); + } + print('delete and close\n'); + + await dlog.delete(); + await dlog.close(); + }; diff --git a/packages/veilid_support/example/integration_test/test_dht_record_pool.dart b/packages/veilid_support/example/integration_test/test_dht_record_pool.dart new file mode 100644 index 0000000..1b300a9 --- /dev/null +++ b/packages/veilid_support/example/integration_test/test_dht_record_pool.dart @@ -0,0 +1,215 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:test/test.dart'; +import 'package:veilid_support/veilid_support.dart'; + +Future testDHTRecordPoolCreate() async { + await DHTRecordPool.init(logger: debugPrintSynchronously); + final pool = DHTRecordPool.instance; + await pool.tick(); + await DHTRecordPool.close(); +} + +Future testDHTRecordCreateDelete() async { + final pool = DHTRecordPool.instance; + + // Close before delete + { + final rec = await pool.createRecord(debugName: 'test_create_delete 1'); + expect(rec.isOpen, isTrue); + await rec.close(); + expect(rec.isOpen, isFalse); + await pool.deleteRecord(rec.key); + // Set should fail + await expectLater(() async => rec.tryWriteBytes(utf8.encode('test')), + throwsA(isA())); + } + + // Close after delete + { + final rec2 = await pool.createRecord(debugName: 'test_create_delete 2'); + expect(rec2.isOpen, isTrue); + await pool.deleteRecord(rec2.key); + expect(rec2.isOpen, isTrue); + await rec2.close(); + expect(rec2.isOpen, isFalse); + // Set should fail + await expectLater(() async => rec2.tryWriteBytes(utf8.encode('test')), + throwsA(isA())); + } + + // Close after delete multiple + // Okay to request delete multiple times before close + { + final rec3 = await pool.createRecord(debugName: 'test_create_delete 3'); + await pool.deleteRecord(rec3.key); + await pool.deleteRecord(rec3.key); + // Set should succeed still + await rec3.tryWriteBytes(utf8.encode('test')); + await rec3.close(); + await expectLater(() async => rec3.close(), throwsA(isA())); + // Set should fail + await expectLater(() async => rec3.tryWriteBytes(utf8.encode('test')), + throwsA(isA())); + // Delete already delete should fail + await expectLater(() async => pool.deleteRecord(rec3.key), + throwsA(isA())); + } +} + +Future testDHTRecordScopes() async { + final pool = DHTRecordPool.instance; + + // Delete scope with exception should propagate exception + { + final rec = await pool.createRecord(debugName: 'test_scope 1'); + await expectLater( + () async => rec.deleteScope((recd) async { + throw Exception(); + }), + throwsA(isA())); + // Set should fail + await expectLater(() async => rec.tryWriteBytes(utf8.encode('test')), + throwsA(isA())); + } + + // Delete scope without exception + { + final rec2 = await pool.createRecord(debugName: 'test_scope 2'); + try { + await rec2.deleteScope((rec2d) async { + // + }); + } on Exception { + assert(false, 'should not throw'); + } + await expectLater(() async => rec2.close(), throwsA(isA())); + await pool.deleteRecord(rec2.key); + } + + // Close scope without exception + { + final rec3 = await pool.createRecord(debugName: 'test_scope 3'); + try { + await rec3.scope((rec3d) async { + // + }); + } on Exception { + assert(false, 'should not throw'); + } + // Set should fail because scope closed it + await expectLater(() async => rec3.tryWriteBytes(utf8.encode('test')), + throwsA(isA())); + await pool.deleteRecord(rec3.key); + } +} + +Future testDHTRecordGetSet() async { + final pool = DHTRecordPool.instance; + final valdata = utf8.encode('test'); + + // Test get without set + { + final rec = await pool.createRecord(debugName: 'test_get_set 1'); + final val = await rec.get(); + await pool.deleteRecord(rec.key); + expect(val, isNull); + await rec.close(); + } + + // Test set then get + { + final rec2 = await pool.createRecord(debugName: 'test_get_set 2'); + expect(await rec2.tryWriteBytes(valdata), isNull); + expect(await rec2.get(), equals(valdata)); + // Invalid subkey should throw + await expectLater( + () async => rec2.get(subkey: 1), throwsA(isA())); + await rec2.close(); + await pool.deleteRecord(rec2.key); + } + + // Test set then delete then open then get + { + final rec3 = await pool.createRecord(debugName: 'test_get_set 3'); + expect(await rec3.tryWriteBytes(valdata), isNull); + expect(await rec3.get(), equals(valdata)); + await rec3.close(); + await pool.deleteRecord(rec3.key); + final rec4 = + await pool.openRecordRead(rec3.key, debugName: 'test_get_set 4'); + expect(await rec4.get(), equals(valdata)); + await rec4.close(); + await pool.deleteRecord(rec4.key); + } +} + +Future testDHTRecordDeepCreateDelete() async { + final pool = DHTRecordPool.instance; + const numChildren = 20; + const numIterations = 10; + + // Make root record + final recroot = await pool.createRecord(debugName: 'test_deep_create_delete'); + + // Make child set 1 + var parent = recroot; + final children = []; + for (var n = 0; n < numChildren; n++) { + final child = + await pool.createRecord(debugName: 'deep $n', parent: parent.key); + children.add(child); + parent = child; + } + + // Should mark for deletion + expect(await pool.deleteRecord(recroot.key), isFalse); + + // Root should still be valid + expect(await pool.isValidRecordKey(recroot.key), isTrue); + + // Close root record + await recroot.close(); + + // Root should still be valid because children still exist + expect(await pool.isValidRecordKey(recroot.key), isTrue); + + for (var d = 0; d < numIterations; d++) { + // Make child set 2 + final children2 = []; + parent = recroot; + for (var n = 0; n < numChildren; n++) { + final child = + await pool.createRecord(debugName: 'deep2 $n ', parent: parent.key); + children2.add(child); + parent = child; + } + + // Delete child set 2 in reverse order + for (var n = numChildren - 1; n >= 0; n--) { + expect(await pool.deleteRecord(children2[n].key), isFalse); + } + + // Root should still be there + expect(await pool.isValidRecordKey(recroot.key), isTrue); + + // Close child set 2 + await children2.map((c) => c.close()).wait; + + // All child set 2 should be invalid + for (final c2 in children2) { + // Children should be invalid and deleted now + expect(await pool.isValidRecordKey(c2.key), isFalse); + } + + // Root should still be valid + expect(await pool.isValidRecordKey(recroot.key), isTrue); + } + + // Close child set 1 + await children.map((c) => c.close()).wait; + + // Root should have gone away + expect(await pool.isValidRecordKey(recroot.key), isFalse); +} diff --git a/packages/veilid_support/example/integration_test/test_dht_short_array.dart b/packages/veilid_support/example/integration_test/test_dht_short_array.dart new file mode 100644 index 0000000..52e7942 --- /dev/null +++ b/packages/veilid_support/example/integration_test/test_dht_short_array.dart @@ -0,0 +1,124 @@ +import 'dart:convert'; + +import 'package:test/test.dart'; +import 'package:veilid_support/veilid_support.dart'; + +Future Function() makeTestDHTShortArrayCreateDelete( + {required int stride}) => + () async { + // Close before delete + { + final arr = await DHTShortArray.create( + debugName: 'sa_create_delete 1 stride $stride', stride: stride); + expect(await arr.operate((r) async => r.length), isZero); + expect(arr.isOpen, isTrue); + await arr.close(); + expect(arr.isOpen, isFalse); + await arr.delete(); + // Operate should fail + await expectLater(() async => arr.operate((r) async => r.length), + throwsA(isA())); + } + + // Close after delete + { + final arr = await DHTShortArray.create( + debugName: 'sa_create_delete 2 stride $stride', stride: stride); + await arr.delete(); + // Operate should still succeed because things aren't closed + expect(await arr.operate((r) async => r.length), isZero); + await arr.close(); + // Operate should fail + await expectLater(() async => arr.operate((r) async => r.length), + throwsA(isA())); + } + + // Close after delete multiple + // Okay to request delete multiple times before close + { + final arr = await DHTShortArray.create( + debugName: 'sa_create_delete 3 stride $stride', stride: stride); + await arr.delete(); + await arr.delete(); + // Operate should still succeed because things aren't closed + expect(await arr.operate((r) async => r.length), isZero); + await arr.close(); + await expectLater(() async => arr.close(), throwsA(isA())); + // Operate should fail + await expectLater(() async => arr.operate((r) async => r.length), + throwsA(isA())); + } + }; + +Future Function() makeTestDHTShortArrayAdd({required int stride}) => + () async { + final arr = await DHTShortArray.create( + debugName: 'sa_add 1 stride $stride', stride: stride); + + final dataset = Iterable.generate(256) + .map((n) => utf8.encode('elem $n')) + .toList(); + + print('adding singles\n'); + { + for (var n = 4; n < 8; n++) { + await arr.operateWriteEventual((w) async { + print('$n '); + await w.add(dataset[n]); + }); + } + } + + print('adding batch\n'); + { + await arr.operateWriteEventual((w) async { + print('${dataset.length ~/ 2}-${dataset.length}'); + await w.addAll(dataset.sublist(dataset.length ~/ 2, dataset.length)); + }); + } + + print('inserting singles\n'); + { + for (var n = 0; n < 4; n++) { + await arr.operateWriteEventual((w) async { + print('$n '); + await w.insert(n, dataset[n]); + }); + } + } + + print('inserting batch\n'); + { + await arr.operateWriteEventual((w) async { + print('8-${dataset.length ~/ 2}'); + await w.insertAll(8, dataset.sublist(8, dataset.length ~/ 2)); + }); + } + + //print('get all\n'); + { + final dataset2 = await arr.operate((r) async => r.getRange(0)); + expect(dataset2, equals(dataset)); + } + { + final dataset3 = + await arr.operate((r) async => r.getRange(64, length: 128)); + expect(dataset3, equals(dataset.sublist(64, 64 + 128))); + } + + //print('clear\n'); + { + await arr.operateWriteEventual((w) async { + await w.clear(); + }); + } + + //print('get all\n'); + { + final dataset4 = await arr.operate((r) async => r.getRange(0)); + expect(dataset4, isEmpty); + } + + await arr.delete(); + await arr.close(); + }; diff --git a/packages/veilid_support/example/integration_test/test_persistent_queue.dart b/packages/veilid_support/example/integration_test/test_persistent_queue.dart new file mode 100644 index 0000000..51f8004 --- /dev/null +++ b/packages/veilid_support/example/integration_test/test_persistent_queue.dart @@ -0,0 +1,196 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:async_tools/async_tools.dart'; +import 'package:collection/collection.dart'; +import 'package:test/test.dart'; +import 'package:veilid_support/veilid_support.dart'; + +Future testPersistentQueueOpenClose() async { + final pq = PersistentQueue( + table: 'persistent_queue_integration_test', + key: 'open_close', + fromBuffer: (buf) => utf8.decode(buf), + toBuffer: (s) => utf8.encode(s), + closure: (elems) async { + // + }); + + await pq.close(); +} + +Future testPersistentQueueAdd() async { + final added = {}; + for (var n = 0; n < 100; n++) { + final elem = 'FOOBAR #$n'; + added.add(elem); + } + + final done = {}; + final pq = PersistentQueue( + table: 'persistent_queue_integration_test', + key: 'add', + fromBuffer: (buf) => utf8.decode(buf), + toBuffer: (s) => utf8.encode(s), + closure: (elems) async { + done.addAll(elems); + }); + + var oddeven = false; + for (final chunk in added.slices(10)) { + if (!oddeven) { + await chunk.map(pq.add).wait; + } else { + await pq.addAll(chunk); + } + oddeven = !oddeven; + } + + await pq.close(); + + expect(done, equals(added)); +} + +Future testPersistentQueueAddSync() async { + final added = {}; + for (var n = 0; n < 100; n++) { + final elem = 'FOOBAR #$n'; + added.add(elem); + } + + final done = {}; + final pq = PersistentQueue( + table: 'persistent_queue_integration_test', + key: 'add_sync', + fromBuffer: (buf) => utf8.decode(buf), + toBuffer: (s) => utf8.encode(s), + closure: (elems) async { + done.addAll(elems); + }); + + var oddeven = false; + for (final chunk in added.slices(10)) { + if (!oddeven) { + await chunk.map((x) async { + await asyncSleep(Duration.zero); + pq.addSync(x); + }).wait; + } else { + pq.addAllSync(chunk); + } + oddeven = !oddeven; + } + + await pq.close(); + + expect(done, equals(added)); +} + +Future testPersistentQueueAddPersist() async { + final added = {}; + for (var n = 0; n < 100; n++) { + final elem = 'FOOBAR #$n'; + added.add(elem); + } + + final done = {}; + + late final PersistentQueue pq; + + pq = PersistentQueue( + table: 'persistent_queue_integration_test', + key: 'add_persist', + fromBuffer: (buf) => utf8.decode(buf), + toBuffer: (s) => utf8.encode(s), + closure: (elems) async { + done.addAll(elems); + }); + + // Start it paused + await pq.pause(); + + // Add all elements + var oddeven = false; + + for (final chunk in added.slices(10)) { + if (!oddeven) { + await chunk.map(pq.add).wait; + } else { + await pq.addAll(chunk); + } + oddeven = !oddeven; + } + + // Close the persistent queue + await pq.close(); + + // Create a new persistent queue that processes the items + final pq2 = PersistentQueue( + table: 'persistent_queue_integration_test', + key: 'add_persist', + fromBuffer: (buf) => utf8.decode(buf), + toBuffer: (s) => utf8.encode(s), + closure: (elems) async { + done.addAll(elems); + }); + await pq2.waitEmpty; + await pq2.close(); + + expect(done, equals(added)); +} + +Future testPersistentQueueAddSyncPersist() async { + final added = {}; + for (var n = 0; n < 100; n++) { + final elem = 'FOOBAR #$n'; + added.add(elem); + } + + final done = {}; + + late final PersistentQueue pq; + + pq = PersistentQueue( + table: 'persistent_queue_integration_test', + key: 'add_persist', + fromBuffer: (buf) => utf8.decode(buf), + toBuffer: (s) => utf8.encode(s), + closure: (elems) async { + done.addAll(elems); + }); + + // Start it paused + await pq.pause(); + + // Add all elements + var oddeven = false; + + for (final chunk in added.slices(10)) { + if (!oddeven) { + await chunk.map((x) async { + await asyncSleep(Duration.zero); + pq.addSync(x); + }).wait; + } else { + pq.addAllSync(chunk); + } + oddeven = !oddeven; + } + + // Close the persistent queue + await pq.close(); + + // Create a new persistent queue that processes the items + final pq2 = PersistentQueue( + table: 'persistent_queue_integration_test', + key: 'add_persist', + fromBuffer: (buf) => utf8.decode(buf), + toBuffer: (s) => utf8.encode(s), + closure: (elems) async { + done.addAll(elems); + }); + await pq2.waitEmpty; + await pq2.close(); + + expect(done, equals(added)); +} diff --git a/packages/veilid_support/example/integration_test/test_table_db_array.dart b/packages/veilid_support/example/integration_test/test_table_db_array.dart new file mode 100644 index 0000000..e67bc39 --- /dev/null +++ b/packages/veilid_support/example/integration_test/test_table_db_array.dart @@ -0,0 +1,250 @@ +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:test/test.dart'; +import 'package:veilid_support/veilid_support.dart'; + +Future testTableDBArrayCreateDelete() async { + // Close before delete + { + final arr = + TableDBArray(table: 'testArray', crypto: const VeilidCryptoPublic()); + expect(() => arr.length, throwsA(isA())); + expect(arr.isOpen, isTrue); + await arr.initWait(); + expect(arr.isOpen, isTrue); + expect(arr.length, isZero); + await arr.close(); + expect(arr.isOpen, isFalse); + await arr.delete(); + expect(arr.isOpen, isFalse); + } + + // Async create with close after delete and then reopen + { + final arr = await TableDBArray.make( + table: 'testArray', crypto: const VeilidCryptoPublic()); + expect(arr.length, isZero); + expect(arr.isOpen, isTrue); + await expectLater(() async { + await arr.delete(); + }, throwsA(isA())); + expect(arr.isOpen, isTrue); + await arr.close(); + expect(arr.isOpen, isFalse); + + final arr2 = await TableDBArray.make( + table: 'testArray', crypto: const VeilidCryptoPublic()); + expect(arr2.isOpen, isTrue); + expect(arr.isOpen, isFalse); + await arr2.close(); + expect(arr2.isOpen, isFalse); + await arr2.delete(); + } +} + +Uint8List makeData(int n) => utf8.encode('elem $n'); +List makeDataBatch(int n, int batchSize) => + List.generate(batchSize, (x) => makeData(n + x)); + +Future Function() makeTestTableDBArrayAddGetClear( + {required int count, + required int singles, + required int batchSize, + required VeilidCrypto crypto}) => + () async { + final arr = await TableDBArray.make(table: 'testArray', crypto: crypto); + + print('adding'); + { + for (var n = 0; n < count;) { + var toAdd = min(batchSize, count - n); + for (var s = 0; s < min(singles, toAdd); s++) { + await arr.add(makeData(n)); + toAdd--; + n++; + } + + await arr.addAll(makeDataBatch(n, toAdd)); + n += toAdd; + + print(' $n/$count'); + } + } + + print('get singles'); + { + for (var n = 0; n < batchSize; n++) { + expect(await arr.get(n), equals(makeData(n))); + } + } + + print('get batch'); + { + for (var n = batchSize; n < count; n += batchSize) { + final toGet = min(batchSize, count - n); + expect(await arr.getRange(n, n + toGet), + equals(makeDataBatch(n, toGet))); + } + } + + print('clear'); + { + await arr.clear(); + expect(arr.length, isZero); + } + + await arr.close(delete: true); + }; + +Future Function() makeTestTableDBArrayInsert( + {required int count, + required int singles, + required int batchSize, + required VeilidCrypto crypto}) => + () async { + final arr = await TableDBArray.make(table: 'testArray', crypto: crypto); + + final match = []; + + print('inserting'); + { + for (var n = 0; n < count;) { + final start = n; + var toAdd = min(batchSize, count - n); + for (var s = 0; s < min(singles, toAdd); s++) { + final data = makeData(n); + await arr.insert(start, data); + match.insert(start, data); + toAdd--; + n++; + } + + final data = makeDataBatch(n, toAdd); + await arr.insertAll(start, data); + match.insertAll(start, data); + n += toAdd; + + print(' $n/$count'); + } + } + + print('get singles'); + { + for (var n = 0; n < batchSize; n++) { + expect(await arr.get(n), equals(match[n])); + } + } + + print('get batch'); + { + for (var n = batchSize; n < count; n += batchSize) { + final toGet = min(batchSize, count - n); + expect(await arr.getRange(n, n + toGet), + equals(match.sublist(n, n + toGet))); + } + } + + print('clear'); + { + await arr.clear(); + expect(arr.length, isZero); + } + + await arr.close(delete: true); + }; + +Future Function() makeTestTableDBArrayRemove( + {required int count, + required int singles, + required int batchSize, + required VeilidCrypto crypto}) => + () async { + final arr = await TableDBArray.make(table: 'testArray', crypto: crypto); + + final match = []; + + { + final rems = [ + (0, 0), + (0, 1), + (0, batchSize), + (1, batchSize - 1), + (batchSize, 1), + (batchSize + 1, batchSize), + (batchSize - 1, batchSize + 1) + ]; + for (final rem in rems) { + print('adding '); + { + for (var n = match.length; n < count;) { + final toAdd = min(batchSize, count - n); + final data = makeDataBatch(n, toAdd); + await arr.addAll(data); + match.addAll(data); + n += toAdd; + print(' $n/$count'); + } + expect(arr.length, equals(match.length)); + } + + { + final start = rem.$1; + final length = rem.$2; + print('removing start=$start length=$length'); + + final out = Output>(); + await arr.removeRange(start, start + length, out: out); + expect(out.value, equals(match.sublist(start, start + length))); + match.removeRange(start, start + length); + expect(arr.length, equals(match.length)); + + print('get batch'); + { + final checkCount = match.length; + for (var n = 0; n < checkCount;) { + final toGet = min(batchSize, checkCount - n); + expect(await arr.getRange(n, n + toGet), + equals(match.sublist(n, n + toGet))); + n += toGet; + print(' $n/$checkCount'); + } + } + } + + { + final start = match.length - rem.$1 - rem.$2; + final length = rem.$2; + print('removing from end start=$start length=$length'); + + final out = Output>(); + await arr.removeRange(start, start + length, out: out); + expect(out.value, equals(match.sublist(start, start + length))); + match.removeRange(start, start + length); + expect(arr.length, equals(match.length)); + + print('get batch'); + { + final checkCount = match.length; + for (var n = 0; n < checkCount;) { + final toGet = min(batchSize, checkCount - n); + expect(await arr.getRange(n, n + toGet), + equals(match.sublist(n, n + toGet))); + n += toGet; + print(' $n/$checkCount'); + } + expect(arr.length, equals(match.length)); + } + } + } + } + + print('clear'); + { + await arr.clear(); + expect(arr.length, isZero); + } + + await arr.close(delete: true); + }; diff --git a/packages/veilid_support/example/ios/.gitignore b/packages/veilid_support/example/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/packages/veilid_support/example/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/packages/veilid_support/example/ios/Flutter/AppFrameworkInfo.plist b/packages/veilid_support/example/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..7c56964 --- /dev/null +++ b/packages/veilid_support/example/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 12.0 + + diff --git a/packages/veilid_support/example/ios/Flutter/Debug.xcconfig b/packages/veilid_support/example/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..ec97fc6 --- /dev/null +++ b/packages/veilid_support/example/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/packages/veilid_support/example/ios/Flutter/Release.xcconfig b/packages/veilid_support/example/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..c4855bf --- /dev/null +++ b/packages/veilid_support/example/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/packages/veilid_support/example/ios/Podfile b/packages/veilid_support/example/ios/Podfile new file mode 100644 index 0000000..cc206f6 --- /dev/null +++ b/packages/veilid_support/example/ios/Podfile @@ -0,0 +1,44 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '12.4' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/packages/veilid_support/example/ios/Runner.xcodeproj/project.pbxproj b/packages/veilid_support/example/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..e9fe777 --- /dev/null +++ b/packages/veilid_support/example/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,647 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 4380113E2BE01E850006987E /* libveilid_flutter.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libveilid_flutter.a; path = "../../../../../veilid/target/lipo-ios/libveilid_flutter.a"; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 4380113D2BE01E850006987E /* Frameworks */ = { + isa = PBXGroup; + children = ( + 4380113E2BE01E850006987E /* libveilid_flutter.a */, + ); + name = Frameworks; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + 4380113D2BE01E850006987E /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.4; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = XP5LBLT7M7; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Veilid Support Tests"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "org.veilid.packages.veilid-support.tests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.4; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.4; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = XP5LBLT7M7; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Veilid Support Tests"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "org.veilid.packages.veilid-support.tests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = XP5LBLT7M7; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Veilid Support Tests"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "org.veilid.packages.veilid-support.tests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/packages/veilid_support/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/packages/veilid_support/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/packages/veilid_support/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/packages/veilid_support/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/veilid_support/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/packages/veilid_support/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/veilid_support/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/packages/veilid_support/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/packages/veilid_support/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/packages/veilid_support/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/veilid_support/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..8e3ca5d --- /dev/null +++ b/packages/veilid_support/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/veilid_support/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/packages/veilid_support/example/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/packages/veilid_support/example/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/packages/veilid_support/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/veilid_support/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/packages/veilid_support/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/veilid_support/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/packages/veilid_support/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/packages/veilid_support/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/packages/veilid_support/example/ios/Runner/AppDelegate.swift b/packages/veilid_support/example/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..70693e4 --- /dev/null +++ b/packages/veilid_support/example/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import UIKit +import Flutter + +@UIApplicationMain +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/packages/veilid_support/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/packages/veilid_support/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/packages/veilid_support/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/packages/veilid_support/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/packages/veilid_support/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/packages/veilid_support/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/packages/veilid_support/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/packages/veilid_support/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/packages/veilid_support/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/packages/veilid_support/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/packages/veilid_support/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/packages/veilid_support/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/packages/veilid_support/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/packages/veilid_support/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/packages/veilid_support/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/packages/veilid_support/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/packages/veilid_support/example/ios/Runner/Base.lproj/LaunchScreen.storyboard b/packages/veilid_support/example/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/packages/veilid_support/example/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/veilid_support/example/ios/Runner/Base.lproj/Main.storyboard b/packages/veilid_support/example/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/packages/veilid_support/example/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/veilid_support/example/ios/Runner/Info.plist b/packages/veilid_support/example/ios/Runner/Info.plist new file mode 100644 index 0000000..f15383a --- /dev/null +++ b/packages/veilid_support/example/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Example + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + example + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/packages/veilid_support/example/ios/Runner/Runner-Bridging-Header.h b/packages/veilid_support/example/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/packages/veilid_support/example/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/packages/veilid_support/example/ios/RunnerTests/RunnerTests.swift b/packages/veilid_support/example/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/packages/veilid_support/example/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/packages/veilid_support/example/lib/main.dart b/packages/veilid_support/example/lib/main.dart new file mode 100644 index 0000000..8e94089 --- /dev/null +++ b/packages/veilid_support/example/lib/main.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; + +void main() { + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + // This widget is the root of your application. + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Flutter Demo', + theme: ThemeData( + // This is the theme of your application. + // + // TRY THIS: Try running your application with "flutter run". You'll see + // the application has a purple toolbar. Then, without quitting the app, + // try changing the seedColor in the colorScheme below to Colors.green + // and then invoke "hot reload" (save your changes or press the "hot + // reload" button in a Flutter-supported IDE, or press "r" if you used + // the command line to start the app). + // + // Notice that the counter didn't reset back to zero; the application + // state is not lost during the reload. To reset the state, use hot + // restart instead. + // + // This works for code too, not just values: Most code changes can be + // tested with just a hot reload. + colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), + useMaterial3: true, + ), + home: const MyHomePage(title: 'Flutter Demo Home Page'), + ); + } +} + +class MyHomePage extends StatefulWidget { + const MyHomePage({super.key, required this.title}); + + // This widget is the home page of your application. It is stateful, meaning + // that it has a State object (defined below) that contains fields that affect + // how it looks. + + // This class is the configuration for the state. It holds the values (in this + // case the title) provided by the parent (in this case the App widget) and + // used by the build method of the State. Fields in a Widget subclass are + // always marked "final". + + final String title; + + @override + State createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State { + int _counter = 0; + + void _incrementCounter() { + setState(() { + // This call to setState tells the Flutter framework that something has + // changed in this State, which causes it to rerun the build method below + // so that the display can reflect the updated values. If we changed + // _counter without calling setState(), then the build method would not be + // called again, and so nothing would appear to happen. + _counter++; + }); + } + + @override + Widget build(BuildContext context) { + // This method is rerun every time setState is called, for instance as done + // by the _incrementCounter method above. + // + // The Flutter framework has been optimized to make rerunning build methods + // fast, so that you can just rebuild anything that needs updating rather + // than having to individually change instances of widgets. + return Scaffold( + appBar: AppBar( + // TRY THIS: Try changing the color here to a specific color (to + // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar + // change color while the other colors stay the same. + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + // Here we take the value from the MyHomePage object that was created by + // the App.build method, and use it to set our appbar title. + title: Text(widget.title), + ), + body: Center( + // Center is a layout widget. It takes a single child and positions it + // in the middle of the parent. + child: Column( + // Column is also a layout widget. It takes a list of children and + // arranges them vertically. By default, it sizes itself to fit its + // children horizontally, and tries to be as tall as its parent. + // + // Column has various properties to control how it sizes itself and + // how it positions its children. Here we use mainAxisAlignment to + // center the children vertically; the main axis here is the vertical + // axis because Columns are vertical (the cross axis would be + // horizontal). + // + // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" + // action in the IDE, or press "p" in the console), to see the + // wireframe for each widget. + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + 'You have pushed the button this many times:', + ), + Text( + '$_counter', + style: Theme.of(context).textTheme.headlineMedium, + ), + ], + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: _incrementCounter, + tooltip: 'Increment', + child: const Icon(Icons.add), + ), // This trailing comma makes auto-formatting nicer for build methods. + ); + } +} diff --git a/packages/veilid_support/example/linux/.gitignore b/packages/veilid_support/example/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/packages/veilid_support/example/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/packages/veilid_support/example/linux/CMakeLists.txt b/packages/veilid_support/example/linux/CMakeLists.txt new file mode 100644 index 0000000..9cb0d1d --- /dev/null +++ b/packages/veilid_support/example/linux/CMakeLists.txt @@ -0,0 +1,145 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.10) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "example") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.example") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Define the application target. To change its name, change BINARY_NAME above, +# not the value here, or `flutter run` will no longer work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/packages/veilid_support/example/linux/flutter/CMakeLists.txt b/packages/veilid_support/example/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/packages/veilid_support/example/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/packages/veilid_support/example/linux/flutter/generated_plugin_registrant.cc b/packages/veilid_support/example/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..cebc32d --- /dev/null +++ b/packages/veilid_support/example/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) veilid_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "VeilidPlugin"); + veilid_plugin_register_with_registrar(veilid_registrar); +} diff --git a/packages/veilid_support/example/linux/flutter/generated_plugin_registrant.h b/packages/veilid_support/example/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/packages/veilid_support/example/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/packages/veilid_support/example/linux/flutter/generated_plugins.cmake b/packages/veilid_support/example/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..003d7b5 --- /dev/null +++ b/packages/veilid_support/example/linux/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + veilid +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/packages/veilid_support/example/linux/main.cc b/packages/veilid_support/example/linux/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/packages/veilid_support/example/linux/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/packages/veilid_support/example/linux/my_application.cc b/packages/veilid_support/example/linux/my_application.cc new file mode 100644 index 0000000..c0530d4 --- /dev/null +++ b/packages/veilid_support/example/linux/my_application.cc @@ -0,0 +1,124 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "example"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "example"); + } + + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/packages/veilid_support/example/linux/my_application.h b/packages/veilid_support/example/linux/my_application.h new file mode 100644 index 0000000..72271d5 --- /dev/null +++ b/packages/veilid_support/example/linux/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/packages/veilid_support/example/macos/.gitignore b/packages/veilid_support/example/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/packages/veilid_support/example/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/packages/veilid_support/example/macos/Flutter/Flutter-Debug.xcconfig b/packages/veilid_support/example/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..4b81f9b --- /dev/null +++ b/packages/veilid_support/example/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/packages/veilid_support/example/macos/Flutter/Flutter-Release.xcconfig b/packages/veilid_support/example/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..5caa9d1 --- /dev/null +++ b/packages/veilid_support/example/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/packages/veilid_support/example/macos/Flutter/GeneratedPluginRegistrant.swift b/packages/veilid_support/example/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..48d1dff --- /dev/null +++ b/packages/veilid_support/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,14 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import path_provider_foundation +import veilid + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + VeilidPlugin.register(with: registry.registrar(forPlugin: "VeilidPlugin")) +} diff --git a/packages/veilid_support/example/macos/Podfile b/packages/veilid_support/example/macos/Podfile new file mode 100644 index 0000000..036dad0 --- /dev/null +++ b/packages/veilid_support/example/macos/Podfile @@ -0,0 +1,43 @@ +platform :osx, '10.14.6' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/packages/veilid_support/example/macos/Podfile.lock b/packages/veilid_support/example/macos/Podfile.lock new file mode 100644 index 0000000..a2618bd --- /dev/null +++ b/packages/veilid_support/example/macos/Podfile.lock @@ -0,0 +1,29 @@ +PODS: + - FlutterMacOS (1.0.0) + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - veilid (0.0.1): + - FlutterMacOS + +DEPENDENCIES: + - FlutterMacOS (from `Flutter/ephemeral`) + - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) + - veilid (from `Flutter/ephemeral/.symlinks/plugins/veilid/macos`) + +EXTERNAL SOURCES: + FlutterMacOS: + :path: Flutter/ephemeral + path_provider_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin + veilid: + :path: Flutter/ephemeral/.symlinks/plugins/veilid/macos + +SPEC CHECKSUMS: + FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + veilid: 319e2e78836d7b3d08203596d0b4a0e244b68d29 + +PODFILE CHECKSUM: 16208599a12443d53889ba2270a4985981cfb204 + +COCOAPODS: 1.16.2 diff --git a/packages/veilid_support/example/macos/Runner.xcodeproj/project.pbxproj b/packages/veilid_support/example/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..91980e8 --- /dev/null +++ b/packages/veilid_support/example/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,832 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 4380113B2BE014F40006987E /* libveilid_flutter.dylib in Frameworks */ = {isa = PBXBuildFile; fileRef = 4380113A2BE014F40006987E /* libveilid_flutter.dylib */; }; + 4380113C2BE014F40006987E /* libveilid_flutter.dylib in Bundle Framework */ = {isa = PBXBuildFile; fileRef = 4380113A2BE014F40006987E /* libveilid_flutter.dylib */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + 6CFA599ADEA1F061ADEDAE10 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 80431090FA5BD99A5B868F84 /* Pods_Runner.framework */; }; + 91F51E973F11EFA10521D360 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4C8526B53F6575BAE32CE8D8 /* Pods_RunnerTests.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 4380113C2BE014F40006987E /* libveilid_flutter.dylib in Bundle Framework */, + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = example.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 3597F23CF6B8931362B671EB /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 4380113A2BE014F40006987E /* libveilid_flutter.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = libveilid_flutter.dylib; path = "../../../../../veilid/target/lipo-darwin/libveilid_flutter.dylib"; sourceTree = ""; }; + 4C8526B53F6575BAE32CE8D8 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 80431090FA5BD99A5B868F84 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 836CFD5B5891A750B1490B1C /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 83A502A4616091D33488BCEE /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 8B20F93F35956FDE2766A851 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + A24D16EEC9CB70E4E4BE3331 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + C581BE4277030CD81FD25B44 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 91F51E973F11EFA10521D360 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4380113B2BE014F40006987E /* libveilid_flutter.dylib in Frameworks */, + 6CFA599ADEA1F061ADEDAE10 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + 7C41EBAAEDA4C42598BAF422 /* Pods */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* example.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + 7C41EBAAEDA4C42598BAF422 /* Pods */ = { + isa = PBXGroup; + children = ( + 3597F23CF6B8931362B671EB /* Pods-Runner.debug.xcconfig */, + 8B20F93F35956FDE2766A851 /* Pods-Runner.release.xcconfig */, + C581BE4277030CD81FD25B44 /* Pods-Runner.profile.xcconfig */, + 836CFD5B5891A750B1490B1C /* Pods-RunnerTests.debug.xcconfig */, + A24D16EEC9CB70E4E4BE3331 /* Pods-RunnerTests.release.xcconfig */, + 83A502A4616091D33488BCEE /* Pods-RunnerTests.profile.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 4380113A2BE014F40006987E /* libveilid_flutter.dylib */, + 80431090FA5BD99A5B868F84 /* Pods_Runner.framework */, + 4C8526B53F6575BAE32CE8D8 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + EFC9A90D4C4DA1DCAD0E0DE7 /* [CP] Check Pods Manifest.lock */, + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 291F0D415C392B5146AB5BB7 /* [CP] Check Pods Manifest.lock */, + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + 7C1E3A39CE352DBCED3F1270 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* example.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 291F0D415C392B5146AB5BB7 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; + 7C1E3A39CE352DBCED3F1270 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + EFC9A90D4C4DA1DCAD0E0DE7 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 836CFD5B5891A750B1490B1C /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A24D16EEC9CB70E4E4BE3331 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 83A502A4616091D33488BCEE /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14.6; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = XP5LBLT7M7; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "\"${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}\"", + /usr/lib/swift, + "../../../../../veilid/target/lipo-darwin", + ); + PRODUCT_BUNDLE_IDENTIFIER = "org.veilid.packages.veilid-support.tests"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14.6; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14.6; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = XP5LBLT7M7; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "\"${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}\"", + /usr/lib/swift, + "../../../../../veilid/target/lipo-darwin", + ); + PRODUCT_BUNDLE_IDENTIFIER = "org.veilid.packages.veilid-support.tests"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = XP5LBLT7M7; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "\"${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}\"", + /usr/lib/swift, + "../../../../../veilid/target/lipo-darwin", + ); + PRODUCT_BUNDLE_IDENTIFIER = "org.veilid.packages.veilid-support.tests"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/packages/veilid_support/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/veilid_support/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/packages/veilid_support/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/veilid_support/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/veilid_support/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..ac78810 --- /dev/null +++ b/packages/veilid_support/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/veilid_support/example/macos/Runner.xcworkspace/contents.xcworkspacedata b/packages/veilid_support/example/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/packages/veilid_support/example/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/packages/veilid_support/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/veilid_support/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/packages/veilid_support/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/veilid_support/example/macos/Runner/AppDelegate.swift b/packages/veilid_support/example/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/packages/veilid_support/example/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/packages/veilid_support/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/veilid_support/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/packages/veilid_support/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/packages/veilid_support/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/packages/veilid_support/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/packages/veilid_support/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/packages/veilid_support/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/packages/veilid_support/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/packages/veilid_support/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/packages/veilid_support/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/packages/veilid_support/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/packages/veilid_support/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/packages/veilid_support/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/packages/veilid_support/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/packages/veilid_support/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/packages/veilid_support/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/packages/veilid_support/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/packages/veilid_support/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/packages/veilid_support/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/packages/veilid_support/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/packages/veilid_support/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/packages/veilid_support/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/packages/veilid_support/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/packages/veilid_support/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/packages/veilid_support/example/macos/Runner/Base.lproj/MainMenu.xib b/packages/veilid_support/example/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/packages/veilid_support/example/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/veilid_support/example/macos/Runner/Configs/AppInfo.xcconfig b/packages/veilid_support/example/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..92fb3cd --- /dev/null +++ b/packages/veilid_support/example/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = example + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.example + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2024 com.example. All rights reserved. diff --git a/packages/veilid_support/example/macos/Runner/Configs/Debug.xcconfig b/packages/veilid_support/example/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/packages/veilid_support/example/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/packages/veilid_support/example/macos/Runner/Configs/Release.xcconfig b/packages/veilid_support/example/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/packages/veilid_support/example/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/packages/veilid_support/example/macos/Runner/Configs/Warnings.xcconfig b/packages/veilid_support/example/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/packages/veilid_support/example/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/packages/veilid_support/example/macos/Runner/DebugProfile.entitlements b/packages/veilid_support/example/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..1871fce --- /dev/null +++ b/packages/veilid_support/example/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,18 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.client + + com.apple.security.network.server + + keychain-access-groups + + $(AppIdentifierPrefix)org.veilid.packages.veilid-support.tests + + + diff --git a/packages/veilid_support/example/macos/Runner/Info.plist b/packages/veilid_support/example/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/packages/veilid_support/example/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/packages/veilid_support/example/macos/Runner/MainFlutterWindow.swift b/packages/veilid_support/example/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/packages/veilid_support/example/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/packages/veilid_support/example/macos/Runner/Release.entitlements b/packages/veilid_support/example/macos/Runner/Release.entitlements new file mode 100644 index 0000000..bc8f1bd --- /dev/null +++ b/packages/veilid_support/example/macos/Runner/Release.entitlements @@ -0,0 +1,16 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + com.apple.security.network.server + + keychain-access-groups + + $(AppIdentifierPrefix)org.veilid.packages.veilid-support.tests + + + diff --git a/packages/veilid_support/example/macos/RunnerTests/RunnerTests.swift b/packages/veilid_support/example/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..5418c9f --- /dev/null +++ b/packages/veilid_support/example/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import FlutterMacOS +import Cocoa +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/packages/veilid_support/example/pubspec.lock b/packages/veilid_support/example/pubspec.lock new file mode 100644 index 0000000..5c4355b --- /dev/null +++ b/packages/veilid_support/example/pubspec.lock @@ -0,0 +1,766 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: e55636ed79578b9abca5fecf9437947798f5ef7456308b5cb85720b793eac92f + url: "https://pub.dev" + source: hosted + version: "82.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "904ae5bb474d32c38fb9482e2d925d5454cda04ddd0e55d2e6826bc72f6ba8c0" + url: "https://pub.dev" + source: hosted + version: "7.4.5" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + async_tools: + dependency: "direct dev" + description: + name: async_tools + sha256: "9611c1efeae7e6d342721d0c2caf2e4783d91fba6a9637d7badfa2dccf8de2a2" + url: "https://pub.dev" + source: hosted + version: "0.1.10" + bloc: + dependency: transitive + description: + name: bloc + sha256: "52c10575f4445c61dd9e0cafcc6356fdd827c4c64dd7945ef3c4105f6b6ac189" + url: "https://pub.dev" + source: hosted + version: "9.0.0" + bloc_advanced_tools: + dependency: transitive + description: + name: bloc_advanced_tools + sha256: "63e57000df7259e3007dbfbbfd7dae3e0eca60eb2ac93cbe0c5a3de0e77c9972" + url: "https://pub.dev" + source: hosted + version: "0.1.13" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + buffer: + dependency: transitive + description: + name: buffer + sha256: "389da2ec2c16283c8787e0adaede82b1842102f8c8aae2f49003a766c5c6b3d1" + url: "https://pub.dev" + source: hosted + version: "1.2.3" + change_case: + dependency: transitive + description: + name: change_case + sha256: e41ef3df58521194ef8d7649928954805aeb08061917cf658322305e61568003 + url: "https://pub.dev" + source: hosted + version: "2.2.0" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a + url: "https://pub.dev" + source: hosted + version: "1.4.0" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: "direct main" + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "802bd084fb82e55df091ec8ad1553a7331b61c08251eef19a508b6f3f3a9858d" + url: "https://pub.dev" + source: hosted + version: "1.13.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + url: "https://pub.dev" + source: hosted + version: "3.0.6" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + equatable: + dependency: transitive + description: + name: equatable + sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + url: "https://pub.dev" + source: hosted + version: "2.0.7" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + fast_immutable_collections: + dependency: transitive + description: + name: fast_immutable_collections + sha256: d1aa3d7788fab06cce7f303f4969c7a16a10c865e1bd2478291a8ebcbee084e5 + url: "https://pub.dev" + source: hosted + version: "11.0.4" + ffi: + dependency: transitive + description: + name: ffi + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_test: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: c87ff004c8aa6af2d531668b46a4ea379f7191dc6dfa066acd53d506da6e044b + url: "https://pub.dev" + source: hosted + version: "3.0.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + globbing: + dependency: transitive + description: + name: globbing + sha256: "4f89cfaf6fa74c9c1740a96259da06bd45411ede56744e28017cc534a12b6e2d" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + indent: + dependency: transitive + description: + name: indent + sha256: "819319a5c185f7fe412750c798953378b37a0d0d32564ce33e7c5acfd1372d2a" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + url: "https://pub.dev" + source: hosted + version: "10.0.9" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + url: "https://pub.dev" + source: hosted + version: "3.0.9" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + lint_hard: + dependency: "direct dev" + description: + name: lint_hard + sha256: "2073d4e83ac4e3f2b87cc615fff41abb5c2c5618e117edcd3d71f40f2186f4d5" + url: "https://pub.dev" + source: hosted + version: "6.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + loggy: + dependency: transitive + description: + name: loggy + sha256: "981e03162bbd3a5a843026f75f73d26e4a0d8aa035ae060456ca7b30dfd1e339" + url: "https://pub.dev" + source: hosted + version: "2.0.3" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 + url: "https://pub.dev" + source: hosted + version: "2.2.17" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + process: + dependency: transitive + description: + name: process + sha256: "107d8be718f120bbba9dcd1e95e3bd325b1b4a4f07db64154635ba03f2567a0d" + url: "https://pub.dev" + source: hosted + version: "5.0.3" + protobuf: + dependency: transitive + description: + name: protobuf + sha256: "579fe5557eae58e3adca2e999e38f02441d8aa908703854a9e0a0f47fa857731" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + 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: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + system_info2: + dependency: transitive + description: + name: system_info2 + sha256: "65206bbef475217008b5827374767550a5420ce70a04d2d7e94d1d2253f3efc9" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + system_info_plus: + dependency: transitive + description: + name: system_info_plus + sha256: df94187e95527f9cb459e6a9f6e0b1ea20c157d8029bc233de34b3c1e17e1c48 + url: "https://pub.dev" + source: hosted + version: "0.0.6" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: "direct dev" + description: + name: test + sha256: "301b213cd241ca982e9ba50266bd3f5bd1ea33f1455554c5abb85d1be0e2d87e" + url: "https://pub.dev" + source: hosted + version: "1.25.15" + test_api: + dependency: transitive + description: + name: test_api + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + url: "https://pub.dev" + source: hosted + version: "0.7.4" + test_core: + dependency: transitive + description: + name: test_core + sha256: "84d17c3486c8dfdbe5e12a50c8ae176d15e2a771b96909a9442b40173649ccaa" + url: "https://pub.dev" + source: hosted + version: "0.6.8" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + veilid: + dependency: transitive + description: + path: "../../../../veilid/veilid-flutter" + relative: true + source: path + version: "0.4.6" + veilid_support: + dependency: "direct main" + description: + path: ".." + relative: true + source: path + version: "1.0.2+0" + veilid_test: + dependency: "direct dev" + description: + path: "../../../../veilid/veilid-flutter/packages/veilid_test" + relative: true + source: path + version: "0.1.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 + url: "https://pub.dev" + source: hosted + version: "15.0.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + 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: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.7.0 <4.0.0" + flutter: ">=3.27.0" diff --git a/packages/veilid_support/example/pubspec.yaml b/packages/veilid_support/example/pubspec.yaml new file mode 100644 index 0000000..86c8e7e --- /dev/null +++ b/packages/veilid_support/example/pubspec.yaml @@ -0,0 +1,31 @@ +name: example +description: "Veilid Support Example" +publish_to: "none" # Remove this line if you wish to publish to pub.dev +version: 1.0.0+1 + +environment: + sdk: ">=3.3.4 <4.0.0" + +dependencies: + collection: ^1.19.1 + cupertino_icons: ^1.0.8 + flutter: + sdk: flutter + veilid_support: + path: ../ + +dev_dependencies: + async_tools: ^0.1.10 + integration_test: + sdk: flutter + lint_hard: ^6.0.0 + test: ^1.25.15 + veilid_test: + path: ../../../../veilid/veilid-flutter/packages/veilid_test + +# dependency_overrides: +# async_tools: +# path: ../../../../dart_async_tools + +flutter: + uses-material-design: true diff --git a/packages/veilid_support/example/test/widget_test.dart b/packages/veilid_support/example/test/widget_test.dart new file mode 100644 index 0000000..d5cfa06 --- /dev/null +++ b/packages/veilid_support/example/test/widget_test.dart @@ -0,0 +1,29 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:example/main.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Counter increments smoke test', (tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const MyApp()); + + // Verify that our counter starts at 0. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Tap the '+' icon and trigger a frame. + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + // Verify that our counter has incremented. + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +} diff --git a/packages/veilid_support/example/web/favicon.png b/packages/veilid_support/example/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/packages/veilid_support/example/web/favicon.png differ diff --git a/packages/veilid_support/example/web/icons/Icon-192.png b/packages/veilid_support/example/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/packages/veilid_support/example/web/icons/Icon-192.png differ diff --git a/packages/veilid_support/example/web/icons/Icon-512.png b/packages/veilid_support/example/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/packages/veilid_support/example/web/icons/Icon-512.png differ diff --git a/packages/veilid_support/example/web/icons/Icon-maskable-192.png b/packages/veilid_support/example/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/packages/veilid_support/example/web/icons/Icon-maskable-192.png differ diff --git a/packages/veilid_support/example/web/icons/Icon-maskable-512.png b/packages/veilid_support/example/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/packages/veilid_support/example/web/icons/Icon-maskable-512.png differ diff --git a/packages/veilid_support/example/web/index.html b/packages/veilid_support/example/web/index.html new file mode 100644 index 0000000..45cf2ca --- /dev/null +++ b/packages/veilid_support/example/web/index.html @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + example + + + + + + + + + + diff --git a/packages/veilid_support/example/web/manifest.json b/packages/veilid_support/example/web/manifest.json new file mode 100644 index 0000000..096edf8 --- /dev/null +++ b/packages/veilid_support/example/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "example", + "short_name": "example", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/packages/veilid_support/example/windows/.gitignore b/packages/veilid_support/example/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/packages/veilid_support/example/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/packages/veilid_support/example/windows/CMakeLists.txt b/packages/veilid_support/example/windows/CMakeLists.txt new file mode 100644 index 0000000..d960948 --- /dev/null +++ b/packages/veilid_support/example/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(example LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "example") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/packages/veilid_support/example/windows/flutter/CMakeLists.txt b/packages/veilid_support/example/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/packages/veilid_support/example/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/packages/veilid_support/example/windows/flutter/generated_plugin_registrant.cc b/packages/veilid_support/example/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..72dbdef --- /dev/null +++ b/packages/veilid_support/example/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,14 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + VeilidPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("VeilidPlugin")); +} diff --git a/packages/veilid_support/example/windows/flutter/generated_plugin_registrant.h b/packages/veilid_support/example/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/packages/veilid_support/example/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/packages/veilid_support/example/windows/flutter/generated_plugins.cmake b/packages/veilid_support/example/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..658ec85 --- /dev/null +++ b/packages/veilid_support/example/windows/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + veilid +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/packages/veilid_support/example/windows/runner/CMakeLists.txt b/packages/veilid_support/example/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/packages/veilid_support/example/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/packages/veilid_support/example/windows/runner/Runner.rc b/packages/veilid_support/example/windows/runner/Runner.rc new file mode 100644 index 0000000..687e6bd --- /dev/null +++ b/packages/veilid_support/example/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "example" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "example" "\0" + VALUE "LegalCopyright", "Copyright (C) 2024 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "example.exe" "\0" + VALUE "ProductName", "example" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/packages/veilid_support/example/windows/runner/flutter_window.cpp b/packages/veilid_support/example/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/packages/veilid_support/example/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/packages/veilid_support/example/windows/runner/flutter_window.h b/packages/veilid_support/example/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/packages/veilid_support/example/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/packages/veilid_support/example/windows/runner/main.cpp b/packages/veilid_support/example/windows/runner/main.cpp new file mode 100644 index 0000000..a61bf80 --- /dev/null +++ b/packages/veilid_support/example/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"example", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/packages/veilid_support/example/windows/runner/resource.h b/packages/veilid_support/example/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/packages/veilid_support/example/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/packages/veilid_support/example/windows/runner/resources/app_icon.ico b/packages/veilid_support/example/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/packages/veilid_support/example/windows/runner/resources/app_icon.ico differ diff --git a/packages/veilid_support/example/windows/runner/runner.exe.manifest b/packages/veilid_support/example/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..a42ea76 --- /dev/null +++ b/packages/veilid_support/example/windows/runner/runner.exe.manifest @@ -0,0 +1,20 @@ + + + + + PerMonitorV2 + + + + + + + + + + + + + + + diff --git a/packages/veilid_support/example/windows/runner/utils.cpp b/packages/veilid_support/example/windows/runner/utils.cpp new file mode 100644 index 0000000..b2b0873 --- /dev/null +++ b/packages/veilid_support/example/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length <= 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/packages/veilid_support/example/windows/runner/utils.h b/packages/veilid_support/example/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/packages/veilid_support/example/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/packages/veilid_support/example/windows/runner/win32_window.cpp b/packages/veilid_support/example/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/packages/veilid_support/example/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/packages/veilid_support/example/windows/runner/win32_window.h b/packages/veilid_support/example/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/packages/veilid_support/example/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/packages/veilid_support/lib/dht_support/dht_support.dart b/packages/veilid_support/lib/dht_support/dht_support.dart new file mode 100644 index 0000000..cc2a8be --- /dev/null +++ b/packages/veilid_support/lib/dht_support/dht_support.dart @@ -0,0 +1,8 @@ +/// Support functions for Veilid DHT data structures + +library dht_support; + +export 'src/dht_log/barrel.dart'; +export 'src/dht_record/barrel.dart'; +export 'src/dht_short_array/barrel.dart'; +export 'src/interfaces/interfaces.dart'; diff --git a/lib/veilid_support/dht_support/proto/dht.proto b/packages/veilid_support/lib/dht_support/proto/dht.proto similarity index 65% rename from lib/veilid_support/dht_support/proto/dht.proto rename to packages/veilid_support/lib/dht_support/proto/dht.proto index 9ad53b6..da1aa15 100644 --- a/lib/veilid_support/dht_support/proto/dht.proto +++ b/packages/veilid_support/lib/dht_support/proto/dht.proto @@ -23,6 +23,17 @@ message DHTData { uint32 size = 4; } +// DHTLog - represents a ring buffer of many elements with append/truncate semantics +// Header in subkey 0 of first key follows this structure +message DHTLog { + // Position of the start of the log (oldest items) + uint32 head = 1; + // Position of the end of the log (newest items) + uint32 tail = 2; + // Stride of each segment of the dhtlog + uint32 stride = 3; +} + // DHTShortArray - represents a re-orderable collection of up to 256 individual elements // Header in subkey 0 of first key follows this structure // @@ -36,45 +47,20 @@ message DHTShortArray { // Uses the same writer as this DHTList with SMPL schema repeated veilid.TypedKey keys = 1; - // Item position index (uint8[256]) + // Item position index (uint8[256./]) // Actual item location is: // idx = index[n] + 1 (offset for header at idx 0) // key = idx / stride // subkey = idx % stride bytes index = 2; + + // Most recent sequence numbers for elements + repeated uint32 seqs = 3; + // Free items are not represented in the list but can be // calculated through iteration } -// DHTLog - represents an appendable/truncatable log collection of individual elements -// Header in subkey 0 of first key follows this structure -// -// stride = descriptor subkey count on first key - 1 -// Subkeys 1..=stride on the first key are individual elements -// Subkeys 0..stride on the 'keys' keys are also individual elements -// -// Keys must use writable schema in order to make this list mutable -message DHTLog { - // Other keys to concatenate - repeated veilid.TypedKey keys = 1; - // Back link to another DHTLog further back - veilid.TypedKey back = 2; - // Count of subkeys in all keys in this DHTLog - repeated uint32 subkey_counts = 3; - // Total count of subkeys in all keys in this DHTLog including all backlogs - uint32 total_subkeys = 4; -} - -// DataReference -// Pointer to data somewhere in Veilid -// Abstraction over DHTData and BlockStore -message DataReference { - oneof kind { - veilid.TypedKey dht_data = 1; - // TypedKey block = 2; - } -} - // A pointer to an child DHT record message OwnedDHTRecordPointer { // DHT Record key diff --git a/packages/veilid_support/lib/dht_support/proto/proto.dart b/packages/veilid_support/lib/dht_support/proto/proto.dart new file mode 100644 index 0000000..ceac3d5 --- /dev/null +++ b/packages/veilid_support/lib/dht_support/proto/proto.dart @@ -0,0 +1,67 @@ +import '../../proto/dht.pb.dart' as dhtproto; +import '../../proto/proto.dart' as veilidproto; +import '../../src/dynamic_debug.dart'; +import '../dht_support.dart'; + +export '../../proto/dht.pb.dart'; +export '../../proto/dht.pbenum.dart'; +export '../../proto/dht.pbjson.dart'; +export '../../proto/dht.pbserver.dart'; +export '../../proto/proto.dart'; + +/// OwnedDHTRecordPointer protobuf marshaling +/// +extension OwnedDHTRecordPointerProto on OwnedDHTRecordPointer { + dhtproto.OwnedDHTRecordPointer toProto() { + final out = dhtproto.OwnedDHTRecordPointer() + ..recordKey = recordKey.toProto() + ..owner = owner.toProto(); + return out; + } +} + +extension ProtoOwnedDHTRecordPointer on dhtproto.OwnedDHTRecordPointer { + OwnedDHTRecordPointer toVeilid() => OwnedDHTRecordPointer( + recordKey: recordKey.toVeilid(), owner: owner.toVeilid()); +} + +void registerVeilidDHTProtoToDebug() { + dynamic toDebug(dynamic obj) { + if (obj is dhtproto.OwnedDHTRecordPointer) { + return { + r'$runtimeType': obj.runtimeType, + 'recordKey': obj.recordKey, + 'owner': obj.owner, + }; + } + if (obj is dhtproto.DHTData) { + return { + r'$runtimeType': obj.runtimeType, + 'keys': obj.keys, + 'hash': obj.hash, + 'chunk': obj.chunk, + 'size': obj.size + }; + } + if (obj is dhtproto.DHTLog) { + return { + r'$runtimeType': obj.runtimeType, + 'head': obj.head, + 'tail': obj.tail, + 'stride': obj.stride, + }; + } + if (obj is dhtproto.DHTShortArray) { + return { + r'$runtimeType': obj.runtimeType, + 'keys': obj.keys, + 'index': obj.index, + 'seqs': obj.seqs, + }; + } + + return obj; + } + + DynamicDebug.registerToDebug(toDebug); +} diff --git a/packages/veilid_support/lib/dht_support/src/dht_log/barrel.dart b/packages/veilid_support/lib/dht_support/src/dht_log/barrel.dart new file mode 100644 index 0000000..39d1c41 --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/dht_log/barrel.dart @@ -0,0 +1,2 @@ +export 'dht_log.dart'; +export 'dht_log_cubit.dart'; diff --git a/packages/veilid_support/lib/dht_support/src/dht_log/dht_log.dart b/packages/veilid_support/lib/dht_support/src/dht_log/dht_log.dart new file mode 100644 index 0000000..da74df1 --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/dht_log/dht_log.dart @@ -0,0 +1,314 @@ +import 'dart:async'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:async_tools/async_tools.dart'; +import 'package:collection/collection.dart'; +import 'package:equatable/equatable.dart'; +import 'package:meta/meta.dart'; + +import '../../../src/veilid_log.dart'; +import '../../../veilid_support.dart'; +import '../../proto/proto.dart' as proto; + +part 'dht_log_spine.dart'; +part 'dht_log_read.dart'; +part 'dht_log_write.dart'; + +/////////////////////////////////////////////////////////////////////// + +@immutable +class DHTLogUpdate extends Equatable { + const DHTLogUpdate( + {required this.headDelta, required this.tailDelta, required this.length}) + : assert(headDelta >= 0, 'should never have negative head delta'), + assert(tailDelta >= 0, 'should never have negative tail delta'), + assert(length >= 0, 'should never have negative length'); + final int headDelta; + final int tailDelta; + final int length; + + @override + List get props => [headDelta, tailDelta, length]; +} + +/// DHTLog is a ring-buffer queue like data structure with the following +/// operations: +/// * Add elements to the tail +/// * Remove elements from the head +/// The structure has a 'spine' record that acts as an indirection table of +/// DHTShortArray record pointers spread over its subkeys. +/// Subkey 0 of the DHTLog is a head subkey that contains housekeeping data: +/// * The head and tail position of the log +/// - subkeyIdx = pos / recordsPerSubkey +/// - recordIdx = pos % recordsPerSubkey +class DHTLog implements DHTDeleteable { + //////////////////////////////////////////////////////////////// + // Constructors + + DHTLog._({required _DHTLogSpine spine}) + : _spine = spine, + _openCount = 1 { + _spine.onUpdatedSpine = (update) { + _watchController?.sink.add(update); + }; + } + + /// Create a DHTLog + static Future create( + {required String debugName, + int stride = DHTShortArray.maxElements, + VeilidRoutingContext? routingContext, + TypedKey? parent, + VeilidCrypto? crypto, + KeyPair? writer}) async { + assert(stride <= DHTShortArray.maxElements, 'stride too long'); + final pool = DHTRecordPool.instance; + + late final DHTRecord spineRecord; + if (writer != null) { + final schema = DHTSchema.smpl( + oCnt: 0, + members: [DHTSchemaMember(mKey: writer.key, mCnt: spineSubkeys + 1)]); + spineRecord = await pool.createRecord( + debugName: debugName, + parent: parent, + routingContext: routingContext, + schema: schema, + crypto: crypto, + writer: writer); + } else { + const schema = DHTSchema.dflt(oCnt: spineSubkeys + 1); + spineRecord = await pool.createRecord( + debugName: debugName, + parent: parent, + routingContext: routingContext, + schema: schema, + crypto: crypto); + } + + try { + final spine = await _DHTLogSpine.create( + spineRecord: spineRecord, segmentStride: stride); + return DHTLog._(spine: spine); + } on Exception catch (_) { + await spineRecord.close(); + await spineRecord.delete(); + rethrow; + } + } + + static Future openRead(TypedKey logRecordKey, + {required String debugName, + VeilidRoutingContext? routingContext, + TypedKey? parent, + VeilidCrypto? crypto}) async { + final spineRecord = await DHTRecordPool.instance.openRecordRead( + logRecordKey, + debugName: debugName, + parent: parent, + routingContext: routingContext, + crypto: crypto); + try { + final spine = await _DHTLogSpine.load(spineRecord: spineRecord); + final dhtLog = DHTLog._(spine: spine); + return dhtLog; + } on Exception catch (_) { + await spineRecord.close(); + rethrow; + } + } + + static Future openWrite( + TypedKey logRecordKey, + KeyPair writer, { + required String debugName, + VeilidRoutingContext? routingContext, + TypedKey? parent, + VeilidCrypto? crypto, + }) async { + final spineRecord = await DHTRecordPool.instance.openRecordWrite( + logRecordKey, writer, + debugName: debugName, + parent: parent, + routingContext: routingContext, + crypto: crypto); + try { + final spine = await _DHTLogSpine.load(spineRecord: spineRecord); + final dhtLog = DHTLog._(spine: spine); + return dhtLog; + } on Exception catch (_) { + await spineRecord.close(); + rethrow; + } + } + + static Future openOwned( + OwnedDHTRecordPointer ownedLogRecordPointer, { + required String debugName, + required TypedKey parent, + VeilidRoutingContext? routingContext, + VeilidCrypto? crypto, + }) => + openWrite( + ownedLogRecordPointer.recordKey, + ownedLogRecordPointer.owner, + debugName: debugName, + routingContext: routingContext, + parent: parent, + crypto: crypto, + ); + + //////////////////////////////////////////////////////////////////////////// + // DHTCloseable + + /// Check if the DHTLog is open + @override + bool get isOpen => _openCount > 0; + + /// The type of the openable scope + @override + FutureOr scoped() => this; + + /// Add a reference to this log + @override + void ref() { + _openCount++; + } + + /// Free all resources for the DHTLog + @override + Future close() async { + if (_openCount == 0) { + throw StateError('already closed'); + } + _openCount--; + if (_openCount != 0) { + return false; + } + // + await _watchController?.close(); + _watchController = null; + await _spine.close(); + return true; + } + + /// Free all resources for the DHTLog and delete it from the DHT + /// Will wait until the short array is closed to delete it + @override + Future delete() => _spine.delete(); + + //////////////////////////////////////////////////////////////////////////// + // Public API + + /// Get the record key for this log + TypedKey get recordKey => _spine.recordKey; + + /// Get the writer for the log + KeyPair? get writer => _spine._spineRecord.writer; + + /// Get the record pointer foir this log + OwnedDHTRecordPointer get recordPointer => _spine.recordPointer; + + /// Runs a closure allowing read-only access to the log + Future operate(Future Function(DHTLogReadOperations) closure) { + if (!isOpen) { + throw StateError('log is not open'); + } + + return _spine.operate((spine) { + final reader = _DHTLogRead._(spine); + return closure(reader); + }); + } + + /// Runs a closure allowing append/truncate access to the log + /// Makes only one attempt to consistently write the changes to the DHT + /// Returns result of the closure if the write could be performed + /// Throws DHTOperateException if the write could not be performed + /// at this time + Future operateAppend( + Future Function(DHTLogWriteOperations) closure) { + if (!isOpen) { + throw StateError('log is not open'); + } + + return _spine.operateAppend((spine) { + final writer = _DHTLogWrite._(spine); + return closure(writer); + }); + } + + /// Runs a closure allowing append/truncate access to the log + /// Will execute the closure multiple times if a consistent write to the DHT + /// is not achieved. Timeout if specified will be thrown as a + /// TimeoutException. The closure should return a value if its changes also + /// succeeded, and throw DHTExceptionOutdated to trigger another + /// eventual consistency pass. + Future operateAppendEventual( + Future Function(DHTLogWriteOperations) closure, + {Duration? timeout}) { + if (!isOpen) { + throw StateError('log is not open'); + } + + return _spine.operateAppendEventual((spine) { + final writer = _DHTLogWrite._(spine); + return closure(writer); + }, timeout: timeout); + } + + /// Listen to and any all changes to the structure of this log + /// regardless of where the changes are coming from + Future> listen( + void Function(DHTLogUpdate) onChanged, + ) { + if (!isOpen) { + throw StateError('log is not open'); + } + + return _listenMutex.protect(() async { + // If don't have a controller yet, set it up + if (_watchController == null) { + // Set up watch requirements + _watchController = + StreamController.broadcast(onCancel: () { + // If there are no more listeners then we can get + // rid of the controller and drop our subscriptions + unawaited(_listenMutex.protect(() async { + // Cancel watches of head record + await _spine.cancelWatch(); + _watchController = null; + })); + }); + + // Start watching head subkey of the spine + await _spine.watch(); + } + // Return subscription + return _watchController!.stream.listen((upd) => onChanged(upd)); + }); + } + + //////////////////////////////////////////////////////////////// + // Fields + + // 55 subkeys * 512 segments * 36 bytes per typedkey = + // 1013760 bytes per record + // Leaves 34816 bytes for 0th subkey as head, 56 subkeys total + // 512*36 = 18432 bytes per subkey + // 28160 shortarrays * 256 elements = 7208960 elements + static const spineSubkeys = 55; + static const segmentsPerSubkey = 512; + + // Internal representation refreshed from spine record + final _DHTLogSpine _spine; + + // Openable + int _openCount; + + // Watch mutex to ensure we keep the representation valid + final _listenMutex = Mutex(debugLockTimeout: kIsDebugMode ? 60 : null); + // Stream of external changes + StreamController? _watchController; +} diff --git a/packages/veilid_support/lib/dht_support/src/dht_log/dht_log_cubit.dart b/packages/veilid_support/lib/dht_support/src/dht_log/dht_log_cubit.dart new file mode 100644 index 0000000..3a618dc --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/dht_log/dht_log_cubit.dart @@ -0,0 +1,251 @@ +import 'dart:async'; + +import 'package:async_tools/async_tools.dart'; +import 'package:bloc/bloc.dart'; +import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; +import 'package:equatable/equatable.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:meta/meta.dart'; + +import '../../../veilid_support.dart'; + +@immutable +class DHTLogStateData extends Equatable { + const DHTLogStateData( + {required this.length, + required this.window, + required this.windowTail, + required this.windowSize, + required this.follow}); + // The total number of elements in the whole log + final int length; + // The view window of the elements in the dhtlog + // Span is from [tail - window.length, tail) + final IList> window; + // The position of the view window, one past the last element + final int windowTail; + // The total number of elements to try to keep in the window + final int windowSize; + // If we have the window following the log + final bool follow; + + @override + List get props => [length, window, windowTail, windowSize, follow]; + + @override + String toString() => 'DHTLogStateData(' + 'length: $length, ' + 'windowTail: $windowTail, ' + 'windowSize: $windowSize, ' + 'follow: $follow, ' + 'window: ${DynamicDebug.toDebug(window)})'; +} + +typedef DHTLogState = AsyncValue>; +typedef DHTLogBusyState = BlocBusyState>; + +class DHTLogCubit extends Cubit> + with BlocBusyWrapper>, RefreshableCubit { + DHTLogCubit({ + required Future Function() open, + required T Function(List data) decodeElement, + }) : _decodeElement = decodeElement, + super(const BlocBusyState(AsyncValue.loading())) { + _initWait.add((cancel) async { + try { + // Do record open/create + while (!cancel.isCompleted) { + try { + // Open DHT record + _log = await open(); + _wantsCloseRecord = true; + break; + } on DHTExceptionNotAvailable { + // Wait for a bit + await asyncSleep(); + } + } + } on Exception catch (e, st) { + addError(e, st); + emit(DHTLogBusyState(AsyncValue.error(e, st))); + return; + } + // Make initial state update + _initialUpdate(); + _subscription = await _log.listen(_update); + }); + } + + // Set the tail position of the log for pagination. + // If tail is 0, the end of the log is used. + // If tail is negative, the position is subtracted from the current log + // length. + // If tail is positive, the position is absolute from the head of the log + // If follow is enabled, the tail offset will update when the log changes + Future setWindow( + {int? windowTail, + int? windowSize, + bool? follow, + bool forceRefresh = false}) async { + await _initWait(); + if (windowTail != null) { + _windowTail = windowTail; + } + if (windowSize != null) { + _windowSize = windowSize; + } + if (follow != null) { + _follow = follow; + } + await _refreshNoWait(forceRefresh: forceRefresh); + } + + @override + Future refresh({bool forceRefresh = false}) async { + await _initWait(); + await _refreshNoWait(forceRefresh: forceRefresh); + } + + Future _refreshNoWait({bool forceRefresh = false}) => + busy((emit) => _refreshInner(emit, forceRefresh: forceRefresh)); + + Future _refreshInner(void Function(AsyncValue>) emit, + {bool forceRefresh = false}) async { + late final int length; + final windowElements = await _log.operate((reader) { + length = reader.length; + return _loadElementsFromReader(reader, _windowTail, _windowSize); + }); + if (windowElements == null) { + setWantsRefresh(); + return; + } + + emit(AsyncValue.data(DHTLogStateData( + length: length, + window: windowElements.$2, + windowTail: windowElements.$1 + windowElements.$2.length, + windowSize: windowElements.$2.length, + follow: _follow))); + setRefreshed(); + } + + // Tail is one past the last element to load + Future<(int, IList>)?> _loadElementsFromReader( + DHTLogReadOperations reader, int tail, int count, + {bool forceRefresh = false}) async { + final length = reader.length; + final end = ((tail - 1) % length) + 1; + final start = (count < end) ? end - count : 0; + if (length == 0) { + return (start, IList>.empty()); + } + + // If this is writeable get the offline positions + Set? offlinePositions; + if (_log.writer != null) { + offlinePositions = await reader.getOfflinePositions(); + } + + // Get the items + final allItems = (await reader.getRange(start, + length: end - start, forceRefresh: forceRefresh)) + ?.indexed + .map((x) => OnlineElementState( + value: _decodeElement(x.$2), + isOffline: offlinePositions?.contains(x.$1) ?? false)) + .toIList(); + if (allItems == null) { + return null; + } + + return (start, allItems); + } + + void _update(DHTLogUpdate upd) { + // Run at most one background update process + // Because this is async, we could get an update while we're + // still processing the last one. Only called after init future has run + // or during it, so we dont have to wait for that here. + + // Accumulate head and tail deltas + _headDelta += upd.headDelta; + _tailDelta += upd.tailDelta; + + _sspUpdate.busyUpdate>(busy, (emit) async { + // apply follow + if (_follow) { + if (_windowTail <= 0) { + // Negative tail is already following tail changes + } else { + // Positive tail is measured from the head, so apply deltas + _windowTail = (_windowTail + _tailDelta - _headDelta) % upd.length; + } + } else { + if (_windowTail <= 0) { + // Negative tail is following tail changes so apply deltas + var posTail = _windowTail + upd.length; + posTail = (posTail + _tailDelta - _headDelta) % upd.length; + _windowTail = posTail - upd.length; + } else { + // Positive tail is measured from head so not following tail + } + } + _headDelta = 0; + _tailDelta = 0; + + await _refreshInner(emit); + }); + } + + void _initialUpdate() { + _sspUpdate.busyUpdate>(busy, (emit) async { + await _refreshInner(emit); + }); + } + + @override + Future close() async { + await _initWait(cancelValue: true); + await _subscription?.cancel(); + _subscription = null; + if (_wantsCloseRecord) { + await _log.close(); + } + await super.close(); + } + + Future operate(Future Function(DHTLogReadOperations) closure) async { + await _initWait(); + return _log.operate(closure); + } + + Future operateAppend( + Future Function(DHTLogWriteOperations) closure) async { + await _initWait(); + return _log.operateAppend(closure); + } + + Future operateAppendEventual( + Future Function(DHTLogWriteOperations) closure, + {Duration? timeout}) async { + await _initWait(); + return _log.operateAppendEventual(closure, timeout: timeout); + } + + final WaitSet _initWait = WaitSet(); + late final DHTLog _log; + final T Function(List data) _decodeElement; + StreamSubscription? _subscription; + var _wantsCloseRecord = false; + final _sspUpdate = SingleStatelessProcessor(); + + // Accumulated deltas since last update + var _headDelta = 0; + var _tailDelta = 0; + + // Cubit window into the DHTLog + var _windowTail = 0; + var _windowSize = DHTShortArray.maxElements; + var _follow = true; +} diff --git a/packages/veilid_support/lib/dht_support/src/dht_log/dht_log_read.dart b/packages/veilid_support/lib/dht_support/src/dht_log/dht_log_read.dart new file mode 100644 index 0000000..3ebb2b8 --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/dht_log/dht_log_read.dart @@ -0,0 +1,130 @@ +part of 'dht_log.dart'; + +//////////////////////////////////////////////////////////////////////////// +// Reader-only implementation + +abstract class DHTLogReadOperations implements DHTRandomRead {} + +class _DHTLogRead implements DHTLogReadOperations { + _DHTLogRead._(_DHTLogSpine spine) : _spine = spine; + + @override + int get length => _spine.length; + + @override + Future get(int pos, {bool forceRefresh = false}) async { + if (pos < 0 || pos >= length) { + throw IndexError.withLength(pos, length); + } + final lookup = await _spine.lookupPosition(pos); + if (lookup == null) { + return null; + } + + return lookup.scope((sa) => sa.operate((read) async { + if (lookup.pos >= read.length) { + veilidLoggy.error('DHTLog shortarray read @ ${lookup.pos}' + ' >= length ${read.length}'); + return null; + } + return read.get(lookup.pos, forceRefresh: forceRefresh); + })); + } + + (int, int) _clampStartLen(int start, int? len) { + len ??= _spine.length; + if (start < 0) { + throw IndexError.withLength(start, _spine.length); + } + if (start > _spine.length) { + throw IndexError.withLength(start, _spine.length); + } + if ((len + start) > _spine.length) { + len = _spine.length - start; + } + return (start, len); + } + + @override + Future?> getRange(int start, + {int? length, bool forceRefresh = false}) async { + final out = []; + (start, length) = _clampStartLen(start, length); + + final chunks = Iterable.generate(length) + .slices(kMaxDHTConcurrency) + .map((chunk) => chunk.map((pos) async { + try { + return await get(pos + start, forceRefresh: forceRefresh); + // Need some way to debug ParallelWaitError + // ignore: avoid_catches_without_on_clauses + } catch (e, st) { + veilidLoggy.error('$e\n$st\n'); + rethrow; + } + })); + + for (final chunk in chunks) { + var elems = await chunk.wait; + + // Return only the first contiguous range, anything else is garbage + // due to a representational error in the head or shortarray legnth + final nullPos = elems.indexOf(null); + if (nullPos != -1) { + elems = elems.sublist(0, nullPos); + } + + out.addAll(elems.cast()); + + if (nullPos != -1) { + break; + } + } + + return out; + } + + @override + Future> getOfflinePositions() async { + final positionOffline = {}; + + // Iterate positions backward from most recent + for (var pos = _spine.length - 1; pos >= 0; pos--) { + // Get position + final lookup = await _spine.lookupPosition(pos); + // If position doesn't exist then it definitely wasn't written to offline + if (lookup == null) { + continue; + } + + // Check each segment for offline positions + var foundOffline = false; + await lookup.scope((sa) => sa.operate((read) async { + final segmentOffline = await read.getOfflinePositions(); + + // For each shortarray segment go through their segment positions + // in reverse order and see if they are offline + for (var segmentPos = lookup.pos; + segmentPos >= 0 && pos >= 0; + segmentPos--, pos--) { + // If the position in the segment is offline, then + // mark the position in the log as offline + if (segmentOffline.contains(segmentPos)) { + positionOffline.add(pos); + foundOffline = true; + } + } + })); + // If we found nothing offline in this segment then we can stop + if (!foundOffline) { + break; + } + } + + return positionOffline; + } + + //////////////////////////////////////////////////////////////////////////// + // Fields + final _DHTLogSpine _spine; +} diff --git a/packages/veilid_support/lib/dht_support/src/dht_log/dht_log_spine.dart b/packages/veilid_support/lib/dht_support/src/dht_log/dht_log_spine.dart new file mode 100644 index 0000000..ce231e2 --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/dht_log/dht_log_spine.dart @@ -0,0 +1,739 @@ +part of 'dht_log.dart'; + +class _DHTLogPosition extends DHTCloseable { + _DHTLogPosition._({ + required _DHTLogSpine dhtLogSpine, + required this.shortArray, + required this.pos, + required int segmentNumber, + }) : _dhtLogSpine = dhtLogSpine, + _segmentNumber = segmentNumber; + final int pos; + + final _DHTLogSpine _dhtLogSpine; + final DHTShortArray shortArray; + final int _segmentNumber; + + /// Check if the DHTLogPosition is open + @override + bool get isOpen => shortArray.isOpen; + + /// The type of the openable scope + @override + FutureOr scoped() => shortArray; + + /// Add a reference to this log + @override + void ref() => shortArray.ref(); + + /// Free all resources for the DHTLogPosition + @override + Future close() => _dhtLogSpine._segmentClosed(_segmentNumber); +} + +class _DHTLogSegmentLookup extends Equatable { + const _DHTLogSegmentLookup({required this.subkey, required this.segment}); + final int subkey; + final int segment; + + @override + List get props => [subkey, segment]; +} + +class _SubkeyData { + _SubkeyData({required this.subkey, required this.data}); + int subkey; + Uint8List data; + // lint conflict + // ignore: omit_obvious_property_types + bool changed = false; +} + +class _DHTLogSpine { + _DHTLogSpine._( + {required DHTRecord spineRecord, + required int head, + required int tail, + required int stride}) + : _spineRecord = spineRecord, + _head = head, + _tail = tail, + _segmentStride = stride, + _openedSegments = {}, + _openCache = []; + + // Create a new spine record and push it to the network + static Future<_DHTLogSpine> create( + {required DHTRecord spineRecord, required int segmentStride}) async { + // Construct new spinehead + final spine = _DHTLogSpine._( + spineRecord: spineRecord, head: 0, tail: 0, stride: segmentStride); + + // Write new spine head record to the network + await spine.operate((spine) async { + // Write first empty subkey + final subkeyData = _makeEmptySubkey(); + final existingSubkeyData = + await spineRecord.tryWriteBytes(subkeyData, subkey: 1); + assert(existingSubkeyData == null, 'Should never conflict on create'); + + final success = await spine.writeSpineHead(); + assert(success, 'false return should never happen on create'); + }); + + return spine; + } + + // Pull the latest or updated copy of the spine head record from the network + static Future<_DHTLogSpine> load({required DHTRecord spineRecord}) async { + // Get an updated spine head record copy if one exists + final spineHead = await spineRecord.getProtobuf(proto.DHTLog.fromBuffer, + subkey: 0, refreshMode: DHTRecordRefreshMode.network); + if (spineHead == null) { + throw StateError('spine head missing during refresh'); + } + return _DHTLogSpine._( + spineRecord: spineRecord, + head: spineHead.head, + tail: spineHead.tail, + stride: spineHead.stride); + } + + proto.DHTLog _toProto() { + assert(_spineMutex.isLocked, 'should be in mutex here'); + + final logHead = proto.DHTLog() + ..head = _head + ..tail = _tail + ..stride = _segmentStride; + return logHead; + } + + Future close() async { + await _spineMutex.protect(() async { + if (!isOpen) { + return; + } + final futures = >[_spineRecord.close()]; + for (final seg in _openCache.toList()) { + futures.add(_segmentClosed(seg)); + } + await Future.wait(futures); + + assert(_openedSegments.isEmpty, 'should have closed all segments by now'); + }); + } + + // Will deep delete all segment records as they are children + Future delete() => _spineMutex.protect(_spineRecord.delete); + + Future operate(Future Function(_DHTLogSpine) closure) => + _spineMutex.protect(() => closure(this)); + + Future operateAppend(Future Function(_DHTLogSpine) closure) => + _spineMutex.protect(() async { + final oldHead = _head; + final oldTail = _tail; + try { + final out = await closure(this); + // Write head assuming it has been changed + if (!await writeSpineHead(old: (oldHead, oldTail))) { + // Failed to write head means head got overwritten so write should + // be considered failed + throw const DHTExceptionOutdated(); + } + return out; + } on Exception { + // Exception means state needs to be reverted + _head = oldHead; + _tail = oldTail; + rethrow; + } + }); + + Future operateAppendEventual(Future Function(_DHTLogSpine) closure, + {Duration? timeout}) { + final timeoutTs = timeout == null + ? null + : Veilid.instance.now().offset(TimestampDuration.fromDuration(timeout)); + + return _spineMutex.protect(() async { + late int oldHead; + late int oldTail; + late T out; + try { + // Iterate until we have a successful element and head write + do { + // Save off old values each pass of writeSpineHead because the head + // will have changed + oldHead = _head; + oldTail = _tail; + + // Try to do the element write + while (true) { + if (timeoutTs != null) { + final now = Veilid.instance.now(); + if (now >= timeoutTs) { + throw TimeoutException('timeout reached'); + } + } + try { + out = await closure(this); + break; + } on DHTExceptionOutdated { + // Failed to write in closure resets state + _head = oldHead; + _tail = oldTail; + } on Exception { + // Failed to write in closure resets state + _head = oldHead; + _tail = oldTail; + rethrow; + } + } + // Try to do the head write + } while (!await writeSpineHead(old: (oldHead, oldTail))); + } on Exception { + // Exception means state needs to be reverted + _head = oldHead; + _tail = oldTail; + rethrow; + } + + return out; + }); + } + + /// Serialize and write out the current spine head subkey, possibly updating + /// it if a newer copy is available online. Returns true if the write was + /// successful + Future writeSpineHead({(int, int)? old}) async { + assert(_spineMutex.isLocked, 'should be in mutex here'); + + final headBuffer = _toProto().writeToBuffer(); + + final existingData = await _spineRecord.tryWriteBytes(headBuffer); + if (existingData != null) { + // Head write failed, incorporate update + final existingHead = proto.DHTLog.fromBuffer(existingData); + _updateHead(existingHead.head, existingHead.tail, old: old); + if (old != null) { + sendUpdate(old.$1, old.$2); + } + return false; + } + if (old != null) { + sendUpdate(old.$1, old.$2); + } + return true; + } + + /// Send a spine update callback + void sendUpdate(int oldHead, int oldTail) { + final oldLength = _ringDistance(oldTail, oldHead); + if (oldHead != _head || oldTail != _tail || oldLength != length) { + onUpdatedSpine?.call(DHTLogUpdate( + headDelta: _ringDistance(_head, oldHead), + tailDelta: _ringDistance(_tail, oldTail), + length: length)); + } + } + + /// Validate a new spine head subkey that has come in from the network + void _updateHead(int newHead, int newTail, {(int, int)? old}) { + assert(_spineMutex.isLocked, 'should be in mutex here'); + + if (old != null) { + final oldHead = old.$1; + final oldTail = old.$2; + + final headDelta = _ringDistance(newHead, oldHead); + final tailDelta = _ringDistance(newTail, oldTail); + if (headDelta > _positionLimit ~/ 2 || tailDelta > _positionLimit ~/ 2) { + throw DHTExceptionInvalidData( + cause: '_DHTLogSpine::_updateHead ' + '_head=$_head _tail=$_tail ' + 'oldHead=$oldHead oldTail=$oldTail ' + 'newHead=$newHead newTail=$newTail ' + 'headDelta=$headDelta tailDelta=$tailDelta ' + '_positionLimit=$_positionLimit'); + } + } + + _head = newHead; + _tail = newTail; + } + + ///////////////////////////////////////////////////////////////////////////// + // Spine element management + + static final _emptySegmentKey = + Uint8List.fromList(List.filled(TypedKey.decodedLength(), 0)); + static Uint8List _makeEmptySubkey() => Uint8List.fromList(List.filled( + DHTLog.segmentsPerSubkey * TypedKey.decodedLength(), 0)); + + static TypedKey? _getSegmentKey(Uint8List subkeyData, int segment) { + final decodedLength = TypedKey.decodedLength(); + final segmentKeyBytes = subkeyData.sublist( + decodedLength * segment, decodedLength * (segment + 1)); + if (segmentKeyBytes.equals(_emptySegmentKey)) { + return null; + } + return TypedKey.fromBytes(segmentKeyBytes); + } + + static void _setSegmentKey( + Uint8List subkeyData, int segment, TypedKey? segmentKey) { + final decodedLength = TypedKey.decodedLength(); + late final Uint8List segmentKeyBytes; + if (segmentKey == null) { + segmentKeyBytes = _emptySegmentKey; + } else { + segmentKeyBytes = segmentKey.decode(); + } + subkeyData.setRange(decodedLength * segment, decodedLength * (segment + 1), + segmentKeyBytes); + } + + Future _openOrCreateSegment(int segmentNumber) async { + assert(_spineMutex.isLocked, 'should be in mutex here'); + assert(_spineRecord.writer != null, 'should be writable'); + + // Lookup what subkey and segment subrange has this position's segment + // shortarray + final l = _lookupSegment(segmentNumber); + final subkey = l.subkey; + final segment = l.segment; + + try { + var subkeyData = await _spineRecord.get(subkey: subkey); + subkeyData ??= _makeEmptySubkey(); + + while (true) { + final segmentKey = _getSegmentKey(subkeyData!, segment); + if (segmentKey == null) { + // Create a shortarray segment + final segmentRec = await DHTShortArray.create( + debugName: '${_spineRecord.debugName}_spine_${subkey}_$segment', + stride: _segmentStride, + crypto: _spineRecord.crypto, + parent: _spineRecord.key, + routingContext: _spineRecord.routingContext, + writer: _spineRecord.writer, + ); + var success = false; + try { + // Write it back to the spine record + _setSegmentKey(subkeyData, segment, segmentRec.recordKey); + subkeyData = + await _spineRecord.tryWriteBytes(subkeyData, subkey: subkey); + // If the write was successful then we're done + if (subkeyData == null) { + // Return it + success = true; + return segmentRec; + } + } finally { + if (!success) { + await segmentRec.close(); + await segmentRec.delete(); + } + } + } else { + // Open a shortarray segment + final segmentRec = await DHTShortArray.openWrite( + segmentKey, + _spineRecord.writer!, + debugName: '${_spineRecord.debugName}_spine_${subkey}_$segment', + crypto: _spineRecord.crypto, + parent: _spineRecord.key, + routingContext: _spineRecord.routingContext, + ); + return segmentRec; + } + // Loop if we need to try again with the new data from the network + } + } on DHTExceptionNotAvailable { + return null; + } + } + + Future _openSegment(int segmentNumber) async { + assert(_spineMutex.isLocked, 'should be in mutex here'); + + // Lookup what subkey and segment subrange has this position's segment + // shortarray + final l = _lookupSegment(segmentNumber); + final subkey = l.subkey; + final segment = l.segment; + + // See if we have the segment key locally + try { + TypedKey? segmentKey; + var subkeyData = await _spineRecord.get( + subkey: subkey, refreshMode: DHTRecordRefreshMode.local); + if (subkeyData != null) { + segmentKey = _getSegmentKey(subkeyData, segment); + } + if (segmentKey == null) { + // If not, try from the network + subkeyData = await _spineRecord.get( + subkey: subkey, refreshMode: DHTRecordRefreshMode.network); + if (subkeyData == null) { + return null; + } + segmentKey = _getSegmentKey(subkeyData, segment); + if (segmentKey == null) { + return null; + } + } + + // Open a shortarray segment + final segmentRec = await DHTShortArray.openRead( + segmentKey, + debugName: '${_spineRecord.debugName}_spine_${subkey}_$segment', + crypto: _spineRecord.crypto, + parent: _spineRecord.key, + routingContext: _spineRecord.routingContext, + ); + return segmentRec; + } on DHTExceptionNotAvailable { + return null; + } + } + + _DHTLogSegmentLookup _lookupSegment(int segmentNumber) { + assert(_spineMutex.isLocked, 'should be in mutex here'); + + if (segmentNumber < 0) { + throw IndexError.withLength( + segmentNumber, DHTLog.spineSubkeys * DHTLog.segmentsPerSubkey); + } + final subkey = segmentNumber ~/ DHTLog.segmentsPerSubkey; + if (subkey >= DHTLog.spineSubkeys) { + throw IndexError.withLength( + segmentNumber, DHTLog.spineSubkeys * DHTLog.segmentsPerSubkey); + } + final segment = segmentNumber % DHTLog.segmentsPerSubkey; + return _DHTLogSegmentLookup(subkey: subkey + 1, segment: segment); + } + + /////////////////////////////////////////// + // API for public interfaces + + Future<_DHTLogPosition?> lookupPositionBySegmentNumber( + int segmentNumber, int segmentPos, + {bool onlyOpened = false}) => + _spineCacheMutex.protect(() async { + // See if we have this segment opened already + final openedSegment = _openedSegments[segmentNumber]; + late DHTShortArray shortArray; + if (openedSegment != null) { + // If so, return a ref + openedSegment.ref(); + shortArray = openedSegment; + } else { + // Otherwise open a segment + if (onlyOpened) { + return null; + } + + final newShortArray = (_spineRecord.writer == null) + ? await _openSegment(segmentNumber) + : await _openOrCreateSegment(segmentNumber); + if (newShortArray == null) { + return null; + } + // Keep in the opened segments table + _openedSegments[segmentNumber] = newShortArray; + shortArray = newShortArray; + } + + // LRU cache the segment number + if (!_openCache.remove(segmentNumber)) { + // If this is new to the cache ref it when it goes in + shortArray.ref(); + } + _openCache.add(segmentNumber); + if (_openCache.length > _openCacheSize) { + // Trim the LRU cache + final lruseg = _openCache.removeAt(0); + final lrusa = _openedSegments[lruseg]!; + if (await lrusa.close()) { + _openedSegments.remove(lruseg); + } + } + + return _DHTLogPosition._( + dhtLogSpine: this, + shortArray: shortArray, + pos: segmentPos, + segmentNumber: segmentNumber); + }); + + Future<_DHTLogPosition?> lookupPosition(int pos) { + assert(_spineMutex.isLocked, 'should be locked'); + + // Check if our position is in bounds + final endPos = length; + if (pos < 0 || pos >= endPos) { + throw IndexError.withLength(pos, endPos); + } + + // Calculate absolute position, ring-buffer style + final absolutePosition = (_head + pos) % _positionLimit; + + // Determine the segment number and position within the segment + final segmentNumber = absolutePosition ~/ DHTShortArray.maxElements; + final segmentPos = absolutePosition % DHTShortArray.maxElements; + + return lookupPositionBySegmentNumber(segmentNumber, segmentPos); + } + + Future _segmentClosed(int segmentNumber) { + assert(_spineMutex.isLocked, 'should be locked'); + return _spineCacheMutex.protect(() async { + final sa = _openedSegments[segmentNumber]!; + if (await sa.close()) { + _openedSegments.remove(segmentNumber); + return true; + } + return false; + }); + } + + void allocateTail(int count) { + assert(_spineMutex.isLocked, 'should be locked'); + + final currentLength = length; + if (count <= 0) { + throw StateError('count should be > 0'); + } + if (currentLength + count >= _positionLimit) { + throw StateError('ring buffer overflow'); + } + + _tail = (_tail + count) % _positionLimit; + } + + Future releaseHead(int count) async { + assert(_spineMutex.isLocked, 'should be locked'); + + final currentLength = length; + if (count <= 0) { + throw StateError('count should be > 0'); + } + if (count > currentLength) { + throw StateError('ring buffer underflow'); + } + + final oldHead = _head; + _head = (_head + count) % _positionLimit; + final newHead = _head; + await _purgeSegments(oldHead, newHead); + } + + Future _deleteSegmentsContiguous(int start, int end) async { + assert(_spineMutex.isLocked, 'should be in mutex here'); + DHTRecordPool.instance + .log('_deleteSegmentsContiguous: start=$start, end=$end'); + + final startSegmentNumber = start ~/ DHTShortArray.maxElements; + final startSegmentPos = start % DHTShortArray.maxElements; + + final endSegmentNumber = end ~/ DHTShortArray.maxElements; + final endSegmentPos = end % DHTShortArray.maxElements; + + final firstDeleteSegment = + (startSegmentPos == 0) ? startSegmentNumber : startSegmentNumber + 1; + final lastDeleteSegment = + (endSegmentPos == 0) ? endSegmentNumber - 1 : endSegmentNumber - 2; + + _SubkeyData? lastSubkeyData; + for (var segmentNumber = firstDeleteSegment; + segmentNumber <= lastDeleteSegment; + segmentNumber++) { + // Lookup what subkey and segment subrange has this position's segment + // shortarray + final l = _lookupSegment(segmentNumber); + final subkey = l.subkey; + final segment = l.segment; + + if (subkey != lastSubkeyData?.subkey) { + // Flush subkey writes + if (lastSubkeyData != null && lastSubkeyData.changed) { + await _spineRecord.eventualWriteBytes(lastSubkeyData.data, + subkey: lastSubkeyData.subkey); + } + + // Get next subkey if available locally + final data = await _spineRecord.get( + subkey: subkey, refreshMode: DHTRecordRefreshMode.local); + if (data != null) { + lastSubkeyData = _SubkeyData(subkey: subkey, data: data); + } else { + lastSubkeyData = null; + // If the subkey was not available locally we can go to the + // last segment number at the end of this subkey + segmentNumber = ((subkey + 1) * DHTLog.segmentsPerSubkey) - 1; + } + } + if (lastSubkeyData != null) { + final segmentKey = _getSegmentKey(lastSubkeyData.data, segment); + if (segmentKey != null) { + await DHTRecordPool.instance.deleteRecord(segmentKey); + _setSegmentKey(lastSubkeyData.data, segment, null); + lastSubkeyData.changed = true; + } + } + } + // Flush subkey writes + if (lastSubkeyData != null) { + await _spineRecord.eventualWriteBytes(lastSubkeyData.data, + subkey: lastSubkeyData.subkey); + } + } + + Future _purgeSegments(int from, int to) async { + assert(_spineMutex.isLocked, 'should be in mutex here'); + if (from < to) { + await _deleteSegmentsContiguous(from, to); + } else if (from > to) { + await _deleteSegmentsContiguous(from, _positionLimit); + await _deleteSegmentsContiguous(0, to); + } + } + + ///////////////////////////////////////////////////////////////////////////// + // Watch For Updates + + // Watch head for changes + Future watch() async { + // This will update any existing watches if necessary + try { + // Update changes to the head record + // xxx: check if this localChanges can be false... + // xxx: Don't watch for local changes because this class already handles + // xxx: notifying listeners and knows when it makes local changes + _subscription ??= + await _spineRecord.listen(localChanges: false, _onSpineChanged); + await _spineRecord.watch(subkeys: [ValueSubkeyRange.single(0)]); + } on Exception { + // If anything fails, try to cancel the watches + await cancelWatch(); + rethrow; + } + } + + // Stop watching for changes to head and linked records + Future cancelWatch() async { + await _spineRecord.cancelWatch(); + await _subscription?.cancel(); + _subscription = null; + } + + // Called when the log changes online and we find out from a watch + // but not when we make a change locally + Future _onSpineChanged( + DHTRecord record, Uint8List? data, List subkeys) async { + // If head record subkey zero changes, then the layout + // of the dhtshortarray has changed + if (data == null) { + throw StateError('spine head changed without data'); + } + if (record.key != _spineRecord.key || + subkeys.length != 1 || + subkeys[0] != ValueSubkeyRange.single(0)) { + throw StateError('watch returning wrong subkey range'); + } + + // Decode updated head + final headData = proto.DHTLog.fromBuffer(data); + + // Then update the head record + _spineChangeProcessor.updateState(headData, (headData) async { + await _spineMutex.protect(() async { + final oldHead = _head; + final oldTail = _tail; + + _updateHead(headData.head, headData.tail, old: (oldHead, oldTail)); + + // Lookup tail position segments that have changed + // and force their short arrays to refresh their heads if + // they are opened + final segmentsToRefresh = <_DHTLogPosition>[]; + var curTail = oldTail; + final endSegmentNumber = _tail ~/ DHTShortArray.maxElements; + while (true) { + final segmentNumber = curTail ~/ DHTShortArray.maxElements; + final segmentPos = curTail % DHTShortArray.maxElements; + final dhtLogPosition = await lookupPositionBySegmentNumber( + segmentNumber, segmentPos, + onlyOpened: true); + if (dhtLogPosition != null) { + segmentsToRefresh.add(dhtLogPosition); + } + + if (segmentNumber == endSegmentNumber) { + break; + } + + curTail = (curTail + + (DHTShortArray.maxElements - + (curTail % DHTShortArray.maxElements))) % + _positionLimit; + } + + // Refresh the segments that have probably changed + await segmentsToRefresh.map((p) async { + await p.shortArray.refresh(); + await p.close(); + }).wait; + + sendUpdate(oldHead, oldTail); + }); + }); + } + + //////////////////////////////////////////////////////////////////////////// + + TypedKey get recordKey => _spineRecord.key; + OwnedDHTRecordPointer get recordPointer => _spineRecord.ownedDHTRecordPointer; + int get length => _ringDistance(_tail, _head); + + bool get isOpen => _spineRecord.isOpen; + + // Ring buffer distance from old to new + static int _ringDistance(int n, int o) => + (n < o) ? (_positionLimit - o) + n : n - o; + + static const _positionLimit = DHTLog.segmentsPerSubkey * + DHTLog.spineSubkeys * + DHTShortArray.maxElements; + + // Spine head mutex to ensure we keep the representation valid + final _spineMutex = Mutex(debugLockTimeout: kIsDebugMode ? 60 : null); + // Subscription to head record internal changes + StreamSubscription? _subscription; + // Notify closure for external spine head changes + void Function(DHTLogUpdate)? onUpdatedSpine; + // Single state processor for spine updates + final _spineChangeProcessor = SingleStateProcessor(); + + // Spine DHT record + final DHTRecord _spineRecord; + // Segment stride to use for spine elements + final int _segmentStride; + + // Position of the start of the log (oldest items) + int _head; + // Position of the end of the log (newest items) (exclusive) + int _tail; + + // LRU cache of DHT spine elements accessed recently + // Pair of position and associated shortarray segment + final _spineCacheMutex = Mutex(debugLockTimeout: kIsDebugMode ? 60 : null); + final List _openCache; + final Map _openedSegments; + static const _openCacheSize = 3; +} diff --git a/packages/veilid_support/lib/dht_support/src/dht_log/dht_log_write.dart b/packages/veilid_support/lib/dht_support/src/dht_log/dht_log_write.dart new file mode 100644 index 0000000..590fbb2 --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/dht_log/dht_log_write.dart @@ -0,0 +1,137 @@ +part of 'dht_log.dart'; + +//////////////////////////////////////////////////////////////////////////// +// Writer implementation + +abstract class DHTLogWriteOperations + implements DHTRandomRead, DHTRandomWrite, DHTAdd, DHTTruncate, DHTClear {} + +class _DHTLogWrite extends _DHTLogRead implements DHTLogWriteOperations { + _DHTLogWrite._(super.spine) : super._(); + + @override + Future tryWriteItem(int pos, Uint8List newValue, + {Output? output}) async { + if (pos < 0 || pos >= _spine.length) { + throw IndexError.withLength(pos, _spine.length); + } + final lookup = await _spine.lookupPosition(pos); + if (lookup == null) { + throw DHTExceptionInvalidData( + cause: '_DHTLogRead::tryWriteItem pos=$pos ' + '_spine.length=${_spine.length}'); + } + + // Write item to the segment + try { + await lookup.scope((sa) => sa.operateWrite((write) async { + final success = + await write.tryWriteItem(lookup.pos, newValue, output: output); + if (!success) { + throw const DHTExceptionOutdated(); + } + })); + } on DHTExceptionOutdated { + return false; + } + return true; + } + + @override + Future add(Uint8List value) async { + // Allocate empty index at the end of the list + final insertPos = _spine.length; + _spine.allocateTail(1); + final lookup = await _spine.lookupPosition(insertPos); + if (lookup == null) { + throw StateError("can't write to dht log"); + } + + // Write item to the segment + return lookup.scope((sa) => sa.operateWrite((write) async { + // If this a new segment, then clear it in case we have wrapped around + if (lookup.pos == 0) { + await write.clear(); + } else if (lookup.pos != write.length) { + // We should always be appending at the length + await write.truncate(lookup.pos); + } + return write.add(value); + })); + } + + @override + Future addAll(List values) async { + // Allocate empty index at the end of the list + final insertPos = _spine.length; + _spine.allocateTail(values.length); + + // Look up the first position and shortarray + final dws = DelayedWaitSet(); + + var success = true; + for (var valueIdxIter = 0; valueIdxIter < values.length;) { + final valueIdx = valueIdxIter; + final remaining = values.length - valueIdx; + + final lookup = await _spine.lookupPosition(insertPos + valueIdx); + if (lookup == null) { + throw DHTExceptionInvalidData( + cause: '_DHTLogWrite::addAll ' + '_spine.length=${_spine.length}' + 'insertPos=$insertPos valueIdx=$valueIdx ' + 'values.length=${values.length} '); + } + + final sacount = min(remaining, DHTShortArray.maxElements - lookup.pos); + final sublistValues = values.sublist(valueIdx, valueIdx + sacount); + + dws.add((_) async { + try { + await lookup.scope((sa) => sa.operateWrite((write) async { + // If this a new segment, then clear it in + // case we have wrapped around + if (lookup.pos == 0) { + await write.clear(); + } else if (lookup.pos != write.length) { + // We should always be appending at the length + await write.truncate(lookup.pos); + } + await write.addAll(sublistValues); + success = true; + })); + } on DHTExceptionOutdated { + success = false; + // Need some way to debug ParallelWaitError + // ignore: avoid_catches_without_on_clauses + } catch (e, st) { + veilidLoggy.error('$e\n$st\n'); + } + }); + + valueIdxIter += sacount; + } + + await dws(); + + if (!success) { + throw const DHTExceptionOutdated(); + } + } + + @override + Future truncate(int newLength) async { + if (newLength < 0) { + throw StateError('can not truncate to negative length'); + } + if (newLength >= _spine.length) { + return; + } + await _spine.releaseHead(_spine.length - newLength); + } + + @override + Future clear() async { + await _spine.releaseHead(_spine.length); + } +} diff --git a/packages/veilid_support/lib/dht_support/src/dht_record/barrel.dart b/packages/veilid_support/lib/dht_support/src/dht_record/barrel.dart new file mode 100644 index 0000000..2d7e677 --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/dht_record/barrel.dart @@ -0,0 +1,4 @@ +export 'default_dht_record_cubit.dart'; +export 'dht_record_cubit.dart'; +export 'dht_record_pool.dart'; +export 'stats.dart'; diff --git a/packages/veilid_support/lib/dht_support/src/dht_record/default_dht_record_cubit.dart b/packages/veilid_support/lib/dht_support/src/dht_record/default_dht_record_cubit.dart new file mode 100644 index 0000000..3d396d2 --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/dht_record/default_dht_record_cubit.dart @@ -0,0 +1,62 @@ +import 'dart:typed_data'; + +import '../../../veilid_support.dart'; + +/// Cubit that watches the default subkey value of a dhtrecord +class DefaultDHTRecordCubit extends DHTRecordCubit { + DefaultDHTRecordCubit({ + required super.open, + required T Function(Uint8List data) decodeState, + }) : super( + initialStateFunction: _makeInitialStateFunction(decodeState), + stateFunction: _makeStateFunction(decodeState), + watchFunction: _makeWatchFunction()); + + static InitialStateFunction _makeInitialStateFunction( + T Function(Uint8List data) decodeState) => + (record) async { + final initialData = await record.get(); + if (initialData == null) { + return null; + } + return decodeState(initialData); + }; + + static StateFunction _makeStateFunction( + T Function(Uint8List data) decodeState) => + (record, subkeys, updatedata) async { + final defaultSubkey = record.subkeyOrDefault(-1); + if (subkeys.containsSubkey(defaultSubkey)) { + final Uint8List data; + final firstSubkey = subkeys.firstOrNull!.low; + if (firstSubkey != defaultSubkey || updatedata == null) { + final maybeData = + await record.get(refreshMode: DHTRecordRefreshMode.network); + if (maybeData == null) { + return null; + } + data = maybeData; + } else { + data = updatedata; + } + final newState = decodeState(data); + return newState; + } + return null; + }; + + static WatchFunction _makeWatchFunction() => (record) async { + final defaultSubkey = record.subkeyOrDefault(-1); + await record.watch(subkeys: [ValueSubkeyRange.single(defaultSubkey)]); + }; + + Future refreshDefault() async { + await initWait(); + final rec = record; + if (rec != null) { + final defaultSubkey = rec.subkeyOrDefault(-1); + await refresh( + [ValueSubkeyRange(low: defaultSubkey, high: defaultSubkey)]); + } + } +} diff --git a/packages/veilid_support/lib/dht_support/src/dht_record/dht_record.dart b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record.dart new file mode 100644 index 0000000..6fb9d9e --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record.dart @@ -0,0 +1,586 @@ +part of 'dht_record_pool.dart'; + +@immutable +class DHTRecordWatchChange extends Equatable { + const DHTRecordWatchChange( + {required this.local, required this.data, required this.subkeys}); + + final bool local; + final Uint8List? data; + final List subkeys; + + @override + List get props => [local, data, subkeys]; +} + +/// Refresh mode for DHT record 'get' +enum DHTRecordRefreshMode { + /// Return existing subkey values if they exist locally already + /// If not, check the network for a value + /// This is the default refresh mode + cached, + + /// Return existing subkey values only if they exist locally already + local, + + /// Always check the network for a newer subkey value + network, + + /// Always check the network for a newer subkey value but only + /// return that value if its sequence number is newer than the local value + update; + + bool get _forceRefresh => this == network || this == update; + bool get _inspectLocal => this == local || this == update; +} + +///////////////////////////////////////////////// + +class DHTRecord implements DHTDeleteable { + DHTRecord._( + {required VeilidRoutingContext routingContext, + required _SharedDHTRecordData sharedDHTRecordData, + required int defaultSubkey, + required KeyPair? writer, + required VeilidCrypto crypto, + required this.debugName}) + : _crypto = crypto, + _routingContext = routingContext, + _defaultSubkey = defaultSubkey, + _writer = writer, + _openCount = 1, + _sharedDHTRecordData = sharedDHTRecordData; + + //////////////////////////////////////////////////////////////////////////// + // DHTCloseable + + /// Check if the DHTRecord is open + @override + bool get isOpen => _openCount > 0; + + /// The type of the openable scope + @override + FutureOr scoped() => this; + + /// Add a reference to this DHTRecord + @override + void ref() { + _openCount++; + } + + /// Free all resources for the DHTRecord + @override + Future close() async { + if (_openCount == 0) { + throw StateError('already closed'); + } + _openCount--; + if (_openCount != 0) { + return false; + } + + await _watchController?.close(); + _watchController = null; + await serialFutureClose((this, _sfListen)); + + await DHTRecordPool.instance._recordClosed(this); + + return true; + } + + /// Free all resources for the DHTRecord and delete it from the DHT + /// Returns true if the deletion was processed immediately + /// Returns false if the deletion was marked for later + @override + Future delete() => DHTRecordPool.instance.deleteRecord(key); + + //////////////////////////////////////////////////////////////////////////// + // Public API + + VeilidRoutingContext get routingContext => _routingContext; + TypedKey get key => _sharedDHTRecordData.recordDescriptor.key; + PublicKey get owner => _sharedDHTRecordData.recordDescriptor.owner; + KeyPair? get ownerKeyPair => + _sharedDHTRecordData.recordDescriptor.ownerKeyPair(); + DHTSchema get schema => _sharedDHTRecordData.recordDescriptor.schema; + int get subkeyCount => + _sharedDHTRecordData.recordDescriptor.schema.subkeyCount(); + KeyPair? get writer => _writer; + VeilidCrypto get crypto => _crypto; + OwnedDHTRecordPointer get ownedDHTRecordPointer => + OwnedDHTRecordPointer(recordKey: key, owner: ownerKeyPair!); + int subkeyOrDefault(int subkey) => (subkey == -1) ? _defaultSubkey : subkey; + + /// Get a subkey value from this record. + /// Returns the most recent value data for this subkey or null if this subkey + /// has not yet been written to. + /// * 'refreshMode' determines whether or not to return a locally existing + /// value or always check the network + /// * 'outSeqNum' optionally returns the sequence number of the value being + /// returned if one was returned. + Future get( + {int subkey = -1, + VeilidCrypto? crypto, + DHTRecordRefreshMode refreshMode = DHTRecordRefreshMode.cached, + Output? outSeqNum}) => + _wrapStats('get', () async { + subkey = subkeyOrDefault(subkey); + + // Get the last sequence number if we need it + final lastSeq = + refreshMode._inspectLocal ? await _localSubkeySeq(subkey) : null; + + // See if we only ever want the locally stored value + if (refreshMode == DHTRecordRefreshMode.local && lastSeq == null) { + // If it's not available locally already just return null now + return null; + } + + var retry = kDHTTryAgainTries; + ValueData? valueData; + while (true) { + try { + valueData = await _routingContext.getDHTValue(key, subkey, + forceRefresh: refreshMode._forceRefresh); + break; + } on VeilidAPIExceptionTryAgain { + retry--; + if (retry == 0) { + throw const DHTExceptionNotAvailable(); + } + await asyncSleep(); + } + } + if (valueData == null) { + return null; + } + + // See if this get resulted in a newer sequence number + if (refreshMode == DHTRecordRefreshMode.update && + lastSeq != null && + valueData.seq <= lastSeq) { + // If we're only returning updates then punt now + return null; + } + // If we're returning a value, decrypt it + final out = (crypto ?? _crypto).decrypt(valueData.data); + if (outSeqNum != null) { + outSeqNum.save(valueData.seq); + } + return out; + }); + + /// Get a subkey value from this record. + /// Process the record returned with a JSON unmarshal function 'fromJson'. + /// Returns the most recent value data for this subkey or null if this subkey + /// has not yet been written to. + /// * 'refreshMode' determines whether or not to return a locally existing + /// value or always check the network + /// * 'outSeqNum' optionally returns the sequence number of the value being + /// returned if one was returned. + Future getJson(T Function(dynamic) fromJson, + {int subkey = -1, + VeilidCrypto? crypto, + DHTRecordRefreshMode refreshMode = DHTRecordRefreshMode.cached, + Output? outSeqNum}) async { + final data = await get( + subkey: subkey, + crypto: crypto, + refreshMode: refreshMode, + outSeqNum: outSeqNum); + if (data == null) { + return null; + } + return jsonDecodeBytes(fromJson, data); + } + + /// Get a subkey value from this record. + /// Process the record returned with a protobuf unmarshal + /// function 'fromBuffer'. + /// Returns the most recent value data for this subkey or null if this subkey + /// has not yet been written to. + /// * 'refreshMode' determines whether or not to return a locally existing + /// value or always check the network + /// * 'outSeqNum' optionally returns the sequence number of the value being + /// returned if one was returned. + Future getProtobuf( + T Function(List i) fromBuffer, + {int subkey = -1, + VeilidCrypto? crypto, + DHTRecordRefreshMode refreshMode = DHTRecordRefreshMode.cached, + Output? outSeqNum}) async { + final data = await get( + subkey: subkey, + crypto: crypto, + refreshMode: refreshMode, + outSeqNum: outSeqNum); + if (data == null) { + return null; + } + return fromBuffer(data.toList()); + } + + /// Attempt to write a byte buffer to a DHTRecord subkey + /// If a newer value was found on the network, it is returned + /// If the value was succesfully written, null is returned + Future tryWriteBytes(Uint8List newValue, + {int subkey = -1, + VeilidCrypto? crypto, + SetDHTValueOptions? options, + Output? outSeqNum}) => + _wrapStats('tryWriteBytes', () async { + subkey = subkeyOrDefault(subkey); + final lastSeq = await _localSubkeySeq(subkey); + final encryptedNewValue = await (crypto ?? _crypto).encrypt(newValue); + + // Set the new data if possible + var newValueData = await _routingContext.setDHTValue( + key, subkey, encryptedNewValue, + options: SetDHTValueOptions( + writer: options?.writer ?? _writer, + allowOffline: options?.allowOffline)); + if (newValueData == null) { + // A newer value wasn't found on the set, but we may get a newer value + // when getting the value for the sequence number + newValueData = await _routingContext.getDHTValue(key, subkey); + if (newValueData == null) { + assert(newValueData != null, "can't get value that was just set"); + return null; + } + } + + // Record new sequence number + final isUpdated = newValueData.seq != lastSeq; + if (isUpdated && outSeqNum != null) { + outSeqNum.save(newValueData.seq); + } + + // See if the encrypted data returned is exactly the same + // if so, shortcut and don't bother decrypting it + if (newValueData.data.equals(encryptedNewValue)) { + if (isUpdated) { + DHTRecordPool.instance + ._processLocalValueChange(key, newValue, subkey); + } + return null; + } + + // Decrypt value to return it + final decryptedNewValue = + await (crypto ?? _crypto).decrypt(newValueData.data); + if (isUpdated) { + DHTRecordPool.instance + ._processLocalValueChange(key, decryptedNewValue, subkey); + } + return decryptedNewValue; + }); + + /// Attempt to write a byte buffer to a DHTRecord subkey + /// If a newer value was found on the network, another attempt + /// will be made to write the subkey until this succeeds + Future eventualWriteBytes(Uint8List newValue, + {int subkey = -1, + VeilidCrypto? crypto, + KeyPair? writer, + Output? outSeqNum}) => + _wrapStats('eventualWriteBytes', () async { + subkey = subkeyOrDefault(subkey); + final lastSeq = await _localSubkeySeq(subkey); + final encryptedNewValue = await (crypto ?? _crypto).encrypt(newValue); + + ValueData? newValueData; + do { + do { + // Set the new data + newValueData = await _routingContext.setDHTValue( + key, subkey, encryptedNewValue, + options: SetDHTValueOptions( + writer: writer ?? _writer, allowOffline: false)); + + // Repeat if newer data on the network was found + } while (newValueData != null); + + // Get the data to check its sequence number + newValueData = await _routingContext.getDHTValue(key, subkey); + if (newValueData == null) { + assert(newValueData != null, "can't get value that was just set"); + return; + } + + // Record new sequence number + if (outSeqNum != null) { + outSeqNum.save(newValueData.seq); + } + + // The encrypted data returned should be exactly the same + // as what we are trying to set, + // otherwise we still need to keep trying to set the value + } while (!newValueData.data.equals(encryptedNewValue)); + + final isUpdated = newValueData.seq != lastSeq; + if (isUpdated) { + DHTRecordPool.instance + ._processLocalValueChange(key, newValue, subkey); + } + }); + + /// Attempt to write a byte buffer to a DHTRecord subkey + /// If a newer value was found on the network, another attempt + /// will be made to write the subkey until this succeeds + /// Each attempt to write the value calls an update function with the + /// old value to determine what new value should be attempted for that write. + Future eventualUpdateBytes( + Future Function(Uint8List? oldValue) update, + {int subkey = -1, + VeilidCrypto? crypto, + KeyPair? writer, + Output? outSeqNum}) => + _wrapStats('eventualUpdateBytes', () async { + subkey = subkeyOrDefault(subkey); + + // Get the existing data, do not allow force refresh here + // because if we need a refresh the setDHTValue will fail anyway + var oldValue = + await get(subkey: subkey, crypto: crypto, outSeqNum: outSeqNum); + + do { + // Update the data + final updatedValue = await update(oldValue); + if (updatedValue == null) { + // If null is returned from the update, stop trying to do the update + break; + } + // Try to write it back to the network + oldValue = await tryWriteBytes(updatedValue, + subkey: subkey, + crypto: crypto, + options: SetDHTValueOptions( + writer: writer ?? _writer, allowOffline: false), + outSeqNum: outSeqNum); + + // Repeat update if newer data on the network was found + } while (oldValue != null); + }); + + /// Like 'tryWriteBytes' but with JSON marshal/unmarshal of the value + Future tryWriteJson(T Function(dynamic) fromJson, T newValue, + {int subkey = -1, + VeilidCrypto? crypto, + SetDHTValueOptions? options, + Output? outSeqNum}) => + tryWriteBytes(jsonEncodeBytes(newValue), + subkey: subkey, + crypto: crypto, + options: options, + outSeqNum: outSeqNum) + .then((out) { + if (out == null) { + return null; + } + return jsonDecodeBytes(fromJson, out); + }); + + /// Like 'tryWriteBytes' but with protobuf marshal/unmarshal of the value + Future tryWriteProtobuf( + T Function(List) fromBuffer, T newValue, + {int subkey = -1, + VeilidCrypto? crypto, + SetDHTValueOptions? options, + Output? outSeqNum}) => + tryWriteBytes(newValue.writeToBuffer(), + subkey: subkey, + crypto: crypto, + options: options, + outSeqNum: outSeqNum) + .then((out) { + if (out == null) { + return null; + } + return fromBuffer(out); + }); + + /// Like 'eventualWriteBytes' but with JSON marshal/unmarshal of the value + Future eventualWriteJson(T newValue, + {int subkey = -1, + VeilidCrypto? crypto, + KeyPair? writer, + Output? outSeqNum}) => + eventualWriteBytes(jsonEncodeBytes(newValue), + subkey: subkey, crypto: crypto, writer: writer, outSeqNum: outSeqNum); + + /// Like 'eventualWriteBytes' but with protobuf marshal/unmarshal of the value + Future eventualWriteProtobuf(T newValue, + {int subkey = -1, + VeilidCrypto? crypto, + KeyPair? writer, + Output? outSeqNum}) => + eventualWriteBytes(newValue.writeToBuffer(), + subkey: subkey, crypto: crypto, writer: writer, outSeqNum: outSeqNum); + + /// Like 'eventualUpdateBytes' but with JSON marshal/unmarshal of the value + Future eventualUpdateJson( + T Function(dynamic) fromJson, Future Function(T?) update, + {int subkey = -1, + VeilidCrypto? crypto, + KeyPair? writer, + Output? outSeqNum}) => + eventualUpdateBytes(jsonUpdate(fromJson, update), + subkey: subkey, crypto: crypto, writer: writer, outSeqNum: outSeqNum); + + /// Like 'eventualUpdateBytes' but with protobuf marshal/unmarshal of the value + Future eventualUpdateProtobuf( + T Function(List) fromBuffer, Future Function(T?) update, + {int subkey = -1, + VeilidCrypto? crypto, + KeyPair? writer, + Output? outSeqNum}) => + eventualUpdateBytes(protobufUpdate(fromBuffer, update), + subkey: subkey, crypto: crypto, writer: writer, outSeqNum: outSeqNum); + + /// Watch a subkey range of this DHT record for changes + /// Takes effect on the next DHTRecordPool tick + Future watch( + {List? subkeys, + Timestamp? expiration, + int? count}) async { + // Set up watch requirements which will get picked up by the next tick + final oldWatchState = _watchState; + _watchState = + _WatchState(subkeys: subkeys, expiration: expiration, count: count); + if (oldWatchState != _watchState) { + _sharedDHTRecordData.needsWatchStateUpdate = true; + } + } + + /// Register a callback for changes made on this this DHT record. + /// You must 'watch' the record as well as listen to it in order for this + /// call back to be called. + /// * 'localChanges' also enables calling the callback if changed are made + /// locally, otherwise only changes seen from the network itself are + /// reported + /// + Future> listen( + Future Function( + DHTRecord record, Uint8List? data, List subkeys) + onUpdate, { + bool localChanges = true, + VeilidCrypto? crypto, + }) async { + // Set up watch requirements + _watchController ??= + StreamController.broadcast(onCancel: () { + // If there are no more listeners then we can get rid of the controller + _watchController = null; + }); + + return _watchController!.stream.listen( + (change) { + if (change.local && !localChanges) { + return; + } + + serialFuture((this, _sfListen), () async { + final Uint8List? data; + if (change.local) { + // local changes are not encrypted + data = change.data; + } else { + // incoming/remote changes are encrypted + final changeData = change.data; + data = changeData == null + ? null + : await (crypto ?? _crypto).decrypt(changeData); + } + await onUpdate(this, data, change.subkeys); + }); + }, + cancelOnError: true, + onError: (e) async { + await _watchController!.close(); + _watchController = null; + }); + } + + /// Stop watching this record for changes + /// Takes effect on the next DHTRecordPool tick + Future cancelWatch() async { + // Tear down watch requirements + if (_watchState != null) { + _watchState = null; + _sharedDHTRecordData.needsWatchStateUpdate = true; + } + } + + /// Return the inspection state of a set of subkeys of the DHTRecord + /// See Veilid's 'inspectDHTRecord' call for details on how this works + Future inspect( + {List? subkeys, + DHTReportScope scope = DHTReportScope.local}) => + _routingContext.inspectDHTRecord(key, subkeys: subkeys, scope: scope); + + ////////////////////////////////////////////////////////////////////////// + + Future _localSubkeySeq(int subkey) async { + final rr = await _routingContext.inspectDHTRecord( + key, + subkeys: [ValueSubkeyRange.single(subkey)], + ); + return rr.localSeqs.firstOrNull; + } + + void _addValueChange( + {required bool local, + required Uint8List? data, + required List subkeys}) { + final ws = _watchState; + if (ws != null) { + final watchedSubkeys = ws.subkeys; + if (watchedSubkeys == null) { + // Report all subkeys + _watchController?.add( + DHTRecordWatchChange(local: local, data: data, subkeys: subkeys)); + } else { + // Only some subkeys are being watched, see if the reported update + // overlaps the subkeys being watched + final overlappedSubkeys = watchedSubkeys.intersectSubkeys(subkeys); + // If the reported data isn't within the + // range we care about, don't pass it through + final overlappedFirstSubkey = overlappedSubkeys.firstSubkey; + final updateFirstSubkey = subkeys.firstSubkey; + if (overlappedFirstSubkey != null && updateFirstSubkey != null) { + final updatedData = + overlappedFirstSubkey == updateFirstSubkey ? data : null; + + // Report only watched subkeys + _watchController?.add(DHTRecordWatchChange( + local: local, data: updatedData, subkeys: overlappedSubkeys)); + } + } + } + } + + void _addLocalValueChange(Uint8List data, int subkey) { + _addValueChange( + local: true, data: data, subkeys: [ValueSubkeyRange.single(subkey)]); + } + + void _addRemoteValueChange(VeilidUpdateValueChange update) { + _addValueChange( + local: false, data: update.value?.data, subkeys: update.subkeys); + } + + Future _wrapStats(String func, Future Function() closure) => + DHTRecordPool.instance._stats.measure(key, debugName, func, closure); + + ////////////////////////////////////////////////////////////// + + final _SharedDHTRecordData _sharedDHTRecordData; + final VeilidRoutingContext _routingContext; + final int _defaultSubkey; + final KeyPair? _writer; + final VeilidCrypto _crypto; + final String debugName; + int _openCount; + StreamController? _watchController; + _WatchState? _watchState; +} diff --git a/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_cubit.dart b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_cubit.dart new file mode 100644 index 0000000..eb56f7c --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_cubit.dart @@ -0,0 +1,125 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:async_tools/async_tools.dart'; +import 'package:bloc/bloc.dart'; +import 'package:meta/meta.dart'; + +import '../../../veilid_support.dart'; + +typedef InitialStateFunction = Future Function(DHTRecord); +typedef StateFunction = Future Function( + DHTRecord, List, Uint8List?); +typedef WatchFunction = Future Function(DHTRecord); + +abstract class DHTRecordCubit extends Cubit> { + DHTRecordCubit({ + required Future Function() open, + required InitialStateFunction initialStateFunction, + required StateFunction stateFunction, + required WatchFunction watchFunction, + }) : _wantsCloseRecord = false, + _stateFunction = stateFunction, + super(const AsyncValue.loading()) { + initWait.add((cancel) async { + try { + // Do record open/create + while (!cancel.isCompleted) { + try { + record = await open(); + _wantsCloseRecord = true; + break; + } on DHTExceptionNotAvailable { + // Wait for a bit + await asyncSleep(); + } + } + } on Exception catch (e, st) { + addError(e, st); + emit(AsyncValue.error(e, st)); + return; + } + await _init(initialStateFunction, stateFunction, watchFunction); + }); + } + + Future _init( + InitialStateFunction initialStateFunction, + StateFunction stateFunction, + WatchFunction watchFunction, + ) async { + // Make initial state update + try { + final initialState = await initialStateFunction(record!); + if (initialState != null) { + emit(AsyncValue.data(initialState)); + } + } on Exception catch (e, st) { + addError(e, st); + emit(AsyncValue.error(e, st)); + } + + _subscription = await record!.listen((record, data, subkeys) async { + try { + final newState = await stateFunction(record, subkeys, data); + if (newState != null) { + emit(AsyncValue.data(newState)); + } + } on Exception catch (e, st) { + addError(e, st); + emit(AsyncValue.error(e, st)); + } + }); + + await watchFunction(record!); + } + + @override + Future close() async { + await initWait(cancelValue: true); + await record?.cancelWatch(); + await _subscription?.cancel(); + _subscription = null; + if (_wantsCloseRecord) { + await record?.close(); + _wantsCloseRecord = false; + } + await super.close(); + } + + Future ready() async { + await initWait(); + } + + Future refresh(List subkeys) async { + await initWait(); + + var updateSubkeys = [...subkeys]; + + for (final skr in subkeys) { + for (var sk = skr.low; sk <= skr.high; sk++) { + final data = await record! + .get(subkey: sk, refreshMode: DHTRecordRefreshMode.update); + if (data != null) { + final newState = await _stateFunction(record!, updateSubkeys, data); + if (newState != null) { + // Emit the new state + emit(AsyncValue.data(newState)); + } + return; + } + // remove sk from update list + // if we did not get an update for that subkey + updateSubkeys = updateSubkeys.removeSubkey(sk); + } + } + } + + @protected + final WaitSet initWait = WaitSet(); + + StreamSubscription? _subscription; + DHTRecord? record; + bool _wantsCloseRecord; + final StateFunction _stateFunction; +} diff --git a/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool.dart b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool.dart new file mode 100644 index 0000000..cfa123d --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool.dart @@ -0,0 +1,959 @@ +import 'dart:async'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:async_tools/async_tools.dart'; +import 'package:equatable/equatable.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:protobuf/protobuf.dart'; + +import '../../../../veilid_support.dart'; +import 'extensions.dart'; + +export 'package:fast_immutable_collections/fast_immutable_collections.dart' + show Output; + +part 'dht_record_pool.freezed.dart'; +part 'dht_record_pool.g.dart'; +part 'dht_record.dart'; +part 'dht_record_pool_private.dart'; + +/// Maximum number of concurrent DHT operations to perform on the network +const kMaxDHTConcurrency = 8; + +/// Total number of times to try in a 'VeilidAPIExceptionKeyNotFound' loop +const kDHTKeyNotFoundTries = 3; + +/// Total number of times to try in a 'VeilidAPIExceptionTryAgain' loop +const kDHTTryAgainTries = 3; + +typedef DHTRecordPoolLogger = void Function(String message); + +/// Record pool that managed DHTRecords and allows for tagged deletion +/// String versions of keys due to IMap<> json unsupported in key +@freezed +sealed class DHTRecordPoolAllocations with _$DHTRecordPoolAllocations { + const factory DHTRecordPoolAllocations({ + @Default(IMapConst>({})) + IMap> childrenByParent, + @Default(IMapConst({})) + IMap parentByChild, + @Default(ISetConst({})) ISet rootRecords, + @Default(IMapConst({})) IMap debugNames, + }) = _DHTRecordPoolAllocations; + + factory DHTRecordPoolAllocations.fromJson(dynamic json) => + _$DHTRecordPoolAllocationsFromJson(json as Map); +} + +/// Pointer to an owned record, with key, owner key and owner secret +/// Ensure that these are only serialized encrypted +@freezed +sealed class OwnedDHTRecordPointer with _$OwnedDHTRecordPointer { + const factory OwnedDHTRecordPointer({ + required TypedKey recordKey, + required KeyPair owner, + }) = _OwnedDHTRecordPointer; + + factory OwnedDHTRecordPointer.fromJson(dynamic json) => + _$OwnedDHTRecordPointerFromJson(json as Map); +} + +////////////////////////////////////////////////////////////////////////////// + +/// Allocator and management system for DHTRecord +class DHTRecordPool with TableDBBackedJson { + DHTRecordPool._(Veilid veilid, VeilidRoutingContext routingContext) + : _state = const DHTRecordPoolAllocations(), + _mutex = Mutex(debugLockTimeout: kIsDebugMode ? 60 : null), + _recordTagLock = AsyncTagLock(), + _opened = {}, + _markedForDelete = {}, + _routingContext = routingContext, + _veilid = veilid; + + ////////////////////////////////////////////////////////////// + + static DHTRecordPool get instance => _singleton!; + + static Future init({DHTRecordPoolLogger? logger}) async { + final routingContext = await Veilid.instance.routingContext(); + final globalPool = DHTRecordPool._(Veilid.instance, routingContext); + globalPool + .._logger = logger + .._state = await globalPool.load() ?? const DHTRecordPoolAllocations(); + _singleton = globalPool; + } + + static Future close() async { + if (_singleton != null) { + _singleton!._routingContext.close(); + _singleton = null; + } + } + + //////////////////////////////////////////////////////////////////////////// + // Public Interface + + /// Create a root DHTRecord that has no dependent records + Future createRecord({ + required String debugName, + VeilidRoutingContext? routingContext, + TypedKey? parent, + DHTSchema schema = const DHTSchema.dflt(oCnt: 1), + int defaultSubkey = 0, + VeilidCrypto? crypto, + KeyPair? writer, + }) => + _mutex.protect(() async { + final dhtctx = routingContext ?? _routingContext; + + final openedRecordInfo = await _recordCreateInner( + debugName: debugName, + dhtctx: dhtctx, + schema: schema, + writer: writer, + parent: parent); + + final rec = DHTRecord._( + debugName: debugName, + routingContext: dhtctx, + defaultSubkey: defaultSubkey, + sharedDHTRecordData: openedRecordInfo.shared, + writer: writer ?? + openedRecordInfo.shared.recordDescriptor.ownerKeyPair(), + crypto: crypto ?? + await privateCryptoFromTypedSecret(openedRecordInfo + .shared.recordDescriptor + .ownerTypedSecret()!)); + + openedRecordInfo.records.add(rec); + + return rec; + }); + + /// Open a DHTRecord readonly + Future openRecordRead(TypedKey recordKey, + {required String debugName, + VeilidRoutingContext? routingContext, + TypedKey? parent, + int defaultSubkey = 0, + VeilidCrypto? crypto}) => + _recordTagLock.protect(recordKey, closure: () async { + final dhtctx = routingContext ?? _routingContext; + + final rec = await _recordOpenCommon( + debugName: debugName, + dhtctx: dhtctx, + recordKey: recordKey, + crypto: crypto ?? const VeilidCryptoPublic(), + writer: null, + parent: parent, + defaultSubkey: defaultSubkey); + + return rec; + }); + + /// Open a DHTRecord writable + Future openRecordWrite( + TypedKey recordKey, + KeyPair writer, { + required String debugName, + VeilidRoutingContext? routingContext, + TypedKey? parent, + int defaultSubkey = 0, + VeilidCrypto? crypto, + }) => + _recordTagLock.protect(recordKey, closure: () async { + final dhtctx = routingContext ?? _routingContext; + + final rec = await _recordOpenCommon( + debugName: debugName, + dhtctx: dhtctx, + recordKey: recordKey, + crypto: crypto ?? + await privateCryptoFromTypedSecret( + TypedKey(kind: recordKey.kind, value: writer.secret)), + writer: writer, + parent: parent, + defaultSubkey: defaultSubkey, + ); + + return rec; + }); + + /// Open a DHTRecord owned + /// This is the same as writable but uses an OwnedDHTRecordPointer + /// for convenience and uses symmetric encryption on the key + /// This is primarily used for backing up private content on to the DHT + /// to synchronizing it between devices. Because it is 'owned', the correct + /// parent must be specified. + Future openRecordOwned( + OwnedDHTRecordPointer ownedDHTRecordPointer, { + required String debugName, + required TypedKey parent, + VeilidRoutingContext? routingContext, + int defaultSubkey = 0, + VeilidCrypto? crypto, + }) => + openRecordWrite( + ownedDHTRecordPointer.recordKey, + ownedDHTRecordPointer.owner, + debugName: debugName, + routingContext: routingContext, + parent: parent, + defaultSubkey: defaultSubkey, + crypto: crypto, + ); + + /// Get the parent of a DHTRecord key if it exists + Future getParentRecordKey(TypedKey child) => + _mutex.protect(() async => _getParentRecordKeyInner(child)); + + /// Check if record is allocated + Future isValidRecordKey(TypedKey key) => + _mutex.protect(() async => _isValidRecordKeyInner(key)); + + /// Check if record is marked for deletion or already gone + Future isDeletedRecordKey(TypedKey key) => + _mutex.protect(() async => _isDeletedRecordKeyInner(key)); + + /// Delete a record and its children if they are all closed + /// otherwise mark that record for deletion eventually + /// Returns true if the deletion was processed immediately + /// Returns false if the deletion was marked for later + Future deleteRecord(TypedKey recordKey) => + _mutex.protect(() => _deleteRecordInner(recordKey)); + + // If everything underneath is closed including itself, return the + // list of children (and itself) to finally actually delete + List _readyForDeleteInner(TypedKey recordKey) { + final allDeps = _collectChildrenInner(recordKey); + for (final dep in allDeps) { + if (_opened.containsKey(dep)) { + return []; + } + } + return allDeps; + } + + /// Collect all dependencies (including the record itself) + /// in reverse (bottom-up/delete order) + Future> collectChildren(TypedKey recordKey) => + _mutex.protect(() async => _collectChildrenInner(recordKey)); + + /// Print children + String debugChildren(TypedKey recordKey, {List? allDeps}) { + allDeps ??= _collectChildrenInner(recordKey); + // Debugging + // ignore: avoid_print + var out = + 'Parent: $recordKey (${_state.debugNames[recordKey.toString()]})\n'; + for (final dep in allDeps) { + if (dep != recordKey) { + // Debugging + // ignore: avoid_print + out += ' Child: $dep (${_state.debugNames[dep.toString()]})\n'; + } + } + return out; + } + + /// Handle the DHT record updates coming from Veilid + void processRemoteValueChange(VeilidUpdateValueChange updateValueChange) { + if (updateValueChange.subkeys.isNotEmpty && updateValueChange.count != 0) { + // Change + for (final kv in _opened.entries) { + if (kv.key == updateValueChange.key) { + for (final rec in kv.value.records) { + rec._addRemoteValueChange(updateValueChange); + } + break; + } + } + } + } + + /// Log the current record allocations + void debugPrintAllocations() { + final sortedAllocations = _state.debugNames.entries.asList() + ..sort((a, b) => a.key.compareTo(b.key)); + + log('DHTRecordPool Allocations: (count=${sortedAllocations.length})'); + + for (final entry in sortedAllocations) { + log(' ${entry.key}: ${entry.value}'); + } + } + + /// Log the current opened record details + void debugPrintOpened() { + final sortedOpened = _opened.entries.asList() + ..sort((a, b) => a.key.toString().compareTo(b.key.toString())); + + log('DHTRecordPool Opened Records: (count=${sortedOpened.length})'); + + for (final entry in sortedOpened) { + log(' ${entry.key}: \n' + ' debugNames=${entry.value.debugNames}\n' + ' details=${entry.value.details}\n' + ' sharedDetails=${entry.value.sharedDetails}\n'); + } + } + + /// Log the performance stats + void debugPrintStats() { + log('DHTRecordPool Stats:\n${_stats.debugString()}'); + } + + /// Public interface to DHTRecordPool logger + void log(String message) { + _logger?.call(message); + } + + /// Generate default VeilidCrypto for a writer + static Future privateCryptoFromTypedSecret( + TypedKey typedSecret) => + VeilidCryptoPrivate.fromTypedKey(typedSecret, _cryptoDomainDHT); + + //////////////////////////////////////////////////////////////////////////// + // Private Implementation + + Future<_OpenedRecordInfo> _recordCreateInner( + {required String debugName, + required VeilidRoutingContext dhtctx, + required DHTSchema schema, + KeyPair? writer, + TypedKey? parent}) async { + if (!_mutex.isLocked) { + throw StateError('should be locked here'); + } + // Create the record + final recordDescriptor = await dhtctx.createDHTRecord(schema); + + log('createDHTRecord: debugName=$debugName key=${recordDescriptor.key}'); + + // Reopen if a writer is specified to ensure + // we switch the default writer + if (writer != null) { + await dhtctx.openDHTRecord(recordDescriptor.key, writer: writer); + } + final openedRecordInfo = _OpenedRecordInfo( + recordDescriptor: recordDescriptor, + defaultWriter: writer ?? recordDescriptor.ownerKeyPair(), + defaultRoutingContext: dhtctx); + _opened[recordDescriptor.key] = openedRecordInfo; + + // Register the dependency + await _addDependencyInner( + parent, + recordDescriptor.key, + debugName: debugName, + ); + + return openedRecordInfo; + } + + Future _recordOpenCommon( + {required String debugName, + required VeilidRoutingContext dhtctx, + required TypedKey recordKey, + required VeilidCrypto crypto, + required KeyPair? writer, + required TypedKey? parent, + required int defaultSubkey}) => + _stats.measure(recordKey, debugName, '_recordOpenCommon', () async { + log('openDHTRecord: debugName=$debugName key=$recordKey'); + + // See if this has been opened yet + final openedRecordInfo = await _mutex.protect(() async { + // If we are opening a key that already exists + // make sure we are using the same parent if one was specified + _validateParentInner(parent, recordKey); + + return _opened[recordKey]; + }); + + if (openedRecordInfo == null) { + // Fresh open, just open the record + var retry = kDHTKeyNotFoundTries; + late final DHTRecordDescriptor recordDescriptor; + while (true) { + try { + recordDescriptor = + await dhtctx.openDHTRecord(recordKey, writer: writer); + break; + } on VeilidAPIExceptionTryAgain { + throw const DHTExceptionNotAvailable(); + } on VeilidAPIExceptionKeyNotFound { + await asyncSleep(); + retry--; + if (retry == 0) { + throw const DHTExceptionNotAvailable(); + } + } + } + + final newOpenedRecordInfo = _OpenedRecordInfo( + recordDescriptor: recordDescriptor, + defaultWriter: writer, + defaultRoutingContext: dhtctx); + + final rec = DHTRecord._( + debugName: debugName, + routingContext: dhtctx, + defaultSubkey: defaultSubkey, + sharedDHTRecordData: newOpenedRecordInfo.shared, + writer: writer, + crypto: crypto); + + await _mutex.protect(() async { + // Register the opened record + _opened[recordDescriptor.key] = newOpenedRecordInfo; + + // Register the dependency + await _addDependencyInner( + parent, + recordKey, + debugName: debugName, + ); + + // Register the newly opened record + newOpenedRecordInfo.records.add(rec); + }); + + return rec; + } + + // Already opened + + // See if we need to reopen the record with a default writer and + // possibly a different routing context + if (writer != null && openedRecordInfo.shared.defaultWriter == null) { + await dhtctx.openDHTRecord(recordKey, writer: writer); + // New writer if we didn't specify one before + openedRecordInfo.shared.defaultWriter = writer; + // New default routing context if we opened it again + openedRecordInfo.shared.defaultRoutingContext = dhtctx; + } + + final rec = DHTRecord._( + debugName: debugName, + routingContext: dhtctx, + defaultSubkey: defaultSubkey, + sharedDHTRecordData: openedRecordInfo.shared, + writer: writer, + crypto: crypto); + + await _mutex.protect(() async { + // Register the dependency + await _addDependencyInner( + parent, + recordKey, + debugName: debugName, + ); + + openedRecordInfo.records.add(rec); + }); + + return rec; + }); + + // Called when a DHTRecord is closed + // Cleans up the opened record housekeeping and processes any late deletions + Future _recordClosed(DHTRecord record) async { + final key = record.key; + await _recordTagLock.protect(key, closure: () async { + await _mutex.protect(() async { + log('closeDHTRecord: debugName=${record.debugName} key=$key'); + + final openedRecordInfo = _opened[key]; + if (openedRecordInfo == null || + !openedRecordInfo.records.remove(record)) { + throw StateError('record already closed'); + } + if (openedRecordInfo.records.isNotEmpty) { + return; + } + _opened.remove(key); + await _routingContext.closeDHTRecord(key); + await _checkForLateDeletesInner(key); + }); + + // This happens after the mutex is released + // because the record has already been removed from _opened + // which means that updates to the state processor won't happen + await _watchStateProcessors.remove(key); + }); + } + + // Check to see if this key can finally be deleted + // If any parents are marked for deletion, try them first + Future _checkForLateDeletesInner(TypedKey key) async { + if (!_mutex.isLocked) { + throw StateError('should be locked here'); + } + + // Get parent list in bottom up order including our own key + final parents = []; + TypedKey? nextParent = key; + while (nextParent != null) { + parents.add(nextParent); + nextParent = _getParentRecordKeyInner(nextParent); + } + + // If any parent is ready to delete all its children do it + for (final parent in parents) { + if (_markedForDelete.contains(parent)) { + final deleted = await _deleteRecordInner(parent); + if (!deleted) { + // If we couldn't delete a child then no 'marked for delete' parents + // above us will be ready to delete either + break; + } + } + } + } + + // Collect all dependencies (including the record itself) + // in reverse (bottom-up/delete order) + List _collectChildrenInner(TypedKey recordKey) { + if (!_mutex.isLocked) { + throw StateError('should be locked here'); + } + final allDeps = []; + final currentDeps = [recordKey]; + while (currentDeps.isNotEmpty) { + final nextDep = currentDeps.removeLast(); + + allDeps.add(nextDep); + final childDeps = + _state.childrenByParent[nextDep.toJson()]?.toList() ?? []; + currentDeps.addAll(childDeps); + } + return allDeps.reversedView; + } + + // Actual delete function + Future _finalizeDeleteRecordInner(TypedKey recordKey) async { + if (!_mutex.isLocked) { + throw StateError('should be locked here'); + } + + log('_finalizeDeleteRecordInner: key=$recordKey'); + + // Remove this child from parents + await _removeDependenciesInner([recordKey]); + await _routingContext.deleteDHTRecord(recordKey); + _markedForDelete.remove(recordKey); + } + + // Deep delete mechanism inside mutex + Future _deleteRecordInner(TypedKey recordKey) async { + if (!_mutex.isLocked) { + throw StateError('should be locked here'); + } + + final toDelete = _readyForDeleteInner(recordKey); + if (toDelete.isNotEmpty) { + // delete now + for (final deleteKey in toDelete) { + await _finalizeDeleteRecordInner(deleteKey); + } + return true; + } + // mark for deletion + _markedForDelete.add(recordKey); + return false; + } + + void _validateParentInner(TypedKey? parent, TypedKey child) { + if (!_mutex.isLocked) { + throw StateError('should be locked here'); + } + + final childJson = child.toJson(); + final existingParent = _state.parentByChild[childJson]; + if (parent == null) { + if (existingParent != null) { + throw StateError('Child is already parented: $child'); + } + } else { + if (_state.rootRecords.contains(child)) { + throw StateError('Child already added as root: $child'); + } + if (existingParent != null && existingParent != parent) { + throw StateError('Child has two parents: $child <- $parent'); + } + } + } + + Future _addDependencyInner(TypedKey? parent, TypedKey child, + {required String debugName}) async { + if (!_mutex.isLocked) { + throw StateError('should be locked here'); + } + if (parent == null) { + if (_state.rootRecords.contains(child)) { + // Dependency already added + return; + } + _state = await store(_state.copyWith( + rootRecords: _state.rootRecords.add(child), + debugNames: _state.debugNames.add(child.toJson(), debugName))); + } else { + final childrenOfParent = + _state.childrenByParent[parent.toJson()] ?? ISet(); + if (childrenOfParent.contains(child)) { + // Dependency already added (consecutive opens, etc) + return; + } + _state = await store(_state.copyWith( + childrenByParent: _state.childrenByParent + .add(parent.toJson(), childrenOfParent.add(child)), + parentByChild: _state.parentByChild.add(child.toJson(), parent), + debugNames: _state.debugNames.add(child.toJson(), debugName))); + } + } + + Future _removeDependenciesInner(List childList) async { + if (!_mutex.isLocked) { + throw StateError('should be locked here'); + } + var state = _state; + + for (final child in childList) { + if (_state.rootRecords.contains(child)) { + state = state.copyWith( + rootRecords: state.rootRecords.remove(child), + debugNames: state.debugNames.remove(child.toJson())); + } else { + final parent = state.parentByChild[child.toJson()]; + if (parent == null) { + continue; + } + final children = state.childrenByParent[parent.toJson()]!.remove(child); + if (children.isEmpty) { + state = state.copyWith( + childrenByParent: state.childrenByParent.remove(parent.toJson()), + parentByChild: state.parentByChild.remove(child.toJson()), + debugNames: state.debugNames.remove(child.toJson())); + } else { + state = state.copyWith( + childrenByParent: + state.childrenByParent.add(parent.toJson(), children), + parentByChild: state.parentByChild.remove(child.toJson()), + debugNames: state.debugNames.remove(child.toJson())); + } + } + } + + if (state != _state) { + _state = await store(state); + } + } + + TypedKey? _getParentRecordKeyInner(TypedKey child) { + if (!_mutex.isLocked) { + throw StateError('should be locked here'); + } + + final childJson = child.toJson(); + return _state.parentByChild[childJson]; + } + + bool _isValidRecordKeyInner(TypedKey key) { + if (!_mutex.isLocked) { + throw StateError('should be locked here'); + } + + if (_state.rootRecords.contains(key)) { + return true; + } + if (_state.childrenByParent.containsKey(key.toJson())) { + return true; + } + return false; + } + + bool _isDeletedRecordKeyInner(TypedKey key) { + if (!_mutex.isLocked) { + throw StateError('should be locked here'); + } + + // Is this key gone? + if (!_isValidRecordKeyInner(key)) { + return true; + } + + // Is this key on its way out because it or one of its parents + // is scheduled to delete everything underneath it? + TypedKey? nextParent = key; + while (nextParent != null) { + if (_markedForDelete.contains(nextParent)) { + return true; + } + nextParent = _getParentRecordKeyInner(nextParent); + } + + return false; + } + + /// Handle the DHT record updates coming from internal to this app + void _processLocalValueChange(TypedKey key, Uint8List data, int subkey) { + // Change + for (final kv in _opened.entries) { + if (kv.key == key) { + for (final rec in kv.value.records) { + rec._addLocalValueChange(data, subkey); + } + break; + } + } + } + + static _WatchState? _collectUnionWatchState(Iterable records) { + // Collect union of opened record watch states + int? totalCount; + Timestamp? maxExpiration; + List? allSubkeys; + + var noExpiration = false; + var everySubkey = false; + var cancelWatch = true; + + for (final rec in records) { + final ws = rec._watchState; + if (ws != null) { + cancelWatch = false; + final wsCount = ws.count; + if (wsCount != null) { + totalCount = totalCount ?? 0 + min(wsCount, 0x7FFFFFFF); + totalCount = min(totalCount, 0x7FFFFFFF); + } + final wsExp = ws.expiration; + if (wsExp != null && !noExpiration) { + maxExpiration = maxExpiration == null + ? wsExp + : wsExp.value > maxExpiration.value + ? wsExp + : maxExpiration; + } else { + noExpiration = true; + } + final wsSubkeys = ws.subkeys; + if (wsSubkeys != null && !everySubkey) { + allSubkeys = allSubkeys == null + ? wsSubkeys + : allSubkeys.unionSubkeys(wsSubkeys); + } else { + everySubkey = true; + } + } + } + if (noExpiration) { + maxExpiration = null; + } + if (everySubkey) { + allSubkeys = null; + } + if (cancelWatch) { + return null; + } + + return _WatchState( + subkeys: allSubkeys, + expiration: maxExpiration, + count: totalCount, + ); + } + + Future _watchStateChange( + TypedKey openedRecordKey, _WatchState? unionWatchState) async { + // Get the current state for this watch + final openedRecordInfo = _opened[openedRecordKey]; + if (openedRecordInfo == null) { + // Record is gone, nothing to do + return; + } + final currentWatchState = openedRecordInfo.shared.unionWatchState; + final dhtctx = openedRecordInfo.shared.defaultRoutingContext; + + // If it's the same as our desired state there is nothing to do here + if (currentWatchState == unionWatchState) { + return; + } + + // Apply watch changes for record + if (unionWatchState == null) { + // Record needs watch cancel + // Only try this once, if it doesn't succeed then it can just expire + // on its own. + try { + final stillActive = await dhtctx.cancelDHTWatch(openedRecordKey); + + log('cancelDHTWatch: key=$openedRecordKey, stillActive=$stillActive, ' + 'debugNames=${openedRecordInfo.debugNames}'); + + openedRecordInfo.shared.unionWatchState = null; + openedRecordInfo.shared.needsWatchStateUpdate = false; + } on VeilidAPIExceptionTimeout { + log('Timeout in watch cancel for key=$openedRecordKey'); + } on VeilidAPIException catch (e) { + // Failed to cancel DHT watch, try again next tick + log('VeilidAPIException in watch cancel for key=$openedRecordKey: $e'); + } catch (e) { + log('Unhandled exception in watch cancel for key=$openedRecordKey: $e'); + rethrow; + } + + return; + } + + // Record needs new watch + try { + final subkeys = unionWatchState.subkeys?.toList(); + final count = unionWatchState.count; + final expiration = unionWatchState.expiration; + + final active = await dhtctx.watchDHTValues(openedRecordKey, + subkeys: unionWatchState.subkeys?.toList(), + count: unionWatchState.count, + expiration: unionWatchState.expiration); + + log('watchDHTValues(active=$active): ' + 'key=$openedRecordKey, subkeys=$subkeys, ' + 'count=$count, expiration=$expiration, ' + 'debugNames=${openedRecordInfo.debugNames}'); + + // Update watch states with real expiration + if (active) { + openedRecordInfo.shared.unionWatchState = unionWatchState; + openedRecordInfo.shared.needsWatchStateUpdate = false; + } + } on VeilidAPIExceptionTimeout { + log('Timeout in watch update for key=$openedRecordKey'); + } on VeilidAPIException catch (e) { + // Failed to update DHT watch, try again next tick + log('VeilidAPIException in watch update for key=$openedRecordKey: $e'); + } catch (e) { + log('Unhandled exception in watch update for key=$openedRecordKey: $e'); + rethrow; + } + + // If we still need a state update after this then do a poll instead + if (openedRecordInfo.shared.needsWatchStateUpdate) { + _pollWatch(openedRecordKey, openedRecordInfo, unionWatchState); + } + } + + // In lieu of a completed watch, set off a polling operation + // on the first value of the watched range, which, due to current + // veilid limitations can only be one subkey at a time right now + void _pollWatch(TypedKey openedRecordKey, _OpenedRecordInfo openedRecordInfo, + _WatchState unionWatchState) { + singleFuture((this, _sfPollWatch, openedRecordKey), () async { + await _stats.measure( + openedRecordKey, openedRecordInfo.debugNames, '_pollWatch', () async { + final dhtctx = openedRecordInfo.shared.defaultRoutingContext; + + final currentReport = await dhtctx.inspectDHTRecord(openedRecordKey, + subkeys: unionWatchState.subkeys, scope: DHTReportScope.syncGet); + + final fsc = currentReport.firstSeqChange; + if (fsc == null) { + return null; + } + final newerSubkeys = currentReport.newerOnlineSubkeys; + + final valueData = await dhtctx.getDHTValue(openedRecordKey, fsc.subkey, + forceRefresh: true); + if (valueData == null) { + return; + } + + if (valueData.seq < fsc.newSeq) { + log('inspect returned a newer seq than get: ${valueData.seq} < $fsc'); + } + + if (fsc.oldSeq == null || valueData.seq > fsc.oldSeq!) { + processRemoteValueChange(VeilidUpdateValueChange( + key: openedRecordKey, + subkeys: newerSubkeys, + count: 0xFFFFFFFF, + value: valueData)); + } + }); + }); + } + + /// Ticker to check watch state change requests + Future tick() => _mutex.protect(() async { + // See if any opened records need watch state changes + for (final kv in _opened.entries) { + final openedRecordKey = kv.key; + final openedRecordInfo = kv.value; + + final wantsWatchStateUpdate = + openedRecordInfo.shared.needsWatchStateUpdate; + + if (wantsWatchStateUpdate) { + // Update union watch state + final unionWatchState = + _collectUnionWatchState(openedRecordInfo.records); + + _watchStateProcessors.updateState( + openedRecordKey, + unionWatchState, + (newState) => _stats.measure( + openedRecordKey, + openedRecordInfo.debugNames, + '_watchStateChange', + () => _watchStateChange(openedRecordKey, unionWatchState))); + } + } + }); + + ////////////////////////////////////////////////////////////// + // AsyncTableDBBacked + @override + String tableName() => 'dht_record_pool'; + @override + String tableKeyName() => 'pool_allocations'; + @override + DHTRecordPoolAllocations valueFromJson(Object? obj) => obj != null + ? DHTRecordPoolAllocations.fromJson(obj) + : const DHTRecordPoolAllocations(); + @override + Object? valueToJson(DHTRecordPoolAllocations? val) => val?.toJson(); + + //////////////////////////////////////////////////////////////////////////// + // Fields + + // Logger + DHTRecordPoolLogger? _logger; + + // Persistent DHT record list + DHTRecordPoolAllocations _state; + // Create/open Mutex + final Mutex _mutex; + // Record key tag lock + final AsyncTagLock _recordTagLock; + // Which DHT records are currently open + final Map _opened; + // Which DHT records are marked for deletion + final Set _markedForDelete; + // Default routing context to use for new keys + final VeilidRoutingContext _routingContext; + // Convenience accessor + final Veilid _veilid; + Veilid get veilid => _veilid; + // Watch state processors + final _watchStateProcessors = + SingleStateProcessorMap(); + // Statistics + final _stats = DHTStats(); + + static DHTRecordPool? _singleton; +} diff --git a/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool.freezed.dart b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool.freezed.dart new file mode 100644 index 0000000..48372bb --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool.freezed.dart @@ -0,0 +1,394 @@ +// dart format width=80 +// 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 'dht_record_pool.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$DHTRecordPoolAllocations { + IMap> get childrenByParent; + IMap get parentByChild; + ISet get rootRecords; + IMap get debugNames; + + /// Create a copy of DHTRecordPoolAllocations + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $DHTRecordPoolAllocationsCopyWith get copyWith => + _$DHTRecordPoolAllocationsCopyWithImpl( + this as DHTRecordPoolAllocations, _$identity); + + /// Serializes this DHTRecordPoolAllocations to a JSON map. + Map toJson(); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is DHTRecordPoolAllocations && + (identical(other.childrenByParent, childrenByParent) || + other.childrenByParent == childrenByParent) && + (identical(other.parentByChild, parentByChild) || + other.parentByChild == parentByChild) && + const DeepCollectionEquality() + .equals(other.rootRecords, rootRecords) && + (identical(other.debugNames, debugNames) || + other.debugNames == debugNames)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, childrenByParent, parentByChild, + const DeepCollectionEquality().hash(rootRecords), debugNames); + + @override + String toString() { + return 'DHTRecordPoolAllocations(childrenByParent: $childrenByParent, parentByChild: $parentByChild, rootRecords: $rootRecords, debugNames: $debugNames)'; + } +} + +/// @nodoc +abstract mixin class $DHTRecordPoolAllocationsCopyWith<$Res> { + factory $DHTRecordPoolAllocationsCopyWith(DHTRecordPoolAllocations value, + $Res Function(DHTRecordPoolAllocations) _then) = + _$DHTRecordPoolAllocationsCopyWithImpl; + @useResult + $Res call( + {IMap>> childrenByParent, + IMap> parentByChild, + ISet> rootRecords, + IMap debugNames}); +} + +/// @nodoc +class _$DHTRecordPoolAllocationsCopyWithImpl<$Res> + implements $DHTRecordPoolAllocationsCopyWith<$Res> { + _$DHTRecordPoolAllocationsCopyWithImpl(this._self, this._then); + + final DHTRecordPoolAllocations _self; + final $Res Function(DHTRecordPoolAllocations) _then; + + /// Create a copy of DHTRecordPoolAllocations + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? childrenByParent = null, + Object? parentByChild = null, + Object? rootRecords = null, + Object? debugNames = null, + }) { + return _then(_self.copyWith( + childrenByParent: null == childrenByParent + ? _self.childrenByParent! + : childrenByParent // ignore: cast_nullable_to_non_nullable + as IMap>>, + parentByChild: null == parentByChild + ? _self.parentByChild! + : parentByChild // ignore: cast_nullable_to_non_nullable + as IMap>, + rootRecords: null == rootRecords + ? _self.rootRecords! + : rootRecords // ignore: cast_nullable_to_non_nullable + as ISet>, + debugNames: null == debugNames + ? _self.debugNames + : debugNames // ignore: cast_nullable_to_non_nullable + as IMap, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _DHTRecordPoolAllocations implements DHTRecordPoolAllocations { + const _DHTRecordPoolAllocations( + {this.childrenByParent = const IMapConst>({}), + this.parentByChild = const IMapConst({}), + this.rootRecords = const ISetConst({}), + this.debugNames = const IMapConst({})}); + factory _DHTRecordPoolAllocations.fromJson(Map json) => + _$DHTRecordPoolAllocationsFromJson(json); + + @override + @JsonKey() + final IMap>> childrenByParent; + @override + @JsonKey() + final IMap> parentByChild; + @override + @JsonKey() + final ISet> rootRecords; + @override + @JsonKey() + final IMap debugNames; + + /// Create a copy of DHTRecordPoolAllocations + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$DHTRecordPoolAllocationsCopyWith<_DHTRecordPoolAllocations> get copyWith => + __$DHTRecordPoolAllocationsCopyWithImpl<_DHTRecordPoolAllocations>( + this, _$identity); + + @override + Map toJson() { + return _$DHTRecordPoolAllocationsToJson( + this, + ); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _DHTRecordPoolAllocations && + (identical(other.childrenByParent, childrenByParent) || + other.childrenByParent == childrenByParent) && + (identical(other.parentByChild, parentByChild) || + other.parentByChild == parentByChild) && + const DeepCollectionEquality() + .equals(other.rootRecords, rootRecords) && + (identical(other.debugNames, debugNames) || + other.debugNames == debugNames)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, childrenByParent, parentByChild, + const DeepCollectionEquality().hash(rootRecords), debugNames); + + @override + String toString() { + return 'DHTRecordPoolAllocations(childrenByParent: $childrenByParent, parentByChild: $parentByChild, rootRecords: $rootRecords, debugNames: $debugNames)'; + } +} + +/// @nodoc +abstract mixin class _$DHTRecordPoolAllocationsCopyWith<$Res> + implements $DHTRecordPoolAllocationsCopyWith<$Res> { + factory _$DHTRecordPoolAllocationsCopyWith(_DHTRecordPoolAllocations value, + $Res Function(_DHTRecordPoolAllocations) _then) = + __$DHTRecordPoolAllocationsCopyWithImpl; + @override + @useResult + $Res call( + {IMap>> childrenByParent, + IMap> parentByChild, + ISet> rootRecords, + IMap debugNames}); +} + +/// @nodoc +class __$DHTRecordPoolAllocationsCopyWithImpl<$Res> + implements _$DHTRecordPoolAllocationsCopyWith<$Res> { + __$DHTRecordPoolAllocationsCopyWithImpl(this._self, this._then); + + final _DHTRecordPoolAllocations _self; + final $Res Function(_DHTRecordPoolAllocations) _then; + + /// Create a copy of DHTRecordPoolAllocations + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? childrenByParent = null, + Object? parentByChild = null, + Object? rootRecords = null, + Object? debugNames = null, + }) { + return _then(_DHTRecordPoolAllocations( + childrenByParent: null == childrenByParent + ? _self.childrenByParent + : childrenByParent // ignore: cast_nullable_to_non_nullable + as IMap>>, + parentByChild: null == parentByChild + ? _self.parentByChild + : parentByChild // ignore: cast_nullable_to_non_nullable + as IMap>, + rootRecords: null == rootRecords + ? _self.rootRecords + : rootRecords // ignore: cast_nullable_to_non_nullable + as ISet>, + debugNames: null == debugNames + ? _self.debugNames + : debugNames // ignore: cast_nullable_to_non_nullable + as IMap, + )); + } +} + +/// @nodoc +mixin _$OwnedDHTRecordPointer { + TypedKey get recordKey; + KeyPair get owner; + + /// Create a copy of OwnedDHTRecordPointer + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $OwnedDHTRecordPointerCopyWith get copyWith => + _$OwnedDHTRecordPointerCopyWithImpl( + this as OwnedDHTRecordPointer, _$identity); + + /// Serializes this OwnedDHTRecordPointer to a JSON map. + Map toJson(); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is OwnedDHTRecordPointer && + (identical(other.recordKey, recordKey) || + other.recordKey == recordKey) && + (identical(other.owner, owner) || other.owner == owner)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, recordKey, owner); + + @override + String toString() { + return 'OwnedDHTRecordPointer(recordKey: $recordKey, owner: $owner)'; + } +} + +/// @nodoc +abstract mixin class $OwnedDHTRecordPointerCopyWith<$Res> { + factory $OwnedDHTRecordPointerCopyWith(OwnedDHTRecordPointer value, + $Res Function(OwnedDHTRecordPointer) _then) = + _$OwnedDHTRecordPointerCopyWithImpl; + @useResult + $Res call({Typed recordKey, KeyPair owner}); +} + +/// @nodoc +class _$OwnedDHTRecordPointerCopyWithImpl<$Res> + implements $OwnedDHTRecordPointerCopyWith<$Res> { + _$OwnedDHTRecordPointerCopyWithImpl(this._self, this._then); + + final OwnedDHTRecordPointer _self; + final $Res Function(OwnedDHTRecordPointer) _then; + + /// Create a copy of OwnedDHTRecordPointer + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? recordKey = null, + Object? owner = null, + }) { + return _then(_self.copyWith( + recordKey: null == recordKey + ? _self.recordKey! + : recordKey // ignore: cast_nullable_to_non_nullable + as Typed, + owner: null == owner + ? _self.owner + : owner // ignore: cast_nullable_to_non_nullable + as KeyPair, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _OwnedDHTRecordPointer implements OwnedDHTRecordPointer { + const _OwnedDHTRecordPointer({required this.recordKey, required this.owner}); + factory _OwnedDHTRecordPointer.fromJson(Map json) => + _$OwnedDHTRecordPointerFromJson(json); + + @override + final Typed recordKey; + @override + final KeyPair owner; + + /// Create a copy of OwnedDHTRecordPointer + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$OwnedDHTRecordPointerCopyWith<_OwnedDHTRecordPointer> get copyWith => + __$OwnedDHTRecordPointerCopyWithImpl<_OwnedDHTRecordPointer>( + this, _$identity); + + @override + Map toJson() { + return _$OwnedDHTRecordPointerToJson( + this, + ); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _OwnedDHTRecordPointer && + (identical(other.recordKey, recordKey) || + other.recordKey == recordKey) && + (identical(other.owner, owner) || other.owner == owner)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, recordKey, owner); + + @override + String toString() { + return 'OwnedDHTRecordPointer(recordKey: $recordKey, owner: $owner)'; + } +} + +/// @nodoc +abstract mixin class _$OwnedDHTRecordPointerCopyWith<$Res> + implements $OwnedDHTRecordPointerCopyWith<$Res> { + factory _$OwnedDHTRecordPointerCopyWith(_OwnedDHTRecordPointer value, + $Res Function(_OwnedDHTRecordPointer) _then) = + __$OwnedDHTRecordPointerCopyWithImpl; + @override + @useResult + $Res call({Typed recordKey, KeyPair owner}); +} + +/// @nodoc +class __$OwnedDHTRecordPointerCopyWithImpl<$Res> + implements _$OwnedDHTRecordPointerCopyWith<$Res> { + __$OwnedDHTRecordPointerCopyWithImpl(this._self, this._then); + + final _OwnedDHTRecordPointer _self; + final $Res Function(_OwnedDHTRecordPointer) _then; + + /// Create a copy of OwnedDHTRecordPointer + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? recordKey = null, + Object? owner = null, + }) { + return _then(_OwnedDHTRecordPointer( + recordKey: null == recordKey + ? _self.recordKey + : recordKey // ignore: cast_nullable_to_non_nullable + as Typed, + owner: null == owner + ? _self.owner + : owner // ignore: cast_nullable_to_non_nullable + as KeyPair, + )); + } +} + +// dart format on diff --git a/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool.g.dart b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool.g.dart new file mode 100644 index 0000000..c2c031f --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool.g.dart @@ -0,0 +1,71 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'dht_record_pool.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_DHTRecordPoolAllocations _$DHTRecordPoolAllocationsFromJson( + Map json) => + _DHTRecordPoolAllocations( + childrenByParent: json['children_by_parent'] == null + ? const IMapConst>({}) + : IMap>>.fromJson( + json['children_by_parent'] as Map, + (value) => value as String, + (value) => ISet>.fromJson(value, + (value) => Typed.fromJson(value))), + parentByChild: json['parent_by_child'] == null + ? const IMapConst({}) + : IMap>.fromJson( + json['parent_by_child'] as Map, + (value) => value as String, + (value) => Typed.fromJson(value)), + rootRecords: json['root_records'] == null + ? const ISetConst({}) + : ISet>.fromJson(json['root_records'], + (value) => Typed.fromJson(value)), + debugNames: json['debug_names'] == null + ? const IMapConst({}) + : IMap.fromJson( + json['debug_names'] as Map, + (value) => value as String, + (value) => value as String), + ); + +Map _$DHTRecordPoolAllocationsToJson( + _DHTRecordPoolAllocations instance) => + { + 'children_by_parent': instance.childrenByParent.toJson( + (value) => value, + (value) => value.toJson( + (value) => value.toJson(), + ), + ), + 'parent_by_child': instance.parentByChild.toJson( + (value) => value, + (value) => value.toJson(), + ), + 'root_records': instance.rootRecords.toJson( + (value) => value.toJson(), + ), + 'debug_names': instance.debugNames.toJson( + (value) => value, + (value) => value, + ), + }; + +_OwnedDHTRecordPointer _$OwnedDHTRecordPointerFromJson( + Map json) => + _OwnedDHTRecordPointer( + recordKey: Typed.fromJson(json['record_key']), + owner: KeyPair.fromJson(json['owner']), + ); + +Map _$OwnedDHTRecordPointerToJson( + _OwnedDHTRecordPointer instance) => + { + 'record_key': instance.recordKey.toJson(), + 'owner': instance.owner.toJson(), + }; diff --git a/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool_private.dart b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool_private.dart new file mode 100644 index 0000000..2474c87 --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool_private.dart @@ -0,0 +1,68 @@ +part of 'dht_record_pool.dart'; + +// DHT crypto domain +const _cryptoDomainDHT = 'dht'; + +// Singlefuture keys +const _sfPollWatch = '_pollWatch'; +const _sfListen = 'listen'; + +/// Watch state +@immutable +class _WatchState extends Equatable { + const _WatchState({ + required this.subkeys, + required this.expiration, + required this.count, + }); + final List? subkeys; + final Timestamp? expiration; + final int? count; + + @override + List get props => [subkeys, expiration, count]; +} + +/// Data shared amongst all DHTRecord instances +class _SharedDHTRecordData { + _SharedDHTRecordData( + {required this.recordDescriptor, + required this.defaultWriter, + required this.defaultRoutingContext}); + DHTRecordDescriptor recordDescriptor; + KeyPair? defaultWriter; + VeilidRoutingContext defaultRoutingContext; + // lint conflict + // ignore: omit_obvious_property_types + bool needsWatchStateUpdate = false; + _WatchState? unionWatchState; +} + +// Per opened record data +class _OpenedRecordInfo { + _OpenedRecordInfo( + {required DHTRecordDescriptor recordDescriptor, + required KeyPair? defaultWriter, + required VeilidRoutingContext defaultRoutingContext}) + : shared = _SharedDHTRecordData( + recordDescriptor: recordDescriptor, + defaultWriter: defaultWriter, + defaultRoutingContext: defaultRoutingContext); + _SharedDHTRecordData shared; + Set records = {}; + + String get debugNames { + final r = records.toList() + ..sort((a, b) => a.key.toString().compareTo(b.key.toString())); + return '[${r.map((x) => x.debugName).join(',')}]'; + } + + String get details { + final r = records.toList() + ..sort((a, b) => a.key.toString().compareTo(b.key.toString())); + return '[${r.map((x) => "writer=${x._writer} " + "defaultSubkey=${x._defaultSubkey}").join(',')}]'; + } + + String get sharedDetails => shared.toString(); +} diff --git a/packages/veilid_support/lib/dht_support/src/dht_record/extensions.dart b/packages/veilid_support/lib/dht_support/src/dht_record/extensions.dart new file mode 100644 index 0000000..b0da9e3 --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/dht_record/extensions.dart @@ -0,0 +1,61 @@ +import 'package:veilid/veilid.dart'; + +class DHTSeqChange { + const DHTSeqChange(this.subkey, this.oldSeq, this.newSeq); + final int subkey; + final int? oldSeq; + final int newSeq; +} + +extension DHTReportReportExt on DHTRecordReport { + List get newerOnlineSubkeys { + if (networkSeqs.isEmpty || localSeqs.isEmpty || subkeys.isEmpty) { + return []; + } + + final currentSubkeys = []; + + var i = 0; + for (final skr in subkeys) { + for (var sk = skr.low; sk <= skr.high; sk++) { + final nseq = networkSeqs[i]; + final lseq = localSeqs[i]; + + if (nseq != null && (lseq == null || nseq > lseq)) { + if (currentSubkeys.isNotEmpty && + currentSubkeys.last.high == (sk - 1)) { + currentSubkeys.add(ValueSubkeyRange( + low: currentSubkeys.removeLast().low, high: sk)); + } else { + currentSubkeys.add(ValueSubkeyRange.single(sk)); + } + } + + i++; + } + } + + return currentSubkeys; + } + + DHTSeqChange? get firstSeqChange { + if (networkSeqs.isEmpty || localSeqs.isEmpty || subkeys.isEmpty) { + return null; + } + + var i = 0; + for (final skr in subkeys) { + for (var sk = skr.low; sk <= skr.high; sk++) { + final nseq = networkSeqs[i]; + final lseq = localSeqs[i]; + + if (nseq != null && (lseq == null || nseq > lseq)) { + return DHTSeqChange(sk, lseq, nseq); + } + i++; + } + } + + return null; + } +} diff --git a/packages/veilid_support/lib/dht_support/src/dht_record/stats.dart b/packages/veilid_support/lib/dht_support/src/dht_record/stats.dart new file mode 100644 index 0000000..22b517b --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/dht_record/stats.dart @@ -0,0 +1,180 @@ +import 'package:collection/collection.dart'; +import 'package:indent/indent.dart'; + +import '../../../veilid_support.dart'; + +const maxLatencySamples = 100; +const timeoutDuration = 10; + +extension LatencyStatsExt on LatencyStats { + String debugString() => 'fast($fastest)/avg($average)/slow($slowest)/' + 'tm90($tm90)/tm75($tm75)/p90($p90)/p75($p75)'; +} + +class LatencyStatsAccounting { + LatencyStatsAccounting({required this.maxSamples}); + + LatencyStats record(TimestampDuration dur) { + _samples.add(dur); + if (_samples.length > maxSamples) { + _samples.removeAt(0); + } + + final sortedList = _samples.sorted(); + + final fastest = sortedList.first; + final slowest = sortedList.last; + final average = TimestampDuration( + value: sortedList.fold(BigInt.zero, (acc, x) => acc + x.value) ~/ + BigInt.from(sortedList.length)); + + final tm90len = (sortedList.length * 90 + 99) ~/ 100; + final tm75len = (sortedList.length * 75 + 99) ~/ 100; + final tm90 = TimestampDuration( + value: sortedList + .sublist(0, tm90len) + .fold(BigInt.zero, (acc, x) => acc + x.value) ~/ + BigInt.from(tm90len)); + final tm75 = TimestampDuration( + value: sortedList + .sublist(0, tm75len) + .fold(BigInt.zero, (acc, x) => acc + x.value) ~/ + BigInt.from(tm90len)); + final p90 = sortedList[tm90len - 1]; + final p75 = sortedList[tm75len - 1]; + + final ls = LatencyStats( + fastest: fastest, + slowest: slowest, + average: average, + tm90: tm90, + tm75: tm75, + p90: p90, + p75: p75); + + return ls; + } + + ///////////////////////////// + final int maxSamples; + final _samples = []; +} + +class DHTCallStats { + void record(TimestampDuration dur, Exception? exc) { + final wasTimeout = + exc is VeilidAPIExceptionTimeout || dur.toSecs() >= timeoutDuration; + + calls++; + if (wasTimeout) { + timeouts++; + } else { + successLatency = successLatencyAcct.record(dur); + } + latency = latencyAcct.record(dur); + } + + String debugString() => + ' timeouts/calls: $timeouts/$calls (${(timeouts * 100 / calls).toStringAsFixed(3)}%)\n' + 'success latency: ${successLatency?.debugString()}\n' + ' all latency: ${latency?.debugString()}\n'; + + ///////////////////////////// + + // lint conflict + // ignore: omit_obvious_property_types + int calls = 0; + // lint conflict + // ignore: omit_obvious_property_types + int timeouts = 0; + LatencyStats? latency; + LatencyStats? successLatency; + final latencyAcct = LatencyStatsAccounting(maxSamples: maxLatencySamples); + final successLatencyAcct = + LatencyStatsAccounting(maxSamples: maxLatencySamples); +} + +class DHTPerKeyStats { + DHTPerKeyStats(this.debugName); + + void record(String func, TimestampDuration dur, Exception? exc) { + final keyFuncStats = _perFuncStats.putIfAbsent(func, DHTCallStats.new); + + _stats.record(dur, exc); + keyFuncStats.record(dur, exc); + } + + String debugString() { + // + final out = StringBuffer() + ..write('Name: $debugName\n') + ..write(_stats.debugString().indent(4)) + ..writeln('Per-Function:'); + for (final entry in _perFuncStats.entries) { + final funcName = entry.key; + final funcStats = entry.value.debugString().indent(4); + out.write('$funcName:\n$funcStats'.indent(4)); + } + + return out.toString(); + } + + ////////////////////////////// + + final String debugName; + final _stats = DHTCallStats(); + final _perFuncStats = {}; +} + +class DHTStats { + DHTStats(); + + Future measure(TypedKey key, String debugName, String func, + Future Function() closure) async { + // + final start = Veilid.instance.now(); + final keyStats = + _statsPerKey.putIfAbsent(key, () => DHTPerKeyStats(debugName)); + final funcStats = _statsPerFunc.putIfAbsent(func, DHTCallStats.new); + + VeilidAPIException? exc; + + try { + final res = await closure(); + + return res; + } on VeilidAPIException catch (e) { + exc = e; + rethrow; + } finally { + final end = Veilid.instance.now(); + final dur = end.diff(start); + + keyStats.record(func, dur, exc); + funcStats.record(dur, exc); + } + } + + String debugString() { + // + final out = StringBuffer()..writeln('Per-Function:'); + for (final entry in _statsPerFunc.entries) { + final funcName = entry.key; + final funcStats = entry.value.debugString().indent(4); + out.write('$funcName:\n$funcStats\n'.indent(4)); + } + out.writeln('Per-Key:'); + for (final entry in _statsPerKey.entries) { + final keyName = entry.key; + final keyStats = entry.value.debugString().indent(4); + out.write('$keyName:\n$keyStats\n'.indent(4)); + } + + return out.toString(); + } + + ////////////////////////////// + + final _statsPerKey = {}; + final _statsPerFunc = {}; +} diff --git a/packages/veilid_support/lib/dht_support/src/dht_short_array/barrel.dart b/packages/veilid_support/lib/dht_support/src/dht_short_array/barrel.dart new file mode 100644 index 0000000..6dc1c97 --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/dht_short_array/barrel.dart @@ -0,0 +1,2 @@ +export 'dht_short_array.dart'; +export 'dht_short_array_cubit.dart'; diff --git a/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array.dart b/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array.dart new file mode 100644 index 0000000..4a03cd9 --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array.dart @@ -0,0 +1,297 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:async_tools/async_tools.dart'; +import 'package:collection/collection.dart'; + +import '../../../src/veilid_log.dart'; +import '../../../veilid_support.dart'; +import '../../proto/proto.dart' as proto; + +part 'dht_short_array_head.dart'; +part 'dht_short_array_read.dart'; +part 'dht_short_array_write.dart'; + +/////////////////////////////////////////////////////////////////////// + +class DHTShortArray implements DHTDeleteable { + //////////////////////////////////////////////////////////////// + // Constructors + + DHTShortArray._({required DHTRecord headRecord}) + : _head = _DHTShortArrayHead(headRecord: headRecord), + _openCount = 1 { + _head.onUpdatedHead = () { + _watchController?.sink.add(null); + }; + } + + // Create a DHTShortArray + // if smplWriter is specified, uses a SMPL schema with a single writer + // rather than the key owner + static Future create( + {required String debugName, + int stride = maxElements, + VeilidRoutingContext? routingContext, + TypedKey? parent, + VeilidCrypto? crypto, + KeyPair? writer}) async { + assert(stride <= maxElements, 'stride too long'); + final pool = DHTRecordPool.instance; + + late final DHTRecord dhtRecord; + if (writer != null) { + final schema = DHTSchema.smpl( + oCnt: 0, + members: [DHTSchemaMember(mKey: writer.key, mCnt: stride + 1)]); + dhtRecord = await pool.createRecord( + debugName: debugName, + parent: parent, + routingContext: routingContext, + schema: schema, + crypto: crypto, + writer: writer); + } else { + final schema = DHTSchema.dflt(oCnt: stride + 1); + dhtRecord = await pool.createRecord( + debugName: debugName, + parent: parent, + routingContext: routingContext, + schema: schema, + crypto: crypto); + } + + try { + final dhtShortArray = DHTShortArray._(headRecord: dhtRecord); + await dhtShortArray._head.operate((head) async { + if (!await head._writeHead()) { + throw StateError('Failed to write head at this time'); + } + }); + return dhtShortArray; + } on Exception { + await dhtRecord.close(); + await pool.deleteRecord(dhtRecord.key); + rethrow; + } + } + + static Future openRead(TypedKey headRecordKey, + {required String debugName, + VeilidRoutingContext? routingContext, + TypedKey? parent, + VeilidCrypto? crypto}) async { + final dhtRecord = await DHTRecordPool.instance.openRecordRead(headRecordKey, + debugName: debugName, + parent: parent, + routingContext: routingContext, + crypto: crypto); + try { + final dhtShortArray = DHTShortArray._(headRecord: dhtRecord); + await dhtShortArray._head.operate((head) => head._loadHead()); + return dhtShortArray; + } on Exception { + await dhtRecord.close(); + rethrow; + } + } + + static Future openWrite( + TypedKey headRecordKey, + KeyPair writer, { + required String debugName, + VeilidRoutingContext? routingContext, + TypedKey? parent, + VeilidCrypto? crypto, + }) async { + final dhtRecord = await DHTRecordPool.instance.openRecordWrite( + headRecordKey, writer, + debugName: debugName, + parent: parent, + routingContext: routingContext, + crypto: crypto); + try { + final dhtShortArray = DHTShortArray._(headRecord: dhtRecord); + await dhtShortArray._head.operate((head) => head._loadHead()); + return dhtShortArray; + } on Exception { + await dhtRecord.close(); + rethrow; + } + } + + static Future openOwned( + OwnedDHTRecordPointer ownedShortArrayRecordPointer, { + required String debugName, + required TypedKey parent, + VeilidRoutingContext? routingContext, + VeilidCrypto? crypto, + }) => + openWrite( + ownedShortArrayRecordPointer.recordKey, + ownedShortArrayRecordPointer.owner, + debugName: debugName, + routingContext: routingContext, + parent: parent, + crypto: crypto, + ); + + //////////////////////////////////////////////////////////////////////////// + // DHTCloseable + + /// Check if the shortarray is open + @override + bool get isOpen => _openCount > 0; + + /// The type of the openable scope + @override + FutureOr scoped() => this; + + /// Add a reference to this shortarray + @override + void ref() { + _openCount++; + } + + /// Free all resources for the DHTShortArray + @override + Future close() async { + if (_openCount == 0) { + throw StateError('already closed'); + } + _openCount--; + if (_openCount != 0) { + return false; + } + + await _watchController?.close(); + _watchController = null; + await _head.close(); + return true; + } + + /// Free all resources for the DHTShortArray and delete it from the DHT + /// Returns true if the deletion was processed immediately + /// Returns false if the deletion was marked for later + @override + Future delete() => _head.delete(); + + //////////////////////////////////////////////////////////////////////////// + // Public API + + /// Get the record key for this shortarray + TypedKey get recordKey => _head.recordKey; + + /// Get the writer for the log + KeyPair? get writer => _head._headRecord.writer; + + /// Get the record pointer foir this shortarray + OwnedDHTRecordPointer get recordPointer => _head.recordPointer; + + /// Refresh this DHTShortArray + /// Useful if you aren't 'watching' the array and want to poll for an update + Future refresh() async { + if (!isOpen) { + throw StateError('short array is not open"'); + } + await _head.operate((head) async { + await head._loadHead(); + }); + } + + /// Runs a closure allowing read-only access to the shortarray + Future operate( + Future Function(DHTShortArrayReadOperations) closure) { + if (!isOpen) { + throw StateError('short array is not open"'); + } + + return _head.operate((head) { + final reader = _DHTShortArrayRead._(head); + return closure(reader); + }); + } + + /// Runs a closure allowing read-write access to the shortarray + /// Makes only one attempt to consistently write the changes to the DHT + /// Returns result of the closure if the write could be performed + /// Throws DHTOperateException if the write could not be performed + /// at this time + Future operateWrite( + Future Function(DHTShortArrayWriteOperations) closure) { + if (!isOpen) { + throw StateError('short array is not open"'); + } + + return _head.operateWrite((head) { + final writer = _DHTShortArrayWrite._(head); + return closure(writer); + }); + } + + /// Runs a closure allowing read-write access to the shortarray + /// Will execute the closure multiple times if a consistent write to the DHT + /// is not achieved. Timeout if specified will be thrown as a + /// TimeoutException. The closure should return a value if its changes also + /// succeeded, and throw DHTExceptionTryAgain to trigger another + /// eventual consistency pass. + Future operateWriteEventual( + Future Function(DHTShortArrayWriteOperations) closure, + {Duration? timeout}) { + if (!isOpen) { + throw StateError('short array is not open"'); + } + + return _head.operateWriteEventual((head) { + final writer = _DHTShortArrayWrite._(head); + return closure(writer); + }, timeout: timeout); + } + + /// Listen to and any all changes to the structure of this short array + /// regardless of where the changes are coming from + Future> listen( + void Function() onChanged, + ) { + if (!isOpen) { + throw StateError('short array is not open"'); + } + + return _listenMutex.protect(() async { + // If don't have a controller yet, set it up + if (_watchController == null) { + // Set up watch requirements + _watchController = StreamController.broadcast(onCancel: () { + // If there are no more listeners then we can get + // rid of the controller and drop our subscriptions + unawaited(_listenMutex.protect(() async { + // Cancel watches of head record + await _head.cancelWatch(); + _watchController = null; + })); + }); + + // Start watching head record + await _head.watch(); + } + // Return subscription + return _watchController!.stream.listen((_) => onChanged()); + }); + } + + //////////////////////////////////////////////////////////////// + // Fields + + static const maxElements = 256; + + // Internal representation refreshed from head record + final _DHTShortArrayHead _head; + + // Openable + int _openCount; + + // Watch mutex to ensure we keep the representation valid + final _listenMutex = Mutex(debugLockTimeout: kIsDebugMode ? 60 : null); + // Stream of external changes + StreamController? _watchController; +} diff --git a/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_cubit.dart b/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_cubit.dart new file mode 100644 index 0000000..246a990 --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_cubit.dart @@ -0,0 +1,135 @@ +import 'dart:async'; + +import 'package:async_tools/async_tools.dart'; +import 'package:bloc/bloc.dart'; +import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; + +import '../../../veilid_support.dart'; + +typedef DHTShortArrayState = AsyncValue>>; +typedef DHTShortArrayCubitState = BlocBusyState>; + +class DHTShortArrayCubit extends Cubit> + with BlocBusyWrapper>, RefreshableCubit { + DHTShortArrayCubit({ + required Future Function() open, + required T Function(List data) decodeElement, + }) : _decodeElement = decodeElement, + super(const BlocBusyState(AsyncValue.loading())) { + _initWait.add((cancel) async { + try { + // Do record open/create + while (!cancel.isCompleted) { + try { + // Open DHT record + _shortArray = await open(); + _wantsCloseRecord = true; + break; + } on DHTExceptionNotAvailable { + // Wait for a bit + await asyncSleep(); + } + } + } on Exception catch (e, st) { + addError(e, st); + emit(DHTShortArrayCubitState(AsyncValue.error(e, st))); + return; + } + + // Kick off initial update + _update(); + + // Subscribe to changes + _subscription = await _shortArray.listen(_update); + }); + } + + @override + Future refresh({bool forceRefresh = false}) async { + await _initWait(); + await _refreshNoWait(forceRefresh: forceRefresh); + } + + Future _refreshNoWait({bool forceRefresh = false}) async => + busy((emit) async => _refreshInner(emit, forceRefresh: forceRefresh)); + + Future _refreshInner(void Function(DHTShortArrayState) emit, + {bool forceRefresh = false}) async { + try { + final newState = await _shortArray.operate((reader) async { + // If this is writeable get the offline positions + Set? offlinePositions; + if (_shortArray.writer != null) { + offlinePositions = await reader.getOfflinePositions(); + } + + // Get the items + final allItems = (await reader.getRange(0, forceRefresh: forceRefresh)) + ?.indexed + .map((x) => OnlineElementState( + value: _decodeElement(x.$2), + isOffline: offlinePositions?.contains(x.$1) ?? false)) + .toIList(); + return allItems; + }); + if (newState == null) { + // Mark us as needing refresh + setWantsRefresh(); + return; + } + emit(AsyncValue.data(newState)); + setRefreshed(); + } on Exception catch (e, st) { + addError(e, st); + emit(AsyncValue.error(e, st)); + } + } + + void _update() { + // Run at most one background update process + // Because this is async, we could get an update while we're + // still processing the last one. + // Only called after init future has run, or during it + // so we dont have to wait for that here. + _sspUpdate.busyUpdate>( + busy, (emit) async => _refreshInner(emit)); + } + + @override + Future close() async { + await _initWait(cancelValue: true); + await _subscription?.cancel(); + _subscription = null; + if (_wantsCloseRecord) { + await _shortArray.close(); + } + await super.close(); + } + + Future operate( + Future Function(DHTShortArrayReadOperations) closure) async { + await _initWait(); + return _shortArray.operate(closure); + } + + Future operateWrite( + Future Function(DHTShortArrayWriteOperations) closure) async { + await _initWait(); + return _shortArray.operateWrite(closure); + } + + Future operateWriteEventual( + Future Function(DHTShortArrayWriteOperations) closure, + {Duration? timeout}) async { + await _initWait(); + return _shortArray.operateWriteEventual(closure, timeout: timeout); + } + + final WaitSet _initWait = WaitSet(); + late final DHTShortArray _shortArray; + final T Function(List data) _decodeElement; + StreamSubscription? _subscription; + bool _wantsCloseRecord = false; + final _sspUpdate = SingleStatelessProcessor(); +} diff --git a/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_head.dart b/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_head.dart new file mode 100644 index 0000000..5b224cb --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_head.dart @@ -0,0 +1,559 @@ +part of 'dht_short_array.dart'; + +class DHTShortArrayHeadLookup { + DHTShortArrayHeadLookup( + {required this.record, required this.recordSubkey, required this.seq}); + final DHTRecord record; + final int recordSubkey; + final int? seq; +} + +class _DHTShortArrayHead { + _DHTShortArrayHead({required DHTRecord headRecord}) + : _headRecord = headRecord, + _linkedRecords = [], + _index = [], + _free = [], + _seqs = [], + _localSeqs = [] { + _calculateStride(); + } + + void _calculateStride() { + switch (_headRecord.schema) { + case DHTSchemaDFLT(oCnt: final oCnt): + if (oCnt <= 1) { + throw StateError('Invalid DFLT schema in DHTShortArray'); + } + _stride = oCnt - 1; + case DHTSchemaSMPL(oCnt: final oCnt, members: final members): + if (oCnt != 0 || members.length != 1 || members[0].mCnt <= 1) { + throw StateError('Invalid SMPL schema in DHTShortArray'); + } + _stride = members[0].mCnt - 1; + } + assert(_stride <= DHTShortArray.maxElements, 'stride too long'); + } + + proto.DHTShortArray _toProto() { + assert(_headMutex.isLocked, 'should be in mutex here'); + + final head = proto.DHTShortArray(); + head.keys.addAll(_linkedRecords.map((lr) => lr.key.toProto())); + head.index = List.of(_index); + head.seqs.addAll(_seqs.map((x) => x ?? 0xFFFFFFFF)); + // Do not serialize free list, it gets recreated + // Do not serialize local seqs, they are only locally relevant + return head; + } + + TypedKey get recordKey => _headRecord.key; + OwnedDHTRecordPointer get recordPointer => _headRecord.ownedDHTRecordPointer; + int get length => _index.length; + bool get isOpen => _headRecord.isOpen; + + Future close() async { + await _headMutex.protect(() async { + if (!isOpen) { + return; + } + final futures = >[_headRecord.close()]; + for (final lr in _linkedRecords) { + futures.add(lr.close()); + } + await Future.wait(futures); + }); + } + + /// Returns true if the deletion was processed immediately + /// Returns false if the deletion was marked for later + Future delete() => _headMutex.protect(_headRecord.delete); + + Future operate(Future Function(_DHTShortArrayHead) closure) async => + _headMutex.protect(() async => closure(this)); + + Future operateWrite( + Future Function(_DHTShortArrayHead) closure) async => + _headMutex.protect(() async { + final oldLinkedRecords = List.of(_linkedRecords); + final oldIndex = List.of(_index); + final oldFree = List.of(_free); + final oldSeqs = List.of(_seqs); + try { + final out = await closure(this); + // Write head assuming it has been changed + if (!await _writeHead()) { + // Failed to write head means head got overwritten so write should + // be considered failed + throw const DHTExceptionOutdated(); + } + + onUpdatedHead?.call(); + return out; + } on Exception { + // Exception means state needs to be reverted + _linkedRecords = oldLinkedRecords; + _index = oldIndex; + _free = oldFree; + _seqs = oldSeqs; + + rethrow; + } + }); + + Future operateWriteEventual( + Future Function(_DHTShortArrayHead) closure, + {Duration? timeout}) async { + final timeoutTs = timeout == null + ? null + : Veilid.instance.now().offset(TimestampDuration.fromDuration(timeout)); + + return _headMutex.protect(() async { + late List oldLinkedRecords; + late List oldIndex; + late List oldFree; + late List oldSeqs; + + late T out; + try { + // Iterate until we have a successful element and head write + + do { + // Save off old values each pass of tryWriteHead because the head + // will have changed + oldLinkedRecords = List.of(_linkedRecords); + oldIndex = List.of(_index); + oldFree = List.of(_free); + oldSeqs = List.of(_seqs); + + // Try to do the element write + while (true) { + if (timeoutTs != null) { + final now = Veilid.instance.now(); + if (now >= timeoutTs) { + throw TimeoutException('timeout reached'); + } + } + try { + out = await closure(this); + break; + } on DHTExceptionOutdated { + // Failed to write in closure resets state + _linkedRecords = List.of(oldLinkedRecords); + _index = List.of(oldIndex); + _free = List.of(oldFree); + _seqs = List.of(oldSeqs); + } on Exception { + // Failed to write in closure resets state + _linkedRecords = List.of(oldLinkedRecords); + _index = List.of(oldIndex); + _free = List.of(oldFree); + _seqs = List.of(oldSeqs); + rethrow; + } + } + // Try to do the head write + } while (!await _writeHead()); + + onUpdatedHead?.call(); + } on Exception { + // Exception means state needs to be reverted + _linkedRecords = oldLinkedRecords; + _index = oldIndex; + _free = oldFree; + _seqs = oldSeqs; + + rethrow; + } + return out; + }); + } + + /// Serialize and write out the current head record, possibly updating it + /// if a newer copy is available online. Returns true if the write was + /// successful + Future _writeHead() async { + assert(_headMutex.isLocked, 'should be in mutex here'); + + final headBuffer = _toProto().writeToBuffer(); + + final existingData = await _headRecord.tryWriteBytes(headBuffer); + if (existingData != null) { + // Head write failed, incorporate update + await _updateHead(proto.DHTShortArray.fromBuffer(existingData)); + return false; + } + + return true; + } + + /// Validate a new head record that has come in from the network + Future _updateHead(proto.DHTShortArray head) async { + assert(_headMutex.isLocked, 'should be in mutex here'); + + // Get the set of new linked keys and validate it + final updatedLinkedKeys = head.keys.map((p) => p.toVeilid()).toList(); + final updatedIndex = List.of(head.index); + final updatedSeqs = + List.of(head.seqs.map((x) => x == 0xFFFFFFFF ? null : x)); + final updatedFree = _makeFreeList(updatedLinkedKeys, updatedIndex); + + // See which records are actually new + final oldRecords = Map.fromEntries( + _linkedRecords.map((lr) => MapEntry(lr.key, lr))); + final newRecords = {}; + final sameRecords = {}; + final updatedLinkedRecords = []; + try { + for (var n = 0; n < updatedLinkedKeys.length; n++) { + final newKey = updatedLinkedKeys[n]; + final oldRecord = oldRecords[newKey]; + if (oldRecord == null) { + // Open the new record + final newRecord = await _openLinkedRecord(newKey, n); + newRecords[newKey] = newRecord; + updatedLinkedRecords.add(newRecord); + } else { + sameRecords[newKey] = oldRecord; + updatedLinkedRecords.add(oldRecord); + } + } + } on Exception catch (_) { + // On any exception close the records we have opened + await newRecords.entries.map((e) => e.value.close()).wait; + rethrow; + } + + // From this point forward we should not throw an exception or everything + // is possibly invalid. Just pass the exception up it happens and the caller + // will have to delete this short array and reopen it if it can + await oldRecords.entries + .where((e) => !sameRecords.containsKey(e.key)) + .map((e) => e.value.close()) + .wait; + + // Get the localseqs list from inspect results + final localReports = await [_headRecord, ...updatedLinkedRecords].map((r) { + final start = (r.key == _headRecord.key) ? 1 : 0; + return r.inspect( + subkeys: [ValueSubkeyRange.make(start, start + _stride - 1)]); + }).wait; + final updatedLocalSeqs = + localReports.map((l) => l.localSeqs).expand((e) => e).toList(); + + // Make the new head cache + _linkedRecords = updatedLinkedRecords; + _index = updatedIndex; + _free = updatedFree; + _seqs = updatedSeqs; + _localSeqs = updatedLocalSeqs; + } + + // Pull the latest or updated copy of the head record from the network + Future _loadHead() async { + // Get an updated head record copy if one exists + final head = await _headRecord.getProtobuf(proto.DHTShortArray.fromBuffer, + subkey: 0, refreshMode: DHTRecordRefreshMode.network); + if (head == null) { + throw StateError('shortarray head missing during refresh'); + } + + await _updateHead(head); + } + + ///////////////////////////////////////////////////////////////////////////// + // Linked record management + + Future _getOrCreateLinkedRecord( + int recordNumber, bool allowCreate) async { + if (recordNumber == 0) { + return _headRecord; + } + recordNumber--; + if (recordNumber < _linkedRecords.length) { + return _linkedRecords[recordNumber]; + } + + if (!allowCreate) { + throw StateError("asked for non-existent record and can't create"); + } + + final pool = DHTRecordPool.instance; + for (var rn = _linkedRecords.length; rn <= recordNumber; rn++) { + // Linked records must use SMPL schema so writer can be specified + // Use the same writer as the head record + final smplWriter = _headRecord.writer!; + final parent = _headRecord.key; + final routingContext = _headRecord.routingContext; + final crypto = _headRecord.crypto; + + final schema = DHTSchema.smpl( + oCnt: 0, + members: [DHTSchemaMember(mKey: smplWriter.key, mCnt: _stride)]); + final dhtRecord = await pool.createRecord( + debugName: '${_headRecord.debugName}_linked_$recordNumber', + parent: parent, + routingContext: routingContext, + schema: schema, + crypto: crypto, + writer: smplWriter); + + // Add to linked records + _linkedRecords.add(dhtRecord); + } + return _linkedRecords[recordNumber]; + } + + /// Open a linked record for reading or writing, same as the head record + Future _openLinkedRecord( + TypedKey recordKey, int recordNumber) async { + final writer = _headRecord.writer; + return (writer != null) + ? await DHTRecordPool.instance.openRecordWrite( + recordKey, + writer, + debugName: '${_headRecord.debugName}_linked_$recordNumber', + parent: _headRecord.key, + routingContext: _headRecord.routingContext, + ) + : await DHTRecordPool.instance.openRecordRead( + recordKey, + debugName: '${_headRecord.debugName}_linked_$recordNumber', + parent: _headRecord.key, + routingContext: _headRecord.routingContext, + ); + } + + Future lookupPosition( + int pos, bool allowCreate) async { + final idx = _index[pos]; + return lookupIndex(idx, allowCreate); + } + + Future lookupIndex(int idx, bool allowCreate) async { + final seq = idx < _seqs.length ? _seqs[idx] : null; + final recordNumber = idx ~/ _stride; + final record = await _getOrCreateLinkedRecord(recordNumber, allowCreate); + final recordSubkey = (idx % _stride) + ((recordNumber == 0) ? 1 : 0); + return DHTShortArrayHeadLookup( + record: record, recordSubkey: recordSubkey, seq: seq); + } + + ///////////////////////////////////////////////////////////////////////////// + // Index management + + /// Allocate an empty index slot at a specific position + void allocateIndex(int pos) { + // Allocate empty index + final idx = _emptyIndex(); + _index.insert(pos, idx); + } + + int _emptyIndex() { + if (_free.isNotEmpty) { + return _free.removeLast(); + } + if (_index.length == DHTShortArray.maxElements) { + throw StateError('too many elements'); + } + return _index.length; + } + + void swapIndex(int aPos, int bPos) { + if (aPos == bPos) { + return; + } + final aIdx = _index[aPos]; + final bIdx = _index[bPos]; + _index[aPos] = bIdx; + _index[bPos] = aIdx; + } + + void clearIndex() { + _index.clear(); + _free.clear(); + } + + /// Release an index at a particular position + void freeIndex(int pos) { + final idx = _index.removeAt(pos); + _free.add(idx); + // xxx: free list optimization here? + } + + /// Truncate index to a particular length + void truncate(int newLength) { + if (newLength >= _index.length) { + return; + } else if (newLength == 0) { + clearIndex(); + return; + } else if (newLength < 0) { + throw StateError('can not truncate to negative length'); + } + + final newIndex = _index.sublist(0, newLength); + final freed = _index.sublist(newLength); + + _index = newIndex; + _free.addAll(freed); + } + + /// Validate the head from the DHT is properly formatted + /// and calculate the free list from it while we're here + List _makeFreeList( + List> linkedKeys, List index) { + // Ensure nothing is duplicated in the linked keys set + final newKeys = linkedKeys.toSet(); + assert( + newKeys.length <= + (DHTShortArray.maxElements + (_stride - 1)) ~/ _stride, + 'too many keys: $newKeys.length'); + assert(newKeys.length == linkedKeys.length, 'duplicated linked keys'); + final newIndex = index.toSet(); + assert(newIndex.length <= DHTShortArray.maxElements, 'too many indexes'); + assert(newIndex.length == index.length, 'duplicated index locations'); + + // Ensure all the index keys fit into the existing records + final indexCapacity = (linkedKeys.length + 1) * _stride; + int? maxIndex; + for (final idx in newIndex) { + assert(idx >= 0 || idx < indexCapacity, 'index out of range'); + if (maxIndex == null || idx > maxIndex) { + maxIndex = idx; + } + } + + // Figure out which indices are free + final free = []; + if (maxIndex != null) { + for (var i = 0; i < maxIndex; i++) { + if (!newIndex.contains(i)) { + free.add(i); + } + } + } + return free; + } + + /// Check if we know that the network has a copy of an index that is newer + /// than our local copy from looking at the seqs list in the head + bool positionNeedsRefresh(int pos) { + final idx = _index[pos]; + + // If our local sequence number is unknown or hasnt been written yet + // then a normal DHT operation is going to pull from the network anyway + if (_localSeqs.length < idx || _localSeqs[idx] == null) { + return false; + } + + // If the remote sequence number record is unknown or hasnt been written + // at this index yet, then we also do not refresh at this time as it + // is the first time the index is being written to + if (_seqs.length < idx || _seqs[idx] == null) { + return false; + } + + return _localSeqs[idx]! < _seqs[idx]!; + } + + /// Update the sequence number for a particular index in + /// our local sequence number list. + /// If a write is happening, update the network copy as well. + void updatePositionSeq(int pos, bool write, int newSeq) { + final idx = _index[pos]; + + while (_localSeqs.length <= idx) { + _localSeqs.add(null); + } + _localSeqs[idx] = newSeq; + if (write) { + while (_seqs.length <= idx) { + _seqs.add(null); + } + _seqs[idx] = newSeq; + } + } + + ///////////////////////////////////////////////////////////////////////////// + // Watch For Updates + + // Watch head for changes + Future watch() async { + // This will update any existing watches if necessary + try { + // Update changes to the head record + // Don't watch for local changes because this class already handles + // notifying listeners and knows when it makes local changes + _subscription ??= + await _headRecord.listen(localChanges: false, _onHeadValueChanged); + + await _headRecord.watch(subkeys: [ValueSubkeyRange.single(0)]); + } on Exception { + // If anything fails, try to cancel the watches + await cancelWatch(); + rethrow; + } + } + + // Stop watching for changes to head and linked records + Future cancelWatch() async { + await _headRecord.cancelWatch(); + await _subscription?.cancel(); + _subscription = null; + } + + // Called when the shortarray changes online and we find out from a watch + // but not when we make a change locally + Future _onHeadValueChanged( + DHTRecord record, Uint8List? data, List subkeys) async { + // If head record subkey zero changes, then the layout + // of the dhtshortarray has changed + if (data == null) { + throw StateError('head value changed without data'); + } + if (record.key != _headRecord.key || + subkeys.length != 1 || + subkeys[0] != ValueSubkeyRange.single(0)) { + throw StateError('watch returning wrong subkey range'); + } + + // Decode updated head + final headData = proto.DHTShortArray.fromBuffer(data); + + // Then update the head record + await _headMutex.protect(() async { + await _updateHead(headData); + onUpdatedHead?.call(); + }); + } + + //////////////////////////////////////////////////////////////////////////// + + // Head/element mutex to ensure we keep the representation valid + final _headMutex = Mutex(debugLockTimeout: kIsDebugMode ? 60 : null); + // Subscription to head record internal changes + StreamSubscription? _subscription; + // Notify closure for external head changes + void Function()? onUpdatedHead; + + // Head DHT record + final DHTRecord _headRecord; + // How many elements per linked record + late final int _stride; + + // List of additional records after the head record used for element data + List _linkedRecords; + // Ordering of the subkey indices. + // Elements are subkey numbers. Represents the element order. + List _index; + // List of free subkeys for elements that have been removed. + // Used to optimize allocations. + List _free; + // The sequence numbers of each subkey. + // Index is by subkey number not by element index. + // (n-1 for head record and then the next n for linked records) + List _seqs; + // The local sequence numbers for each subkey. + List _localSeqs; +} diff --git a/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_read.dart b/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_read.dart new file mode 100644 index 0000000..eeb9648 --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_read.dart @@ -0,0 +1,132 @@ +part of 'dht_short_array.dart'; + +//////////////////////////////////////////////////////////////////////////// +// Reader-only implementation + +abstract class DHTShortArrayReadOperations implements DHTRandomRead {} + +class _DHTShortArrayRead implements DHTShortArrayReadOperations { + _DHTShortArrayRead._(_DHTShortArrayHead head) : _head = head; + + @override + int get length => _head.length; + + @override + Future get(int pos, {bool forceRefresh = false}) async { + if (pos < 0 || pos >= length) { + throw IndexError.withLength(pos, length); + } + + try { + final lookup = await _head.lookupPosition(pos, false); + + final refresh = forceRefresh || _head.positionNeedsRefresh(pos); + final outSeqNum = Output(); + final out = await lookup.record.get( + subkey: lookup.recordSubkey, + refreshMode: refresh + ? DHTRecordRefreshMode.network + : DHTRecordRefreshMode.cached, + outSeqNum: outSeqNum); + if (outSeqNum.value != null) { + _head.updatePositionSeq(pos, false, outSeqNum.value!); + } + return out; + } on DHTExceptionNotAvailable { + // If any element is not available, return null + return null; + } + } + + (int, int) _clampStartLen(int start, int? len) { + len ??= _head.length; + if (start < 0) { + throw IndexError.withLength(start, _head.length); + } + if (start > _head.length) { + throw IndexError.withLength(start, _head.length); + } + if ((len + start) > _head.length) { + len = _head.length - start; + } + return (start, len); + } + + @override + Future?> getRange(int start, + {int? length, bool forceRefresh = false}) async { + final out = []; + (start, length) = _clampStartLen(start, length); + + final chunks = Iterable.generate(length) + .slices(kMaxDHTConcurrency) + .map((chunk) => chunk.map((pos) async { + try { + return await get(pos + start, forceRefresh: forceRefresh); + // Need some way to debug ParallelWaitError + // ignore: avoid_catches_without_on_clauses + } catch (e, st) { + veilidLoggy.error('$e\n$st\n'); + rethrow; + } + })); + + for (final chunk in chunks) { + var elems = await chunk.wait; + + // Return only the first contiguous range, anything else is garbage + // due to a representational error in the head or shortarray legnth + final nullPos = elems.indexOf(null); + if (nullPos != -1) { + elems = elems.sublist(0, nullPos); + } + + out.addAll(elems.cast()); + + if (nullPos != -1) { + break; + } + } + + return out; + } + + @override + Future> getOfflinePositions() async { + final (start, length) = _clampStartLen(0, DHTShortArray.maxElements); + + final indexOffline = {}; + final inspects = await [ + _head._headRecord.inspect(), + ..._head._linkedRecords.map((lr) => lr.inspect()) + ].wait; + + // Add to offline index + var strideOffset = 0; + for (final inspect in inspects) { + for (final r in inspect.offlineSubkeys) { + for (var i = r.low; i <= r.high; i++) { + // If this is the head record, ignore the first head subkey + if (strideOffset != 0 || i != 0) { + indexOffline.add(i + ((strideOffset == 0) ? -1 : strideOffset)); + } + } + } + strideOffset += _head._stride; + } + + // See which positions map to offline indexes + final positionOffline = {}; + for (var i = start; i < (start + length); i++) { + final idx = _head._index[i]; + if (indexOffline.contains(idx)) { + positionOffline.add(i); + } + } + return positionOffline; + } + + //////////////////////////////////////////////////////////////////////////// + // Fields + final _DHTShortArrayHead _head; +} diff --git a/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_write.dart b/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_write.dart new file mode 100644 index 0000000..bd3431d --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_write.dart @@ -0,0 +1,190 @@ +part of 'dht_short_array.dart'; + +//////////////////////////////////////////////////////////////////////////// +// Writer implementation + +abstract class DHTShortArrayWriteOperations + implements + DHTRandomRead, + DHTRandomSwap, + DHTRandomWrite, + DHTInsertRemove, + DHTAdd, + DHTTruncate, + DHTClear {} + +class _DHTShortArrayWrite extends _DHTShortArrayRead + implements DHTShortArrayWriteOperations { + _DHTShortArrayWrite._(super.head) : super._(); + + @override + Future add(Uint8List value) => insert(_head.length, value); + + @override + Future addAll(List values) => + insertAll(_head.length, values); + + @override + Future insert(int pos, Uint8List value) async { + if (pos < 0 || pos > _head.length) { + throw IndexError.withLength(pos, _head.length); + } + + // Allocate empty index at position + _head.allocateIndex(pos); + var success = false; + try { + // Write item + success = await tryWriteItem(pos, value); + } finally { + if (!success) { + _head.freeIndex(pos); + } + } + if (!success) { + throw const DHTExceptionOutdated(); + } + } + + @override + Future insertAll(int pos, List values) async { + if (pos < 0 || pos > _head.length) { + throw IndexError.withLength(pos, _head.length); + } + + // Allocate empty indices + for (var i = 0; i < values.length; i++) { + _head.allocateIndex(pos + i); + } + + var success = true; + final outSeqNums = List.generate(values.length, (_) => Output()); + final lookups = []; + try { + // do all lookups + for (var i = 0; i < values.length; i++) { + final lookup = await _head.lookupPosition(pos + i, true); + lookups.add(lookup); + } + + // Write items in parallel + final dws = DelayedWaitSet(); + for (var i = 0; i < values.length; i++) { + final lookup = lookups[i]; + final value = values[i]; + final outSeqNum = outSeqNums[i]; + dws.add((_) async { + try { + final outValue = await lookup.record.tryWriteBytes(value, + subkey: lookup.recordSubkey, outSeqNum: outSeqNum); + if (outValue != null) { + success = false; + } + // Need some way to debug ParallelWaitError + // ignore: avoid_catches_without_on_clauses + } catch (e, st) { + veilidLoggy.error('$e\n$st\n'); + } + }); + } + + await dws(chunkSize: kMaxDHTConcurrency, onChunkDone: (_) => success); + } finally { + // Update sequence numbers + for (var i = 0; i < values.length; i++) { + if (outSeqNums[i].value != null) { + _head.updatePositionSeq(pos + i, true, outSeqNums[i].value!); + } + } + + // Free indices if this was a failure + if (!success) { + for (var i = 0; i < values.length; i++) { + _head.freeIndex(pos); + } + } + } + if (!success) { + throw const DHTExceptionOutdated(); + } + } + + @override + Future swap(int aPos, int bPos) async { + if (aPos < 0 || aPos >= _head.length) { + throw IndexError.withLength(aPos, _head.length); + } + if (bPos < 0 || bPos >= _head.length) { + throw IndexError.withLength(bPos, _head.length); + } + // Swap indices + _head.swapIndex(aPos, bPos); + } + + @override + Future remove(int pos, {Output? output}) async { + if (pos < 0 || pos >= _head.length) { + throw IndexError.withLength(pos, _head.length); + } + final lookup = await _head.lookupPosition(pos, true); + + final outSeqNum = Output(); + + final result = lookup.seq == null + ? null + : await lookup.record.get(subkey: lookup.recordSubkey); + + if (outSeqNum.value != null) { + _head.updatePositionSeq(pos, false, outSeqNum.value!); + } + + if (result == null) { + throw StateError('Element does not exist'); + } + _head.freeIndex(pos); + output?.save(result); + } + + @override + Future clear() async { + _head.clearIndex(); + } + + @override + Future truncate(int newLength) async { + _head.truncate(newLength); + } + + @override + Future tryWriteItem(int pos, Uint8List newValue, + {Output? output}) async { + if (pos < 0 || pos >= _head.length) { + throw IndexError.withLength(pos, _head.length); + } + final lookup = await _head.lookupPosition(pos, true); + + final outSeqNumRead = Output(); + final oldValue = lookup.seq == null + ? null + : await lookup.record + .get(subkey: lookup.recordSubkey, outSeqNum: outSeqNumRead); + if (outSeqNumRead.value != null) { + _head.updatePositionSeq(pos, false, outSeqNumRead.value!); + } + + final outSeqNumWrite = Output(); + final result = await lookup.record.tryWriteBytes(newValue, + subkey: lookup.recordSubkey, outSeqNum: outSeqNumWrite); + if (outSeqNumWrite.value != null) { + _head.updatePositionSeq(pos, true, outSeqNumWrite.value!); + } + + if (result != null) { + // A result coming back means the element was overwritten already + output?.save(result); + return false; + } + output?.save(oldValue); + return true; + } +} diff --git a/packages/veilid_support/lib/dht_support/src/interfaces/dht_add.dart b/packages/veilid_support/lib/dht_support/src/interfaces/dht_add.dart new file mode 100644 index 0000000..28d2fbb --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/interfaces/dht_add.dart @@ -0,0 +1,51 @@ +import 'dart:typed_data'; + +import 'package:protobuf/protobuf.dart'; + +import '../../../veilid_support.dart'; + +//////////////////////////////////////////////////////////////////////////// +// Add +abstract class DHTAdd { + /// Try to add an item to the DHT container. + /// Return if the element was successfully added, + /// Throws DHTExceptionTryAgain if the state changed before the element could + /// be added or a newer value was found on the network. + /// Throws a StateError if the container exceeds its maximum size. + Future add(Uint8List value); + + /// Try to add a list of items to the DHT container. + /// Return the number of elements successfully added. + /// Throws DHTExceptionTryAgain if the state changed before any elements could + /// be added or a newer value was found on the network. + /// Throws DHTConcurrencyLimit if the number values in the list was too large + /// at this time + /// Throws a StateError if the container exceeds its maximum size. + Future addAll(List values); +} + +extension DHTAddExt on DHTAdd { + /// Convenience function: + /// Like add but also encodes the input value as JSON + Future addJson( + T newValue, + ) => + add(jsonEncodeBytes(newValue)); + + /// Convenience function: + /// Like add but also encodes the input value as a protobuf object + Future addProtobuf( + T newValue, + ) => + add(newValue.writeToBuffer()); + + /// Convenience function: + /// Like addAll but also encodes the input values as JSON + Future addAllJson(List values) => + addAll(values.map(jsonEncodeBytes).toList()); + + /// Convenience function: + /// Like addAll but also encodes the input values as protobuf objects + Future addAllProtobuf(List values) => + addAll(values.map((x) => x.writeToBuffer()).toList()); +} diff --git a/packages/veilid_support/lib/dht_support/src/interfaces/dht_clear.dart b/packages/veilid_support/lib/dht_support/src/interfaces/dht_clear.dart new file mode 100644 index 0000000..f7ac9dd --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/interfaces/dht_clear.dart @@ -0,0 +1,7 @@ +//////////////////////////////////////////////////////////////////////////// +// Clear interface +// ignore: one_member_abstracts +abstract class DHTClear { + /// Remove all items in the DHT container. + Future clear(); +} diff --git a/packages/veilid_support/lib/dht_support/src/interfaces/dht_closeable.dart b/packages/veilid_support/lib/dht_support/src/interfaces/dht_closeable.dart new file mode 100644 index 0000000..bda4afb --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/interfaces/dht_closeable.dart @@ -0,0 +1,65 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; + +abstract class DHTCloseable { + // Public interface + void ref(); + Future close(); + + // Internal implementation + @protected + bool get isOpen; + @protected + FutureOr scoped(); +} + +abstract class DHTDeleteable extends DHTCloseable { + /// Returns true if the deletion was processed immediately + /// Returns false if the deletion was marked for later + Future delete(); +} + +extension DHTCloseableExt on DHTCloseable { + /// Runs a closure that guarantees the DHTCloseable + /// will be closed upon exit, even if an uncaught exception is thrown + Future scope(Future Function(D) scopeFunction) async { + if (!isOpen) { + throw StateError('not open in scope'); + } + try { + return await scopeFunction(await scoped()); + } finally { + await close(); + } + } +} + +extension DHTDeletableExt on DHTDeleteable { + /// Runs a closure that guarantees the DHTCloseable + /// will be closed upon exit, and deleted if an an + /// uncaught exception is thrown + Future deleteScope(Future Function(D) scopeFunction) async { + if (!isOpen) { + throw StateError('not open in deleteScope'); + } + + try { + return await scopeFunction(await scoped()); + } on Exception { + await delete(); + rethrow; + } finally { + await close(); + } + } + + /// Scopes a closure that conditionally deletes the DHTCloseable on exit + Future maybeDeleteScope( + bool delete, Future Function(D) scopeFunction) { + if (delete) { + return deleteScope(scopeFunction); + } + return scope(scopeFunction); + } +} diff --git a/packages/veilid_support/lib/dht_support/src/interfaces/dht_insert_remove.dart b/packages/veilid_support/lib/dht_support/src/interfaces/dht_insert_remove.dart new file mode 100644 index 0000000..55967f7 --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/interfaces/dht_insert_remove.dart @@ -0,0 +1,55 @@ +import 'dart:typed_data'; + +import 'package:protobuf/protobuf.dart'; + +import '../../../veilid_support.dart'; + +//////////////////////////////////////////////////////////////////////////// +// Insert/Remove interface +abstract class DHTInsertRemove { + /// Try to insert an item as position 'pos' of the DHT container. + /// Return if the element was successfully inserted + /// Throws DHTExceptionTryAgain if the state changed before the element could + /// be inserted or a newer value was found on the network. + /// Throws an IndexError if the position removed exceeds the length of + /// the container. + /// Throws a StateError if the container exceeds its maximum size. + Future insert(int pos, Uint8List value); + + /// Try to insert items at position 'pos' of the DHT container. + /// Return if the elements were successfully inserted + /// Throws DHTExceptionTryAgain if the state changed before the elements could + /// be inserted or a newer value was found on the network. + /// Throws an IndexError if the position removed exceeds the length of + /// the container. + /// Throws a StateError if the container exceeds its maximum size. + Future insertAll(int pos, List values); + + /// Remove an item at position 'pos' in the DHT container. + /// If the remove was successful this returns: + /// * outValue will return the prior contents of the element + /// Throws an IndexError if the position removed exceeds the length of + /// the container. + Future remove(int pos, {Output? output}); +} + +extension DHTInsertRemoveExt on DHTInsertRemove { + /// Convenience function: + /// Like remove but also parses the returned element as JSON + Future removeJson(T Function(dynamic) fromJson, int pos, + {Output? output}) async { + final outValueBytes = output == null ? null : Output(); + await remove(pos, output: outValueBytes); + output.mapSave(outValueBytes, (b) => jsonDecodeBytes(fromJson, b)); + } + + /// Convenience function: + /// Like remove but also parses the returned element as JSON + Future removeProtobuf( + T Function(List) fromBuffer, int pos, + {Output? output}) async { + final outValueBytes = output == null ? null : Output(); + await remove(pos, output: outValueBytes); + output.mapSave(outValueBytes, fromBuffer); + } +} diff --git a/packages/veilid_support/lib/dht_support/src/interfaces/dht_random_read.dart b/packages/veilid_support/lib/dht_support/src/interfaces/dht_random_read.dart new file mode 100644 index 0000000..d361757 --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/interfaces/dht_random_read.dart @@ -0,0 +1,65 @@ +import 'dart:typed_data'; + +import 'package:protobuf/protobuf.dart'; + +import '../../../veilid_support.dart'; + +//////////////////////////////////////////////////////////////////////////// +// Reader interface +abstract class DHTRandomRead { + /// Returns the number of elements in the DHT container + int get length; + + /// Return the item at position 'pos' in the DHT container. If 'forceRefresh' + /// is specified, the network will always be checked for newer values + /// rather than returning the existing locally stored copy of the elements. + /// Throws an IndexError if the 'pos' is not within the length + /// of the container. May return null if the item is not available at this + /// time. + Future get(int pos, {bool forceRefresh = false}); + + /// Return a list of a range of items in the DHTArray. If 'forceRefresh' + /// is specified, the network will always be checked for newer values + /// rather than returning the existing locally stored copy of the elements. + /// Throws an IndexError if either 'start' or '(start+length)' is not within + /// the length of the container. May return fewer items than the length + /// expected if the requested items are not available, but will always + /// return a contiguous range starting at 'start'. + Future?> getRange(int start, + {int? length, bool forceRefresh = false}); + + /// Get a list of the positions that were written offline and not flushed yet + Future> getOfflinePositions(); +} + +extension DHTRandomReadExt on DHTRandomRead { + /// Convenience function: + /// Like get but also parses the returned element as JSON + Future getJson(T Function(dynamic) fromJson, int pos, + {bool forceRefresh = false}) => + get(pos, forceRefresh: forceRefresh) + .then((out) => jsonDecodeOptBytes(fromJson, out)); + + /// Convenience function: + /// Like getRange but also parses the returned elements as JSON + Future?> getRangeJson(T Function(dynamic) fromJson, int start, + {int? length, bool forceRefresh = false}) => + getRange(start, length: length, forceRefresh: forceRefresh) + .then((out) => out?.map(fromJson).toList()); + + /// Convenience function: + /// Like get but also parses the returned element as a protobuf object + Future getProtobuf( + T Function(List) fromBuffer, int pos, + {bool forceRefresh = false}) => + get(pos, forceRefresh: forceRefresh) + .then((out) => (out == null) ? null : fromBuffer(out)); + + /// Convenience function: + /// Like getRange but also parses the returned elements as protobuf objects + Future?> getRangeProtobuf( + T Function(List) fromBuffer, int start, + {int? length, bool forceRefresh = false}) => + getRange(start, length: length, forceRefresh: forceRefresh) + .then((out) => out?.map(fromBuffer).toList()); +} diff --git a/packages/veilid_support/lib/dht_support/src/interfaces/dht_random_swap.dart b/packages/veilid_support/lib/dht_support/src/interfaces/dht_random_swap.dart new file mode 100644 index 0000000..8aa4dc1 --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/interfaces/dht_random_swap.dart @@ -0,0 +1,9 @@ +//////////////////////////////////////////////////////////////////////////// +// Writer interface +// ignore: one_member_abstracts +abstract class DHTRandomSwap { + /// Swap items at position 'aPos' and 'bPos' in the DHTArray. + /// Throws an IndexError if either of the positions swapped exceeds the length + /// of the container + Future swap(int aPos, int bPos); +} diff --git a/packages/veilid_support/lib/dht_support/src/interfaces/dht_random_write.dart b/packages/veilid_support/lib/dht_support/src/interfaces/dht_random_write.dart new file mode 100644 index 0000000..0d8f3ac --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/interfaces/dht_random_write.dart @@ -0,0 +1,54 @@ +import 'dart:typed_data'; + +import 'package:protobuf/protobuf.dart'; + +import '../../../veilid_support.dart'; + +//////////////////////////////////////////////////////////////////////////// +// Writer interface +// ignore: one_member_abstracts +abstract class DHTRandomWrite { + /// Try to set an item at position 'pos' of the DHT container. + /// If the set was successful this returns: + /// * A boolean true + /// * outValue will return the prior contents of the element, + /// or null if there was no value yet + /// + /// If the set was found a newer value on the network this returns: + /// * A boolean false + /// * outValue will return the newer value of the element, + /// or null if the head record changed. + /// + /// Throws an IndexError if the position is not within the length + /// of the container. + Future tryWriteItem(int pos, Uint8List newValue, + {Output? output}); +} + +extension DHTRandomWriteExt on DHTRandomWrite { + /// Convenience function: + /// Like tryWriteItem but also encodes the input value as JSON and parses the + /// returned element as JSON + Future tryWriteItemJson( + T Function(dynamic) fromJson, int pos, T newValue, + {Output? output}) async { + final outValueBytes = output == null ? null : Output(); + final out = await tryWriteItem(pos, jsonEncodeBytes(newValue), + output: outValueBytes); + output.mapSave(outValueBytes, (b) => jsonDecodeBytes(fromJson, b)); + return out; + } + + /// Convenience function: + /// Like tryWriteItem but also encodes the input value as a protobuf object + /// and parses the returned element as a protobuf object + Future tryWriteItemProtobuf( + T Function(List) fromBuffer, int pos, T newValue, + {Output? output}) async { + final outValueBytes = output == null ? null : Output(); + final out = await tryWriteItem(pos, newValue.writeToBuffer(), + output: outValueBytes); + output.mapSave(outValueBytes, fromBuffer); + return out; + } +} diff --git a/packages/veilid_support/lib/dht_support/src/interfaces/dht_truncate.dart b/packages/veilid_support/lib/dht_support/src/interfaces/dht_truncate.dart new file mode 100644 index 0000000..cbda00f --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/interfaces/dht_truncate.dart @@ -0,0 +1,8 @@ +//////////////////////////////////////////////////////////////////////////// +// Truncate interface +// ignore: one_member_abstracts +abstract class DHTTruncate { + /// Remove items from the DHT container to shrink its size to 'newLength' + /// Throws StateError if newLength < 0 + Future truncate(int newLength); +} diff --git a/packages/veilid_support/lib/dht_support/src/interfaces/exceptions.dart b/packages/veilid_support/lib/dht_support/src/interfaces/exceptions.dart new file mode 100644 index 0000000..01354f0 --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/interfaces/exceptions.dart @@ -0,0 +1,44 @@ +class DHTExceptionOutdated implements Exception { + const DHTExceptionOutdated( + {this.cause = 'operation failed due to newer dht value'}); + final String cause; + + @override + String toString() => 'DHTExceptionOutdated: $cause'; +} + +class DHTConcurrencyLimit implements Exception { + const DHTConcurrencyLimit( + {required this.limit, + this.cause = 'failed due to maximum parallel operation limit'}); + final String cause; + final int limit; + + @override + String toString() => 'DHTConcurrencyLimit: $cause (limit=$limit)'; +} + +class DHTExceptionInvalidData implements Exception { + const DHTExceptionInvalidData({this.cause = 'data was invalid'}); + final String cause; + + @override + String toString() => 'DHTExceptionInvalidData: $cause'; +} + +class DHTExceptionCancelled implements Exception { + const DHTExceptionCancelled({this.cause = 'operation was cancelled'}); + final String cause; + + @override + String toString() => 'DHTExceptionCancelled: $cause'; +} + +class DHTExceptionNotAvailable implements Exception { + const DHTExceptionNotAvailable( + {this.cause = 'request could not be completed at this time'}); + final String cause; + + @override + String toString() => 'DHTExceptionNotAvailable: $cause'; +} diff --git a/packages/veilid_support/lib/dht_support/src/interfaces/interfaces.dart b/packages/veilid_support/lib/dht_support/src/interfaces/interfaces.dart new file mode 100644 index 0000000..8a019dd --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/interfaces/interfaces.dart @@ -0,0 +1,10 @@ +export 'dht_add.dart'; +export 'dht_clear.dart'; +export 'dht_closeable.dart'; +export 'dht_insert_remove.dart'; +export 'dht_random_read.dart'; +export 'dht_random_swap.dart'; +export 'dht_random_write.dart'; +export 'dht_truncate.dart'; +export 'exceptions.dart'; +export 'refreshable_cubit.dart'; diff --git a/packages/veilid_support/lib/dht_support/src/interfaces/refreshable_cubit.dart b/packages/veilid_support/lib/dht_support/src/interfaces/refreshable_cubit.dart new file mode 100644 index 0000000..d987b38 --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/interfaces/refreshable_cubit.dart @@ -0,0 +1,16 @@ +abstract mixin class RefreshableCubit { + Future refresh({bool forceRefresh = false}); + + void setWantsRefresh() { + _wantsRefresh = true; + } + + void setRefreshed() { + _wantsRefresh = false; + } + + bool get wantsRefresh => _wantsRefresh; + + //////////////////////////////////////////////////////////////////////////// + var _wantsRefresh = false; +} diff --git a/packages/veilid_support/lib/identity_support/account_record_info.dart b/packages/veilid_support/lib/identity_support/account_record_info.dart new file mode 100644 index 0000000..c74baac --- /dev/null +++ b/packages/veilid_support/lib/identity_support/account_record_info.dart @@ -0,0 +1,19 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../veilid_support.dart'; + +part 'account_record_info.freezed.dart'; +part 'account_record_info.g.dart'; + +/// AccountRecordInfo is the key and owner info for the account dht record that +/// is stored in the identity instance record +@freezed +sealed class AccountRecordInfo with _$AccountRecordInfo { + const factory AccountRecordInfo({ + // Top level account keys and secrets + required OwnedDHTRecordPointer accountRecord, + }) = _AccountRecordInfo; + + factory AccountRecordInfo.fromJson(dynamic json) => + _$AccountRecordInfoFromJson(json as Map); +} diff --git a/packages/veilid_support/lib/identity_support/account_record_info.freezed.dart b/packages/veilid_support/lib/identity_support/account_record_info.freezed.dart new file mode 100644 index 0000000..b1796f6 --- /dev/null +++ b/packages/veilid_support/lib/identity_support/account_record_info.freezed.dart @@ -0,0 +1,189 @@ +// dart format width=80 +// 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_record_info.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$AccountRecordInfo { +// Top level account keys and secrets + OwnedDHTRecordPointer get accountRecord; + + /// Create a copy of AccountRecordInfo + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $AccountRecordInfoCopyWith get copyWith => + _$AccountRecordInfoCopyWithImpl( + this as AccountRecordInfo, _$identity); + + /// Serializes this AccountRecordInfo to a JSON map. + Map toJson(); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is AccountRecordInfo && + (identical(other.accountRecord, accountRecord) || + other.accountRecord == accountRecord)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, accountRecord); + + @override + String toString() { + return 'AccountRecordInfo(accountRecord: $accountRecord)'; + } +} + +/// @nodoc +abstract mixin class $AccountRecordInfoCopyWith<$Res> { + factory $AccountRecordInfoCopyWith( + AccountRecordInfo value, $Res Function(AccountRecordInfo) _then) = + _$AccountRecordInfoCopyWithImpl; + @useResult + $Res call({OwnedDHTRecordPointer accountRecord}); + + $OwnedDHTRecordPointerCopyWith<$Res> get accountRecord; +} + +/// @nodoc +class _$AccountRecordInfoCopyWithImpl<$Res> + implements $AccountRecordInfoCopyWith<$Res> { + _$AccountRecordInfoCopyWithImpl(this._self, this._then); + + final AccountRecordInfo _self; + final $Res Function(AccountRecordInfo) _then; + + /// Create a copy of AccountRecordInfo + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? accountRecord = null, + }) { + return _then(_self.copyWith( + accountRecord: null == accountRecord + ? _self.accountRecord + : accountRecord // ignore: cast_nullable_to_non_nullable + as OwnedDHTRecordPointer, + )); + } + + /// Create a copy of AccountRecordInfo + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $OwnedDHTRecordPointerCopyWith<$Res> get accountRecord { + return $OwnedDHTRecordPointerCopyWith<$Res>(_self.accountRecord, (value) { + return _then(_self.copyWith(accountRecord: value)); + }); + } +} + +/// @nodoc +@JsonSerializable() +class _AccountRecordInfo implements AccountRecordInfo { + const _AccountRecordInfo({required this.accountRecord}); + factory _AccountRecordInfo.fromJson(Map json) => + _$AccountRecordInfoFromJson(json); + +// Top level account keys and secrets + @override + final OwnedDHTRecordPointer accountRecord; + + /// Create a copy of AccountRecordInfo + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$AccountRecordInfoCopyWith<_AccountRecordInfo> get copyWith => + __$AccountRecordInfoCopyWithImpl<_AccountRecordInfo>(this, _$identity); + + @override + Map toJson() { + return _$AccountRecordInfoToJson( + this, + ); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _AccountRecordInfo && + (identical(other.accountRecord, accountRecord) || + other.accountRecord == accountRecord)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, accountRecord); + + @override + String toString() { + return 'AccountRecordInfo(accountRecord: $accountRecord)'; + } +} + +/// @nodoc +abstract mixin class _$AccountRecordInfoCopyWith<$Res> + implements $AccountRecordInfoCopyWith<$Res> { + factory _$AccountRecordInfoCopyWith( + _AccountRecordInfo value, $Res Function(_AccountRecordInfo) _then) = + __$AccountRecordInfoCopyWithImpl; + @override + @useResult + $Res call({OwnedDHTRecordPointer accountRecord}); + + @override + $OwnedDHTRecordPointerCopyWith<$Res> get accountRecord; +} + +/// @nodoc +class __$AccountRecordInfoCopyWithImpl<$Res> + implements _$AccountRecordInfoCopyWith<$Res> { + __$AccountRecordInfoCopyWithImpl(this._self, this._then); + + final _AccountRecordInfo _self; + final $Res Function(_AccountRecordInfo) _then; + + /// Create a copy of AccountRecordInfo + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? accountRecord = null, + }) { + return _then(_AccountRecordInfo( + accountRecord: null == accountRecord + ? _self.accountRecord + : accountRecord // ignore: cast_nullable_to_non_nullable + as OwnedDHTRecordPointer, + )); + } + + /// Create a copy of AccountRecordInfo + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $OwnedDHTRecordPointerCopyWith<$Res> get accountRecord { + return $OwnedDHTRecordPointerCopyWith<$Res>(_self.accountRecord, (value) { + return _then(_self.copyWith(accountRecord: value)); + }); + } +} + +// dart format on diff --git a/packages/veilid_support/lib/identity_support/account_record_info.g.dart b/packages/veilid_support/lib/identity_support/account_record_info.g.dart new file mode 100644 index 0000000..429f9d0 --- /dev/null +++ b/packages/veilid_support/lib/identity_support/account_record_info.g.dart @@ -0,0 +1,17 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'account_record_info.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_AccountRecordInfo _$AccountRecordInfoFromJson(Map json) => + _AccountRecordInfo( + accountRecord: OwnedDHTRecordPointer.fromJson(json['account_record']), + ); + +Map _$AccountRecordInfoToJson(_AccountRecordInfo instance) => + { + 'account_record': instance.accountRecord.toJson(), + }; diff --git a/packages/veilid_support/lib/identity_support/exceptions.dart b/packages/veilid_support/lib/identity_support/exceptions.dart new file mode 100644 index 0000000..280f468 --- /dev/null +++ b/packages/veilid_support/lib/identity_support/exceptions.dart @@ -0,0 +1,14 @@ +/// Identity errors +enum IdentityException implements Exception { + readError('identity could not be read'), + noAccount('no account record info'), + limitExceeded('too many items for the limit'), + invalid('identity is corrupted or secret is invalid'), + cancelled('account operation cancelled'); + + const IdentityException(this.message); + final String message; + + @override + String toString() => 'IdentityException($name): $message'; +} diff --git a/packages/veilid_support/lib/identity_support/identity.dart b/packages/veilid_support/lib/identity_support/identity.dart new file mode 100644 index 0000000..c1c7113 --- /dev/null +++ b/packages/veilid_support/lib/identity_support/identity.dart @@ -0,0 +1,25 @@ +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'account_record_info.dart'; + +part 'identity.freezed.dart'; +part 'identity.g.dart'; + +/// Identity points to accounts associated with this IdentityInstance +/// accountRecords field has a map of bundle id or uuid to account key pairs +/// DHT Schema: DFLT(1) +/// DHT Key (Private): IdentityInstance.recordKey +/// DHT Owner Key: IdentityInstance.publicKey +/// DHT Secret: IdentityInstance Secret Key (stored encrypted with unlock code +/// in local table store) +@freezed +sealed class Identity with _$Identity { + const factory Identity({ + // Top level account keys and secrets + required IMap> accountRecords, + }) = _Identity; + + factory Identity.fromJson(dynamic json) => + _$IdentityFromJson(json as Map); +} diff --git a/packages/veilid_support/lib/identity_support/identity.freezed.dart b/packages/veilid_support/lib/identity_support/identity.freezed.dart new file mode 100644 index 0000000..d9f08f9 --- /dev/null +++ b/packages/veilid_support/lib/identity_support/identity.freezed.dart @@ -0,0 +1,159 @@ +// dart format width=80 +// 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 'identity.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$Identity { +// Top level account keys and secrets + IMap> get accountRecords; + + /// Create a copy of Identity + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $IdentityCopyWith get copyWith => + _$IdentityCopyWithImpl(this as Identity, _$identity); + + /// Serializes this Identity to a JSON map. + Map toJson(); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is Identity && + (identical(other.accountRecords, accountRecords) || + other.accountRecords == accountRecords)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, accountRecords); + + @override + String toString() { + return 'Identity(accountRecords: $accountRecords)'; + } +} + +/// @nodoc +abstract mixin class $IdentityCopyWith<$Res> { + factory $IdentityCopyWith(Identity value, $Res Function(Identity) _then) = + _$IdentityCopyWithImpl; + @useResult + $Res call({IMap> accountRecords}); +} + +/// @nodoc +class _$IdentityCopyWithImpl<$Res> implements $IdentityCopyWith<$Res> { + _$IdentityCopyWithImpl(this._self, this._then); + + final Identity _self; + final $Res Function(Identity) _then; + + /// Create a copy of Identity + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? accountRecords = null, + }) { + return _then(_self.copyWith( + accountRecords: null == accountRecords + ? _self.accountRecords + : accountRecords // ignore: cast_nullable_to_non_nullable + as IMap>, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _Identity implements Identity { + const _Identity({required this.accountRecords}); + factory _Identity.fromJson(Map json) => + _$IdentityFromJson(json); + +// Top level account keys and secrets + @override + final IMap> accountRecords; + + /// Create a copy of Identity + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$IdentityCopyWith<_Identity> get copyWith => + __$IdentityCopyWithImpl<_Identity>(this, _$identity); + + @override + Map toJson() { + return _$IdentityToJson( + this, + ); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _Identity && + (identical(other.accountRecords, accountRecords) || + other.accountRecords == accountRecords)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, accountRecords); + + @override + String toString() { + return 'Identity(accountRecords: $accountRecords)'; + } +} + +/// @nodoc +abstract mixin class _$IdentityCopyWith<$Res> + implements $IdentityCopyWith<$Res> { + factory _$IdentityCopyWith(_Identity value, $Res Function(_Identity) _then) = + __$IdentityCopyWithImpl; + @override + @useResult + $Res call({IMap> accountRecords}); +} + +/// @nodoc +class __$IdentityCopyWithImpl<$Res> implements _$IdentityCopyWith<$Res> { + __$IdentityCopyWithImpl(this._self, this._then); + + final _Identity _self; + final $Res Function(_Identity) _then; + + /// Create a copy of Identity + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? accountRecords = null, + }) { + return _then(_Identity( + accountRecords: null == accountRecords + ? _self.accountRecords + : accountRecords // ignore: cast_nullable_to_non_nullable + as IMap>, + )); + } +} + +// dart format on diff --git a/packages/veilid_support/lib/identity_support/identity.g.dart b/packages/veilid_support/lib/identity_support/identity.g.dart new file mode 100644 index 0000000..1ee10b8 --- /dev/null +++ b/packages/veilid_support/lib/identity_support/identity.g.dart @@ -0,0 +1,24 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'identity.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_Identity _$IdentityFromJson(Map json) => _Identity( + accountRecords: IMap>.fromJson( + json['account_records'] as Map, + (value) => value as String, + (value) => ISet.fromJson( + value, (value) => AccountRecordInfo.fromJson(value))), + ); + +Map _$IdentityToJson(_Identity instance) => { + 'account_records': instance.accountRecords.toJson( + (value) => value, + (value) => value.toJson( + (value) => value.toJson(), + ), + ), + }; diff --git a/packages/veilid_support/lib/identity_support/identity_instance.dart b/packages/veilid_support/lib/identity_support/identity_instance.dart new file mode 100644 index 0000000..d2bc323 --- /dev/null +++ b/packages/veilid_support/lib/identity_support/identity_instance.dart @@ -0,0 +1,331 @@ +import 'dart:typed_data'; + +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../src/veilid_log.dart'; +import '../veilid_support.dart'; + +part 'identity_instance.freezed.dart'; +part 'identity_instance.g.dart'; + +@freezed +sealed class IdentityInstance with _$IdentityInstance { + const factory IdentityInstance({ + // Private DHT record storing identity account mapping + required TypedKey recordKey, + + // Public key of identity instance + required PublicKey publicKey, + + // Secret key of identity instance + // Encrypted with appended salt, key is DeriveSharedSecret( + // password = SuperIdentity.secret, + // salt = publicKey) + // Used to recover accounts without generating a new instance + @Uint8ListJsonConverter() required Uint8List encryptedSecretKey, + + // Signature of SuperInstance recordKey and SuperInstance publicKey + // by publicKey + required Signature superSignature, + + // Signature of recordKey, publicKey, encryptedSecretKey, and superSignature + // by SuperIdentity publicKey + required Signature signature, + }) = _IdentityInstance; + + factory IdentityInstance.fromJson(dynamic json) => + _$IdentityInstanceFromJson(json as Map); + + const IdentityInstance._(); + + //////////////////////////////////////////////////////////////////////////// + // Public interface + + /// Delete this identity instance record + /// Only deletes from the local machine not the DHT + Future delete() async { + final pool = DHTRecordPool.instance; + await pool.deleteRecord(recordKey); + } + + Future get cryptoSystem => + Veilid.instance.getCryptoSystem(recordKey.kind); + + Future getPrivateCrypto(SecretKey secretKey) async => + DHTRecordPool.privateCryptoFromTypedSecret( + TypedKey(kind: recordKey.kind, value: secretKey)); + + KeyPair writer(SecretKey secret) => KeyPair(key: publicKey, secret: secret); + + TypedKey get typedPublicKey => + TypedKey(kind: recordKey.kind, value: publicKey); + + Future validateIdentitySecret(SecretKey secretKey) async { + final cs = await cryptoSystem; + final keyOk = await cs.validateKeyPair(publicKey, secretKey); + if (!keyOk) { + throw IdentityException.invalid; + } + return cs; + } + + /// Read the account record info for a specific applicationId from the + /// identity instance record using the identity instance secret key to decrypt + Future> readAccount( + {required TypedKey superRecordKey, + required SecretKey secretKey, + required String applicationId}) async { + // Read the identity key to get the account keys + final pool = DHTRecordPool.instance; + + final identityRecordCrypto = await getPrivateCrypto(secretKey); + + late final List accountRecordInfo; + await (await pool.openRecordRead(recordKey, + debugName: 'IdentityInstance::readAccounts::IdentityRecord', + parent: superRecordKey, + crypto: identityRecordCrypto)) + .scope((identityRec) async { + final identity = await identityRec.getJson(Identity.fromJson); + if (identity == null) { + // Identity could not be read or decrypted from DHT + throw IdentityException.readError; + } + final accountRecords = IMapOfSets.from(identity.accountRecords); + final vcAccounts = accountRecords.get(applicationId); + + accountRecordInfo = vcAccounts.toList(); + }); + + return accountRecordInfo; + } + + /// Creates a new Account associated with super identity and store it in the + /// identity instance record. + Future addAccount({ + required TypedKey superRecordKey, + required SecretKey secretKey, + required String applicationId, + required Future Function(TypedKey parent) createAccountCallback, + int maxAccounts = 1, + }) async { + final pool = DHTRecordPool.instance; + + /////// Add account with profile to DHT + + // Open identity key for writing + veilidLoggy.debug('Opening identity record'); + return (await pool.openRecordWrite(recordKey, writer(secretKey), + debugName: 'IdentityInstance::addAccount::IdentityRecord', + parent: superRecordKey)) + .scope((identityRec) async { + // Create new account to insert into identity + veilidLoggy.debug('Creating new account'); + return (await pool.createRecord( + debugName: + 'IdentityInstance::addAccount::IdentityRecord::AccountRecord', + parent: identityRec.key)) + .deleteScope((accountRec) async { + final account = await createAccountCallback(accountRec.key); + // Write account key + veilidLoggy.debug('Writing account record'); + await accountRec.eventualWriteBytes(account); + + // Update identity key to include account + final newAccountRecordInfo = AccountRecordInfo( + accountRecord: OwnedDHTRecordPointer( + recordKey: accountRec.key, owner: accountRec.ownerKeyPair!)); + + veilidLoggy.debug('Updating identity with new account'); + await identityRec.eventualUpdateJson(Identity.fromJson, + (oldIdentity) async { + if (oldIdentity == null) { + throw IdentityException.readError; + } + final oldAccountRecords = IMapOfSets.from(oldIdentity.accountRecords); + + if (oldAccountRecords.get(applicationId).length >= maxAccounts) { + throw IdentityException.limitExceeded; + } + final accountRecords = oldAccountRecords + .add(applicationId, newAccountRecordInfo) + .asIMap(); + return oldIdentity.copyWith(accountRecords: accountRecords); + }); + + return newAccountRecordInfo; + }); + }); + } + + /// Removes an Account associated with super identity from the identity + /// instance record. 'removeAccountCallback' returns the account to be + /// removed from the list passed to it. + Future removeAccount({ + required TypedKey superRecordKey, + required SecretKey secretKey, + required String applicationId, + required Future Function( + List accountRecordInfos) + removeAccountCallback, + }) async { + final pool = DHTRecordPool.instance; + + /////// Add account with profile to DHT + + // Open identity key for writing + veilidLoggy.debug('Opening identity record'); + return (await pool.openRecordWrite(recordKey, writer(secretKey), + debugName: 'IdentityInstance::addAccount::IdentityRecord', + parent: superRecordKey)) + .scope((identityRec) async { + try { + // Update identity key to remove account + veilidLoggy.debug('Updating identity to remove account'); + await identityRec.eventualUpdateJson(Identity.fromJson, + (oldIdentity) async { + if (oldIdentity == null) { + throw IdentityException.readError; + } + final oldAccountRecords = IMapOfSets.from(oldIdentity.accountRecords); + + // Get list of accounts associated with the application + final vcAccounts = oldAccountRecords.get(applicationId); + final accountRecordInfos = vcAccounts.toList(); + + // Call the callback to return what account to remove + final toRemove = await removeAccountCallback(accountRecordInfos); + if (toRemove == null) { + throw IdentityException.cancelled; + } + final newAccountRecords = + oldAccountRecords.remove(applicationId, toRemove).asIMap(); + + return oldIdentity.copyWith(accountRecords: newAccountRecords); + }); + } on IdentityException catch (e) { + if (e == IdentityException.cancelled) { + return false; + } + rethrow; + } + return true; + }); + } + + //////////////////////////////////////////////////////////////////////////// + // Internal implementation + + Future validateIdentityInstance( + {required TypedKey superRecordKey, + required PublicKey superPublicKey}) async { + final sigValid = await IdentityInstance.validateIdentitySignature( + recordKey: recordKey, + publicKey: publicKey, + encryptedSecretKey: encryptedSecretKey, + superSignature: superSignature, + superPublicKey: superPublicKey, + signature: signature); + if (!sigValid) { + return false; + } + + final superSigValid = await IdentityInstance.validateSuperSignature( + superRecordKey: superRecordKey, + superPublicKey: superPublicKey, + publicKey: publicKey, + superSignature: superSignature); + if (!superSigValid) { + return false; + } + + return true; + } + + static Uint8List signatureBytes({ + required TypedKey recordKey, + required PublicKey publicKey, + required Uint8List encryptedSecretKey, + required Signature superSignature, + }) { + final sigBuf = BytesBuilder() + ..add(recordKey.decode()) + ..add(publicKey.decode()) + ..add(encryptedSecretKey) + ..add(superSignature.decode()); + return sigBuf.toBytes(); + } + + static Future validateIdentitySignature({ + required TypedKey recordKey, + required PublicKey publicKey, + required Uint8List encryptedSecretKey, + required Signature superSignature, + required PublicKey superPublicKey, + required Signature signature, + }) async { + final cs = await Veilid.instance.getCryptoSystem(recordKey.kind); + final identitySigBytes = IdentityInstance.signatureBytes( + recordKey: recordKey, + publicKey: publicKey, + encryptedSecretKey: encryptedSecretKey, + superSignature: superSignature); + return cs.verify(superPublicKey, identitySigBytes, signature); + } + + static Future createIdentitySignature({ + required TypedKey recordKey, + required PublicKey publicKey, + required Uint8List encryptedSecretKey, + required Signature superSignature, + required PublicKey superPublicKey, + required SecretKey superSecret, + }) async { + final cs = await Veilid.instance.getCryptoSystem(recordKey.kind); + final identitySigBytes = IdentityInstance.signatureBytes( + recordKey: recordKey, + publicKey: publicKey, + encryptedSecretKey: encryptedSecretKey, + superSignature: superSignature); + return cs.sign(superPublicKey, superSecret, identitySigBytes); + } + + static Uint8List superSignatureBytes({ + required TypedKey superRecordKey, + required PublicKey superPublicKey, + }) { + final superSigBuf = BytesBuilder() + ..add(superRecordKey.decode()) + ..add(superPublicKey.decode()); + return superSigBuf.toBytes(); + } + + static Future validateSuperSignature({ + required TypedKey superRecordKey, + required PublicKey superPublicKey, + required PublicKey publicKey, + required Signature superSignature, + }) async { + final cs = await Veilid.instance.getCryptoSystem(superRecordKey.kind); + final superSigBytes = IdentityInstance.superSignatureBytes( + superRecordKey: superRecordKey, + superPublicKey: superPublicKey, + ); + return cs.verify(publicKey, superSigBytes, superSignature); + } + + static Future createSuperSignature({ + required TypedKey superRecordKey, + required PublicKey superPublicKey, + required PublicKey publicKey, + required SecretKey secretKey, + }) async { + final cs = await Veilid.instance.getCryptoSystem(superRecordKey.kind); + final superSigBytes = IdentityInstance.superSignatureBytes( + superRecordKey: superRecordKey, + superPublicKey: superPublicKey, + ); + return cs.sign(publicKey, secretKey, superSigBytes); + } +} diff --git a/packages/veilid_support/lib/identity_support/identity_instance.freezed.dart b/packages/veilid_support/lib/identity_support/identity_instance.freezed.dart new file mode 100644 index 0000000..42522d4 --- /dev/null +++ b/packages/veilid_support/lib/identity_support/identity_instance.freezed.dart @@ -0,0 +1,280 @@ +// dart format width=80 +// 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 'identity_instance.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$IdentityInstance { +// Private DHT record storing identity account mapping + TypedKey get recordKey; // Public key of identity instance + PublicKey get publicKey; // Secret key of identity instance +// Encrypted with appended salt, key is DeriveSharedSecret( +// password = SuperIdentity.secret, +// salt = publicKey) +// Used to recover accounts without generating a new instance + @Uint8ListJsonConverter() + Uint8List + get encryptedSecretKey; // Signature of SuperInstance recordKey and SuperInstance publicKey +// by publicKey + Signature + get superSignature; // Signature of recordKey, publicKey, encryptedSecretKey, and superSignature +// by SuperIdentity publicKey + Signature get signature; + + /// Create a copy of IdentityInstance + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $IdentityInstanceCopyWith get copyWith => + _$IdentityInstanceCopyWithImpl( + this as IdentityInstance, _$identity); + + /// Serializes this IdentityInstance to a JSON map. + Map toJson(); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is IdentityInstance && + (identical(other.recordKey, recordKey) || + other.recordKey == recordKey) && + (identical(other.publicKey, publicKey) || + other.publicKey == publicKey) && + const DeepCollectionEquality() + .equals(other.encryptedSecretKey, encryptedSecretKey) && + (identical(other.superSignature, superSignature) || + other.superSignature == superSignature) && + (identical(other.signature, signature) || + other.signature == signature)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + recordKey, + publicKey, + const DeepCollectionEquality().hash(encryptedSecretKey), + superSignature, + signature); + + @override + String toString() { + return 'IdentityInstance(recordKey: $recordKey, publicKey: $publicKey, encryptedSecretKey: $encryptedSecretKey, superSignature: $superSignature, signature: $signature)'; + } +} + +/// @nodoc +abstract mixin class $IdentityInstanceCopyWith<$Res> { + factory $IdentityInstanceCopyWith( + IdentityInstance value, $Res Function(IdentityInstance) _then) = + _$IdentityInstanceCopyWithImpl; + @useResult + $Res call( + {Typed recordKey, + FixedEncodedString43 publicKey, + @Uint8ListJsonConverter() Uint8List encryptedSecretKey, + FixedEncodedString86 superSignature, + FixedEncodedString86 signature}); +} + +/// @nodoc +class _$IdentityInstanceCopyWithImpl<$Res> + implements $IdentityInstanceCopyWith<$Res> { + _$IdentityInstanceCopyWithImpl(this._self, this._then); + + final IdentityInstance _self; + final $Res Function(IdentityInstance) _then; + + /// Create a copy of IdentityInstance + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? recordKey = null, + Object? publicKey = null, + Object? encryptedSecretKey = null, + Object? superSignature = null, + Object? signature = null, + }) { + return _then(_self.copyWith( + recordKey: null == recordKey + ? _self.recordKey! + : recordKey // ignore: cast_nullable_to_non_nullable + as Typed, + publicKey: null == publicKey + ? _self.publicKey! + : publicKey // ignore: cast_nullable_to_non_nullable + as FixedEncodedString43, + encryptedSecretKey: null == encryptedSecretKey + ? _self.encryptedSecretKey + : encryptedSecretKey // ignore: cast_nullable_to_non_nullable + as Uint8List, + superSignature: null == superSignature + ? _self.superSignature! + : superSignature // ignore: cast_nullable_to_non_nullable + as FixedEncodedString86, + signature: null == signature + ? _self.signature! + : signature // ignore: cast_nullable_to_non_nullable + as FixedEncodedString86, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _IdentityInstance extends IdentityInstance { + const _IdentityInstance( + {required this.recordKey, + required this.publicKey, + @Uint8ListJsonConverter() required this.encryptedSecretKey, + required this.superSignature, + required this.signature}) + : super._(); + factory _IdentityInstance.fromJson(Map json) => + _$IdentityInstanceFromJson(json); + +// Private DHT record storing identity account mapping + @override + final Typed recordKey; +// Public key of identity instance + @override + final FixedEncodedString43 publicKey; +// Secret key of identity instance +// Encrypted with appended salt, key is DeriveSharedSecret( +// password = SuperIdentity.secret, +// salt = publicKey) +// Used to recover accounts without generating a new instance + @override + @Uint8ListJsonConverter() + final Uint8List encryptedSecretKey; +// Signature of SuperInstance recordKey and SuperInstance publicKey +// by publicKey + @override + final FixedEncodedString86 superSignature; +// Signature of recordKey, publicKey, encryptedSecretKey, and superSignature +// by SuperIdentity publicKey + @override + final FixedEncodedString86 signature; + + /// Create a copy of IdentityInstance + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$IdentityInstanceCopyWith<_IdentityInstance> get copyWith => + __$IdentityInstanceCopyWithImpl<_IdentityInstance>(this, _$identity); + + @override + Map toJson() { + return _$IdentityInstanceToJson( + this, + ); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _IdentityInstance && + (identical(other.recordKey, recordKey) || + other.recordKey == recordKey) && + (identical(other.publicKey, publicKey) || + other.publicKey == publicKey) && + const DeepCollectionEquality() + .equals(other.encryptedSecretKey, encryptedSecretKey) && + (identical(other.superSignature, superSignature) || + other.superSignature == superSignature) && + (identical(other.signature, signature) || + other.signature == signature)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + recordKey, + publicKey, + const DeepCollectionEquality().hash(encryptedSecretKey), + superSignature, + signature); + + @override + String toString() { + return 'IdentityInstance(recordKey: $recordKey, publicKey: $publicKey, encryptedSecretKey: $encryptedSecretKey, superSignature: $superSignature, signature: $signature)'; + } +} + +/// @nodoc +abstract mixin class _$IdentityInstanceCopyWith<$Res> + implements $IdentityInstanceCopyWith<$Res> { + factory _$IdentityInstanceCopyWith( + _IdentityInstance value, $Res Function(_IdentityInstance) _then) = + __$IdentityInstanceCopyWithImpl; + @override + @useResult + $Res call( + {Typed recordKey, + FixedEncodedString43 publicKey, + @Uint8ListJsonConverter() Uint8List encryptedSecretKey, + FixedEncodedString86 superSignature, + FixedEncodedString86 signature}); +} + +/// @nodoc +class __$IdentityInstanceCopyWithImpl<$Res> + implements _$IdentityInstanceCopyWith<$Res> { + __$IdentityInstanceCopyWithImpl(this._self, this._then); + + final _IdentityInstance _self; + final $Res Function(_IdentityInstance) _then; + + /// Create a copy of IdentityInstance + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? recordKey = null, + Object? publicKey = null, + Object? encryptedSecretKey = null, + Object? superSignature = null, + Object? signature = null, + }) { + return _then(_IdentityInstance( + recordKey: null == recordKey + ? _self.recordKey + : recordKey // ignore: cast_nullable_to_non_nullable + as Typed, + publicKey: null == publicKey + ? _self.publicKey + : publicKey // ignore: cast_nullable_to_non_nullable + as FixedEncodedString43, + encryptedSecretKey: null == encryptedSecretKey + ? _self.encryptedSecretKey + : encryptedSecretKey // ignore: cast_nullable_to_non_nullable + as Uint8List, + superSignature: null == superSignature + ? _self.superSignature + : superSignature // ignore: cast_nullable_to_non_nullable + as FixedEncodedString86, + signature: null == signature + ? _self.signature + : signature // ignore: cast_nullable_to_non_nullable + as FixedEncodedString86, + )); + } +} + +// dart format on diff --git a/packages/veilid_support/lib/identity_support/identity_instance.g.dart b/packages/veilid_support/lib/identity_support/identity_instance.g.dart new file mode 100644 index 0000000..eddbcf6 --- /dev/null +++ b/packages/veilid_support/lib/identity_support/identity_instance.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'identity_instance.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_IdentityInstance _$IdentityInstanceFromJson(Map json) => + _IdentityInstance( + recordKey: Typed.fromJson(json['record_key']), + publicKey: FixedEncodedString43.fromJson(json['public_key']), + encryptedSecretKey: + const Uint8ListJsonConverter().fromJson(json['encrypted_secret_key']), + superSignature: FixedEncodedString86.fromJson(json['super_signature']), + signature: FixedEncodedString86.fromJson(json['signature']), + ); + +Map _$IdentityInstanceToJson(_IdentityInstance instance) => + { + 'record_key': instance.recordKey.toJson(), + 'public_key': instance.publicKey.toJson(), + 'encrypted_secret_key': + const Uint8ListJsonConverter().toJson(instance.encryptedSecretKey), + 'super_signature': instance.superSignature.toJson(), + 'signature': instance.signature.toJson(), + }; diff --git a/packages/veilid_support/lib/identity_support/identity_support.dart b/packages/veilid_support/lib/identity_support/identity_support.dart new file mode 100644 index 0000000..68723bf --- /dev/null +++ b/packages/veilid_support/lib/identity_support/identity_support.dart @@ -0,0 +1,7 @@ +export 'account_record_info.dart'; +export 'exceptions.dart'; +export 'identity.dart'; +export 'identity_instance.dart'; +export 'super_identity.dart'; +export 'super_identity_cubit.dart'; +export 'writable_super_identity.dart'; diff --git a/packages/veilid_support/lib/identity_support/super_identity.dart b/packages/veilid_support/lib/identity_support/super_identity.dart new file mode 100644 index 0000000..008967d --- /dev/null +++ b/packages/veilid_support/lib/identity_support/super_identity.dart @@ -0,0 +1,180 @@ +import 'dart:typed_data'; + +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../veilid_support.dart'; + +part 'super_identity.freezed.dart'; +part 'super_identity.g.dart'; + +/// SuperIdentity key structure for created account +/// +/// SuperIdentity key allows for regeneration of identity DHT record +/// Bidirectional Super<->Instance signature allows for +/// chain of identity ownership for account recovery process +/// +/// Backed by a DHT key at superRecordKey, the secret is kept +/// completely offline and only written to upon account recovery +/// +/// DHT Schema: DFLT(1) +/// DHT Record Key (Public): SuperIdentity.recordKey +/// DHT Owner Key: SuperIdentity.publicKey +/// DHT Owner Secret: SuperIdentity Secret Key (kept offline) +/// Encryption: None +@freezed +sealed class SuperIdentity with _$SuperIdentity { + @JsonSerializable() + const factory SuperIdentity({ + /// Public DHT record storing this structure for account recovery + /// changing this can migrate/forward the SuperIdentity to a new DHT record + /// Instances should not hash this recordKey, rather the actual record + /// key used to store the superIdentity, as this may change. + required TypedKey recordKey, + + /// Public key of the SuperIdentity used to sign identity keys for recovery + /// This must match the owner of the superRecord DHT record and can not be + /// changed without changing the record + required PublicKey publicKey, + + /// Current identity instance + /// The most recently generated identity instance for this SuperIdentity + required IdentityInstance currentInstance, + + /// Deprecated identity instances + /// These may be compromised and should not be considered valid for + /// new signatures, but may be used to validate old signatures + required List deprecatedInstances, + + /// Deprecated superRecords + /// These may be compromised and should not be considered valid for + /// new signatures, but may be used to validate old signatures + required List deprecatedSuperRecordKeys, + + /// Signature of recordKey, currentInstance signature, + /// signatures of deprecatedInstances, and deprecatedSuperRecordKeys + /// by publicKey + required Signature signature, + }) = _SuperIdentity; + + //////////////////////////////////////////////////////////////////////////// + // Constructors + + factory SuperIdentity.fromJson(dynamic json) => + _$SuperIdentityFromJson(json as Map); + + const SuperIdentity._(); + + /// Ensure a SuperIdentity is valid + Future validate({required TypedKey superRecordKey}) async { + // Validate current IdentityInstance + if (!await currentInstance.validateIdentityInstance( + superRecordKey: superRecordKey, superPublicKey: publicKey)) { + // Invalid current IdentityInstance signature(s) + throw IdentityException.invalid; + } + + // Validate deprecated IdentityInstances + for (final deprecatedInstance in deprecatedInstances) { + if (!await deprecatedInstance.validateIdentityInstance( + superRecordKey: superRecordKey, superPublicKey: publicKey)) { + // Invalid deprecated IdentityInstance signature(s) + throw IdentityException.invalid; + } + } + + // Validate SuperIdentity + final deprecatedInstancesSignatures = + deprecatedInstances.map((x) => x.signature).toList(); + if (!await _validateSuperIdentitySignature( + recordKey: recordKey, + currentInstanceSignature: currentInstance.signature, + deprecatedInstancesSignatures: deprecatedInstancesSignatures, + deprecatedSuperRecordKeys: deprecatedSuperRecordKeys, + publicKey: publicKey, + signature: signature)) { + // Invalid SuperIdentity signature + throw IdentityException.invalid; + } + } + + /// Opens an existing super identity, validates it, and returns it + static Future open({required TypedKey superRecordKey}) async { + final pool = DHTRecordPool.instance; + + // SuperIdentity DHT record is public/unencrypted + return (await pool.openRecordRead(superRecordKey, + debugName: 'SuperIdentity::openSuperIdentity::SuperIdentityRecord')) + .deleteScope((superRec) async { + final superIdentity = await superRec.getJson(SuperIdentity.fromJson, + refreshMode: DHTRecordRefreshMode.network); + if (superIdentity == null) { + return null; + } + + await superIdentity.validate(superRecordKey: superRecordKey); + return superIdentity; + }); + } + + //////////////////////////////////////////////////////////////////////////// + // Public Interface + + /// Deletes a super identity and the identity instance records under it + /// Only deletes from the local machine not the DHT + Future delete() async { + final pool = DHTRecordPool.instance; + await pool.deleteRecord(recordKey); + } + + Future get cryptoSystem => + Veilid.instance.getCryptoSystem(recordKey.kind); + + KeyPair writer(SecretKey secretKey) => + KeyPair(key: publicKey, secret: secretKey); + + TypedKey get typedPublicKey => + TypedKey(kind: recordKey.kind, value: publicKey); + + Future validateSecret(SecretKey secretKey) async { + final cs = await cryptoSystem; + final keyOk = await cs.validateKeyPair(publicKey, secretKey); + if (!keyOk) { + throw IdentityException.invalid; + } + return cs; + } + + //////////////////////////////////////////////////////////////////////////// + // Internal implementation + + static Uint8List signatureBytes({ + required TypedKey recordKey, + required Signature currentInstanceSignature, + required List deprecatedInstancesSignatures, + required List deprecatedSuperRecordKeys, + }) { + final sigBuf = BytesBuilder() + ..add(recordKey.decode()) + ..add(currentInstanceSignature.decode()) + ..add(deprecatedInstancesSignatures.expand((s) => s.decode()).toList()) + ..add(deprecatedSuperRecordKeys.expand((s) => s.decode()).toList()); + return sigBuf.toBytes(); + } + + static Future _validateSuperIdentitySignature({ + required TypedKey recordKey, + required Signature currentInstanceSignature, + required List deprecatedInstancesSignatures, + required List deprecatedSuperRecordKeys, + required PublicKey publicKey, + required Signature signature, + }) async { + final cs = await Veilid.instance.getCryptoSystem(recordKey.kind); + final sigBytes = SuperIdentity.signatureBytes( + recordKey: recordKey, + currentInstanceSignature: currentInstanceSignature, + deprecatedInstancesSignatures: deprecatedInstancesSignatures, + deprecatedSuperRecordKeys: deprecatedSuperRecordKeys); + return cs.verify(publicKey, sigBytes, signature); + } +} diff --git a/packages/veilid_support/lib/identity_support/super_identity.freezed.dart b/packages/veilid_support/lib/identity_support/super_identity.freezed.dart new file mode 100644 index 0000000..3144205 --- /dev/null +++ b/packages/veilid_support/lib/identity_support/super_identity.freezed.dart @@ -0,0 +1,375 @@ +// dart format width=80 +// 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 'super_identity.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$SuperIdentity { + /// Public DHT record storing this structure for account recovery + /// changing this can migrate/forward the SuperIdentity to a new DHT record + /// Instances should not hash this recordKey, rather the actual record + /// key used to store the superIdentity, as this may change. + TypedKey get recordKey; + + /// Public key of the SuperIdentity used to sign identity keys for recovery + /// This must match the owner of the superRecord DHT record and can not be + /// changed without changing the record + PublicKey get publicKey; + + /// Current identity instance + /// The most recently generated identity instance for this SuperIdentity + IdentityInstance get currentInstance; + + /// Deprecated identity instances + /// These may be compromised and should not be considered valid for + /// new signatures, but may be used to validate old signatures + List get deprecatedInstances; + + /// Deprecated superRecords + /// These may be compromised and should not be considered valid for + /// new signatures, but may be used to validate old signatures + List get deprecatedSuperRecordKeys; + + /// Signature of recordKey, currentInstance signature, + /// signatures of deprecatedInstances, and deprecatedSuperRecordKeys + /// by publicKey + Signature get signature; + + /// Create a copy of SuperIdentity + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $SuperIdentityCopyWith get copyWith => + _$SuperIdentityCopyWithImpl( + this as SuperIdentity, _$identity); + + /// Serializes this SuperIdentity to a JSON map. + Map toJson(); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is SuperIdentity && + (identical(other.recordKey, recordKey) || + other.recordKey == recordKey) && + (identical(other.publicKey, publicKey) || + other.publicKey == publicKey) && + (identical(other.currentInstance, currentInstance) || + other.currentInstance == currentInstance) && + const DeepCollectionEquality() + .equals(other.deprecatedInstances, deprecatedInstances) && + const DeepCollectionEquality().equals( + other.deprecatedSuperRecordKeys, deprecatedSuperRecordKeys) && + (identical(other.signature, signature) || + other.signature == signature)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + recordKey, + publicKey, + currentInstance, + const DeepCollectionEquality().hash(deprecatedInstances), + const DeepCollectionEquality().hash(deprecatedSuperRecordKeys), + signature); + + @override + String toString() { + return 'SuperIdentity(recordKey: $recordKey, publicKey: $publicKey, currentInstance: $currentInstance, deprecatedInstances: $deprecatedInstances, deprecatedSuperRecordKeys: $deprecatedSuperRecordKeys, signature: $signature)'; + } +} + +/// @nodoc +abstract mixin class $SuperIdentityCopyWith<$Res> { + factory $SuperIdentityCopyWith( + SuperIdentity value, $Res Function(SuperIdentity) _then) = + _$SuperIdentityCopyWithImpl; + @useResult + $Res call( + {Typed recordKey, + FixedEncodedString43 publicKey, + IdentityInstance currentInstance, + List deprecatedInstances, + List> deprecatedSuperRecordKeys, + FixedEncodedString86 signature}); + + $IdentityInstanceCopyWith<$Res> get currentInstance; +} + +/// @nodoc +class _$SuperIdentityCopyWithImpl<$Res> + implements $SuperIdentityCopyWith<$Res> { + _$SuperIdentityCopyWithImpl(this._self, this._then); + + final SuperIdentity _self; + final $Res Function(SuperIdentity) _then; + + /// Create a copy of SuperIdentity + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? recordKey = null, + Object? publicKey = null, + Object? currentInstance = null, + Object? deprecatedInstances = null, + Object? deprecatedSuperRecordKeys = null, + Object? signature = null, + }) { + return _then(_self.copyWith( + recordKey: null == recordKey + ? _self.recordKey! + : recordKey // ignore: cast_nullable_to_non_nullable + as Typed, + publicKey: null == publicKey + ? _self.publicKey! + : publicKey // ignore: cast_nullable_to_non_nullable + as FixedEncodedString43, + currentInstance: null == currentInstance + ? _self.currentInstance + : currentInstance // ignore: cast_nullable_to_non_nullable + as IdentityInstance, + deprecatedInstances: null == deprecatedInstances + ? _self.deprecatedInstances + : deprecatedInstances // ignore: cast_nullable_to_non_nullable + as List, + deprecatedSuperRecordKeys: null == deprecatedSuperRecordKeys + ? _self.deprecatedSuperRecordKeys! + : deprecatedSuperRecordKeys // ignore: cast_nullable_to_non_nullable + as List>, + signature: null == signature + ? _self.signature! + : signature // ignore: cast_nullable_to_non_nullable + as FixedEncodedString86, + )); + } + + /// Create a copy of SuperIdentity + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $IdentityInstanceCopyWith<$Res> get currentInstance { + return $IdentityInstanceCopyWith<$Res>(_self.currentInstance, (value) { + return _then(_self.copyWith(currentInstance: value)); + }); + } +} + +/// @nodoc + +@JsonSerializable() +class _SuperIdentity extends SuperIdentity { + const _SuperIdentity( + {required this.recordKey, + required this.publicKey, + required this.currentInstance, + required final List deprecatedInstances, + required final List> + deprecatedSuperRecordKeys, + required this.signature}) + : _deprecatedInstances = deprecatedInstances, + _deprecatedSuperRecordKeys = deprecatedSuperRecordKeys, + super._(); + factory _SuperIdentity.fromJson(Map json) => + _$SuperIdentityFromJson(json); + + /// Public DHT record storing this structure for account recovery + /// changing this can migrate/forward the SuperIdentity to a new DHT record + /// Instances should not hash this recordKey, rather the actual record + /// key used to store the superIdentity, as this may change. + @override + final Typed recordKey; + + /// Public key of the SuperIdentity used to sign identity keys for recovery + /// This must match the owner of the superRecord DHT record and can not be + /// changed without changing the record + @override + final FixedEncodedString43 publicKey; + + /// Current identity instance + /// The most recently generated identity instance for this SuperIdentity + @override + final IdentityInstance currentInstance; + + /// Deprecated identity instances + /// These may be compromised and should not be considered valid for + /// new signatures, but may be used to validate old signatures + final List _deprecatedInstances; + + /// Deprecated identity instances + /// These may be compromised and should not be considered valid for + /// new signatures, but may be used to validate old signatures + @override + List get deprecatedInstances { + if (_deprecatedInstances is EqualUnmodifiableListView) + return _deprecatedInstances; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_deprecatedInstances); + } + + /// Deprecated superRecords + /// These may be compromised and should not be considered valid for + /// new signatures, but may be used to validate old signatures + final List> _deprecatedSuperRecordKeys; + + /// Deprecated superRecords + /// These may be compromised and should not be considered valid for + /// new signatures, but may be used to validate old signatures + @override + List> get deprecatedSuperRecordKeys { + if (_deprecatedSuperRecordKeys is EqualUnmodifiableListView) + return _deprecatedSuperRecordKeys; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_deprecatedSuperRecordKeys); + } + + /// Signature of recordKey, currentInstance signature, + /// signatures of deprecatedInstances, and deprecatedSuperRecordKeys + /// by publicKey + @override + final FixedEncodedString86 signature; + + /// Create a copy of SuperIdentity + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$SuperIdentityCopyWith<_SuperIdentity> get copyWith => + __$SuperIdentityCopyWithImpl<_SuperIdentity>(this, _$identity); + + @override + Map toJson() { + return _$SuperIdentityToJson( + this, + ); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _SuperIdentity && + (identical(other.recordKey, recordKey) || + other.recordKey == recordKey) && + (identical(other.publicKey, publicKey) || + other.publicKey == publicKey) && + (identical(other.currentInstance, currentInstance) || + other.currentInstance == currentInstance) && + const DeepCollectionEquality() + .equals(other._deprecatedInstances, _deprecatedInstances) && + const DeepCollectionEquality().equals( + other._deprecatedSuperRecordKeys, _deprecatedSuperRecordKeys) && + (identical(other.signature, signature) || + other.signature == signature)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + recordKey, + publicKey, + currentInstance, + const DeepCollectionEquality().hash(_deprecatedInstances), + const DeepCollectionEquality().hash(_deprecatedSuperRecordKeys), + signature); + + @override + String toString() { + return 'SuperIdentity(recordKey: $recordKey, publicKey: $publicKey, currentInstance: $currentInstance, deprecatedInstances: $deprecatedInstances, deprecatedSuperRecordKeys: $deprecatedSuperRecordKeys, signature: $signature)'; + } +} + +/// @nodoc +abstract mixin class _$SuperIdentityCopyWith<$Res> + implements $SuperIdentityCopyWith<$Res> { + factory _$SuperIdentityCopyWith( + _SuperIdentity value, $Res Function(_SuperIdentity) _then) = + __$SuperIdentityCopyWithImpl; + @override + @useResult + $Res call( + {Typed recordKey, + FixedEncodedString43 publicKey, + IdentityInstance currentInstance, + List deprecatedInstances, + List> deprecatedSuperRecordKeys, + FixedEncodedString86 signature}); + + @override + $IdentityInstanceCopyWith<$Res> get currentInstance; +} + +/// @nodoc +class __$SuperIdentityCopyWithImpl<$Res> + implements _$SuperIdentityCopyWith<$Res> { + __$SuperIdentityCopyWithImpl(this._self, this._then); + + final _SuperIdentity _self; + final $Res Function(_SuperIdentity) _then; + + /// Create a copy of SuperIdentity + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? recordKey = null, + Object? publicKey = null, + Object? currentInstance = null, + Object? deprecatedInstances = null, + Object? deprecatedSuperRecordKeys = null, + Object? signature = null, + }) { + return _then(_SuperIdentity( + recordKey: null == recordKey + ? _self.recordKey + : recordKey // ignore: cast_nullable_to_non_nullable + as Typed, + publicKey: null == publicKey + ? _self.publicKey + : publicKey // ignore: cast_nullable_to_non_nullable + as FixedEncodedString43, + currentInstance: null == currentInstance + ? _self.currentInstance + : currentInstance // ignore: cast_nullable_to_non_nullable + as IdentityInstance, + deprecatedInstances: null == deprecatedInstances + ? _self._deprecatedInstances + : deprecatedInstances // ignore: cast_nullable_to_non_nullable + as List, + deprecatedSuperRecordKeys: null == deprecatedSuperRecordKeys + ? _self._deprecatedSuperRecordKeys + : deprecatedSuperRecordKeys // ignore: cast_nullable_to_non_nullable + as List>, + signature: null == signature + ? _self.signature + : signature // ignore: cast_nullable_to_non_nullable + as FixedEncodedString86, + )); + } + + /// Create a copy of SuperIdentity + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $IdentityInstanceCopyWith<$Res> get currentInstance { + return $IdentityInstanceCopyWith<$Res>(_self.currentInstance, (value) { + return _then(_self.copyWith(currentInstance: value)); + }); + } +} + +// dart format on diff --git a/packages/veilid_support/lib/identity_support/super_identity.g.dart b/packages/veilid_support/lib/identity_support/super_identity.g.dart new file mode 100644 index 0000000..1b52492 --- /dev/null +++ b/packages/veilid_support/lib/identity_support/super_identity.g.dart @@ -0,0 +1,34 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'super_identity.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_SuperIdentity _$SuperIdentityFromJson(Map json) => + _SuperIdentity( + recordKey: Typed.fromJson(json['record_key']), + publicKey: FixedEncodedString43.fromJson(json['public_key']), + currentInstance: IdentityInstance.fromJson(json['current_instance']), + deprecatedInstances: (json['deprecated_instances'] as List) + .map(IdentityInstance.fromJson) + .toList(), + deprecatedSuperRecordKeys: + (json['deprecated_super_record_keys'] as List) + .map(Typed.fromJson) + .toList(), + signature: FixedEncodedString86.fromJson(json['signature']), + ); + +Map _$SuperIdentityToJson(_SuperIdentity instance) => + { + 'record_key': instance.recordKey.toJson(), + 'public_key': instance.publicKey.toJson(), + 'current_instance': instance.currentInstance.toJson(), + 'deprecated_instances': + instance.deprecatedInstances.map((e) => e.toJson()).toList(), + 'deprecated_super_record_keys': + instance.deprecatedSuperRecordKeys.map((e) => e.toJson()).toList(), + 'signature': instance.signature.toJson(), + }; diff --git a/packages/veilid_support/lib/identity_support/super_identity_cubit.dart b/packages/veilid_support/lib/identity_support/super_identity_cubit.dart new file mode 100644 index 0000000..9de55ad --- /dev/null +++ b/packages/veilid_support/lib/identity_support/super_identity_cubit.dart @@ -0,0 +1,21 @@ +import 'package:async_tools/async_tools.dart'; + +import '../veilid_support.dart'; + +typedef SuperIdentityState = AsyncValue; + +class SuperIdentityCubit extends DefaultDHTRecordCubit { + SuperIdentityCubit({required TypedKey superRecordKey}) + : super( + open: () => _open(superRecordKey: superRecordKey), + decodeState: (buf) => jsonDecodeBytes(SuperIdentity.fromJson, buf)); + + static Future _open({required TypedKey superRecordKey}) async { + final pool = DHTRecordPool.instance; + + return pool.openRecordRead( + superRecordKey, + debugName: 'SuperIdentityCubit::_open::SuperIdentityRecord', + ); + } +} diff --git a/packages/veilid_support/lib/identity_support/writable_super_identity.dart b/packages/veilid_support/lib/identity_support/writable_super_identity.dart new file mode 100644 index 0000000..093073f --- /dev/null +++ b/packages/veilid_support/lib/identity_support/writable_super_identity.dart @@ -0,0 +1,164 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; + +import '../src/veilid_log.dart'; +import '../veilid_support.dart'; + +Uint8List identityCryptoDomain = utf8.encode('identity'); + +/// SuperIdentity creator with secret +/// Not freezed because we never persist this class in its entirety. +class WritableSuperIdentity { + WritableSuperIdentity._({ + required this.superIdentity, + required this.superSecret, + required this.identitySecret, + }); + + static Future create() async { + final pool = DHTRecordPool.instance; + + // SuperIdentity DHT record is public/unencrypted + veilidLoggy.debug('Creating super identity record'); + return (await pool.createRecord( + debugName: 'WritableSuperIdentity::create::SuperIdentityRecord', + crypto: const VeilidCryptoPublic())) + .deleteScope((superRec) async { + final superRecordKey = superRec.key; + final superPublicKey = superRec.ownerKeyPair!.key; + final superSecret = superRec.ownerKeyPair!.secret; + + return _createIdentityInstance( + superRecordKey: superRecordKey, + superPublicKey: superPublicKey, + superSecret: superSecret, + closure: (identityInstance, identitySecret) async { + final signature = await _createSuperIdentitySignature( + recordKey: superRecordKey, + publicKey: superPublicKey, + secretKey: superSecret, + currentInstanceSignature: identityInstance.signature, + deprecatedInstancesSignatures: [], + deprecatedSuperRecordKeys: [], + ); + + final superIdentity = SuperIdentity( + recordKey: superRecordKey, + publicKey: superPublicKey, + currentInstance: identityInstance, + deprecatedInstances: [], + deprecatedSuperRecordKeys: [], + signature: signature); + + // Write superidentity to dht record + await superRec.eventualWriteJson(superIdentity); + + return WritableSuperIdentity._( + superIdentity: superIdentity, + superSecret: superSecret, + identitySecret: identitySecret); + }); + }); + } + + //////////////////////////////////////////////////////////////////////////// + // Public Interface + + /// Delete a super identity with secrets + Future delete() async => superIdentity.delete(); + + /// Produce a recovery key for this superIdentity + Uint8List get recoveryKey => (BytesBuilder() + ..add(superIdentity.recordKey.decode()) + ..add(superSecret.decode())) + .toBytes(); + + /// xxx: migration support, new identities, reveal identity secret etc + + //////////////////////////////////////////////////////////////////////////// + /// Private Implementation + + static Future _createSuperIdentitySignature({ + required TypedKey recordKey, + required Signature currentInstanceSignature, + required List deprecatedInstancesSignatures, + required List deprecatedSuperRecordKeys, + required PublicKey publicKey, + required SecretKey secretKey, + }) async { + final cs = await Veilid.instance.getCryptoSystem(recordKey.kind); + final sigBytes = SuperIdentity.signatureBytes( + recordKey: recordKey, + currentInstanceSignature: currentInstanceSignature, + deprecatedInstancesSignatures: deprecatedInstancesSignatures, + deprecatedSuperRecordKeys: deprecatedSuperRecordKeys); + return cs.sign(publicKey, secretKey, sigBytes); + } + + static Future _createIdentityInstance({ + required TypedKey superRecordKey, + required PublicKey superPublicKey, + required SecretKey superSecret, + required Future Function(IdentityInstance, SecretKey) closure, + }) async { + final pool = DHTRecordPool.instance; + veilidLoggy.debug('Creating identity instance record'); + // Identity record is private + return (await pool.createRecord( + debugName: 'SuperIdentityWithSecrets::create::IdentityRecord', + parent: superRecordKey)) + .deleteScope((identityRec) async { + final identityRecordKey = identityRec.key; + assert(superRecordKey.kind == identityRecordKey.kind, + 'new super and identity should have same cryptosystem'); + final identityPublicKey = identityRec.ownerKeyPair!.key; + final identitySecretKey = identityRec.ownerKeyPair!.secret; + + // Make encrypted secret key + final cs = await Veilid.instance.getCryptoSystem(identityRecordKey.kind); + + final encryptionKey = await cs.deriveSharedSecret( + superSecret.decode(), identityPublicKey.decode()); + final encryptedSecretKey = await cs.encryptNoAuthWithNonce( + identitySecretKey.decode(), encryptionKey); + + // Make supersignature + final superSigBuf = BytesBuilder() + ..add(superRecordKey.decode()) + ..add(superPublicKey.decode()); + + final superSignature = await cs.signWithKeyPair( + identityRec.ownerKeyPair!, superSigBuf.toBytes()); + + // Make signature + final signature = await IdentityInstance.createIdentitySignature( + recordKey: identityRecordKey, + publicKey: identityPublicKey, + encryptedSecretKey: encryptedSecretKey, + superSignature: superSignature, + superPublicKey: superPublicKey, + superSecret: superSecret); + + // Make empty identity + const identity = Identity(accountRecords: IMapConst({})); + + // Write empty identity to identity dht key + await identityRec.eventualWriteJson(identity); + + final identityInstance = IdentityInstance( + recordKey: identityRecordKey, + publicKey: identityPublicKey, + encryptedSecretKey: encryptedSecretKey, + superSignature: superSignature, + signature: signature); + + return closure(identityInstance, identitySecretKey); + }); + } + + SuperIdentity superIdentity; + SecretKey superSecret; + SecretKey identitySecret; +} diff --git a/lib/proto/dht.pb.dart b/packages/veilid_support/lib/proto/dht.pb.dart similarity index 72% rename from lib/proto/dht.pb.dart rename to packages/veilid_support/lib/proto/dht.pb.dart index 94d516b..b1c0b47 100644 --- a/lib/proto/dht.pb.dart +++ b/packages/veilid_support/lib/proto/dht.pb.dart @@ -4,7 +4,7 @@ // // @dart = 2.12 -// ignore_for_file: annotate_overrides, camel_case_types +// ignore_for_file: annotate_overrides, camel_case_types, comment_references // ignore_for_file: constant_identifier_names, library_prefixes // ignore_for_file: non_constant_identifier_names, prefer_final_fields // ignore_for_file: unnecessary_import, unnecessary_this, unused_import @@ -16,7 +16,27 @@ import 'package:protobuf/protobuf.dart' as $pb; import 'veilid.pb.dart' as $0; class DHTData extends $pb.GeneratedMessage { - factory DHTData() => create(); + factory DHTData({ + $core.Iterable<$0.TypedKey>? keys, + $0.TypedKey? hash, + $core.int? chunk, + $core.int? size, + }) { + final $result = create(); + if (keys != null) { + $result.keys.addAll(keys); + } + if (hash != null) { + $result.hash = hash; + } + if (chunk != null) { + $result.chunk = chunk; + } + if (size != null) { + $result.size = size; + } + return $result; + } DHTData._() : super(); factory DHTData.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory DHTData.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -50,9 +70,12 @@ class DHTData extends $pb.GeneratedMessage { static DHTData getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static DHTData? _defaultInstance; + /// Other keys to concatenate + /// Uses the same writer as this DHTList with SMPL schema @$pb.TagNumber(1) $core.List<$0.TypedKey> get keys => $_getList(0); + /// Hash of reassembled data to verify contents @$pb.TagNumber(2) $0.TypedKey get hash => $_getN(1); @$pb.TagNumber(2) @@ -64,6 +87,7 @@ class DHTData extends $pb.GeneratedMessage { @$pb.TagNumber(2) $0.TypedKey ensureHash() => $_ensure(1); + /// Chunk size per subkey @$pb.TagNumber(3) $core.int get chunk => $_getIZ(2); @$pb.TagNumber(3) @@ -73,6 +97,7 @@ class DHTData extends $pb.GeneratedMessage { @$pb.TagNumber(3) void clearChunk() => clearField(3); + /// Total data size @$pb.TagNumber(4) $core.int get size => $_getIZ(3); @$pb.TagNumber(4) @@ -83,63 +108,34 @@ class DHTData extends $pb.GeneratedMessage { void clearSize() => clearField(4); } -class DHTShortArray extends $pb.GeneratedMessage { - factory DHTShortArray() => create(); - DHTShortArray._() : super(); - factory DHTShortArray.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); - factory DHTShortArray.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); - - static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'DHTShortArray', package: const $pb.PackageName(_omitMessageNames ? '' : 'dht'), createEmptyInstance: create) - ..pc<$0.TypedKey>(1, _omitFieldNames ? '' : 'keys', $pb.PbFieldType.PM, subBuilder: $0.TypedKey.create) - ..a<$core.List<$core.int>>(2, _omitFieldNames ? '' : 'index', $pb.PbFieldType.OY) - ..hasRequiredFields = false - ; - - @$core.Deprecated( - 'Using this can add significant overhead to your binary. ' - 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' - 'Will be removed in next major version') - DHTShortArray clone() => DHTShortArray()..mergeFromMessage(this); - @$core.Deprecated( - 'Using this can add significant overhead to your binary. ' - 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' - 'Will be removed in next major version') - DHTShortArray copyWith(void Function(DHTShortArray) updates) => super.copyWith((message) => updates(message as DHTShortArray)) as DHTShortArray; - - $pb.BuilderInfo get info_ => _i; - - @$core.pragma('dart2js:noInline') - static DHTShortArray create() => DHTShortArray._(); - DHTShortArray createEmptyInstance() => create(); - static $pb.PbList createRepeated() => $pb.PbList(); - @$core.pragma('dart2js:noInline') - static DHTShortArray getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); - static DHTShortArray? _defaultInstance; - - @$pb.TagNumber(1) - $core.List<$0.TypedKey> get keys => $_getList(0); - - @$pb.TagNumber(2) - $core.List<$core.int> get index => $_getN(1); - @$pb.TagNumber(2) - set index($core.List<$core.int> v) { $_setBytes(1, v); } - @$pb.TagNumber(2) - $core.bool hasIndex() => $_has(1); - @$pb.TagNumber(2) - void clearIndex() => clearField(2); -} - +/// DHTLog - represents a ring buffer of many elements with append/truncate semantics +/// Header in subkey 0 of first key follows this structure class DHTLog extends $pb.GeneratedMessage { - factory DHTLog() => create(); + factory DHTLog({ + $core.int? head, + $core.int? tail, + $core.int? stride, + }) { + final $result = create(); + if (head != null) { + $result.head = head; + } + if (tail != null) { + $result.tail = tail; + } + if (stride != null) { + $result.stride = stride; + } + return $result; + } DHTLog._() : super(); factory DHTLog.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory DHTLog.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'DHTLog', package: const $pb.PackageName(_omitMessageNames ? '' : 'dht'), createEmptyInstance: create) - ..pc<$0.TypedKey>(1, _omitFieldNames ? '' : 'keys', $pb.PbFieldType.PM, subBuilder: $0.TypedKey.create) - ..aOM<$0.TypedKey>(2, _omitFieldNames ? '' : 'back', subBuilder: $0.TypedKey.create) - ..p<$core.int>(3, _omitFieldNames ? '' : 'subkeyCounts', $pb.PbFieldType.KU3) - ..a<$core.int>(4, _omitFieldNames ? '' : 'totalSubkeys', $pb.PbFieldType.OU3) + ..a<$core.int>(1, _omitFieldNames ? '' : 'head', $pb.PbFieldType.OU3) + ..a<$core.int>(2, _omitFieldNames ? '' : 'tail', $pb.PbFieldType.OU3) + ..a<$core.int>(3, _omitFieldNames ? '' : 'stride', $pb.PbFieldType.OU3) ..hasRequiredFields = false ; @@ -164,51 +160,71 @@ class DHTLog extends $pb.GeneratedMessage { static DHTLog getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static DHTLog? _defaultInstance; + /// Position of the start of the log (oldest items) @$pb.TagNumber(1) - $core.List<$0.TypedKey> get keys => $_getList(0); + $core.int get head => $_getIZ(0); + @$pb.TagNumber(1) + set head($core.int v) { $_setUnsignedInt32(0, v); } + @$pb.TagNumber(1) + $core.bool hasHead() => $_has(0); + @$pb.TagNumber(1) + void clearHead() => clearField(1); + /// Position of the end of the log (newest items) @$pb.TagNumber(2) - $0.TypedKey get back => $_getN(1); + $core.int get tail => $_getIZ(1); @$pb.TagNumber(2) - set back($0.TypedKey v) { setField(2, v); } + set tail($core.int v) { $_setUnsignedInt32(1, v); } @$pb.TagNumber(2) - $core.bool hasBack() => $_has(1); + $core.bool hasTail() => $_has(1); @$pb.TagNumber(2) - void clearBack() => clearField(2); - @$pb.TagNumber(2) - $0.TypedKey ensureBack() => $_ensure(1); + void clearTail() => clearField(2); + /// Stride of each segment of the dhtlog @$pb.TagNumber(3) - $core.List<$core.int> get subkeyCounts => $_getList(2); - - @$pb.TagNumber(4) - $core.int get totalSubkeys => $_getIZ(3); - @$pb.TagNumber(4) - set totalSubkeys($core.int v) { $_setUnsignedInt32(3, v); } - @$pb.TagNumber(4) - $core.bool hasTotalSubkeys() => $_has(3); - @$pb.TagNumber(4) - void clearTotalSubkeys() => clearField(4); + $core.int get stride => $_getIZ(2); + @$pb.TagNumber(3) + set stride($core.int v) { $_setUnsignedInt32(2, v); } + @$pb.TagNumber(3) + $core.bool hasStride() => $_has(2); + @$pb.TagNumber(3) + void clearStride() => clearField(3); } -enum DataReference_Kind { - dhtData, - notSet -} +/// DHTShortArray - represents a re-orderable collection of up to 256 individual elements +/// Header in subkey 0 of first key follows this structure +/// +/// stride = descriptor subkey count on first key - 1 +/// Subkeys 1..=stride on the first key are individual elements +/// Subkeys 0..stride on the 'keys' keys are also individual elements +/// +/// Keys must use writable schema in order to make this list mutable +class DHTShortArray extends $pb.GeneratedMessage { + factory DHTShortArray({ + $core.Iterable<$0.TypedKey>? keys, + $core.List<$core.int>? index, + $core.Iterable<$core.int>? seqs, + }) { + final $result = create(); + if (keys != null) { + $result.keys.addAll(keys); + } + if (index != null) { + $result.index = index; + } + if (seqs != null) { + $result.seqs.addAll(seqs); + } + return $result; + } + DHTShortArray._() : super(); + factory DHTShortArray.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory DHTShortArray.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); -class DataReference extends $pb.GeneratedMessage { - factory DataReference() => create(); - DataReference._() : super(); - factory DataReference.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); - factory DataReference.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); - - static const $core.Map<$core.int, DataReference_Kind> _DataReference_KindByTag = { - 1 : DataReference_Kind.dhtData, - 0 : DataReference_Kind.notSet - }; - static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'DataReference', package: const $pb.PackageName(_omitMessageNames ? '' : 'dht'), createEmptyInstance: create) - ..oo(0, [1]) - ..aOM<$0.TypedKey>(1, _omitFieldNames ? '' : 'dhtData', subBuilder: $0.TypedKey.create) + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'DHTShortArray', package: const $pb.PackageName(_omitMessageNames ? '' : 'dht'), createEmptyInstance: create) + ..pc<$0.TypedKey>(1, _omitFieldNames ? '' : 'keys', $pb.PbFieldType.PM, subBuilder: $0.TypedKey.create) + ..a<$core.List<$core.int>>(2, _omitFieldNames ? '' : 'index', $pb.PbFieldType.OY) + ..p<$core.int>(3, _omitFieldNames ? '' : 'seqs', $pb.PbFieldType.KU3) ..hasRequiredFields = false ; @@ -216,40 +232,62 @@ class DataReference extends $pb.GeneratedMessage { 'Using this can add significant overhead to your binary. ' 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' 'Will be removed in next major version') - DataReference clone() => DataReference()..mergeFromMessage(this); + DHTShortArray clone() => DHTShortArray()..mergeFromMessage(this); @$core.Deprecated( 'Using this can add significant overhead to your binary. ' 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' 'Will be removed in next major version') - DataReference copyWith(void Function(DataReference) updates) => super.copyWith((message) => updates(message as DataReference)) as DataReference; + DHTShortArray copyWith(void Function(DHTShortArray) updates) => super.copyWith((message) => updates(message as DHTShortArray)) as DHTShortArray; $pb.BuilderInfo get info_ => _i; @$core.pragma('dart2js:noInline') - static DataReference create() => DataReference._(); - DataReference createEmptyInstance() => create(); - static $pb.PbList createRepeated() => $pb.PbList(); + static DHTShortArray create() => DHTShortArray._(); + DHTShortArray createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); @$core.pragma('dart2js:noInline') - static DataReference getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); - static DataReference? _defaultInstance; + static DHTShortArray getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static DHTShortArray? _defaultInstance; - DataReference_Kind whichKind() => _DataReference_KindByTag[$_whichOneof(0)]!; - void clearKind() => clearField($_whichOneof(0)); + /// Other keys to concatenate + /// Uses the same writer as this DHTList with SMPL schema + @$pb.TagNumber(1) + $core.List<$0.TypedKey> get keys => $_getList(0); - @$pb.TagNumber(1) - $0.TypedKey get dhtData => $_getN(0); - @$pb.TagNumber(1) - set dhtData($0.TypedKey v) { setField(1, v); } - @$pb.TagNumber(1) - $core.bool hasDhtData() => $_has(0); - @$pb.TagNumber(1) - void clearDhtData() => clearField(1); - @$pb.TagNumber(1) - $0.TypedKey ensureDhtData() => $_ensure(0); + /// Item position index (uint8[256./]) + /// Actual item location is: + /// idx = index[n] + 1 (offset for header at idx 0) + /// key = idx / stride + /// subkey = idx % stride + @$pb.TagNumber(2) + $core.List<$core.int> get index => $_getN(1); + @$pb.TagNumber(2) + set index($core.List<$core.int> v) { $_setBytes(1, v); } + @$pb.TagNumber(2) + $core.bool hasIndex() => $_has(1); + @$pb.TagNumber(2) + void clearIndex() => clearField(2); + + /// Most recent sequence numbers for elements + @$pb.TagNumber(3) + $core.List<$core.int> get seqs => $_getList(2); } +/// A pointer to an child DHT record class OwnedDHTRecordPointer extends $pb.GeneratedMessage { - factory OwnedDHTRecordPointer() => create(); + factory OwnedDHTRecordPointer({ + $0.TypedKey? recordKey, + $0.KeyPair? owner, + }) { + final $result = create(); + if (recordKey != null) { + $result.recordKey = recordKey; + } + if (owner != null) { + $result.owner = owner; + } + return $result; + } OwnedDHTRecordPointer._() : super(); factory OwnedDHTRecordPointer.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory OwnedDHTRecordPointer.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -281,6 +319,7 @@ class OwnedDHTRecordPointer extends $pb.GeneratedMessage { static OwnedDHTRecordPointer getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static OwnedDHTRecordPointer? _defaultInstance; + /// DHT Record key @$pb.TagNumber(1) $0.TypedKey get recordKey => $_getN(0); @$pb.TagNumber(1) @@ -292,6 +331,7 @@ class OwnedDHTRecordPointer extends $pb.GeneratedMessage { @$pb.TagNumber(1) $0.TypedKey ensureRecordKey() => $_ensure(0); + /// DHT record owner key @$pb.TagNumber(2) $0.KeyPair get owner => $_getN(1); @$pb.TagNumber(2) diff --git a/lib/proto/dht.pbenum.dart b/packages/veilid_support/lib/proto/dht.pbenum.dart similarity index 78% rename from lib/proto/dht.pbenum.dart rename to packages/veilid_support/lib/proto/dht.pbenum.dart index f76992d..7059e85 100644 --- a/lib/proto/dht.pbenum.dart +++ b/packages/veilid_support/lib/proto/dht.pbenum.dart @@ -4,7 +4,7 @@ // // @dart = 2.12 -// ignore_for_file: annotate_overrides, camel_case_types +// ignore_for_file: annotate_overrides, camel_case_types, comment_references // ignore_for_file: constant_identifier_names, library_prefixes // ignore_for_file: non_constant_identifier_names, prefer_final_fields // ignore_for_file: unnecessary_import, unnecessary_this, unused_import diff --git a/lib/proto/dht.pbjson.dart b/packages/veilid_support/lib/proto/dht.pbjson.dart similarity index 68% rename from lib/proto/dht.pbjson.dart rename to packages/veilid_support/lib/proto/dht.pbjson.dart index 939cf65..dd14566 100644 --- a/lib/proto/dht.pbjson.dart +++ b/packages/veilid_support/lib/proto/dht.pbjson.dart @@ -4,7 +4,7 @@ // // @dart = 2.12 -// ignore_for_file: annotate_overrides, camel_case_types +// ignore_for_file: annotate_overrides, camel_case_types, comment_references // ignore_for_file: constant_identifier_names, library_prefixes // ignore_for_file: non_constant_identifier_names, prefer_final_fields // ignore_for_file: unnecessary_import, unnecessary_this, unused_import @@ -30,52 +30,35 @@ final $typed_data.Uint8List dHTDataDescriptor = $convert.base64Decode( 'gCIAEoCzIQLnZlaWxpZC5UeXBlZEtleVIEaGFzaBIUCgVjaHVuaxgDIAEoDVIFY2h1bmsSEgoE' 'c2l6ZRgEIAEoDVIEc2l6ZQ=='); +@$core.Deprecated('Use dHTLogDescriptor instead') +const DHTLog$json = { + '1': 'DHTLog', + '2': [ + {'1': 'head', '3': 1, '4': 1, '5': 13, '10': 'head'}, + {'1': 'tail', '3': 2, '4': 1, '5': 13, '10': 'tail'}, + {'1': 'stride', '3': 3, '4': 1, '5': 13, '10': 'stride'}, + ], +}; + +/// Descriptor for `DHTLog`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List dHTLogDescriptor = $convert.base64Decode( + 'CgZESFRMb2cSEgoEaGVhZBgBIAEoDVIEaGVhZBISCgR0YWlsGAIgASgNUgR0YWlsEhYKBnN0cm' + 'lkZRgDIAEoDVIGc3RyaWRl'); + @$core.Deprecated('Use dHTShortArrayDescriptor instead') const DHTShortArray$json = { '1': 'DHTShortArray', '2': [ {'1': 'keys', '3': 1, '4': 3, '5': 11, '6': '.veilid.TypedKey', '10': 'keys'}, {'1': 'index', '3': 2, '4': 1, '5': 12, '10': 'index'}, + {'1': 'seqs', '3': 3, '4': 3, '5': 13, '10': 'seqs'}, ], }; /// Descriptor for `DHTShortArray`. Decode as a `google.protobuf.DescriptorProto`. final $typed_data.Uint8List dHTShortArrayDescriptor = $convert.base64Decode( 'Cg1ESFRTaG9ydEFycmF5EiQKBGtleXMYASADKAsyEC52ZWlsaWQuVHlwZWRLZXlSBGtleXMSFA' - 'oFaW5kZXgYAiABKAxSBWluZGV4'); - -@$core.Deprecated('Use dHTLogDescriptor instead') -const DHTLog$json = { - '1': 'DHTLog', - '2': [ - {'1': 'keys', '3': 1, '4': 3, '5': 11, '6': '.veilid.TypedKey', '10': 'keys'}, - {'1': 'back', '3': 2, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'back'}, - {'1': 'subkey_counts', '3': 3, '4': 3, '5': 13, '10': 'subkeyCounts'}, - {'1': 'total_subkeys', '3': 4, '4': 1, '5': 13, '10': 'totalSubkeys'}, - ], -}; - -/// Descriptor for `DHTLog`. Decode as a `google.protobuf.DescriptorProto`. -final $typed_data.Uint8List dHTLogDescriptor = $convert.base64Decode( - 'CgZESFRMb2cSJAoEa2V5cxgBIAMoCzIQLnZlaWxpZC5UeXBlZEtleVIEa2V5cxIkCgRiYWNrGA' - 'IgASgLMhAudmVpbGlkLlR5cGVkS2V5UgRiYWNrEiMKDXN1YmtleV9jb3VudHMYAyADKA1SDHN1' - 'YmtleUNvdW50cxIjCg10b3RhbF9zdWJrZXlzGAQgASgNUgx0b3RhbFN1YmtleXM='); - -@$core.Deprecated('Use dataReferenceDescriptor instead') -const DataReference$json = { - '1': 'DataReference', - '2': [ - {'1': 'dht_data', '3': 1, '4': 1, '5': 11, '6': '.veilid.TypedKey', '9': 0, '10': 'dhtData'}, - ], - '8': [ - {'1': 'kind'}, - ], -}; - -/// Descriptor for `DataReference`. Decode as a `google.protobuf.DescriptorProto`. -final $typed_data.Uint8List dataReferenceDescriptor = $convert.base64Decode( - 'Cg1EYXRhUmVmZXJlbmNlEi0KCGRodF9kYXRhGAEgASgLMhAudmVpbGlkLlR5cGVkS2V5SABSB2' - 'RodERhdGFCBgoEa2luZA=='); + 'oFaW5kZXgYAiABKAxSBWluZGV4EhIKBHNlcXMYAyADKA1SBHNlcXM='); @$core.Deprecated('Use ownedDHTRecordPointerDescriptor instead') const OwnedDHTRecordPointer$json = { diff --git a/lib/proto/dht.pbserver.dart b/packages/veilid_support/lib/proto/dht.pbserver.dart similarity index 82% rename from lib/proto/dht.pbserver.dart rename to packages/veilid_support/lib/proto/dht.pbserver.dart index ffbf990..02e8c03 100644 --- a/lib/proto/dht.pbserver.dart +++ b/packages/veilid_support/lib/proto/dht.pbserver.dart @@ -4,7 +4,7 @@ // // @dart = 2.12 -// ignore_for_file: annotate_overrides, camel_case_types +// ignore_for_file: annotate_overrides, camel_case_types, comment_references // ignore_for_file: constant_identifier_names // ignore_for_file: deprecated_member_use_from_same_package, library_prefixes // ignore_for_file: non_constant_identifier_names, prefer_final_fields diff --git a/packages/veilid_support/lib/proto/proto.dart b/packages/veilid_support/lib/proto/proto.dart new file mode 100644 index 0000000..936bbdf --- /dev/null +++ b/packages/veilid_support/lib/proto/proto.dart @@ -0,0 +1,176 @@ +import 'dart:typed_data'; + +import '../src/dynamic_debug.dart'; +import '../veilid_support.dart' as veilid; +import 'veilid.pb.dart' as proto; + +export 'veilid.pb.dart'; +export 'veilid.pbenum.dart'; +export 'veilid.pbjson.dart'; +export 'veilid.pbserver.dart'; + +/// CryptoKey protobuf marshaling +/// +extension CryptoKeyProto on veilid.CryptoKey { + proto.CryptoKey toProto() { + final b = decode().buffer.asByteData(); + final out = proto.CryptoKey() + ..u0 = b.getUint32(0 * 4) + ..u1 = b.getUint32(1 * 4) + ..u2 = b.getUint32(2 * 4) + ..u3 = b.getUint32(3 * 4) + ..u4 = b.getUint32(4 * 4) + ..u5 = b.getUint32(5 * 4) + ..u6 = b.getUint32(6 * 4) + ..u7 = b.getUint32(7 * 4); + return out; + } +} + +extension ProtoCryptoKey on proto.CryptoKey { + veilid.CryptoKey toVeilid() { + final b = ByteData(32) + ..setUint32(0 * 4, u0) + ..setUint32(1 * 4, u1) + ..setUint32(2 * 4, u2) + ..setUint32(3 * 4, u3) + ..setUint32(4 * 4, u4) + ..setUint32(5 * 4, u5) + ..setUint32(6 * 4, u6) + ..setUint32(7 * 4, u7); + return veilid.CryptoKey.fromBytes(Uint8List.view(b.buffer)); + } +} + +/// Signature protobuf marshaling +/// +extension SignatureProto on veilid.Signature { + proto.Signature toProto() { + final b = decode().buffer.asByteData(); + final out = proto.Signature() + ..u0 = b.getUint32(0 * 4) + ..u1 = b.getUint32(1 * 4) + ..u2 = b.getUint32(2 * 4) + ..u3 = b.getUint32(3 * 4) + ..u4 = b.getUint32(4 * 4) + ..u5 = b.getUint32(5 * 4) + ..u6 = b.getUint32(6 * 4) + ..u7 = b.getUint32(7 * 4) + ..u8 = b.getUint32(8 * 4) + ..u9 = b.getUint32(9 * 4) + ..u10 = b.getUint32(10 * 4) + ..u11 = b.getUint32(11 * 4) + ..u12 = b.getUint32(12 * 4) + ..u13 = b.getUint32(13 * 4) + ..u14 = b.getUint32(14 * 4) + ..u15 = b.getUint32(15 * 4); + return out; + } +} + +extension ProtoSignature on proto.Signature { + veilid.Signature toVeilid() { + final b = ByteData(64) + ..setUint32(0 * 4, u0) + ..setUint32(1 * 4, u1) + ..setUint32(2 * 4, u2) + ..setUint32(3 * 4, u3) + ..setUint32(4 * 4, u4) + ..setUint32(5 * 4, u5) + ..setUint32(6 * 4, u6) + ..setUint32(7 * 4, u7) + ..setUint32(8 * 4, u8) + ..setUint32(9 * 4, u9) + ..setUint32(10 * 4, u10) + ..setUint32(11 * 4, u11) + ..setUint32(12 * 4, u12) + ..setUint32(13 * 4, u13) + ..setUint32(14 * 4, u14) + ..setUint32(15 * 4, u15); + return veilid.Signature.fromBytes(Uint8List.view(b.buffer)); + } +} + +/// Nonce protobuf marshaling +/// +extension NonceProto on veilid.Nonce { + proto.Nonce toProto() { + final b = decode().buffer.asByteData(); + final out = proto.Nonce() + ..u0 = b.getUint32(0 * 4) + ..u1 = b.getUint32(1 * 4) + ..u2 = b.getUint32(2 * 4) + ..u3 = b.getUint32(3 * 4) + ..u4 = b.getUint32(4 * 4) + ..u5 = b.getUint32(5 * 4); + return out; + } +} + +extension ProtoNonce on proto.Nonce { + veilid.Nonce toVeilid() { + final b = ByteData(24) + ..setUint32(0 * 4, u0) + ..setUint32(1 * 4, u1) + ..setUint32(2 * 4, u2) + ..setUint32(3 * 4, u3) + ..setUint32(4 * 4, u4) + ..setUint32(5 * 4, u5); + return veilid.Nonce.fromBytes(Uint8List.view(b.buffer)); + } +} + +/// TypedKey protobuf marshaling +/// +extension TypedKeyProto on veilid.TypedKey { + proto.TypedKey toProto() { + final out = proto.TypedKey() + ..kind = kind + ..value = value.toProto(); + return out; + } +} + +extension ProtoTypedKey on proto.TypedKey { + veilid.TypedKey toVeilid() => + veilid.TypedKey(kind: kind, value: value.toVeilid()); +} + +/// KeyPair protobuf marshaling +/// +extension KeyPairProto on veilid.KeyPair { + proto.KeyPair toProto() { + final out = proto.KeyPair() + ..key = key.toProto() + ..secret = secret.toProto(); + return out; + } +} + +extension ProtoKeyPair on proto.KeyPair { + veilid.KeyPair toVeilid() => + veilid.KeyPair(key: key.toVeilid(), secret: secret.toVeilid()); +} + +void registerVeilidProtoToDebug() { + dynamic toDebug(dynamic protoObj) { + if (protoObj is proto.CryptoKey) { + return protoObj.toVeilid(); + } + if (protoObj is proto.Signature) { + return protoObj.toVeilid(); + } + if (protoObj is proto.Nonce) { + return protoObj.toVeilid(); + } + if (protoObj is proto.TypedKey) { + return protoObj.toVeilid(); + } + if (protoObj is proto.KeyPair) { + return protoObj.toVeilid(); + } + return protoObj; + } + + DynamicDebug.registerToDebug(toDebug); +} diff --git a/lib/proto/veilid.pb.dart b/packages/veilid_support/lib/proto/veilid.pb.dart similarity index 85% rename from lib/proto/veilid.pb.dart rename to packages/veilid_support/lib/proto/veilid.pb.dart index a53133a..5431b80 100644 --- a/lib/proto/veilid.pb.dart +++ b/packages/veilid_support/lib/proto/veilid.pb.dart @@ -4,7 +4,7 @@ // // @dart = 2.12 -// ignore_for_file: annotate_overrides, camel_case_types +// ignore_for_file: annotate_overrides, camel_case_types, comment_references // ignore_for_file: constant_identifier_names, library_prefixes // ignore_for_file: non_constant_identifier_names, prefer_final_fields // ignore_for_file: unnecessary_import, unnecessary_this, unused_import @@ -13,8 +13,45 @@ import 'dart:core' as $core; import 'package:protobuf/protobuf.dart' as $pb; +/// 32-byte value in bigendian format class CryptoKey extends $pb.GeneratedMessage { - factory CryptoKey() => create(); + factory CryptoKey({ + $core.int? u0, + $core.int? u1, + $core.int? u2, + $core.int? u3, + $core.int? u4, + $core.int? u5, + $core.int? u6, + $core.int? u7, + }) { + final $result = create(); + if (u0 != null) { + $result.u0 = u0; + } + if (u1 != null) { + $result.u1 = u1; + } + if (u2 != null) { + $result.u2 = u2; + } + if (u3 != null) { + $result.u3 = u3; + } + if (u4 != null) { + $result.u4 = u4; + } + if (u5 != null) { + $result.u5 = u5; + } + if (u6 != null) { + $result.u6 = u6; + } + if (u7 != null) { + $result.u7 = u7; + } + return $result; + } CryptoKey._() : super(); factory CryptoKey.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory CryptoKey.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -125,8 +162,77 @@ class CryptoKey extends $pb.GeneratedMessage { void clearU7() => clearField(8); } +/// 64-byte value in bigendian format class Signature extends $pb.GeneratedMessage { - factory Signature() => create(); + factory Signature({ + $core.int? u0, + $core.int? u1, + $core.int? u2, + $core.int? u3, + $core.int? u4, + $core.int? u5, + $core.int? u6, + $core.int? u7, + $core.int? u8, + $core.int? u9, + $core.int? u10, + $core.int? u11, + $core.int? u12, + $core.int? u13, + $core.int? u14, + $core.int? u15, + }) { + final $result = create(); + if (u0 != null) { + $result.u0 = u0; + } + if (u1 != null) { + $result.u1 = u1; + } + if (u2 != null) { + $result.u2 = u2; + } + if (u3 != null) { + $result.u3 = u3; + } + if (u4 != null) { + $result.u4 = u4; + } + if (u5 != null) { + $result.u5 = u5; + } + if (u6 != null) { + $result.u6 = u6; + } + if (u7 != null) { + $result.u7 = u7; + } + if (u8 != null) { + $result.u8 = u8; + } + if (u9 != null) { + $result.u9 = u9; + } + if (u10 != null) { + $result.u10 = u10; + } + if (u11 != null) { + $result.u11 = u11; + } + if (u12 != null) { + $result.u12 = u12; + } + if (u13 != null) { + $result.u13 = u13; + } + if (u14 != null) { + $result.u14 = u14; + } + if (u15 != null) { + $result.u15 = u15; + } + return $result; + } Signature._() : super(); factory Signature.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory Signature.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -317,8 +423,37 @@ class Signature extends $pb.GeneratedMessage { void clearU15() => clearField(16); } +/// 24-byte value in bigendian format class Nonce extends $pb.GeneratedMessage { - factory Nonce() => create(); + factory Nonce({ + $core.int? u0, + $core.int? u1, + $core.int? u2, + $core.int? u3, + $core.int? u4, + $core.int? u5, + }) { + final $result = create(); + if (u0 != null) { + $result.u0 = u0; + } + if (u1 != null) { + $result.u1 = u1; + } + if (u2 != null) { + $result.u2 = u2; + } + if (u3 != null) { + $result.u3 = u3; + } + if (u4 != null) { + $result.u4 = u4; + } + if (u5 != null) { + $result.u5 = u5; + } + return $result; + } Nonce._() : super(); factory Nonce.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory Nonce.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -409,8 +544,21 @@ class Nonce extends $pb.GeneratedMessage { void clearU5() => clearField(6); } +/// 36-byte typed crypto key class TypedKey extends $pb.GeneratedMessage { - factory TypedKey() => create(); + factory TypedKey({ + $core.int? kind, + CryptoKey? value, + }) { + final $result = create(); + if (kind != null) { + $result.kind = kind; + } + if (value != null) { + $result.value = value; + } + return $result; + } TypedKey._() : super(); factory TypedKey.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory TypedKey.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -442,6 +590,7 @@ class TypedKey extends $pb.GeneratedMessage { static TypedKey getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static TypedKey? _defaultInstance; + /// CryptoKind FourCC in bigendian format @$pb.TagNumber(1) $core.int get kind => $_getIZ(0); @$pb.TagNumber(1) @@ -451,6 +600,7 @@ class TypedKey extends $pb.GeneratedMessage { @$pb.TagNumber(1) void clearKind() => clearField(1); + /// Key value @$pb.TagNumber(2) CryptoKey get value => $_getN(1); @$pb.TagNumber(2) @@ -463,8 +613,21 @@ class TypedKey extends $pb.GeneratedMessage { CryptoKey ensureValue() => $_ensure(1); } +/// Key pair class KeyPair extends $pb.GeneratedMessage { - factory KeyPair() => create(); + factory KeyPair({ + CryptoKey? key, + CryptoKey? secret, + }) { + final $result = create(); + if (key != null) { + $result.key = key; + } + if (secret != null) { + $result.secret = secret; + } + return $result; + } KeyPair._() : super(); factory KeyPair.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); factory KeyPair.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); @@ -496,6 +659,7 @@ class KeyPair extends $pb.GeneratedMessage { static KeyPair getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); static KeyPair? _defaultInstance; + /// Public key @$pb.TagNumber(1) CryptoKey get key => $_getN(0); @$pb.TagNumber(1) @@ -507,6 +671,7 @@ class KeyPair extends $pb.GeneratedMessage { @$pb.TagNumber(1) CryptoKey ensureKey() => $_ensure(0); + /// Private key @$pb.TagNumber(2) CryptoKey get secret => $_getN(1); @$pb.TagNumber(2) diff --git a/lib/proto/veilid.pbenum.dart b/packages/veilid_support/lib/proto/veilid.pbenum.dart similarity index 79% rename from lib/proto/veilid.pbenum.dart rename to packages/veilid_support/lib/proto/veilid.pbenum.dart index 1ade7e9..89c0019 100644 --- a/lib/proto/veilid.pbenum.dart +++ b/packages/veilid_support/lib/proto/veilid.pbenum.dart @@ -4,7 +4,7 @@ // // @dart = 2.12 -// ignore_for_file: annotate_overrides, camel_case_types +// ignore_for_file: annotate_overrides, camel_case_types, comment_references // ignore_for_file: constant_identifier_names, library_prefixes // ignore_for_file: non_constant_identifier_names, prefer_final_fields // ignore_for_file: unnecessary_import, unnecessary_this, unused_import diff --git a/lib/proto/veilid.pbjson.dart b/packages/veilid_support/lib/proto/veilid.pbjson.dart similarity index 98% rename from lib/proto/veilid.pbjson.dart rename to packages/veilid_support/lib/proto/veilid.pbjson.dart index b92b4e5..db8318e 100644 --- a/lib/proto/veilid.pbjson.dart +++ b/packages/veilid_support/lib/proto/veilid.pbjson.dart @@ -4,7 +4,7 @@ // // @dart = 2.12 -// ignore_for_file: annotate_overrides, camel_case_types +// ignore_for_file: annotate_overrides, camel_case_types, comment_references // ignore_for_file: constant_identifier_names, library_prefixes // ignore_for_file: non_constant_identifier_names, prefer_final_fields // ignore_for_file: unnecessary_import, unnecessary_this, unused_import diff --git a/lib/proto/veilid.pbserver.dart b/packages/veilid_support/lib/proto/veilid.pbserver.dart similarity index 83% rename from lib/proto/veilid.pbserver.dart rename to packages/veilid_support/lib/proto/veilid.pbserver.dart index 2de2834..f799a3f 100644 --- a/lib/proto/veilid.pbserver.dart +++ b/packages/veilid_support/lib/proto/veilid.pbserver.dart @@ -4,7 +4,7 @@ // // @dart = 2.12 -// ignore_for_file: annotate_overrides, camel_case_types +// ignore_for_file: annotate_overrides, camel_case_types, comment_references // ignore_for_file: constant_identifier_names // ignore_for_file: deprecated_member_use_from_same_package, library_prefixes // ignore_for_file: non_constant_identifier_names, prefer_final_fields diff --git a/lib/veilid_support/proto/veilid.proto b/packages/veilid_support/lib/proto/veilid.proto similarity index 100% rename from lib/veilid_support/proto/veilid.proto rename to packages/veilid_support/lib/proto/veilid.proto diff --git a/packages/veilid_support/lib/src/async_table_db_backed_cubit.dart b/packages/veilid_support/lib/src/async_table_db_backed_cubit.dart new file mode 100644 index 0000000..313d3e2 --- /dev/null +++ b/packages/veilid_support/lib/src/async_table_db_backed_cubit.dart @@ -0,0 +1,50 @@ +import 'dart:async'; + +import 'package:async_tools/async_tools.dart'; +import 'package:bloc/bloc.dart'; +import 'package:meta/meta.dart'; + +import 'config.dart'; +import 'table_db.dart'; + +abstract class AsyncTableDBBackedCubit extends Cubit> + with TableDBBackedJson { + AsyncTableDBBackedCubit() : super(const AsyncValue.loading()) { + _initWait.add(_build); + } + + @override + Future close() async { + // Ensure the init finished + await _initWait(); + // Wait for any setStates to finish + await _mutex.acquire(); + + await super.close(); + } + + Future _build(_) async { + try { + await _mutex.protect(() async { + emit(AsyncValue.data(await load())); + }); + } on Exception catch (e, st) { + addError(e, st); + emit(AsyncValue.error(e, st)); + } + } + + @protected + Future setState(T? newState) async { + await _initWait(); + try { + emit(AsyncValue.data(await store(newState))); + } on Exception catch (e, st) { + addError(e, st); + emit(AsyncValue.error(e, st)); + } + } + + final WaitSet _initWait = WaitSet(); + final Mutex _mutex = Mutex(debugLockTimeout: kIsDebugMode ? 60 : null); +} diff --git a/packages/veilid_support/lib/src/config.dart b/packages/veilid_support/lib/src/config.dart new file mode 100644 index 0000000..6902479 --- /dev/null +++ b/packages/veilid_support/lib/src/config.dart @@ -0,0 +1,134 @@ +import 'dart:io' show Platform; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:veilid/veilid.dart'; + +// Allowed to pull sentinel value +// ignore: do_not_use_environment +const bool kIsReleaseMode = bool.fromEnvironment('dart.vm.product'); +// Allowed to pull sentinel value +// ignore: do_not_use_environment +const bool kIsProfileMode = bool.fromEnvironment('dart.vm.profile'); +const bool kIsDebugMode = !kIsReleaseMode && !kIsProfileMode; + +Future> getDefaultVeilidPlatformConfig( + bool isWeb, String appName) async { + final ignoreLogTargetsStr = + // Allowed to change settings + // ignore: do_not_use_environment + const String.fromEnvironment('IGNORE_LOG_TARGETS').trim(); + final ignoreLogTargets = ignoreLogTargetsStr.isEmpty + ? [] + : ignoreLogTargetsStr.split(',').map((e) => e.trim()).toList(); + + // Allowed to change settings + // ignore: do_not_use_environment + var flamePathStr = const String.fromEnvironment('FLAME').trim(); + if (flamePathStr == '1') { + flamePathStr = p.join( + (await getApplicationSupportDirectory()).absolute.path, + '$appName.folded'); + // Allowed for debugging + // ignore: avoid_print + print('Flame data logged to $flamePathStr'); + } + + if (isWeb) { + return VeilidWASMConfig( + logging: VeilidWASMConfigLogging( + performance: VeilidWASMConfigLoggingPerformance( + enabled: true, + level: kIsDebugMode + ? VeilidConfigLogLevel.debug + : VeilidConfigLogLevel.info, + logsInTimings: true, + logsInConsole: false, + ignoreLogTargets: ignoreLogTargets), + api: VeilidWASMConfigLoggingApi( + enabled: true, + level: VeilidConfigLogLevel.info, + ignoreLogTargets: ignoreLogTargets))) + .toJson(); + } + return VeilidFFIConfig( + logging: VeilidFFIConfigLogging( + terminal: VeilidFFIConfigLoggingTerminal( + enabled: false, + level: kIsDebugMode + ? VeilidConfigLogLevel.debug + : VeilidConfigLogLevel.info, + ignoreLogTargets: ignoreLogTargets), + otlp: VeilidFFIConfigLoggingOtlp( + enabled: false, + level: VeilidConfigLogLevel.trace, + grpcEndpoint: '127.0.0.1:4317', + serviceName: appName, + ignoreLogTargets: ignoreLogTargets), + api: VeilidFFIConfigLoggingApi( + enabled: true, + level: VeilidConfigLogLevel.info, + ignoreLogTargets: ignoreLogTargets), + flame: VeilidFFIConfigLoggingFlame( + enabled: flamePathStr.isNotEmpty, path: flamePathStr))) + .toJson(); +} + +Future getVeilidConfig(bool isWeb, String programName) async { + var config = await getDefaultVeilidConfig( + isWeb: isWeb, + programName: programName, + // Allowed to change settings + // ignore: avoid_redundant_argument_values, do_not_use_environment + namespace: const String.fromEnvironment('NAMESPACE'), + // Allowed to change settings + // ignore: avoid_redundant_argument_values, do_not_use_environment + bootstrap: const String.fromEnvironment('BOOTSTRAP'), + // Allowed to change settings + // ignore: avoid_redundant_argument_values, do_not_use_environment + networkKeyPassword: const String.fromEnvironment('NETWORK_KEY'), + ); + + // Allowed to change settings + // ignore: do_not_use_environment + if (const String.fromEnvironment('DELETE_TABLE_STORE') == '1') { + config = + config.copyWith(tableStore: config.tableStore.copyWith(delete: true)); + } + // Allowed to change settings + // ignore: do_not_use_environment + if (const String.fromEnvironment('DELETE_PROTECTED_STORE') == '1') { + config = config.copyWith( + protectedStore: config.protectedStore.copyWith(delete: true)); + } + // Allowed to change settings + // ignore: do_not_use_environment + if (const String.fromEnvironment('DELETE_BLOCK_STORE') == '1') { + config = + config.copyWith(blockStore: config.blockStore.copyWith(delete: true)); + } + + // Allowed to change settings + // ignore: do_not_use_environment + const envNetwork = String.fromEnvironment('NETWORK'); + if (envNetwork.isNotEmpty) { + final bootstrap = isWeb + ? ['ws://bootstrap.$envNetwork.veilid.net:5150/ws'] + : ['bootstrap.$envNetwork.veilid.net']; + config = config.copyWith( + network: config.network.copyWith( + routingTable: + config.network.routingTable.copyWith(bootstrap: bootstrap))); + } + + return config.copyWith( + capabilities: + // XXX: Remove DHTV and DHTW after DHT widening (and maybe remote + // rehydration?) + const VeilidConfigCapabilities(disable: ['DHTV', 'DHTW', 'TUNL']), + protectedStore: + // XXX: Linux often does not have a secret storage mechanism installed + config.protectedStore + .copyWith(allowInsecureFallback: !isWeb && Platform.isLinux), + ); +} diff --git a/packages/veilid_support/lib/src/dynamic_debug.dart b/packages/veilid_support/lib/src/dynamic_debug.dart new file mode 100644 index 0000000..1c38d96 --- /dev/null +++ b/packages/veilid_support/lib/src/dynamic_debug.dart @@ -0,0 +1,130 @@ +import 'package:async_tools/async_tools.dart'; +import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; +import 'package:convert/convert.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; + +import 'online_element_state.dart'; + +typedef ToDebugFunction = dynamic Function(dynamic protoObj); + +// This should be implemented to add toDebug capability +// ignore: one_member_abstracts +abstract class ToDebugMap { + Map toDebugMap(); +} + +// We explicitly want this class to avoid having a global function 'toDebug' +// ignore: avoid_classes_with_only_static_members +class DynamicDebug { + /// Add a 'toDebug' handler to the chain + static void registerToDebug(ToDebugFunction toDebugFunction) { + final _oldToDebug = _toDebug; + _toDebug = (obj) => _oldToDebug(toDebugFunction(obj)); + } + + /// Convert a type to a debug version of the same type that + /// has a better `toString` representation and possibly other extra debug + /// information + static dynamic toDebug(dynamic obj) { + try { + return _toDebug(obj); + // In this case we watch to catch everything + // because toDebug need to never fail + // ignore: avoid_catches_without_on_clauses + } catch (e) { + // Ensure this gets printed, but continue + // ignore: avoid_print + print('Exception in toDebug: $e'); + return obj.toString(); + } + } + + ////////////////////////////////////////////////////////////// + static dynamic _baseToDebug(dynamic obj) { + if (obj is AsyncValue) { + if (obj.isLoading) { + return {r'$runtimeType': obj.runtimeType, 'loading': null}; + } + if (obj.isError) { + return { + r'$runtimeType': obj.runtimeType, + 'error': toDebug(obj.asError!.error), + 'stackTrace': toDebug(obj.asError!.stackTrace), + }; + } + if (obj.isData) { + return { + r'$runtimeType': obj.runtimeType, + 'data': toDebug(obj.asData!.value), + }; + } + return obj.toString(); + } + if (obj is IMap) { + // Handled by Map + return _baseToDebug(obj.unlockView); + } + if (obj is IMapOfSets) { + // Handled by Map + return _baseToDebug(obj.unlock); + } + if (obj is ISet) { + // Handled by Iterable + return _baseToDebug(obj.unlockView); + } + if (obj is IList) { + return _baseToDebug(obj.unlockView); + } + if (obj is BlocBusyState) { + return { + r'$runtimeType': obj.runtimeType, + 'busy': obj.busy, + 'state': toDebug(obj.state), + }; + } + if (obj is OnlineElementState) { + return { + r'$runtimeType': obj.runtimeType, + 'isOffline': obj.isOffline, + 'value': toDebug(obj.value), + }; + } + if (obj is List) { + try { + // Do bytes as a hex string for brevity and clarity + return 'List: ${hex.encode(obj)}'; + // One has to be able to catch this + // ignore: avoid_catching_errors + } on RangeError { + // Otherwise directly convert as list of integers + return obj.toString(); + } + } + if (obj is Map) { + return obj.map((k, v) => MapEntry(toDebug(k), toDebug(v))); + } + if (obj is Iterable) { + return obj.map(toDebug).toList(); + } + if (obj is String || obj is bool || obj is num || obj == null) { + return obj; + } + if (obj is ToDebugMap) { + // Handled by Map + return _baseToDebug(obj.toDebugMap()); + } + + try { + // Let's try convering to a json object + // ignore: avoid_dynamic_calls + return obj.toJson(); + + // No matter how this fails, we shouldn't throw + // ignore: avoid_catches_without_on_clauses + } catch (_) {} + + return obj.toString(); + } + + static ToDebugFunction _toDebug = _baseToDebug; +} diff --git a/lib/veilid_support/src/json_tools.dart b/packages/veilid_support/lib/src/json_tools.dart similarity index 66% rename from lib/veilid_support/src/json_tools.dart rename to packages/veilid_support/lib/src/json_tools.dart index e7bfd09..dd0f20b 100644 --- a/lib/veilid_support/src/json_tools.dart +++ b/packages/veilid_support/lib/src/json_tools.dart @@ -12,15 +12,19 @@ Uint8List jsonEncodeBytes(Object? object, Uint8List.fromList( utf8.encode(jsonEncode(object, toEncodable: toEncodable))); -Future jsonUpdateBytes(T Function(dynamic) fromJson, - Uint8List oldBytes, Future Function(T) update) async { - final oldObj = fromJson(jsonDecode(utf8.decode(oldBytes))); +Future jsonUpdateBytes(T Function(dynamic) fromJson, + Uint8List? oldBytes, Future Function(T?) update) async { + final oldObj = + oldBytes == null ? null : fromJson(jsonDecode(utf8.decode(oldBytes))); final newObj = await update(oldObj); + if (newObj == null) { + return null; + } return jsonEncodeBytes(newObj); } -Future Function(Uint8List) jsonUpdate( - T Function(dynamic) fromJson, Future Function(T) update) => +Future Function(Uint8List?) jsonUpdate( + T Function(dynamic) fromJson, Future Function(T?) update) => (oldBytes) => jsonUpdateBytes(fromJson, oldBytes, update); T Function(Object?) genericFromJson( diff --git a/packages/veilid_support/lib/src/memory_tools.dart b/packages/veilid_support/lib/src/memory_tools.dart new file mode 100644 index 0000000..08aa8dc --- /dev/null +++ b/packages/veilid_support/lib/src/memory_tools.dart @@ -0,0 +1,72 @@ +import 'dart:math'; +import 'dart:typed_data'; + +/// Compares two [Uint8List] contents for equality by comparing words at a time. +/// Returns true if this == other +extension Uint8ListCompare on Uint8List { + bool equals(Uint8List other) { + if (identical(this, other)) { + return true; + } + if (length != other.length) { + return false; + } + + final words = buffer.asUint32List(); + final otherwords = other.buffer.asUint32List(); + final wordLen = words.length; + + var i = 0; + for (; i < wordLen; i++) { + if (words[i] != otherwords[i]) { + break; + } + } + i <<= 2; + for (; i < length; i++) { + if (this[i] != other[i]) { + return false; + } + } + return true; + } + + /// Compares two [Uint8List] contents for + /// numeric ordering by comparing words at a time. + /// Returns -1 for this < other, 1 for this > other, and 0 for this == other. + int compare(Uint8List other) { + if (identical(this, other)) { + return 0; + } + + final words = buffer.asUint32List(); + final otherwords = other.buffer.asUint32List(); + final minWordLen = min(words.length, otherwords.length); + + var i = 0; + for (; i < minWordLen; i++) { + if (words[i] != otherwords[i]) { + break; + } + } + i <<= 2; + final minLen = min(length, other.length); + for (; i < minLen; i++) { + final a = this[i]; + final b = other[i]; + if (a < b) { + return -1; + } + if (a > b) { + return 1; + } + } + if (length < other.length) { + return -1; + } + if (length > other.length) { + return 1; + } + return 0; + } +} diff --git a/packages/veilid_support/lib/src/online_element_state.dart b/packages/veilid_support/lib/src/online_element_state.dart new file mode 100644 index 0000000..8cbd38b --- /dev/null +++ b/packages/veilid_support/lib/src/online_element_state.dart @@ -0,0 +1,12 @@ +import 'package:equatable/equatable.dart'; +import 'package:meta/meta.dart'; + +@immutable +class OnlineElementState extends Equatable { + const OnlineElementState({required this.value, required this.isOffline}); + final T value; + final bool isOffline; + + @override + List get props => [value, isOffline]; +} diff --git a/packages/veilid_support/lib/src/output.dart b/packages/veilid_support/lib/src/output.dart new file mode 100644 index 0000000..78902b3 --- /dev/null +++ b/packages/veilid_support/lib/src/output.dart @@ -0,0 +1,33 @@ +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; + +export 'package:fast_immutable_collections/fast_immutable_collections.dart' + show Output; + +extension OutputNullExt on Output? { + void mapSave(Output? other, T Function(S output) closure) { + if (this == null) { + return; + } + if (other == null) { + return; + } + final v = other.value; + if (v == null) { + return; + } + return this!.save(closure(v)); + } +} + +extension OutputExt on Output { + void mapSave(Output? other, T Function(S output) closure) { + if (other == null) { + return; + } + final v = other.value; + if (v == null) { + return; + } + return save(closure(v)); + } +} diff --git a/packages/veilid_support/lib/src/persistent_queue.dart b/packages/veilid_support/lib/src/persistent_queue.dart new file mode 100644 index 0000000..efb4c86 --- /dev/null +++ b/packages/veilid_support/lib/src/persistent_queue.dart @@ -0,0 +1,242 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:async_tools/async_tools.dart'; +import 'package:buffer/buffer.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; + +import 'config.dart'; +import 'table_db.dart'; +import 'veilid_log.dart'; + +const _ksfSyncAdd = 'ksfSyncAdd'; + +class PersistentQueue with TableDBBackedFromBuffer> { + // + PersistentQueue( + {required String table, + required String key, + required T Function(Uint8List) fromBuffer, + required Uint8List Function(T) toBuffer, + required Future Function(IList) closure, + bool deleteOnClose = false, + void Function(Object, StackTrace)? onError}) + : _table = table, + _key = key, + _fromBuffer = fromBuffer, + _toBuffer = toBuffer, + _closure = closure, + _deleteOnClose = deleteOnClose, + _onError = onError { + _initWait.add(_init); + } + + Future close() async { + // Ensure the init finished + await _initWait(); + + // Finish all sync adds + await serialFutureClose((this, _ksfSyncAdd)); + + // Stop the processing trigger + await _sspQueueReady.close(); + await _queueReady.close(); + + // No more queue actions + await _queueMutex.acquire(); + + // Clean up table if desired + if (_deleteOnClose) { + await delete(); + } + } + + set deleteOnClose(bool d) { + _deleteOnClose = d; + } + + bool get deleteOnClose => _deleteOnClose; + + Future get waitEmpty async { + // Ensure the init finished + await _initWait(); + + if (_queue.isEmpty) { + return; + } + final completer = Completer(); + _queueDoneCompleter = completer; + await completer.future; + } + + Future _init(Completer _) async { + // Start the processor + _sspQueueReady.follow(_queueReady.stream, true, (more) async { + await _initWait(); + if (more) { + await _process(); + } + }); + + // Load the queue if we have one + try { + await _queueMutex.protect(() async { + _queue = await load() ?? await store(IList.empty()); + _sendUpdateEventsInner(); + }); + } on Exception catch (e, st) { + if (_onError != null) { + _onError(e, st); + } else { + rethrow; + } + } + } + + void _sendUpdateEventsInner() { + assert(_queueMutex.isLocked, 'must be locked'); + if (_queue.isNotEmpty) { + if (!_queueReady.isClosed) { + _queueReady.sink.add(true); + } + } else { + _queueDoneCompleter?.complete(); + } + } + + Future _updateQueueInner(IList newQueue) async { + _queue = await store(newQueue); + _sendUpdateEventsInner(); + } + + Future add(T item) async { + await _initWait(); + await _queueMutex.protect(() async { + final newQueue = _queue.add(item); + await _updateQueueInner(newQueue); + }); + } + + Future addAll(Iterable items) async { + await _initWait(); + await _queueMutex.protect(() async { + final newQueue = _queue.addAll(items); + await _updateQueueInner(newQueue); + }); + } + + void addSync(T item) { + serialFuture((this, _ksfSyncAdd), () async { + await add(item); + }); + } + + void addAllSync(Iterable items) { + serialFuture((this, _ksfSyncAdd), () async { + await addAll(items); + }); + } + + Future pause() async { + await _sspQueueReady.pause(); + } + + Future resume() async { + await _sspQueueReady.resume(); + } + + Future _process() async { + try { + // Take a copy of the current queue + // (doesn't need queue mutex because this is a sync operation) + final toProcess = _queue; + final processCount = toProcess.length; + if (processCount == 0) { + return; + } + + // Run the processing closure + await _closure(toProcess); + + // If there was no exception, remove the processed items + await _queueMutex.protect(() async { + // Get the queue from the state again as items could + // have been added during processing + final newQueue = _queue.skip(processCount).toIList(); + await _updateQueueInner(newQueue); + }); + } on Exception catch (e, sp) { + if (_onError != null) { + _onError(e, sp); + } else { + rethrow; + } + } + } + + IList get queue => _queue; + + // TableDBBacked + @override + String tableKeyName() => _key; + + @override + String tableName() => _table; + + @override + IList valueFromBuffer(Uint8List bytes) { + var out = IList(); + try { + final reader = ByteDataReader()..add(bytes); + while (reader.remainingLength != 0) { + final count = reader.readUint32(); + final bytes = reader.read(count); + try { + final item = _fromBuffer(bytes); + out = out.add(item); + } on Exception catch (e, st) { + veilidLoggy.debug( + 'Dropping invalid item from persistent queue: $bytes\n' + 'tableName=${tableName()}:tableKeyName=${tableKeyName()}\n', + e, + st); + } + } + } on Exception catch (e, st) { + veilidLoggy.debug( + 'Dropping remainder of invalid persistent queue\n' + 'tableName=${tableName()}:tableKeyName=${tableKeyName()}\n', + e, + st); + } + return out; + } + + @override + Uint8List valueToBuffer(IList val) { + final writer = ByteDataWriter(); + for (final elem in val) { + final bytes = _toBuffer(elem); + final count = bytes.lengthInBytes; + writer + ..writeUint32(count) + ..write(bytes); + } + return writer.toBytes(); + } + + final String _table; + final String _key; + final T Function(Uint8List) _fromBuffer; + final Uint8List Function(T) _toBuffer; + bool _deleteOnClose; + final WaitSet _initWait = WaitSet(); + final _queueMutex = Mutex(debugLockTimeout: kIsDebugMode ? 60 : null); + var _queue = IList.empty(); + final Future Function(IList) _closure; + final void Function(Object, StackTrace)? _onError; + Completer? _queueDoneCompleter; + + final StreamController _queueReady = StreamController(); + final _sspQueueReady = SingleStateProcessor(); +} diff --git a/packages/veilid_support/lib/src/protobuf_tools.dart b/packages/veilid_support/lib/src/protobuf_tools.dart new file mode 100644 index 0000000..0120e06 --- /dev/null +++ b/packages/veilid_support/lib/src/protobuf_tools.dart @@ -0,0 +1,20 @@ +import 'dart:typed_data'; + +import 'package:protobuf/protobuf.dart'; + +Future protobufUpdateBytes( + T Function(List) fromBuffer, + Uint8List? oldBytes, + Future Function(T?) update) async { + final oldObj = oldBytes == null ? null : fromBuffer(oldBytes); + final newObj = await update(oldObj); + if (newObj == null) { + return null; + } + return Uint8List.fromList(newObj.writeToBuffer()); +} + +Future Function(Uint8List?) + protobufUpdate( + T Function(List) fromBuffer, Future Function(T?) update) => + (oldBytes) => protobufUpdateBytes(fromBuffer, oldBytes, update); diff --git a/packages/veilid_support/lib/src/table_db.dart b/packages/veilid_support/lib/src/table_db.dart new file mode 100644 index 0000000..eb7fe99 --- /dev/null +++ b/packages/veilid_support/lib/src/table_db.dart @@ -0,0 +1,199 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:async_tools/async_tools.dart'; +import 'package:meta/meta.dart'; +import 'package:veilid/veilid.dart'; + +import '../veilid_support.dart'; +import 'veilid_log.dart'; + +Future tableScope( + String name, Future Function(VeilidTableDB tdb) callback, + {int columnCount = 1}) async { + final tableDB = await Veilid.instance.openTableDB(name, columnCount); + try { + return await callback(tableDB); + } finally { + tableDB.close(); + } +} + +Future transactionScope( + VeilidTableDB tdb, + Future Function(VeilidTableDBTransaction tdbt) callback, +) async { + final tdbt = tdb.transact(); + try { + final ret = await callback(tdbt); + if (!tdbt.isDone()) { + await tdbt.commit(); + } + return ret; + } finally { + if (!tdbt.isDone()) { + await tdbt.rollback(); + } + } +} + +abstract mixin class TableDBBackedJson { + @protected + String tableName(); + @protected + String tableKeyName(); + @protected + T? valueFromJson(Object? obj); + @protected + Object? valueToJson(T? val); + + /// Load things from storage + @protected + Future load() async { + try { + final obj = await tableScope(tableName(), (tdb) async { + final objJson = await tdb.loadStringJson(0, tableKeyName()); + return valueFromJson(objJson); + }); + return obj; + } on Exception catch (e, st) { + veilidLoggy.debug( + 'Unable to load data from table store: ' + '${tableName()}:${tableKeyName()}', + e, + st); + return null; + } + } + + /// Store things to storage + @protected + Future store(T obj) async { + await tableScope(tableName(), (tdb) async { + await tdb.storeStringJson(0, tableKeyName(), valueToJson(obj)); + }); + return obj; + } + + /// Delete things from storage + @protected + Future delete() async { + final obj = await tableScope(tableName(), (tdb) async { + final objJson = await tdb.deleteStringJson(0, tableKeyName()); + return valueFromJson(objJson); + }); + return obj; + } +} + +abstract mixin class TableDBBackedFromBuffer { + @protected + String tableName(); + @protected + String tableKeyName(); + @protected + T valueFromBuffer(Uint8List bytes); + @protected + Uint8List valueToBuffer(T val); + + /// Load things from storage + @protected + Future load() async { + final obj = await tableScope(tableName(), (tdb) async { + final objBytes = await tdb.load(0, utf8.encode(tableKeyName())); + if (objBytes == null) { + return null; + } + return valueFromBuffer(objBytes); + }); + return obj; + } + + /// Store things to storage + @protected + Future store(T obj) async { + await tableScope(tableName(), (tdb) async { + await tdb.store(0, utf8.encode(tableKeyName()), valueToBuffer(obj)); + }); + return obj; + } + + /// Delete things from storage + @protected + Future delete() async { + final obj = await tableScope(tableName(), (tdb) async { + final objBytes = await tdb.delete(0, utf8.encode(tableKeyName())); + if (objBytes == null) { + return null; + } + return valueFromBuffer(objBytes); + }); + return obj; + } +} + +class TableDBValue extends TableDBBackedJson { + TableDBValue({ + required String tableName, + required String tableKeyName, + required T? Function(Object? obj) valueFromJson, + required Object? Function(T? obj) valueToJson, + required T Function() makeInitialValue, + }) : _tableName = tableName, + _valueFromJson = valueFromJson, + _valueToJson = valueToJson, + _tableKeyName = tableKeyName, + _makeInitialValue = makeInitialValue, + _streamController = StreamController.broadcast() { + _initWait.add((_) async { + await get(); + }); + } + + Future init() async { + await _initWait(); + } + + Future close() async { + await _initWait(); + } + + T get value => _value!.value; + Stream get stream => _streamController.stream; + + Future get() async { + final val = _value; + if (val != null) { + return val.value; + } + final loadedValue = await load() ?? await store(_makeInitialValue()); + _value = AsyncData(loadedValue); + return loadedValue; + } + + Future set(T newVal) async { + _value = AsyncData(await store(newVal)); + _streamController.add(newVal); + } + + AsyncData? _value; + final T Function() _makeInitialValue; + final String _tableName; + final String _tableKeyName; + final T? Function(Object? obj) _valueFromJson; + final Object? Function(T? obj) _valueToJson; + final StreamController _streamController; + final WaitSet _initWait = WaitSet(); + + ////////////////////////////////////////////////////////////// + /// AsyncTableDBBacked + @override + String tableName() => _tableName; + @override + String tableKeyName() => _tableKeyName; + @override + T? valueFromJson(Object? obj) => _valueFromJson(obj); + @override + Object? valueToJson(T? val) => _valueToJson(val); +} diff --git a/packages/veilid_support/lib/src/table_db_array.dart b/packages/veilid_support/lib/src/table_db_array.dart new file mode 100644 index 0000000..53adeb0 --- /dev/null +++ b/packages/veilid_support/lib/src/table_db_array.dart @@ -0,0 +1,809 @@ +import 'dart:async'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:async_tools/async_tools.dart'; +import 'package:charcode/charcode.dart'; +import 'package:equatable/equatable.dart'; +import 'package:meta/meta.dart'; +import 'package:protobuf/protobuf.dart'; + +import '../veilid_support.dart'; +import 'veilid_log.dart'; + +@immutable +class TableDBArrayUpdate extends Equatable { + const TableDBArrayUpdate( + {required this.headDelta, required this.tailDelta, required this.length}) + : assert(length >= 0, 'should never have negative length'); + final int headDelta; + final int tailDelta; + final int length; + + @override + List get props => [headDelta, tailDelta, length]; +} + +class _TableDBArrayBase { + _TableDBArrayBase({ + required String table, + required VeilidCrypto crypto, + }) : _table = table, + _crypto = crypto { + _initWait.add(_init); + } + + // static Future make({ + // required String table, + // required VeilidCrypto crypto, + // }) async { + // final out = TableDBArray(table: table, crypto: crypto); + // await out._initWait(); + // return out; + // } + + Future initWait() async { + await _initWait(); + } + + Future _init(Completer _) async { + // Load the array details + await _mutex.protect(() async { + _tableDB = await Veilid.instance.openTableDB(_table, 1); + await _loadHead(); + _initDone = true; + }); + } + + Future close({bool delete = false}) async { + // Ensure the init finished + await _initWait(); + + // Allow multiple attempts to close + if (_open) { + await _mutex.protect(() async { + await _changeStream.close(); + _tableDB.close(); + _open = false; + }); + } + if (delete) { + await Veilid.instance.deleteTableDB(_table); + } + } + + Future delete() async { + await _initWait(); + if (_open) { + throw StateError('should be closed first'); + } + await Veilid.instance.deleteTableDB(_table); + } + + Future> listen( + void Function(TableDBArrayUpdate) onChanged) async => + _changeStream.stream.listen(onChanged); + + //////////////////////////////////////////////////////////// + // Public interface + + int get length { + if (!_open) { + throw StateError('not open'); + } + if (!_initDone) { + throw StateError('not initialized'); + } + + return _length; + } + + bool get isOpen => _open; + + Future _add(Uint8List value) async { + await _initWait(); + return _writeTransaction((t) => _addInner(t, value)); + } + + Future _addAll(List values) async { + await _initWait(); + return _writeTransaction((t) => _addAllInner(t, values)); + } + + Future _insert(int pos, Uint8List value) async { + await _initWait(); + return _writeTransaction((t) => _insertInner(t, pos, value)); + } + + Future _insertAll(int pos, List values) async { + await _initWait(); + return _writeTransaction((t) => _insertAllInner(t, pos, values)); + } + + Future _get(int pos) async { + await _initWait(); + return _mutex.protect(() { + if (!_open) { + throw StateError('not open'); + } + return _getInner(pos); + }); + } + + Future> _getRange(int start, [int? end]) async { + await _initWait(); + return _mutex.protect(() { + if (!_open) { + throw StateError('not open'); + } + return _getRangeInner(start, end ?? _length); + }); + } + + Future _remove(int pos, {Output? out}) async { + await _initWait(); + return _writeTransaction((t) => _removeInner(t, pos, out: out)); + } + + Future _removeRange(int start, int end, + {Output>? out}) async { + await _initWait(); + return _writeTransaction((t) => _removeRangeInner(t, start, end, out: out)); + } + + Future clear() async { + await _initWait(); + return _writeTransaction((t) async { + final keys = await _tableDB.getKeys(0); + for (final key in keys) { + await t.delete(0, key); + } + _length = 0; + _nextFree = 0; + _maxEntry = 0; + _dirtyChunks.clear(); + _chunkCache.clear(); + }); + } + + //////////////////////////////////////////////////////////// + // Inner interface + + Future _addInner(VeilidTableDBTransaction t, Uint8List value) async { + // Allocate an entry to store the value + final entry = await _allocateEntry(); + await _storeEntry(t, entry, value); + + // Put the entry in the index + final pos = _length; + _length++; + _tailDelta++; + await _setIndexEntry(pos, entry); + } + + Future _addAllInner( + VeilidTableDBTransaction t, List values) async { + var pos = _length; + _length += values.length; + _tailDelta += values.length; + for (final value in values) { + // Allocate an entry to store the value + final entry = await _allocateEntry(); + await _storeEntry(t, entry, value); + + // Put the entry in the index + await _setIndexEntry(pos, entry); + pos++; + } + } + + Future _insertInner( + VeilidTableDBTransaction t, int pos, Uint8List value) async { + if (pos == _length) { + return _addInner(t, value); + } + if (pos < 0 || pos >= _length) { + throw IndexError.withLength(pos, _length); + } + // Allocate an entry to store the value + final entry = await _allocateEntry(); + await _storeEntry(t, entry, value); + + // Put the entry in the index + await _insertIndexEntry(pos); + await _setIndexEntry(pos, entry); + } + + Future _insertAllInner( + VeilidTableDBTransaction t, int pos, List values) async { + if (pos == _length) { + return _addAllInner(t, values); + } + if (pos < 0 || pos >= _length) { + throw IndexError.withLength(pos, _length); + } + await _insertIndexEntries(pos, values.length); + for (final value in values) { + // Allocate an entry to store the value + final entry = await _allocateEntry(); + await _storeEntry(t, entry, value); + + // Put the entry in the index + await _setIndexEntry(pos, entry); + pos++; + } + } + + Future _getInner(int pos) async { + if (pos < 0 || pos >= _length) { + throw IndexError.withLength(pos, _length); + } + final entry = await _getIndexEntry(pos); + return (await _loadEntry(entry))!; + } + + Future> _getRangeInner(int start, int end) async { + final length = end - start; + if (length < 0) { + throw StateError('length should not be negative'); + } + if (start < 0 || start >= _length) { + throw IndexError.withLength(start, _length); + } + if (end > _length) { + throw IndexError.withLength(end, _length); + } + + final out = []; + const batchSize = 16; + + for (var pos = start; pos < end;) { + var batchLen = min(batchSize, end - pos); + final dws = DelayedWaitSet(); + while (batchLen > 0) { + final entry = await _getIndexEntry(pos); + dws.add((_) async { + try { + return (await _loadEntry(entry))!; + // Need some way to debug ParallelWaitError + // ignore: avoid_catches_without_on_clauses + } catch (e, st) { + veilidLoggy.error('$e\n$st\n'); + rethrow; + } + }); + pos++; + batchLen--; + } + final batchOut = await dws(); + out.addAll(batchOut); + } + + return out; + } + + Future _removeInner(VeilidTableDBTransaction t, int pos, + {Output? out}) async { + if (pos < 0 || pos >= _length) { + throw IndexError.withLength(pos, _length); + } + + final entry = await _getIndexEntry(pos); + if (out != null) { + final value = (await _loadEntry(entry))!; + out.save(value); + } + + await _freeEntry(t, entry); + await _removeIndexEntry(pos); + } + + Future _removeRangeInner(VeilidTableDBTransaction t, int start, int end, + {Output>? out}) async { + final length = end - start; + if (length < 0) { + throw StateError('length should not be negative'); + } + if (start < 0) { + throw IndexError.withLength(start, _length); + } + if (end > _length) { + throw IndexError.withLength(end, _length); + } + + final outList = []; + for (var pos = start; pos < end; pos++) { + final entry = await _getIndexEntry(pos); + if (out != null) { + final value = (await _loadEntry(entry))!; + outList.add(value); + } + await _freeEntry(t, entry); + } + if (out != null) { + out.save(outList); + } + + await _removeIndexEntries(start, length); + } + + //////////////////////////////////////////////////////////// + // Private implementation + + static final _headKey = Uint8List.fromList([$_, $H, $E, $A, $D]); + static Uint8List _entryKey(int k) => + (ByteData(4)..setUint32(0, k)).buffer.asUint8List(); + static Uint8List _chunkKey(int n) => + (ByteData(2)..setUint16(0, n)).buffer.asUint8List(); + + Future _writeTransaction( + Future Function(VeilidTableDBTransaction) closure) => + _mutex.protect(() async { + if (!_open) { + throw StateError('not open'); + } + + final oldLength = _length; + final oldNextFree = _nextFree; + final oldMaxEntry = _maxEntry; + final oldHeadDelta = _headDelta; + final oldTailDelta = _tailDelta; + try { + final out = await transactionScope(_tableDB, (t) async { + final out = await closure(t); + await _saveHead(t); + await _flushDirtyChunks(t); + // Send change + _changeStream.add(TableDBArrayUpdate( + headDelta: _headDelta, tailDelta: _tailDelta, length: _length)); + _headDelta = 0; + _tailDelta = 0; + return out; + }); + + return out; + } on Exception { + // restore head + _length = oldLength; + _nextFree = oldNextFree; + _maxEntry = oldMaxEntry; + _headDelta = oldHeadDelta; + _tailDelta = oldTailDelta; + // invalidate caches because they could have been written to + _chunkCache.clear(); + _dirtyChunks.clear(); + // propagate exception + rethrow; + } + }); + + Future _storeEntry( + VeilidTableDBTransaction t, int entry, Uint8List value) async => + t.store(0, _entryKey(entry), await _crypto.encrypt(value)); + + Future _loadEntry(int entry) async { + final encryptedValue = await _tableDB.load(0, _entryKey(entry)); + return (encryptedValue == null) + ? null + : await _crypto.decrypt(encryptedValue); + } + + Future _getIndexEntry(int pos) async { + if (pos < 0 || pos >= _length) { + throw IndexError.withLength(pos, _length); + } + final chunkNumber = pos ~/ _indexStride; + final chunkOffset = pos % _indexStride; + + final chunk = await _loadIndexChunk(chunkNumber); + + return chunk.buffer.asByteData().getUint32(chunkOffset * 4); + } + + Future _setIndexEntry(int pos, int entry) async { + if (pos < 0 || pos >= _length) { + throw IndexError.withLength(pos, _length); + } + + final chunkNumber = pos ~/ _indexStride; + final chunkOffset = pos % _indexStride; + + final chunk = await _loadIndexChunk(chunkNumber); + chunk.buffer.asByteData().setUint32(chunkOffset * 4, entry); + + _dirtyChunks[chunkNumber] = chunk; + } + + Future _insertIndexEntry(int pos) => _insertIndexEntries(pos, 1); + + Future _insertIndexEntries(int start, int length) async { + if (length == 0) { + return; + } + if (length < 0) { + throw StateError('length should not be negative'); + } + if (start < 0 || start >= _length) { + throw IndexError.withLength(start, _length); + } + + // Slide everything over in reverse + var src = _length - 1; + var dest = src + length; + + (int, Uint8List)? lastSrcChunk; + (int, Uint8List)? lastDestChunk; + while (src >= start) { + final remaining = (src - start) + 1; + final srcChunkNumber = src ~/ _indexStride; + final srcIndex = src % _indexStride; + final srcLength = min(remaining, srcIndex + 1); + + final srcChunk = + (lastSrcChunk != null && (lastSrcChunk.$1 == srcChunkNumber)) + ? lastSrcChunk.$2 + : await _loadIndexChunk(srcChunkNumber); + _dirtyChunks[srcChunkNumber] = srcChunk; + lastSrcChunk = (srcChunkNumber, srcChunk); + + final destChunkNumber = dest ~/ _indexStride; + final destIndex = dest % _indexStride; + final destLength = min(remaining, destIndex + 1); + + final destChunk = + (lastDestChunk != null && (lastDestChunk.$1 == destChunkNumber)) + ? lastDestChunk.$2 + : await _loadIndexChunk(destChunkNumber); + _dirtyChunks[destChunkNumber] = destChunk; + lastDestChunk = (destChunkNumber, destChunk); + + final toCopy = min(srcLength, destLength); + destChunk.setRange((destIndex - (toCopy - 1)) * 4, (destIndex + 1) * 4, + srcChunk, (srcIndex - (toCopy - 1)) * 4); + + dest -= toCopy; + src -= toCopy; + } + + // Then add to length + _length += length; + if (start == 0) { + _headDelta += length; + } + _tailDelta += length; + } + + Future _removeIndexEntry(int pos) => _removeIndexEntries(pos, 1); + + Future _removeIndexEntries(int start, int length) async { + if (length == 0) { + return; + } + if (length < 0) { + throw StateError('length should not be negative'); + } + if (start < 0 || start >= _length) { + throw IndexError.withLength(start, _length); + } + final end = start + length - 1; + if (end < 0 || end >= _length) { + throw IndexError.withLength(end, _length); + } + + // Slide everything over + var dest = start; + var src = end + 1; + (int, Uint8List)? lastSrcChunk; + (int, Uint8List)? lastDestChunk; + while (src < _length) { + final srcChunkNumber = src ~/ _indexStride; + final srcIndex = src % _indexStride; + final srcLength = _indexStride - srcIndex; + + final srcChunk = + (lastSrcChunk != null && (lastSrcChunk.$1 == srcChunkNumber)) + ? lastSrcChunk.$2 + : await _loadIndexChunk(srcChunkNumber); + _dirtyChunks[srcChunkNumber] = srcChunk; + lastSrcChunk = (srcChunkNumber, srcChunk); + + final destChunkNumber = dest ~/ _indexStride; + final destIndex = dest % _indexStride; + final destLength = _indexStride - destIndex; + + final destChunk = + (lastDestChunk != null && (lastDestChunk.$1 == destChunkNumber)) + ? lastDestChunk.$2 + : await _loadIndexChunk(destChunkNumber); + _dirtyChunks[destChunkNumber] = destChunk; + lastDestChunk = (destChunkNumber, destChunk); + + final toCopy = min(srcLength, destLength); + destChunk.setRange( + destIndex * 4, (destIndex + toCopy) * 4, srcChunk, srcIndex * 4); + + dest += toCopy; + src += toCopy; + } + + // Then truncate + _length -= length; + if (start == 0) { + _headDelta -= length; + } + _tailDelta -= length; + } + + Future _loadIndexChunk(int chunkNumber) async { + // Get it from the dirty chunks if we have it + final dirtyChunk = _dirtyChunks[chunkNumber]; + if (dirtyChunk != null) { + return dirtyChunk; + } + + // Get from cache if we have it + for (var i = 0; i < _chunkCache.length; i++) { + if (_chunkCache[i].$1 == chunkNumber) { + // Touch the element + final x = _chunkCache.removeAt(i); + _chunkCache.add(x); + // Return the chunk for this position + return x.$2; + } + } + + // Get chunk from disk + var chunk = await _tableDB.load(0, _chunkKey(chunkNumber)); + chunk ??= Uint8List(_indexStride * 4); + + // Cache the chunk + _chunkCache.add((chunkNumber, chunk)); + if (_chunkCache.length > _chunkCacheLength) { + // Trim the LRU cache + final (_, _) = _chunkCache.removeAt(0); + } + + return chunk; + } + + Future _flushDirtyChunks(VeilidTableDBTransaction t) async { + for (final ec in _dirtyChunks.entries) { + await t.store(0, _chunkKey(ec.key), ec.value); + } + _dirtyChunks.clear(); + } + + Future _loadHead() async { + assert(_mutex.isLocked, 'should be locked'); + final headBytes = await _tableDB.load(0, _headKey); + if (headBytes == null) { + _length = 0; + _nextFree = 0; + _maxEntry = 0; + } else { + final b = headBytes.buffer.asByteData(); + _length = b.getUint32(0); + _nextFree = b.getUint32(4); + _maxEntry = b.getUint32(8); + } + } + + Future _saveHead(VeilidTableDBTransaction t) async { + assert(_mutex.isLocked, 'should be locked'); + final b = ByteData(12) + ..setUint32(0, _length) + ..setUint32(4, _nextFree) + ..setUint32(8, _maxEntry); + await t.store(0, _headKey, b.buffer.asUint8List()); + } + + Future _allocateEntry() async { + assert(_mutex.isLocked, 'should be locked'); + if (_nextFree == 0) { + return _maxEntry++; + } + // pop endogenous free list + final free = _nextFree; + final nextFreeBytes = await _tableDB.load(0, _entryKey(free)); + _nextFree = nextFreeBytes!.buffer.asByteData().getUint8(0); + return free; + } + + Future _freeEntry(VeilidTableDBTransaction t, int entry) async { + assert(_mutex.isLocked, 'should be locked'); + // push endogenous free list + final b = ByteData(4)..setUint32(0, _nextFree); + await t.store(0, _entryKey(entry), b.buffer.asUint8List()); + _nextFree = entry; + } + + final String _table; + late final VeilidTableDB _tableDB; + var _open = true; + var _initDone = false; + final VeilidCrypto _crypto; + final WaitSet _initWait = WaitSet(); + final _mutex = Mutex(debugLockTimeout: kIsDebugMode ? 60 : null); + + // Change tracking + var _headDelta = 0; + var _tailDelta = 0; + + // Head state + var _length = 0; + var _nextFree = 0; + var _maxEntry = 0; + static const _indexStride = 16384; + final List<(int, Uint8List)> _chunkCache = []; + final Map _dirtyChunks = {}; + static const _chunkCacheLength = 3; + + final StreamController _changeStream = + StreamController.broadcast(); +} + +////////////////////////////////////////////////////////////////////////////// + +class TableDBArray extends _TableDBArrayBase { + TableDBArray({ + required super.table, + required super.crypto, + }); + + static Future make({ + required String table, + required VeilidCrypto crypto, + }) async { + final out = TableDBArray(table: table, crypto: crypto); + await out._initWait(); + return out; + } + + //////////////////////////////////////////////////////////// + // Public interface + + Future add(Uint8List value) => _add(value); + + Future addAll(List values) => _addAll(values); + + Future insert(int pos, Uint8List value) => _insert(pos, value); + + Future insertAll(int pos, List values) => + _insertAll(pos, values); + + Future get( + int pos, + ) => + _get(pos); + + Future> getRange(int start, [int? end]) => + _getRange(start, end); + + Future remove(int pos, {Output? out}) => + _remove(pos, out: out); + + Future removeRange(int start, int end, + {Output>? out}) => + _removeRange(start, end, out: out); +} +////////////////////////////////////////////////////////////////////////////// + +class TableDBArrayJson extends _TableDBArrayBase { + TableDBArrayJson( + {required super.table, + required super.crypto, + required T Function(dynamic) fromJson}) + : _fromJson = fromJson; + + static Future> make( + {required String table, + required VeilidCrypto crypto, + required T Function(dynamic) fromJson}) async { + final out = + TableDBArrayJson(table: table, crypto: crypto, fromJson: fromJson); + await out._initWait(); + return out; + } + + //////////////////////////////////////////////////////////// + // Public interface + + Future add(T value) => _add(jsonEncodeBytes(value)); + + Future addAll(List values) => + _addAll(values.map(jsonEncodeBytes).toList()); + + Future insert(int pos, T value) => _insert(pos, jsonEncodeBytes(value)); + + Future insertAll(int pos, List values) => + _insertAll(pos, values.map(jsonEncodeBytes).toList()); + + Future get( + int pos, + ) => + _get(pos).then((out) => jsonDecodeBytes(_fromJson, out)); + + Future> getRange(int start, [int? end]) => + _getRange(start, end).then((out) => out.map(_fromJson).toList()); + + Future remove(int pos, {Output? out}) async { + final outJson = (out != null) ? Output() : null; + await _remove(pos, out: outJson); + if (outJson != null && outJson.value != null) { + out!.save(jsonDecodeBytes(_fromJson, outJson.value!)); + } + } + + Future removeRange(int start, int end, {Output>? out}) async { + final outJson = (out != null) ? Output>() : null; + await _removeRange(start, end, out: outJson); + if (outJson != null && outJson.value != null) { + out!.save( + outJson.value!.map((x) => jsonDecodeBytes(_fromJson, x)).toList()); + } + } + + //////////////////////////////////////////////////////////////////////////// + final T Function(dynamic) _fromJson; +} + +////////////////////////////////////////////////////////////////////////////// + +class TableDBArrayProtobuf + extends _TableDBArrayBase { + TableDBArrayProtobuf( + {required super.table, + required super.crypto, + required T Function(List) fromBuffer}) + : _fromBuffer = fromBuffer; + + static Future> make( + {required String table, + required VeilidCrypto crypto, + required T Function(List) fromBuffer}) async { + final out = TableDBArrayProtobuf( + table: table, crypto: crypto, fromBuffer: fromBuffer); + await out._initWait(); + return out; + } + + //////////////////////////////////////////////////////////// + // Public interface + + Future add(T value) => _add(value.writeToBuffer()); + + Future addAll(List values) => + _addAll(values.map((x) => x.writeToBuffer()).toList()); + + Future insert(int pos, T value) => _insert(pos, value.writeToBuffer()); + + Future insertAll(int pos, List values) => + _insertAll(pos, values.map((x) => x.writeToBuffer()).toList()); + + Future get( + int pos, + ) => + _get(pos).then(_fromBuffer); + + Future> getRange(int start, [int? end]) => + _getRange(start, end).then((out) => out.map(_fromBuffer).toList()); + + Future remove(int pos, {Output? out}) async { + final outProto = (out != null) ? Output() : null; + await _remove(pos, out: outProto); + if (outProto != null && outProto.value != null) { + out!.save(_fromBuffer(outProto.value!)); + } + } + + Future removeRange(int start, int end, {Output>? out}) async { + final outProto = (out != null) ? Output>() : null; + await _removeRange(start, end, out: outProto); + if (outProto != null && outProto.value != null) { + out!.save(outProto.value!.map(_fromBuffer).toList()); + } + } + + //////////////////////////////////////////////////////////////////////////// + final T Function(List) _fromBuffer; +} diff --git a/packages/veilid_support/lib/src/table_db_array_protobuf_cubit.dart b/packages/veilid_support/lib/src/table_db_array_protobuf_cubit.dart new file mode 100644 index 0000000..92b920f --- /dev/null +++ b/packages/veilid_support/lib/src/table_db_array_protobuf_cubit.dart @@ -0,0 +1,199 @@ +import 'dart:async'; + +import 'package:async_tools/async_tools.dart'; +import 'package:bloc/bloc.dart'; +import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; +import 'package:equatable/equatable.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:meta/meta.dart'; +import 'package:protobuf/protobuf.dart'; + +import '../../../veilid_support.dart'; + +@immutable +class TableDBArrayProtobufStateData + extends Equatable { + const TableDBArrayProtobufStateData( + {required this.windowElements, + required this.length, + required this.windowTail, + required this.windowCount, + required this.follow}); + // The view of the elements in the dhtlog + // Span is from [tail-length, tail) + final IList windowElements; + // The length of the entire array + final int length; + // One past the end of the last element (modulo length, can be zero) + final int windowTail; + // The total number of elements to try to keep in 'elements' + final int windowCount; + // If we should have the tail following the array + final bool follow; + + @override + List get props => [windowElements, windowTail, windowCount, follow]; +} + +typedef TableDBArrayProtobufState + = AsyncValue>; +typedef TableDBArrayProtobufBusyState + = BlocBusyState>; + +class TableDBArrayProtobufCubit + extends Cubit> + with BlocBusyWrapper> { + TableDBArrayProtobufCubit({ + required Future> Function() open, + }) : super(const BlocBusyState(AsyncValue.loading())) { + _initWait.add((_) async { + // Open table db array + _array = await open(); + _wantsCloseArray = true; + + // Make initial state update + await _refreshNoWait(); + _subscription = await _array.listen(_update); + }); + } + + // Set the tail position of the array for pagination. + // If tail is 0, the end of the array is used. + // If tail is negative, the position is subtracted from the current array + // length. + // If tail is positive, the position is absolute from the head of the array + // If follow is enabled, the tail offset will update when the array changes + Future setWindow( + {int? tail, int? count, bool? follow, bool forceRefresh = false}) async { + await _initWait(); + if (tail != null) { + _tail = tail; + } + if (count != null) { + _count = count; + } + if (follow != null) { + _follow = follow; + } + await _refreshNoWait(forceRefresh: forceRefresh); + } + + Future refresh({bool forceRefresh = false}) async { + await _initWait(); + await _refreshNoWait(forceRefresh: forceRefresh); + } + + Future _refreshNoWait({bool forceRefresh = false}) async => + busy((emit) async => _refreshInner(emit, forceRefresh: forceRefresh)); + + Future _refreshInner( + void Function(AsyncValue>) emit, + {bool forceRefresh = false}) async { + final avElements = await _loadElements(_tail, _count); + final err = avElements.asError; + if (err != null) { + addError(err.error, err.stackTrace); + emit(AsyncValue.error(err.error, err.stackTrace)); + return; + } + final loading = avElements.asLoading; + if (loading != null) { + emit(const AsyncValue.loading()); + return; + } + final elements = avElements.asData!.value; + emit(AsyncValue.data(TableDBArrayProtobufStateData( + windowElements: elements, + length: _array.length, + windowTail: _tail, + windowCount: _count, + follow: _follow))); + } + + Future>> _loadElements( + int tail, + int count, + ) async { + try { + final length = _array.length; + if (length == 0) { + return AsyncValue.data(IList.empty()); + } + final end = ((tail - 1) % length) + 1; + final start = (count < end) ? end - count : 0; + final allItems = (await _array.getRange(start, end)).toIList(); + return AsyncValue.data(allItems); + } on Exception catch (e, st) { + addError(e, st); + return AsyncValue.error(e, st); + } + } + + void _update(TableDBArrayUpdate upd) { + // Run at most one background update process + // Because this is async, we could get an update while we're + // still processing the last one. Only called after init future has run + // so we dont have to wait for that here. + + // Accumulate head and tail deltas + _headDelta += upd.headDelta; + _tailDelta += upd.tailDelta; + + _sspUpdate.busyUpdate>(busy, (emit) async { + // apply follow + if (_follow) { + if (_tail <= 0) { + // Negative tail is already following tail changes + } else { + // Positive tail is measured from the head, so apply deltas + _tail = (_tail + _tailDelta - _headDelta) % upd.length; + } + } else { + if (_tail <= 0) { + // Negative tail is following tail changes so apply deltas + var posTail = _tail + upd.length; + posTail = (posTail + _tailDelta - _headDelta) % upd.length; + _tail = posTail - upd.length; + } else { + // Positive tail is measured from head so not following tail + } + } + _headDelta = 0; + _tailDelta = 0; + + await _refreshInner(emit); + }); + } + + @override + Future close() async { + await _initWait(); + await _subscription?.cancel(); + _subscription = null; + if (_wantsCloseArray) { + await _array.close(); + } + await super.close(); + } + + Future operate( + Future Function(TableDBArrayProtobuf) closure) async { + await _initWait(); + return closure(_array); + } + + final WaitSet _initWait = WaitSet(); + late final TableDBArrayProtobuf _array; + StreamSubscription? _subscription; + bool _wantsCloseArray = false; + final _sspUpdate = SingleStatelessProcessor(); + + // Accumulated deltas since last update + var _headDelta = 0; + var _tailDelta = 0; + + // Cubit window into the TableDBArray + var _tail = 0; + var _count = DHTShortArray.maxElements; + var _follow = true; +} diff --git a/packages/veilid_support/lib/src/veilid_crypto.dart b/packages/veilid_support/lib/src/veilid_crypto.dart new file mode 100644 index 0000000..565459c --- /dev/null +++ b/packages/veilid_support/lib/src/veilid_crypto.dart @@ -0,0 +1,62 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; +import '../../../veilid_support.dart'; + +abstract class VeilidCrypto { + Future encrypt(Uint8List data); + Future decrypt(Uint8List data); +} + +//////////////////////////////////// +/// Encrypted for a specific symmetric key +class VeilidCryptoPrivate implements VeilidCrypto { + VeilidCryptoPrivate._(VeilidCryptoSystem cryptoSystem, SharedSecret secretKey) + : _cryptoSystem = cryptoSystem, + _secret = secretKey; + final VeilidCryptoSystem _cryptoSystem; + final SharedSecret _secret; + + static Future fromTypedKey( + TypedKey typedSecret, String domain) async { + final cryptoSystem = + await Veilid.instance.getCryptoSystem(typedSecret.kind); + final keyMaterial = Uint8List.fromList( + [...typedSecret.value.decode(), ...utf8.encode(domain)]); + final secretKey = await cryptoSystem.generateHash(keyMaterial); + return VeilidCryptoPrivate._(cryptoSystem, secretKey); + } + + static Future fromTypedKeyPair( + TypedKeyPair typedKeyPair, String domain) async { + final typedSecret = + TypedKey(kind: typedKeyPair.kind, value: typedKeyPair.secret); + return fromTypedKey(typedSecret, domain); + } + + static Future fromSharedSecret( + CryptoKind kind, SharedSecret sharedSecret) async { + final cryptoSystem = await Veilid.instance.getCryptoSystem(kind); + return VeilidCryptoPrivate._(cryptoSystem, sharedSecret); + } + + @override + Future encrypt(Uint8List data) => + _cryptoSystem.encryptNoAuthWithNonce(data, _secret); + + @override + Future decrypt(Uint8List data) => + _cryptoSystem.decryptNoAuthWithNonce(data, _secret); +} + +//////////////////////////////////// +/// No encryption +class VeilidCryptoPublic implements VeilidCrypto { + const VeilidCryptoPublic(); + + @override + Future encrypt(Uint8List data) async => data; + + @override + Future decrypt(Uint8List data) async => data; +} diff --git a/lib/veilid_support/src/veilid_log.dart b/packages/veilid_support/lib/src/veilid_log.dart similarity index 72% rename from lib/veilid_support/src/veilid_log.dart rename to packages/veilid_support/lib/src/veilid_log.dart index 8a343eb..4c9ad0b 100644 --- a/lib/veilid_support/src/veilid_log.dart +++ b/packages/veilid_support/lib/src/veilid_log.dart @@ -1,5 +1,5 @@ -import 'package:flutter/foundation.dart'; import 'package:loggy/loggy.dart'; +import 'package:meta/meta.dart'; import 'package:veilid/veilid.dart'; // Loggy tools @@ -33,14 +33,19 @@ void setVeilidLogLevel(LogLevel? level) { Veilid.instance.changeLogLevel('all', convertToVeilidConfigLogLevel(level)); } +void changeVeilidLogIgnore(String change) { + Veilid.instance.changeLogIgnore('all', change.split(',')); +} + class VeilidLoggy implements LoggyType { @override Loggy get loggy => Loggy('Veilid'); } -Loggy get _veilidLoggy => Loggy('Veilid'); +@internal +Loggy get veilidLoggy => Loggy('Veilid'); -Future processLog(VeilidLog log) async { +void processLog(VeilidLog log) { StackTrace? stackTrace; Object? error; final backtrace = log.backtrace; @@ -51,31 +56,27 @@ Future processLog(VeilidLog log) async { switch (log.logLevel) { case VeilidLogLevel.error: - _veilidLoggy.error(log.message, error, stackTrace); - break; + veilidLoggy.error(log.message, error, stackTrace); case VeilidLogLevel.warn: - _veilidLoggy.warning(log.message, error, stackTrace); - break; + veilidLoggy.warning(log.message, error, stackTrace); case VeilidLogLevel.info: - _veilidLoggy.info(log.message, error, stackTrace); - break; + veilidLoggy.info(log.message, error, stackTrace); case VeilidLogLevel.debug: - _veilidLoggy.debug(log.message, error, stackTrace); - break; + veilidLoggy.debug(log.message, error, stackTrace); case VeilidLogLevel.trace: - _veilidLoggy.trace(log.message, error, stackTrace); - break; + veilidLoggy.trace(log.message, error, stackTrace); } } -void initVeilidLog() { +void initVeilidLog(bool debugMode) { + // Always allow LOG_TRACE option // ignore: do_not_use_environment const isTrace = String.fromEnvironment('LOG_TRACE') != ''; LogLevel logLevel; if (isTrace) { logLevel = traceLevel; } else { - logLevel = kDebugMode ? LogLevel.debug : LogLevel.info; + logLevel = debugMode ? LogLevel.debug : LogLevel.info; } setVeilidLogLevel(logLevel); } diff --git a/packages/veilid_support/lib/veilid_support.dart b/packages/veilid_support/lib/veilid_support.dart new file mode 100644 index 0000000..2f4da90 --- /dev/null +++ b/packages/veilid_support/lib/veilid_support.dart @@ -0,0 +1,22 @@ +/// Dart Veilid Support Library +/// Common functionality for interfacing with Veilid + +library; + +export 'package:veilid/veilid.dart'; + +export 'dht_support/dht_support.dart'; +export 'identity_support/identity_support.dart'; +export 'src/config.dart'; +export 'src/dynamic_debug.dart'; +export 'src/json_tools.dart'; +export 'src/memory_tools.dart'; +export 'src/online_element_state.dart'; +export 'src/output.dart'; +export 'src/persistent_queue.dart'; +export 'src/protobuf_tools.dart'; +export 'src/table_db.dart'; +export 'src/table_db_array.dart'; +export 'src/table_db_array_protobuf_cubit.dart'; +export 'src/veilid_crypto.dart'; +export 'src/veilid_log.dart' hide veilidLoggy; diff --git a/packages/veilid_support/pubspec.lock b/packages/veilid_support/pubspec.lock new file mode 100644 index 0000000..0d2320e --- /dev/null +++ b/packages/veilid_support/pubspec.lock @@ -0,0 +1,820 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: e55636ed79578b9abca5fecf9437947798f5ef7456308b5cb85720b793eac92f + url: "https://pub.dev" + source: hosted + version: "82.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "904ae5bb474d32c38fb9482e2d925d5454cda04ddd0e55d2e6826bc72f6ba8c0" + url: "https://pub.dev" + source: hosted + version: "7.4.5" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + async_tools: + dependency: "direct main" + description: + name: async_tools + sha256: "9611c1efeae7e6d342721d0c2caf2e4783d91fba6a9637d7badfa2dccf8de2a2" + url: "https://pub.dev" + source: hosted + version: "0.1.10" + bloc: + dependency: "direct main" + description: + name: bloc + sha256: "52c10575f4445c61dd9e0cafcc6356fdd827c4c64dd7945ef3c4105f6b6ac189" + url: "https://pub.dev" + source: hosted + version: "9.0.0" + bloc_advanced_tools: + dependency: "direct main" + description: + name: bloc_advanced_tools + sha256: "63e57000df7259e3007dbfbbfd7dae3e0eca60eb2ac93cbe0c5a3de0e77c9972" + url: "https://pub.dev" + source: hosted + version: "0.1.13" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + buffer: + dependency: "direct main" + description: + name: buffer + sha256: "389da2ec2c16283c8787e0adaede82b1842102f8c8aae2f49003a766c5c6b3d1" + url: "https://pub.dev" + source: hosted + version: "1.2.3" + build: + dependency: transitive + description: + name: build + sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: "8e928697a82be082206edb0b9c99c5a4ad6bc31c9e9b8b2f291ae65cd4a25daa" + url: "https://pub.dev" + source: hosted + version: "4.0.4" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0 + url: "https://pub.dev" + source: hosted + version: "2.4.4" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "058fe9dce1de7d69c4b84fada934df3e0153dd000758c4d65964d0166779aa99" + url: "https://pub.dev" + source: hosted + version: "2.4.15" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021" + url: "https://pub.dev" + source: hosted + version: "8.0.0" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: ea90e81dc4a25a043d9bee692d20ed6d1c4a1662a28c03a96417446c093ed6b4 + url: "https://pub.dev" + source: hosted + version: "8.9.5" + change_case: + dependency: transitive + description: + name: change_case + sha256: e41ef3df58521194ef8d7649928954805aeb08061917cf658322305e61568003 + url: "https://pub.dev" + source: hosted + version: "2.2.0" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + charcode: + dependency: "direct main" + description: + name: charcode + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + url: "https://pub.dev" + source: hosted + version: "2.0.3" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" + url: "https://pub.dev" + source: hosted + version: "4.10.1" + collection: + dependency: "direct main" + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: "direct main" + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "802bd084fb82e55df091ec8ad1553a7331b61c08251eef19a508b6f3f3a9858d" + url: "https://pub.dev" + source: hosted + version: "1.13.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + url: "https://pub.dev" + source: hosted + version: "3.0.6" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "5b236382b47ee411741447c1f1e111459c941ea1b3f2b540dde54c210a3662af" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + equatable: + dependency: "direct main" + description: + name: equatable + sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + url: "https://pub.dev" + source: hosted + version: "2.0.7" + fast_immutable_collections: + dependency: "direct main" + description: + name: fast_immutable_collections + sha256: d1aa3d7788fab06cce7f303f4969c7a16a10c865e1bd2478291a8ebcbee084e5 + url: "https://pub.dev" + source: hosted + version: "11.0.4" + ffi: + dependency: transitive + description: + name: ffi + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + freezed: + dependency: "direct dev" + description: + name: freezed + sha256: "6022db4c7bfa626841b2a10f34dd1e1b68e8f8f9650db6112dcdeeca45ca793c" + url: "https://pub.dev" + source: hosted + version: "3.0.6" + freezed_annotation: + dependency: "direct main" + description: + name: freezed_annotation + sha256: c87ff004c8aa6af2d531668b46a4ea379f7191dc6dfa066acd53d506da6e044b + url: "https://pub.dev" + source: hosted + version: "3.0.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + globbing: + dependency: transitive + description: + name: globbing + sha256: "4f89cfaf6fa74c9c1740a96259da06bd45411ede56744e28017cc534a12b6e2d" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + http: + dependency: transitive + description: + name: http + sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" + url: "https://pub.dev" + source: hosted + version: "1.4.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + indent: + dependency: "direct main" + description: + name: indent + sha256: "819319a5c185f7fe412750c798953378b37a0d0d32564ce33e7c5acfd1372d2a" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c + url: "https://pub.dev" + source: hosted + version: "6.9.5" + lint_hard: + dependency: "direct dev" + description: + name: lint_hard + sha256: "2073d4e83ac4e3f2b87cc615fff41abb5c2c5618e117edcd3d71f40f2186f4d5" + url: "https://pub.dev" + source: hosted + version: "6.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + loggy: + dependency: "direct main" + description: + name: loggy + sha256: "981e03162bbd3a5a843026f75f73d26e4a0d8aa035ae060456ca7b30dfd1e339" + url: "https://pub.dev" + source: hosted + version: "2.0.3" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: "direct main" + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: "direct main" + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 + url: "https://pub.dev" + source: hosted + version: "2.2.17" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + protobuf: + dependency: "direct main" + description: + name: protobuf + sha256: "579fe5557eae58e3adca2e999e38f02441d8aa908703854a9e0a0f47fa857731" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + 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: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c" + url: "https://pub.dev" + source: hosted + version: "1.3.5" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + system_info2: + dependency: transitive + description: + name: system_info2 + sha256: "65206bbef475217008b5827374767550a5420ce70a04d2d7e94d1d2253f3efc9" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + system_info_plus: + dependency: transitive + description: + name: system_info_plus + sha256: df94187e95527f9cb459e6a9f6e0b1ea20c157d8029bc233de34b3c1e17e1c48 + url: "https://pub.dev" + source: hosted + version: "0.0.6" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: "direct dev" + description: + name: test + sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" + url: "https://pub.dev" + source: hosted + version: "1.26.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + url: "https://pub.dev" + source: hosted + version: "0.7.6" + test_core: + dependency: transitive + description: + name: test_core + sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" + url: "https://pub.dev" + source: hosted + version: "0.6.11" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + veilid: + dependency: "direct main" + description: + path: "../../../veilid/veilid-flutter" + relative: true + source: path + version: "0.4.6" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "6f82e9ee8e7339f5d8b699317f6f3afc17c80a68ebef1bc0d6f52a678c14b1e6" + url: "https://pub.dev" + source: hosted + version: "15.0.1" + watcher: + dependency: transitive + description: + name: watcher + sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + 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: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + 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" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.7.0 <4.0.0" + flutter: ">=3.27.0" diff --git a/packages/veilid_support/pubspec.yaml b/packages/veilid_support/pubspec.yaml new file mode 100644 index 0000000..5fdb74b --- /dev/null +++ b/packages/veilid_support/pubspec.yaml @@ -0,0 +1,43 @@ +name: veilid_support +description: Veilid Support Library +publish_to: "none" +version: 1.0.2+0 + +environment: + sdk: ">=3.2.0 <4.0.0" + +dependencies: + async_tools: ^0.1.10 + bloc: ^9.0.0 + bloc_advanced_tools: ^0.1.13 + buffer: ^1.2.3 + charcode: ^1.4.0 + collection: ^1.19.1 + convert: ^3.1.2 + equatable: ^2.0.7 + fast_immutable_collections: ^11.0.3 + freezed_annotation: ^3.0.0 + indent: ^2.0.0 + json_annotation: ^4.9.0 + loggy: ^2.0.3 + meta: ^1.16.0 + + path: ^1.9.1 + path_provider: ^2.1.5 + protobuf: ^4.1.0 + veilid: + # veilid: ^0.0.1 + path: ../../../veilid/veilid-flutter + +# dependency_overrides: +# async_tools: +# path: ../../../dart_async_tools +# bloc_advanced_tools: +# path: ../../../bloc_advanced_tools + +dev_dependencies: + build_runner: ^2.4.15 + freezed: ^3.0.4 + json_serializable: ^6.9.4 + lint_hard: ^6.0.0 + test: ^1.25.15 diff --git a/packages/veilid_support/run_integration_tests.sh b/packages/veilid_support/run_integration_tests.sh new file mode 100755 index 0000000..9c5b882 --- /dev/null +++ b/packages/veilid_support/run_integration_tests.sh @@ -0,0 +1,4 @@ +#!/bin/bash +pushd example 2>/dev/null +flutter test -r expanded integration_test/app_test.dart $@ +popd 2>/dev/null diff --git a/packages/veilid_support/run_integration_tests_web.sh b/packages/veilid_support/run_integration_tests_web.sh new file mode 100755 index 0000000..c74f9fd --- /dev/null +++ b/packages/veilid_support/run_integration_tests_web.sh @@ -0,0 +1,5 @@ +#!/bin/bash +echo Ensure chromedriver is running on port 4444 and you have compiled veilid-wasm with wasm_build.sh +pushd example 2>/dev/null +flutter drive --driver=test_driver/integration_test.dart --target=integration_test/app_test.dart -d chrome $@ +popd 2>/dev/null diff --git a/packages/veilid_support/run_unit_tests.sh b/packages/veilid_support/run_unit_tests.sh new file mode 100755 index 0000000..388715e --- /dev/null +++ b/packages/veilid_support/run_unit_tests.sh @@ -0,0 +1,2 @@ +#!/bin/bash +flutter test -r expanded $@ diff --git a/process_flame.sh b/process_flame.sh new file mode 100755 index 0000000..8f03418 --- /dev/null +++ b/process_flame.sh @@ -0,0 +1,3 @@ +#!/bin/bash +cat "/Users/$USER/Library/Containers/com.veilid.veilidchat/Data/Library/Application Support/com.veilid.veilidchat/VeilidChat.folded" | inferno-flamegraph -c purple --fontsize 8 --height 24 --title "VeilidChat" --factor 0.000000001 --countname secs > /tmp/veilidchat.svg +cat "/Users/$USER/Library/Containers/com.veilid.veilidchat/Data/Library/Application Support/com.veilid.veilidchat/VeilidChat.folded" | inferno-flamegraph --reverse -c aqua --fontsize 8 --height 24 --title "VeilidChat Reverse" --factor 0.000000001 --countname secs > /tmp/veilidchat-reverse.svg diff --git a/pubspec.lock b/pubspec.lock index 04e023f..95c1262 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,74 +5,114 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: eb376e9acf6938204f90eb3b1f00b578640d3188b4c8a8ec054f9f479af8d051 + sha256: e55636ed79578b9abca5fecf9437947798f5ef7456308b5cb85720b793eac92f url: "https://pub.dev" source: hosted - version: "64.0.0" + version: "82.0.0" + accordion: + dependency: "direct main" + description: + name: accordion + sha256: "0eca3d1c619c6df63d6e384010fd2ef1164e7385d7102f88a6b56f658f160cd0" + url: "https://pub.dev" + source: hosted + version: "2.6.0" analyzer: dependency: transitive description: name: analyzer - sha256: "69f54f967773f6c26c7dcb13e93d7ccee8b17a641689da39e878d5cf13b06893" + sha256: "904ae5bb474d32c38fb9482e2d925d5454cda04ddd0e55d2e6826bc72f6ba8c0" url: "https://pub.dev" source: hosted - version: "6.2.0" - analyzer_plugin: - dependency: transitive + version: "7.4.5" + animated_bottom_navigation_bar: + dependency: "direct main" description: - name: analyzer_plugin - sha256: "9661b30b13a685efaee9f02e5d01ed9f2b423bd889d28a304d02d704aee69161" + name: animated_bottom_navigation_bar + sha256: "94971fdfd53acd443acd0d17ce1cb5219ad833f20c75b50c55b205e54a5d6117" url: "https://pub.dev" source: hosted - version: "0.11.3" + version: "1.4.0" + animated_custom_dropdown: + dependency: "direct main" + description: + name: animated_custom_dropdown + sha256: "5a72dc209041bb53f6c7164bc2e366552d5197cdb032b1c9b2c36e3013024486" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + animated_switcher_transitions: + dependency: "direct main" + description: + name: animated_switcher_transitions + sha256: "0f3ef1b46ab3f0b5efe784dcff55bbeabdc75a3b9bcbefbf2315468c9cec87c3" + url: "https://pub.dev" + source: hosted + version: "1.0.0" animated_theme_switcher: dependency: "direct main" description: name: animated_theme_switcher - sha256: a131266f7021a8a663da4c4848c53c62178949a7517c2af00b22e4c614352302 + sha256: "24ccd74437b8db78f6d1ec701804702817bced5f925b1b3419c7a93071e3d3e9" url: "https://pub.dev" source: hosted - version: "2.0.8" + version: "2.0.10" ansicolor: dependency: "direct main" description: name: ansicolor - sha256: "607f8fa9786f392043f169898923e6c59b4518242b68b8862eb8a8b7d9c30b4a" + sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.0.3" archive: dependency: "direct main" description: name: archive - sha256: "20071638cbe4e5964a427cfa0e86dce55d060bc7d82d56f3554095d7239a8765" + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" url: "https://pub.dev" source: hosted - version: "3.4.2" + version: "4.0.7" 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: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" url: "https://pub.dev" source: hosted - version: "2.11.0" + version: "2.13.0" + async_tools: + dependency: "direct main" + description: + name: async_tools + sha256: "9611c1efeae7e6d342721d0c2caf2e4783d91fba6a9637d7badfa2dccf8de2a2" + url: "https://pub.dev" + source: hosted + version: "0.1.10" + auto_size_text: + dependency: "direct main" + description: + name: auto_size_text + sha256: "3f5261cd3fb5f2a9ab4e2fc3fba84fd9fcaac8821f20a1d4e71f557521b22599" + url: "https://pub.dev" + source: hosted + version: "3.0.0" awesome_extensions: dependency: "direct main" description: name: awesome_extensions - sha256: "6b9c6a5f70d17959ace71d649d3b816b13b73267196035d431ff17e65a228608" + sha256: "9b1693e986e4045141add298fa2d7f9aa6cdd3c125b951e2cde739a5058ed879" url: "https://pub.dev" source: hosted - version: "2.0.9" + version: "2.0.21" badges: dependency: "direct main" description: @@ -81,14 +121,46 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + barcode: + dependency: transitive + description: + name: barcode + sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4" + url: "https://pub.dev" + source: hosted + version: "2.2.9" basic_utils: dependency: "direct main" description: name: basic_utils - sha256: "1fb8c5493fc1b9500512b2e153c0b9bcc9e281621cde7f810420f4761be9e38d" + sha256: "548047bef0b3b697be19fa62f46de54d99c9019a69fb7db92c69e19d87f633c7" url: "https://pub.dev" source: hosted - version: "5.6.1" + version: "5.8.2" + bidi: + dependency: transitive + description: + name: bidi + sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d" + url: "https://pub.dev" + source: hosted + version: "2.0.13" + bloc: + dependency: "direct main" + description: + name: bloc + sha256: "52c10575f4445c61dd9e0cafcc6356fdd827c4c64dd7945ef3c4105f6b6ac189" + url: "https://pub.dev" + source: hosted + version: "9.0.0" + bloc_advanced_tools: + dependency: "direct main" + description: + name: bloc_advanced_tools + sha256: "63e57000df7259e3007dbfbbfd7dae3e0eca60eb2ac93cbe0c5a3de0e77c9972" + url: "https://pub.dev" + source: hosted + version: "0.1.13" blurry_modal_progress_hud: dependency: "direct main" description: @@ -101,58 +173,66 @@ packages: dependency: transitive description: name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" + buffer: + dependency: transitive + description: + name: buffer + sha256: "389da2ec2c16283c8787e0adaede82b1842102f8c8aae2f49003a766c5c6b3d1" + url: "https://pub.dev" + source: hosted + version: "1.2.3" build: dependency: transitive description: name: build - sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" + sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0 url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" build_config: dependency: transitive description: name: build_config - sha256: bf80fcfb46a29945b423bd9aad884590fb1dc69b330a4d4700cac476af1708d1 + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" build_daemon: dependency: transitive description: name: build_daemon - sha256: "5f02d73eb2ba16483e693f80bee4f088563a820e47d1027d4cdfe62b5bb43e65" + sha256: "8e928697a82be082206edb0b9c99c5a4ad6bc31c9e9b8b2f291ae65cd4a25daa" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.0.4" build_resolvers: dependency: transitive description: name: build_resolvers - sha256: "0713a05b0386bd97f9e63e78108805a4feca5898a4b821d6610857f10c91e975" + sha256: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0 url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.4" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "10c6bcdbf9d049a0b666702cf1cee4ddfdc38f02a19d35ae392863b47519848b" + sha256: "058fe9dce1de7d69c4b84fada934df3e0153dd000758c4d65964d0166779aa99" url: "https://pub.dev" source: hosted - version: "2.4.6" + version: "2.4.15" build_runner_core: dependency: transitive description: name: build_runner_core - sha256: c9e32d21dd6626b5c163d48b037ce906bbe428bc23ab77bcd77bb21e593b6185 + sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021" url: "https://pub.dev" source: hosted - version: "7.2.11" + version: "8.0.0" built_collection: dependency: transitive description: @@ -165,106 +245,106 @@ packages: dependency: transitive description: name: built_value - sha256: a8de5955205b4d1dbbbc267daddf2178bd737e4bab8987c04a500478c9651e74 + sha256: ea90e81dc4a25a043d9bee692d20ed6d1c4a1662a28c03a96417446c093ed6b4 url: "https://pub.dev" source: hosted - version: "8.6.3" + version: "8.9.5" cached_network_image: dependency: transitive description: name: cached_network_image - sha256: f98972704692ba679db144261172a8e20feb145636c617af0eb4022132a6797f + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" url: "https://pub.dev" source: hosted - version: "3.3.0" + version: "3.4.1" cached_network_image_platform_interface: dependency: transitive description: name: cached_network_image_platform_interface - sha256: "56aa42a7a01e3c9db8456d9f3f999931f1e05535b5a424271e9a38cabf066613" + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "4.1.1" cached_network_image_web: dependency: transitive description: name: cached_network_image_web - sha256: "759b9a9f8f6ccbb66c185df805fac107f05730b1dab9c64626d1008cca532257" + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.3.1" camera: - dependency: transitive + dependency: "direct main" description: name: camera - sha256: f63f2687fb1795c36f7c57b18a03071880eabb0fd8b5291b0fcd3fb979cb0fb1 + sha256: "413d2b34fe28496c35c69ede5b232fb9dd5ca2c3a4cb606b14efc1c7546cc8cb" url: "https://pub.dev" source: hosted - version: "0.10.5+4" - camera_android: + version: "0.11.1" + camera_android_camerax: dependency: transitive description: - name: camera_android - sha256: c978373b41a463c9edda3fea0a06966299f55db63232cd0f0d4efc21a59a0006 + name: camera_android_camerax + sha256: "9fb44e73e0fea3647a904dc26d38db24055e5b74fc68fd2b6d3abfa1bd20f536" url: "https://pub.dev" source: hosted - version: "0.10.8+12" + version: "0.6.17" camera_avfoundation: dependency: transitive description: name: camera_avfoundation - sha256: dde42d19ad4cdf79287f9e410599db72beaec7e505787dc6abfd0ce5b526e9c0 + sha256: ca36181194f429eef3b09de3c96280f2400693f9735025f90d1f4a27465fdd72 url: "https://pub.dev" source: hosted - version: "0.9.13+5" + version: "0.9.19" camera_platform_interface: dependency: transitive description: name: camera_platform_interface - sha256: "8734d1c682f034bdb12d0d6ff379b0535a9b8e44266b530025bf8266d6a62f28" + sha256: "2f757024a48696ff4814a789b0bd90f5660c0fb25f393ab4564fb483327930e2" url: "https://pub.dev" source: hosted - version: "2.5.2" + version: "2.10.0" camera_web: dependency: transitive description: name: camera_web - sha256: d4c2c571c7af04f8b10702ca16bb9ed2a26e64534171e8f75c9349b2c004d8f1 + sha256: "595f28c89d1fb62d77c73c633193755b781c6d2e0ebcd8dc25b763b514e6ba8f" url: "https://pub.dev" source: hosted - version: "0.3.2+3" + version: "0.3.5" change_case: dependency: "direct main" description: name: change_case - sha256: f4e08feaa845e75e4f5ad2b0e15f24813d7ea6c27e7b78252f0c17f752cf1157 + sha256: e41ef3df58521194ef8d7649928954805aeb08061917cf658322305e61568003 url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "2.2.0" characters: dependency: transitive description: name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.0" charcode: dependency: "direct main" description: name: charcode - sha256: fb98c0f6d12c920a02ee2d998da788bca066ca5f148492b7085ee23372b12306 + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.4.0" charset: dependency: transitive description: name: charset - sha256: e8346cf597b6cea278d2d3a29b2d01ed8fb325aad718e70f22b0cb653cb31700 + sha256: "27802032a581e01ac565904ece8c8962564b1070690794f0072f6865958ce8b9" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "2.0.1" checked_yaml: dependency: transitive description: @@ -273,14 +353,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" - ci: - dependency: transitive - description: - name: ci - sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13" - url: "https://pub.dev" - source: hosted - version: "0.1.0" circular_profile_avatar: dependency: "direct main" description: @@ -297,174 +369,166 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" - cli_util: - dependency: transitive - description: - name: cli_util - sha256: b8db3080e59b2503ca9e7922c3df2072cf13992354d5e944074ffa836fba43b7 - url: "https://pub.dev" - source: hosted - version: "0.4.0" 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_builder: dependency: transitive description: name: code_builder - sha256: "1be9be30396d7e4c0db42c35ea6ccd7cc6a1e19916b5dc64d6ac216b5544d677" + sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" url: "https://pub.dev" source: hosted - version: "4.7.0" + version: "4.10.1" 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" - url: "https://pub.dev" - source: hosted - version: "3.1.1" - cool_dropdown: dependency: "direct main" description: - name: cool_dropdown - sha256: "24400f57740b4779407586121e014bef241699ad2a52c506a7e1e7616cb68653" + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "3.1.2" + cross_cache: + dependency: transitive + description: + name: cross_cache + sha256: "007d0340c19d4d201192a3335c4034f4b79eae5ea53f90b69eeb5d239d9fbd1d" + url: "https://pub.dev" + source: hosted + version: "1.0.2" cross_file: dependency: transitive description: name: cross_file - sha256: fd832b5384d0d6da4f6df60b854d33accaaeb63aa9e10e736a87381f08dee2cb + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" url: "https://pub.dev" source: hosted - version: "0.3.3+5" + version: "0.3.4+2" crypto: dependency: transitive description: name: crypto - sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.6" csslib: dependency: transitive description: name: csslib - sha256: "706b5707578e0c1b4b7550f64078f0a0f19dec3f50a178ffae7006b0a9ca58fb" + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.0.2" cupertino_icons: dependency: "direct main" description: name: cupertino_icons - sha256: d57953e10f9f8327ce64a508a355f0b1ec902193f66288e8cb5070e7c47eeb2d + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 url: "https://pub.dev" source: hosted - version: "1.0.6" - custom_lint: - dependency: transitive - description: - name: custom_lint - sha256: "837821e4619c167fd5a547b03bb2fc6be7e65b800ec75528848429705c31ceba" - url: "https://pub.dev" - source: hosted - version: "0.5.3" - custom_lint_core: - dependency: transitive - description: - name: custom_lint_core - sha256: "3bdebdd52a42b4d6e5be9cd833ad1ecfbbc23e1020ca537060e54085497aea9c" - url: "https://pub.dev" - source: hosted - version: "0.5.3" + version: "1.0.8" dart_style: dependency: transitive description: name: dart_style - sha256: abd7625e16f51f554ea244d090292945ec4d4be7bfbaf2ec8cccea568919d334 + sha256: "5b236382b47ee411741447c1f1e111459c941ea1b3f2b540dde54c210a3662af" url: "https://pub.dev" source: hosted - version: "2.3.3" + version: "3.1.0" diffutil_dart: dependency: transitive description: name: diffutil_dart - sha256: e0297e4600b9797edff228ed60f4169a778ea357691ec98408fa3b72994c7d06 + sha256: "5e74883aedf87f3b703cb85e815bdc1ed9208b33501556e4a8a5572af9845c81" url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "4.0.1" + dio: + dependency: transitive + description: + name: dio + sha256: "253a18bbd4851fecba42f7343a1df3a9a4c1d31a2c1b37e221086b4fa8c8dbc9" + url: "https://pub.dev" + source: hosted + version: "5.8.0+1" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + url: "https://pub.dev" + source: hosted + version: "2.1.1" equatable: dependency: "direct main" description: name: equatable - sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2 + sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" url: "https://pub.dev" source: hosted - version: "2.0.5" - fake_async: - dependency: transitive + version: "2.0.7" + expansion_tile_group: + dependency: "direct main" description: - name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + name: expansion_tile_group + sha256: "894c5088d94dda5d1ddde50463881935ff41b15850fe674605b9003d09716c8e" url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "2.3.0" fast_immutable_collections: dependency: "direct main" description: name: fast_immutable_collections - sha256: b4f7d3af6e90a80cf7a3dddd0de3b4a46acb446320795b77b034535c4d267fbe + sha256: d1aa3d7788fab06cce7f303f4969c7a16a10c865e1bd2478291a8ebcbee084e5 url: "https://pub.dev" source: hosted - version: "9.1.5" + version: "11.0.4" ffi: dependency: transitive description: name: ffi - sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878" + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.4" file: dependency: transitive description: name: file - sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d" + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 url: "https://pub.dev" source: hosted - version: "6.1.4" - file_utils: - dependency: transitive + version: "7.0.1" + file_saver: + dependency: "direct main" description: - name: file_utils - sha256: d1e64389a22649095c8405c9e177272caf05139255931c9ff30d53b5c9bcaa34 + name: file_saver + sha256: "017a127de686af2d2fbbd64afea97052d95f2a0f87d19d25b87e097407bf9c1e" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "0.2.14" fixnum: dependency: "direct main" description: name: fixnum - sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -474,66 +538,58 @@ packages: dependency: "direct main" description: name: flutter_animate - sha256: "62f346340a96192070e31e3f2a1bd30a28530f1fe8be978821e06cd56b74d6d2" + sha256: "7befe2d3252728afb77aecaaea1dec88a89d35b9b1d2eea6d04479e8af9117b5" url: "https://pub.dev" source: hosted - version: "4.2.0+1" + version: "4.5.2" + flutter_bloc: + dependency: "direct main" + description: + name: flutter_bloc + sha256: cf51747952201a455a1c840f8171d273be009b932c75093020f9af64f2123e38 + url: "https://pub.dev" + source: hosted + version: "9.1.1" flutter_cache_manager: dependency: transitive description: name: flutter_cache_manager - sha256: "8207f27539deb83732fdda03e259349046a39a4c767269285f449ade355d54ba" + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" url: "https://pub.dev" source: hosted - version: "3.3.1" - flutter_chat_types: + version: "3.4.1" + flutter_chat_core: dependency: "direct main" description: - name: flutter_chat_types - sha256: e285b588f6d19d907feb1f6d912deaf22e223656769c34093b64e1c59b094fb9 + name: flutter_chat_core + sha256: "7875785bc4aa0b1dce56a76d2a8bd65841c130a3deb2c527878ebfdf8c54f971" url: "https://pub.dev" source: hosted - version: "3.6.2" + version: "2.3.0" flutter_chat_ui: dependency: "direct main" description: name: flutter_chat_ui - sha256: d2b7d99fae88d17fdab13f4be3e6ae15c4ceaa5d3e199b61c254a67222d42611 + sha256: "012aa0d9cc2898b8f89b48f66adb106de9547e466ba21ad54ccef25515f68dcc" url: "https://pub.dev" source: hosted - version: "1.6.9" + version: "2.3.0" flutter_form_builder: dependency: "direct main" description: name: flutter_form_builder - sha256: "8973beed34b6d951d36bf688b52e9e3040b47b763c35c320bd6f4c2f6b13f3a2" + sha256: aa3901466c70b69ae6c7f3d03fcbccaec5fde179d3fded0b10203144b546ad28 url: "https://pub.dev" source: hosted - version: "9.1.1" + version: "10.0.1" flutter_hooks: dependency: "direct main" description: name: flutter_hooks - sha256: "6ae13b1145c589112cbd5c4fda6c65908993a9cb18d4f82042e9c28dd9fbf611" + sha256: b772e710d16d7a20c0740c4f855095026b31c7eb5ba3ab67d2bd52021cd9461d url: "https://pub.dev" source: hosted - version: "0.20.1" - flutter_link_previewer: - dependency: transitive - description: - name: flutter_link_previewer - sha256: "007069e60f42419fb59872beb7a3cc3ea21e9f1bdff5d40239f376fa62ca9f20" - url: "https://pub.dev" - source: hosted - version: "3.2.2" - flutter_linkify: - dependency: transitive - description: - name: flutter_linkify - sha256: "74669e06a8f358fee4512b4320c0b80e51cffc496607931de68d28f099254073" - url: "https://pub.dev" - source: hosted - version: "6.0.0" + version: "0.21.2" flutter_localizations: dependency: "direct main" description: flutter @@ -543,116 +599,127 @@ packages: dependency: "direct main" description: name: flutter_native_splash - sha256: ecff62b3b893f2f665de7e4ad3de89f738941fcfcaaba8ee601e749efafa4698 + sha256: "8321a6d11a8d13977fa780c89de8d257cce3d841eecfb7a4cadffcc4f12d82dc" url: "https://pub.dev" source: hosted - version: "2.3.2" - flutter_parsed_text: - dependency: transitive - description: - name: flutter_parsed_text - sha256: "529cf5793b7acdf16ee0f97b158d0d4ba0bf06e7121ef180abe1a5b59e32c1e2" - url: "https://pub.dev" - source: hosted - version: "2.2.1" + version: "2.4.6" flutter_plugin_android_lifecycle: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: f185ac890306b5779ecbd611f52502d8d4d63d27703ef73161ca0407e815f02c + sha256: f948e346c12f8d5480d2825e03de228d0eb8c3a737e4cdaa122267b89c022b5e url: "https://pub.dev" source: hosted - version: "2.0.16" - flutter_riverpod: - dependency: "direct main" + version: "2.0.28" + flutter_shaders: + dependency: transitive description: - name: flutter_riverpod - sha256: fcea39b84b666649280f6f678bc0bb479253bf865abc0387a8b11dac6477bf92 + name: flutter_shaders + sha256: "34794acadd8275d971e02df03afee3dee0f98dbfb8c4837082ad0034f612a3e2" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "0.1.3" flutter_slidable: dependency: "direct main" description: name: flutter_slidable - sha256: cc4231579e3eae41ae166660df717f4bad1359c87f4a4322ad8ba1befeb3d2be + sha256: ab7dbb16f783307c9d7762ede2593ce32c220ba2ba0fd540a3db8e9a3acba71a url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "4.0.0" flutter_spinkit: dependency: "direct main" description: name: flutter_spinkit - sha256: b39c753e909d4796906c5696a14daf33639a76e017136c8d82bf3e620ce5bb8e + sha256: d2696eed13732831414595b98863260e33e8882fc069ee80ec35d4ac9ddb0472 url: "https://pub.dev" source: hosted - version: "5.2.0" + version: "5.2.1" + flutter_sticky_header: + dependency: "direct main" + description: + name: flutter_sticky_header + sha256: fb4fda6164ef3e5fc7ab73aba34aad253c17b7c6ecf738fa26f1a905b7d2d1e2 + url: "https://pub.dev" + source: hosted + version: "0.8.0" flutter_svg: dependency: "direct main" description: name: flutter_svg - sha256: "8c5d68a82add3ca76d792f058b186a0599414f279f00ece4830b9b231b570338" + sha256: d44bf546b13025ec7353091516f6881f1d4c633993cb109c3916c3a0159dadf1 url: "https://pub.dev" source: hosted - version: "2.0.7" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" + version: "2.1.0" flutter_translate: dependency: "direct main" description: name: flutter_translate - sha256: "8b1c449bf6d17753e6f188185f735ebc0a328d21d745878a43be66857de8ebb3" + sha256: bc09db690098879e3f90eb3aac3499e5282f32d5f9d8f1cc597d67bbc1e065ef url: "https://pub.dev" source: hosted - version: "4.0.4" + version: "4.1.0" flutter_web_plugins: dependency: transitive description: flutter source: sdk version: "0.0.0" + flutter_zoom_drawer: + dependency: "direct main" + description: + name: flutter_zoom_drawer + sha256: "5a3708548868463fb36e0e3171761ab7cb513df88d2f14053802812d2e855060" + url: "https://pub.dev" + source: hosted + version: "3.2.0" form_builder_validators: dependency: "direct main" description: name: form_builder_validators - sha256: "19aa5282b7cede82d0025ab031a98d0554b84aa2ba40f12013471a3b3e22bf02" + sha256: cd617fa346250293ff3e2709961d0faf7b80e6e4f0ff7b500126b28d7422dd67 url: "https://pub.dev" source: hosted - version: "9.1.0" + version: "11.1.2" freezed: dependency: "direct dev" description: name: freezed - sha256: be7826ed5d87e98c924a839542674fc14edbcb3e4fc0adbc058d680f2b241837 + sha256: "6022db4c7bfa626841b2a10f34dd1e1b68e8f8f9650db6112dcdeeca45ca793c" url: "https://pub.dev" source: hosted - version: "2.4.3" + version: "3.0.6" freezed_annotation: dependency: "direct main" description: name: freezed_annotation - sha256: c3fd9336eb55a38cc1bbd79ab17573113a8deccd0ecbbf926cca3c62803b5c2d + sha256: c87ff004c8aa6af2d531668b46a4ea379f7191dc6dfa066acd53d506da6e044b url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "3.0.0" 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" + get: + dependency: transitive + description: + name: get + sha256: c79eeb4339f1f3deffd9ec912f8a923834bec55f7b49c9e882b8fef2c139d425 + url: "https://pub.dev" + source: hosted + version: "4.7.2" glob: dependency: transitive description: name: glob - sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" globbing: dependency: transitive description: @@ -665,138 +732,146 @@ packages: dependency: "direct main" description: name: go_router - sha256: a07c781bf55bf11ae85133338e4850f0b4e33e261c44a66c750fc707d65d8393 + sha256: "0b1e06223bee260dee31a171fb1153e306907563a0b0225e8c1733211911429a" url: "https://pub.dev" source: hosted - version: "11.1.2" + version: "15.1.2" graphs: dependency: transitive description: name: graphs - sha256: aedc5a15e78fc65a6e23bcd927f24c64dd995062bcd1ca6eda65a3cff92a4d19 + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" url: "https://pub.dev" source: hosted - version: "2.3.1" - hooks_riverpod: - dependency: "direct main" - description: - name: hooks_riverpod - sha256: a5242fee89736eaf7e5565c271e2d87b0aeb9953ee936de819339366aebc6882 - url: "https://pub.dev" - source: hosted - version: "2.4.1" + version: "2.3.2" html: dependency: transitive description: name: html - sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a" + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" url: "https://pub.dev" source: hosted - version: "0.15.4" + version: "0.15.6" http: dependency: transitive description: name: http - sha256: "759d1a329847dd0f39226c688d3e06a6b8679668e350e2891a6474f8b4bb8525" + sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.4.0" 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" icons_launcher: dependency: "direct dev" description: name: icons_launcher - sha256: "69de6373013966ea033f4cefbbbae258ccbfe790a6cfc69796cb33fda996298a" + sha256: "2949eef3d336028d89133f69ef221d877e09deed04ebd8e738ab4a427850a7a2" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "3.0.1" + iconsax_flutter: + dependency: transitive + description: + name: iconsax_flutter + sha256: "95b65699da8ea98f87c5d232f06b0debaaf1ec1332b697e4d90969ec9a93037d" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + idb_shim: + dependency: transitive + description: + name: idb_shim + sha256: "40e872276d79a1a97cc2c1ea0ecf046b8e34d788f16a8ea8f0da3e9b337d42da" + url: "https://pub.dev" + source: hosted + version: "2.6.6+1" image: dependency: "direct main" description: name: image - sha256: "028f61960d56f26414eb616b48b04eb37d700cbe477b7fb09bf1d7ce57fd9271" + sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" url: "https://pub.dev" source: hosted - version: "4.1.3" + version: "4.5.4" + indent: + dependency: transitive + description: + name: indent + sha256: "819319a5c185f7fe412750c798953378b37a0d0d32564ce33e7c5acfd1372d2a" + url: "https://pub.dev" + source: hosted + version: "2.0.0" intl: dependency: "direct main" description: name: intl - sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" url: "https://pub.dev" source: hosted - version: "0.18.1" + version: "0.20.2" io: dependency: transitive description: name: io - sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.0.5" js: dependency: transitive description: name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" url: "https://pub.dev" source: hosted - version: "0.6.7" + version: "0.7.2" json_annotation: dependency: "direct main" description: name: json_annotation - sha256: b10a7b2ff83d83c777edba3c6a0f97045ddadd56c944e1a23a3fdf43a1bf4467 + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" url: "https://pub.dev" source: hosted - version: "4.8.1" + version: "4.9.0" json_serializable: dependency: "direct dev" description: name: json_serializable - sha256: aa1f5a8912615733e0fdc7a02af03308933c93235bdc8d50d0b0c8a8ccb0b969 + sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c url: "https://pub.dev" source: hosted - version: "6.7.1" - linkify: - dependency: transitive - description: - name: linkify - sha256: "4139ea77f4651ab9c315b577da2dd108d9aa0bd84b5d03d33323f1970c645832" - url: "https://pub.dev" - source: hosted - version: "5.0.0" + version: "6.9.5" lint_hard: dependency: "direct dev" description: name: lint_hard - sha256: "44d15ec309b1a8e1aff99069df9dcb1597f49d5f588f32811ca28fb7b38c32fe" + sha256: "2073d4e83ac4e3f2b87cc615fff41abb5c2c5618e117edcd3d71f40f2186f4d5" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "6.1.1" logging: dependency: transitive description: name: logging - sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" loggy: dependency: "direct main" description: @@ -809,122 +884,122 @@ packages: dependency: transitive description: name: matcher - sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.16" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.5.0" + version: "0.11.1" meta: - dependency: transitive + dependency: "direct main" description: name: meta - sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.9.1" + version: "1.16.0" mime: dependency: transitive description: name: mime - sha256: e4ff8e8564c03f255408decd16e7899da1733852a9110a58fe6d1b817684a63e + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" url: "https://pub.dev" source: hosted - version: "1.0.4" - mobile_scanner: - dependency: "direct main" + version: "2.0.0" + nested: + dependency: transitive description: - name: mobile_scanner - sha256: "2fbc3914fe625e196c64ea8ffc4084cd36781d2be276d4d5923b11af3b5d44ff" + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" url: "https://pub.dev" source: hosted - version: "3.4.1" - motion_toast: - dependency: "direct main" - description: - name: motion_toast - sha256: "5742e33ec2f11210f5269294304fb9bd0f30eace78ad23925eb9306dce7763c9" - url: "https://pub.dev" - source: hosted - version: "2.7.9" - mutex: - dependency: "direct main" - description: - name: mutex - sha256: "03116a4e46282a671b46c12de649d72c0ed18188ffe12a8d0fc63e83f4ad88f4" - url: "https://pub.dev" - source: hosted - version: "3.0.1" + version: "1.0.0" octo_image: dependency: transitive description: name: octo_image - sha256: "45b40f99622f11901238e18d48f5f12ea36426d8eced9f4cbf58479c7aa2430d" + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.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" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "7976bfe4c583170d6cdc7077e3237560b364149fcd268b5f53d95a991963b191" + url: "https://pub.dev" + source: hosted + version: "8.3.0" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "6c935fb612dff8e3cc9632c2b301720c77450a126114126ffaafe28d2e87956c" + url: "https://pub.dev" + source: hosted + version: "3.2.0" pasteboard: dependency: "direct main" description: name: pasteboard - sha256: "1c8b6a8b3f1d12e55d4e9404433cda1b4abe66db6b17bc2d2fb5965772c04674" + sha256: "9ff73ada33f79a59ff91f6c01881fd4ed0a0031cfc4ae2d86c0384471525fca1" url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "0.4.0" path: dependency: "direct main" description: name: path - sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.dev" source: hosted - version: "1.8.3" + version: "1.9.1" path_parsing: dependency: transitive description: name: path_parsing - sha256: e3e67b1629e6f7e8100b367d3db6ba6af4b1f0bb80f64db18ef1fbabd2fa9ccf + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.1.0" path_provider: dependency: "direct main" description: name: path_provider - sha256: a1aa8aaa2542a6bc57e381f132af822420216c80d4781f7aa085ca3229208aaa + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.5" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: "6b8b19bd80da4f11ce91b2d1fb931f3006911477cec227cce23d3253d80df3f1" + sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.2.17" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "19314d595120f82aca0ba62787d58dde2cc6b5df7d2f0daf72489e38d1b57f2d" + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.4.1" path_provider_linux: dependency: transitive description: @@ -937,74 +1012,82 @@ packages: dependency: transitive description: name: path_provider_platform_interface - sha256: "94b1e0dd80970c1ce43d5d4e050a9918fce4f4a775e6142424c30a29a363265c" + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" 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" + pausable_timer: + dependency: transitive + description: + name: pausable_timer + sha256: "6ef1a95441ec3439de6fb63f39a011b67e693198e7dae14e20675c3c00e86074" + url: "https://pub.dev" + source: hosted + version: "3.1.0+3" + pdf: + dependency: "direct main" + description: + name: pdf + sha256: "28eacad99bffcce2e05bba24e50153890ad0255294f4dd78a17075a2ba5c8416" + url: "https://pub.dev" + source: hosted + version: "3.11.3" + pdf_widget_wrapper: + dependency: transitive + description: + name: pdf_widget_wrapper + sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5 + url: "https://pub.dev" + source: hosted + version: "1.0.4" petitparser: dependency: transitive description: name: petitparser - sha256: cb3798bef7fc021ac45b308f4b51208a152792445cce0448c9a4ba5879dd8750 + sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" url: "https://pub.dev" source: hosted - version: "5.4.0" - photo_view: - dependency: transitive - description: - name: photo_view - sha256: "8036802a00bae2a78fc197af8a158e3e2f7b500561ed23b4c458107685e645bb" - url: "https://pub.dev" - source: hosted - version: "0.14.0" + version: "6.1.0" pinput: dependency: "direct main" description: name: pinput - sha256: a92b55ecf9c25d1b9e100af45905385d5bc34fc9b6b04177a9e82cb88fe4d805 + sha256: "8a73be426a91fefec90a7f130763ca39772d547e92f19a827cf4aa02e323d35a" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "5.0.1" platform: dependency: transitive description: name: platform - sha256: ae68c7bfcd7383af3629daafb32fb4e8681c7154428da4febcff06200585f102 + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" url: "https://pub.dev" source: hosted - version: "3.1.2" - platform_info: - dependency: transitive - description: - name: platform_info - sha256: "012e73712166cf0b56d3eb95c0d33491f56b428c169eca385f036448474147e4" - url: "https://pub.dev" - source: hosted - version: "3.2.0" + 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" + version: "2.1.8" pointycastle: dependency: transitive description: name: pointycastle - sha256: "7c1e5f0d23c9016c5bbd8b1473d0d3fb3fc851b876046039509e18e0c7485f2c" + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" url: "https://pub.dev" source: hosted - version: "3.7.3" + version: "4.0.0" pool: dependency: transitive description: @@ -1013,6 +1096,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.1" + posix: + dependency: transitive + description: + name: posix + sha256: f0d7856b6ca1887cfa6d1d394056a296ae33489db914e365e2044fdada449e62 + url: "https://pub.dev" + source: hosted + version: "6.0.2" preload_page_view: dependency: "direct main" description: @@ -1021,46 +1112,70 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.0" + printing: + dependency: "direct main" + description: + name: printing + sha256: "482cd5a5196008f984bb43ed0e47cbfdca7373490b62f3b27b3299275bf22a93" + url: "https://pub.dev" + source: hosted + version: "5.14.2" protobuf: dependency: "direct main" description: name: protobuf - sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d" + sha256: "579fe5557eae58e3adca2e999e38f02441d8aa908703854a9e0a0f47fa857731" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "4.1.0" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4abbd070a04e9ddc287673bf5a030c7ca8b685ff70218720abab8b092f53dd84" + url: "https://pub.dev" + source: hosted + version: "6.1.5" pub_semver: dependency: transitive description: name: pub_semver - sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" pubspec_parse: dependency: transitive description: name: pubspec_parse - sha256: c63b2876e58e194e4b0828fcb080ad0e06d051cb607a6be51a9e084f47cb9367 + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" url: "https://pub.dev" source: hosted - version: "1.2.3" + version: "1.5.0" qr: dependency: transitive description: name: qr - sha256: "64957a3930367bf97cc211a5af99551d630f2f4625e38af10edd6b19131b64b3" + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" + qr_code_dart_decoder: + dependency: transitive + description: + name: qr_code_dart_decoder + sha256: "6da7eda27726d504bed3c30eabf78ddca3eb9265e1c8dc49b30ef5974b9c267f" + url: "https://pub.dev" + source: hosted + version: "0.0.5" qr_code_dart_scan: dependency: "direct main" description: name: qr_code_dart_scan - sha256: "4b5222c044700f9ecb3d1c39ca9c5cf433b508f81a0649b768628d3b5ee2ffc4" + sha256: bc4fc6f400b4350c6946d123c7871e156459703a61f8fa57d7144df9bbb46610 url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "0.10.0" qr_flutter: dependency: "direct main" description: @@ -1069,22 +1184,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" - quickalert: - dependency: "direct main" - description: - name: quickalert - sha256: "0c21c9be68b9ae76082e1ad56db9f51202a38e617e08376f05375238277cfb5a" - url: "https://pub.dev" - source: hosted - version: "1.0.2" quiver: dependency: transitive description: name: quiver - sha256: b1c1ac5ce6688d77f65f3375a9abb9319b3cb32486bdc7a1e0fdf004d7ba4e47 + sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "3.2.2" radix_colors: dependency: "direct main" description: @@ -1097,162 +1204,195 @@ packages: dependency: "direct main" description: name: reorderable_grid - sha256: a1322139ec59134e2180acb1b84fe436ea927ce2712ae01da511614131a07d85 + sha256: "0b9cd95ef0f070ef99f92affe9cf85a4aa127099cd1334e5940950ce58cd981d" url: "https://pub.dev" source: hosted - version: "1.0.8" - riverpod: - dependency: transitive - description: - name: riverpod - sha256: ff676bd8a715c7085692fe4919564f78fb90d33b10a1c5c14e740581857cc914 - url: "https://pub.dev" - source: hosted - version: "2.4.1" - riverpod_analyzer_utils: - dependency: transitive - description: - name: riverpod_analyzer_utils - sha256: d72d7096964baf288b55619fe48100001fc4564ab7923ed0a7f5c7650e03c0d6 - url: "https://pub.dev" - source: hosted - version: "0.3.4" - riverpod_annotation: + version: "1.0.10" + rflutter_alert: dependency: "direct main" description: - name: riverpod_annotation - sha256: aeeb1eb6ccf2d779f2ef730e6d96d560316b677662222316779a8cf0a94ee317 + name: rflutter_alert + sha256: "8ff35e3f9712ba24c746499cfa95bf320385edf38901a1a4eab0fe555867f66c" url: "https://pub.dev" source: hosted - version: "2.1.6" - riverpod_generator: - dependency: "direct dev" - description: - name: riverpod_generator - sha256: "5b36ad2f2b562cffb37212e8d59390b25499bf045b732276e30a207b16a25f61" - url: "https://pub.dev" - source: hosted - version: "2.3.3" + version: "2.0.7" rxdart: dependency: transitive description: name: rxdart - sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb" + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" url: "https://pub.dev" source: hosted - version: "0.27.7" + version: "0.28.0" screen_retriever: dependency: transitive description: name: screen_retriever - sha256: "6ee02c8a1158e6dae7ca430da79436e3b1c9563c8cf02f524af997c201ac2b90" + sha256: "570dbc8e4f70bac451e0efc9c9bb19fa2d6799a11e6ef04f946d7886d2e23d0c" url: "https://pub.dev" source: hosted - version: "0.1.9" - scroll_to_index: + version: "0.2.0" + screen_retriever_linux: dependency: transitive + description: + name: screen_retriever_linux + sha256: f7f8120c92ef0784e58491ab664d01efda79a922b025ff286e29aa123ea3dd18 + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_macos: + dependency: transitive + description: + name: screen_retriever_macos + sha256: "71f956e65c97315dd661d71f828708bd97b6d358e776f1a30d5aa7d22d78a149" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_platform_interface: + dependency: transitive + description: + name: screen_retriever_platform_interface + sha256: ee197f4581ff0d5608587819af40490748e1e39e648d7680ecf95c05197240c0 + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_windows: + dependency: transitive + description: + name: screen_retriever_windows + sha256: "449ee257f03ca98a57288ee526a301a430a344a161f9202b4fcc38576716fe13" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screenshot: + dependency: "direct main" + description: + name: screenshot + sha256: "63817697a7835e6ce82add4228e15d233b74d42975c143ad8cfe07009fab866b" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + scroll_to_index: + dependency: "direct main" description: name: scroll_to_index sha256: b707546e7500d9f070d63e5acf74fd437ec7eeeb68d3412ef7b0afada0b4f176 url: "https://pub.dev" source: hosted version: "3.0.1" + scrollview_observer: + dependency: transitive + description: + name: scrollview_observer + sha256: "174d4efe7b79459a07662175c4db42c9862dcf78d3978e6e9c2d6c0d8137f4ca" + url: "https://pub.dev" + source: hosted + version: "1.26.1" searchable_listview: dependency: "direct main" description: - name: searchable_listview - sha256: e1ba75eda1460c24648e54c543843a7142811ea4966c2106e0cc6792128b7127 + path: "." + ref: main + resolved-ref: f367c2f713dcc0c965a4f7af5952d94b2f699998 + url: "https://gitlab.com/veilid/Searchable-Listview.git" + source: git + version: "2.16.0" + sembast: + dependency: transitive + description: + name: sembast + sha256: d3f0d0ba501a5f1fd7d6c8532ee01385977c8a069c334635dae390d059ae3d6d url: "https://pub.dev" source: hosted - version: "2.7.1" + version: "3.8.5" share_plus: dependency: "direct main" description: name: share_plus - sha256: "6cec740fa0943a826951223e76218df002804adb588235a8910dc3d6b0654e11" + sha256: b2961506569e28948d75ec346c28775bb111986bb69dc6a20754a457e3d97fa0 url: "https://pub.dev" source: hosted - version: "7.1.0" + version: "11.0.0" share_plus_platform_interface: dependency: transitive description: name: share_plus_platform_interface - sha256: "357412af4178d8e11d14f41723f80f12caea54cf0d5cd29af9dcdab85d58aea7" + sha256: "1032d392bc5d2095a77447a805aa3f804d2ae6a4d5eef5e6ebb3bd94c1bc19ef" url: "https://pub.dev" source: hosted - version: "3.3.0" + version: "6.0.0" shared_preferences: dependency: "direct main" description: name: shared_preferences - sha256: b7f41bad7e521d205998772545de63ff4e6c97714775902c199353f8bf1511ac + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.5.3" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "8568a389334b6e83415b6aae55378e158fbc2314e074983362d20c562780fb06" + sha256: "20cbd561f743a342c76c151d6ddb93a9ce6005751e7aa458baad3858bfbfb6ac" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.4.10" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "7bf53a9f2d007329ee6f3df7268fd498f8373602f943c975598bbb34649b62a7" + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" url: "https://pub.dev" source: hosted - version: "2.3.4" + version: "2.5.4" shared_preferences_linux: dependency: transitive description: name: shared_preferences_linux - sha256: c2eb5bf57a2fe9ad6988121609e47d3e07bb3bdca5b6f8444e4cf302428a128a + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.4.1" shared_preferences_platform_interface: dependency: transitive description: name: shared_preferences_platform_interface - sha256: d4ec5fc9ebb2f2e056c617112aa75dcf92fc2e4faaf2ae999caa297473f75d8a + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.4.1" shared_preferences_web: dependency: transitive description: name: shared_preferences_web - sha256: d762709c2bbe80626ecc819143013cc820fa49ca5e363620ee20a8b15a3e3daf + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.4.3" shared_preferences_windows: dependency: transitive description: name: shared_preferences_windows - sha256: f763a101313bd3be87edffe0560037500967de9c394a714cd598d945517f694f + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.4.1" 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_web_socket: dependency: transitive description: name: shelf_web_socket - sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "3.0.0" signal_strength_indicator: dependency: "direct main" description: @@ -1265,39 +1405,64 @@ packages: dependency: transitive description: flutter source: sdk - version: "0.0.99" - smart_auth: - dependency: transitive + version: "0.0.0" + sliver_expandable: + dependency: "direct main" description: - name: smart_auth - sha256: a25229b38c02f733d0a4e98d941b42bed91a976cb589e934895e60ccfa674cf6 + name: sliver_expandable + sha256: "046d8912ebd072bf9d8e8161e50a4669c520f691fce8bfcbae4ada6982b18ba3" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" + sliver_fill_remaining_box_adapter: + dependency: "direct main" + description: + name: sliver_fill_remaining_box_adapter + sha256: "2a222c0f09eb07c37857ce2526c0fbf3b17b2bd1b1ff0e890085f2f7a9ba1927" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + sliver_tools: + dependency: "direct main" + description: + name: sliver_tools + sha256: eae28220badfb9d0559207badcbbc9ad5331aac829a88cb0964d330d2a4636a6 + url: "https://pub.dev" + source: hosted + version: "0.2.12" + sorted_list: + dependency: "direct main" + description: + path: "." + ref: main + resolved-ref: "090eb9be48ab85ff064a0a1d8175b4a72d79b139" + url: "https://gitlab.com/veilid/dart-sorted-list-improved.git" + source: git + version: "1.0.0" source_gen: dependency: transitive description: name: source_gen - sha256: fc0da689e5302edb6177fdd964efcb7f58912f43c28c2047a808f5bfff643d16 + sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "2.0.0" source_helper: dependency: transitive description: name: source_helper - sha256: "6adebc0006c37dd63fe05bca0a929b99f06402fc95aa35bf36d67f5c06de01fd" + sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c" url: "https://pub.dev" source: hosted - version: "1.3.4" + version: "1.3.5" source_span: dependency: transitive description: name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.10.1" split_view: dependency: "direct main" description: @@ -1306,126 +1471,158 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.1" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" sqflite: dependency: transitive description: name: sqflite - sha256: "591f1602816e9c31377d5f008c2d9ef7b8aca8941c3f89cc5fd9d84da0c38a9a" + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.4.2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b" + url: "https://pub.dev" + source: hosted + version: "2.4.1" sqflite_common: dependency: transitive description: name: sqflite_common - sha256: "1b92f368f44b0dee2425bb861cfa17b6f6cf3961f762ff6f941d20b33355660a" + sha256: "84731e8bfd8303a3389903e01fb2141b6e59b5973cacbb0929021df08dddbe8b" url: "https://pub.dev" source: hosted - version: "2.5.0" - stack_trace: + version: "2.5.5" + sqflite_darwin: dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + stack_trace: + dependency: "direct main" description: name: stack_trace - sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5 + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" url: "https://pub.dev" source: hosted - version: "1.11.0" - state_notifier: - dependency: transitive - description: - name: state_notifier - sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb - url: "https://pub.dev" - source: hosted - version: "1.0.0" + version: "1.12.1" stream_channel: dependency: transitive description: name: stream_channel - sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8" + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: "direct main" + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 url: "https://pub.dev" source: hosted version: "2.1.1" - stream_transform: - dependency: transitive - description: - name: stream_transform - sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f" - url: "https://pub.dev" - source: hosted - version: "2.1.0" string_scanner: dependency: transitive description: name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" url: "https://pub.dev" source: hosted - version: "1.2.0" - stylish_bottom_bar: - dependency: "direct main" - description: - name: stylish_bottom_bar - sha256: "54970e4753b4273239b6dea0d1175c56beabcf39b5c65df4cbf86f1b86568d2b" - url: "https://pub.dev" - source: hosted - version: "1.0.3" + version: "1.4.1" synchronized: dependency: transitive description: name: synchronized - sha256: "5fcbd27688af6082f5abd611af56ee575342c30e87541d0245f7ff99faa02c60" + sha256: "0669c70faae6270521ee4f05bffd2919892d42d1276e6c495be80174b6bc0ef6" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.3.1" system_info2: dependency: transitive description: name: system_info2 - sha256: af2f948e3f31a3367a049932a8ad59faf0063ecf836a020d975b9f41566d8bc9 + sha256: "65206bbef475217008b5827374767550a5420ce70a04d2d7e94d1d2253f3efc9" url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "4.0.0" system_info_plus: dependency: transitive description: name: system_info_plus - sha256: b915c811c6605b802f3988859bc2bb79c95f735762a75b5451741f7a2b949d1b + sha256: df94187e95527f9cb459e6a9f6e0b1ea20c157d8029bc233de34b3c1e17e1c48 url: "https://pub.dev" source: hosted - version: "0.0.5" + version: "0.0.6" 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_api: dependency: transitive description: name: test_api - sha256: "75760ffd7786fffdfb9597c35c5b27eaeec82be8edfb6d71d32651128ed7aab8" + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.6.0" + version: "0.7.6" timing: dependency: transitive description: name: timing - sha256: "70a3b636575d4163c477e6de42f247a23b315ae20e86442bebe32d3cabf61c32" + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.0.2" + toastification: + dependency: "direct main" + description: + name: toastification + sha256: "9713989549d60754fd0522425d1251501919cfb7bab4ffbbb36ef40de5ea72b9" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + transitioned_indexed_stack: + dependency: "direct main" + description: + name: transitioned_indexed_stack + sha256: "8023abb5efe72e6d40cc3775fb03d7504c32ac918ec2ce7f9ba6804753820259" + url: "https://pub.dev" + source: hosted + version: "1.0.2" 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: @@ -1438,106 +1635,114 @@ packages: dependency: transitive description: name: universal_platform - sha256: d315be0f6641898b280ffa34e2ddb14f3d12b1a37882557869646e0cc363d0cc + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" url: "https://pub.dev" source: hosted - version: "1.0.0+1" + version: "1.1.0" url_launcher: - dependency: transitive + dependency: "direct main" description: name: url_launcher - sha256: "47e208a6711459d813ba18af120d9663c20bdf6985d6ad39fe165d2538378d27" + sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" url: "https://pub.dev" source: hosted - version: "6.1.14" + version: "6.3.1" url_launcher_android: dependency: transitive description: name: url_launcher_android - sha256: b04af59516ab45762b2ca6da40fa830d72d0f6045cd97744450b73493fa76330 + sha256: "8582d7f6fe14d2652b4c45c9b6c14c0b678c2af2d083a11b604caeba51930d79" url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "6.3.16" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "7c65021d5dee51813d652357bc65b8dd4a6177082a9966bc8ba6ee477baa795f" + sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb" url: "https://pub.dev" source: hosted - version: "6.1.5" + version: "6.3.3" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: b651aad005e0cb06a01dbd84b428a301916dc75f0e7ea6165f80057fee2d8e8e + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" url: "https://pub.dev" source: hosted - version: "3.0.6" + version: "3.2.1" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: b55486791f666e62e0e8ff825e58a023fd6b1f71c49926483f1128d3bbd8fe88 + sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" url: "https://pub.dev" source: hosted - version: "3.0.7" + version: "3.2.2" url_launcher_platform_interface: dependency: transitive description: name: url_launcher_platform_interface - sha256: "95465b39f83bfe95fcb9d174829d6476216f2d548b79c38ab2506e0458787618" + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.3.2" url_launcher_web: dependency: transitive description: name: url_launcher_web - sha256: "2942294a500b4fa0b918685aff406773ba0a4cd34b7f42198742a94083020ce5" + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" url: "https://pub.dev" source: hosted - version: "2.0.20" + version: "2.4.1" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: "95fef3129dc7cfaba2bc3d5ba2e16063bb561fc6d78e63eee16162bc70029069" + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" url: "https://pub.dev" source: hosted - version: "3.0.8" + version: "3.1.4" uuid: dependency: "direct main" description: name: uuid - sha256: "648e103079f7c64a36dc7d39369cabb358d377078a051d6ae2ad3aa539519313" + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff url: "https://pub.dev" source: hosted - version: "3.0.7" + version: "4.5.1" + value_layout_builder: + dependency: transitive + description: + name: value_layout_builder + sha256: ab4b7d98bac8cefeb9713154d43ee0477490183f5aa23bb4ffa5103d9bbf6275 + url: "https://pub.dev" + source: hosted + version: "0.5.0" vector_graphics: dependency: transitive description: name: vector_graphics - sha256: "670f6e07aca990b4a2bcdc08a784193c4ccdd1932620244c3a86bb72a0eac67f" + sha256: "44cc7104ff32563122a929e4620cf3efd584194eec6d1d913eb5ba593dbcf6de" url: "https://pub.dev" source: hosted - version: "1.1.7" + version: "1.1.18" vector_graphics_codec: dependency: transitive description: name: vector_graphics_codec - sha256: "7451721781d967db9933b63f5733b1c4533022c0ba373a01bdd79d1a5457f69f" + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" url: "https://pub.dev" source: hosted - version: "1.1.7" + version: "1.1.13" vector_graphics_compiler: dependency: transitive description: name: vector_graphics_compiler - sha256: "80a13c613c8bde758b1464a1755a7b3a8f2b6cec61fbf0f5a53c94c30f03ba2e" + sha256: "557a315b7d2a6dbb0aaaff84d857967ce6bdc96a63dc6ee2a57ce5a6ee5d3331" url: "https://pub.dev" source: hosted - version: "1.1.7" + version: "1.1.17" vector_math: dependency: transitive description: @@ -1552,103 +1757,118 @@ packages: path: "../veilid/veilid-flutter" relative: true source: path - version: "0.2.5" - visibility_detector: - dependency: transitive + version: "0.4.7" + veilid_support: + dependency: "direct main" description: - name: visibility_detector - sha256: dd5cc11e13494f432d15939c3aa8ae76844c42b723398643ce9addb88a5ed420 - url: "https://pub.dev" - source: hosted - version: "0.4.0+2" + path: "packages/veilid_support" + relative: true + source: path + version: "1.0.2+0" watcher: dependency: transitive description: name: watcher - sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.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" win32: dependency: transitive description: name: win32 - sha256: "350a11abd2d1d97e0cc7a28a81b781c08002aa2864d9e3f192ca0ffa18b06ed3" + sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba" url: "https://pub.dev" source: hosted - version: "5.0.9" + version: "5.13.0" window_manager: dependency: "direct main" description: name: window_manager - sha256: "6ee795be9124f90660ea9d05e581a466de19e1c89ee74fc4bf528f60c8600edd" + sha256: "732896e1416297c63c9e3fb95aea72d0355f61390263982a47fd519169dc5059" url: "https://pub.dev" source: hosted - version: "0.3.6" + version: "0.4.3" 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: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 url: "https://pub.dev" source: hosted - version: "6.3.0" + version: "6.5.0" xterm: dependency: "direct main" description: name: xterm - sha256: "6a02b15d03152b8186e12790902ff28c8a932fc441e89fa7255a7491661a8e69" + sha256: "168dfedca77cba33fdb6f52e2cd001e9fde216e398e89335c19b524bb22da3a2" url: "https://pub.dev" source: hosted - version: "3.5.0" + version: "4.0.0" yaml: dependency: transitive description: name: yaml - sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "3.1.3" + zmodem: + dependency: transitive + description: + name: zmodem + sha256: "3b7e5b29f3a7d8aee472029b05165a68438eff2f3f7766edf13daba1e297adbf" + url: "https://pub.dev" + source: hosted + version: "0.0.6" zxing2: dependency: "direct main" description: name: zxing2 - sha256: "1e141568c9646bc262fa75aacf739bc151ef6ad0226997c0016cc3da358a1bbc" + sha256: "6cf995abd3c86f01ba882968dedffa7bc130185e382f2300239d2e857fc7912c" url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "0.2.3" zxing_lib: dependency: transitive description: name: zxing_lib - sha256: "84f6ec19b04dd54bc0b25c539c7c3567a5f9e872e3feb23763df027a1f855c11" + sha256: "870a63610be3f20009ca9201f7ba2d53d7eaefa675c154b3e8c1f6fc55984d04" url: "https://pub.dev" source: hosted - version: "0.9.0" + version: "1.1.2" sdks: - dart: ">=3.1.0 <4.0.0" - flutter: ">=3.13.0" + dart: ">=3.7.0 <4.0.0" + flutter: ">=3.32.0" diff --git a/pubspec.yaml b/pubspec.yaml index faa4993..5cdbcb0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,109 +1,150 @@ name: veilidchat description: VeilidChat -publish_to: 'none' -version: 0.1.2+4 +publish_to: "none" +version: 0.4.8+21 environment: - sdk: '>=3.0.5 <4.0.0' - flutter: ">=3.10.0" + sdk: ">=3.2.0 <4.0.0" + flutter: ">=3.32.0" dependencies: - animated_theme_switcher: ^2.0.7 - ansicolor: ^2.0.1 - archive: ^3.3.7 - awesome_extensions: ^2.0.9 - badges: ^3.1.1 - basic_utils: ^5.6.1 - blurry_modal_progress_hud: ^1.1.0 - change_case: ^1.1.0 - charcode: ^1.3.1 + accordion: ^2.6.0 + animated_bottom_navigation_bar: ^1.4.0 + animated_custom_dropdown: ^3.1.1 + animated_switcher_transitions: ^1.0.0 + animated_theme_switcher: ^2.0.10 + ansicolor: ^2.0.3 + archive: ^4.0.4 + async_tools: ^0.1.10 + auto_size_text: ^3.0.0 + awesome_extensions: ^2.0.21 + badges: ^3.1.2 + basic_utils: ^5.8.2 + bloc: ^9.0.0 + bloc_advanced_tools: ^0.1.13 + blurry_modal_progress_hud: ^1.1.1 + camera: ^0.11.1 + change_case: ^2.2.0 + charcode: ^1.4.0 circular_profile_avatar: ^2.0.5 circular_reveal_animation: ^2.0.1 - cool_dropdown: ^2.1.0 - cupertino_icons: ^1.0.2 - equatable: ^2.0.5 - fast_immutable_collections: ^9.1.5 - fixnum: ^1.1.0 + convert: ^3.1.2 + cupertino_icons: ^1.0.8 + equatable: ^2.0.7 + expansion_tile_group: ^2.2.0 + fast_immutable_collections: ^11.0.3 + file_saver: ^0.2.14 + fixnum: ^1.1.1 flutter: sdk: flutter - flutter_animate: ^4.2.0+1 - flutter_chat_types: ^3.6.2 - flutter_chat_ui: ^1.6.9 - flutter_form_builder: ^9.1.0 - flutter_hooks: ^0.20.1 + flutter_animate: ^4.5.2 + flutter_bloc: ^9.1.0 + flutter_chat_core: ^2.2.0 + flutter_chat_ui: ^2.2.0 + flutter_form_builder: ^10.0.1 + flutter_hooks: ^0.21.2 flutter_localizations: sdk: flutter - flutter_native_splash: ^2.3.2 - flutter_riverpod: ^2.1.3 - flutter_slidable: ^3.0.0 - flutter_spinkit: ^5.2.0 - flutter_svg: ^2.0.7 - flutter_translate: ^4.0.4 - form_builder_validators: ^9.0.0 - freezed_annotation: ^2.2.0 - go_router: ^11.0.0 - hooks_riverpod: ^2.1.3 - image: ^4.1.3 - intl: ^0.18.0 - json_annotation: ^4.8.1 + flutter_native_splash: ^2.4.5 + flutter_slidable: ^4.0.0 + flutter_spinkit: ^5.2.1 + flutter_sticky_header: ^0.8.0 + flutter_svg: ^2.0.17 + flutter_translate: ^4.1.0 + flutter_zoom_drawer: ^3.2.0 + form_builder_validators: ^11.1.2 + freezed_annotation: ^3.0.0 + go_router: ^15.1.2 + image: ^4.5.3 + intl: ^0.19.0 + json_annotation: ^4.9.0 loggy: ^2.0.3 - mobile_scanner: ^3.4.1 - motion_toast: ^2.7.8 - mutex: ^3.0.1 - pasteboard: ^0.2.0 - path: ^1.8.2 - path_provider: ^2.0.11 - pinput: ^3.0.1 + meta: ^1.16.0 + package_info_plus: ^8.3.0 + pasteboard: ^0.4.0 + path: ^1.9.1 + path_provider: ^2.1.5 + pdf: ^3.11.3 + pinput: ^5.0.1 preload_page_view: ^0.2.0 - protobuf: ^3.0.0 - qr_code_dart_scan: ^0.7.2 + printing: ^5.14.2 + protobuf: ^4.1.0 + provider: ^6.1.2 + qr_code_dart_scan: ^0.10.0 qr_flutter: ^4.1.0 - quickalert: ^1.0.1 radix_colors: ^1.0.4 - reorderable_grid: ^1.0.7 - riverpod_annotation: ^2.1.1 - searchable_listview: ^2.7.0 - share_plus: ^7.0.2 - shared_preferences: ^2.0.15 + reorderable_grid: ^1.0.10 + rflutter_alert: ^2.0.7 + screenshot: ^3.0.0 + scroll_to_index: ^3.0.1 + searchable_listview: + git: + url: https://gitlab.com/veilid/Searchable-Listview.git + ref: main + share_plus: ^11.0.0 + shared_preferences: ^2.5.2 signal_strength_indicator: ^0.4.1 + sliver_expandable: ^1.1.2 + sliver_fill_remaining_box_adapter: ^1.0.0 + sliver_tools: ^0.2.12 + sorted_list: + git: + url: https://gitlab.com/veilid/dart-sorted-list-improved.git + ref: main split_view: ^3.2.1 - stylish_bottom_bar: ^1.0.3 - uuid: ^3.0.7 + stack_trace: ^1.12.1 + stream_transform: ^2.1.1 + toastification: ^3.0.2 + transitioned_indexed_stack: ^1.0.2 + url_launcher: ^6.3.1 + uuid: ^4.5.1 veilid: # veilid: ^0.0.1 path: ../veilid/veilid-flutter - window_manager: ^0.3.5 - xterm: ^3.5.0 - zxing2: ^0.2.0 + veilid_support: + path: packages/veilid_support + window_manager: ^0.4.3 + xterm: ^4.0.0 + zxing2: ^0.2.3 + +dependency_overrides: + intl: ^0.20.2 # Until flutter_translate updates intl +# async_tools: +# path: ../dart_async_tools +# bloc_advanced_tools: +# path: ../bloc_advanced_tools +# searchable_listview: +# path: ../Searchable-Listview +# flutter_chat_core: +# path: ../flutter_chat_ui/packages/flutter_chat_core +# flutter_chat_ui: +# path: ../flutter_chat_ui/packages/flutter_chat_ui dev_dependencies: - build_runner: ^2.4.6 - flutter_test: - sdk: flutter - freezed: ^2.3.5 - icons_launcher: ^2.1.3 - json_serializable: ^6.7.1 - lint_hard: ^4.0.0 - riverpod_generator: ^2.2.3 + build_runner: ^2.4.15 + freezed: ^3.0.4 + icons_launcher: ^3.0.1 + json_serializable: ^6.9.4 + lint_hard: ^6.0.0 flutter_native_splash: color: "#8588D0" - + icons_launcher: - image_path: 'assets/launcher/icon.png' + image_path: "assets/launcher/icon.png" platforms: android: enable: true - adaptive_background_color: '#ffffff' - adaptive_foreground_image: 'assets/launcher/icon.png' - adaptive_round_image: 'assets/launcher/icon.png' + adaptive_background_color: "#ffffff" + adaptive_foreground_image: "assets/launcher/icon.png" + adaptive_round_image: "assets/launcher/icon.png" ios: enable: true web: enable: true macos: enable: true - image_path: 'assets/launcher/macos_icon.png' + image_path: "assets/launcher/macos_icon.png" windows: enable: true linux: @@ -116,12 +157,34 @@ flutter: - assets/i18n/en.json # Launcher icon - assets/launcher/icon.png - # Images - - assets/images/splash.svg + # Theme wallpaper + - assets/images/wallpaper/arctic.svg + - assets/images/wallpaper/babydoll.svg + - assets/images/wallpaper/eggplant.svg + - assets/images/wallpaper/elite.svg + - assets/images/wallpaper/forest.svg + - assets/images/wallpaper/garden.svg + - assets/images/wallpaper/gold.svg + - assets/images/wallpaper/grim.svg + - assets/images/wallpaper/lapis.svg + - assets/images/wallpaper/lime.svg + - assets/images/wallpaper/scarlet.svg + - assets/images/wallpaper/vapor.svg + # Vector Images - assets/images/icon.svg + - assets/images/splash.svg - assets/images/title.svg - assets/images/vlogo.svg + - assets/images/toilet.svg + # Raster Images - assets/images/ellet.png + # Printing + - assets/js/pdf/3.2.146/pdf.min.js + # Sounds + - assets/sounds/bonk.wav + - assets/sounds/boop.wav + - assets/sounds/badeep.wav + - assets/sounds/beepbadeep.wav # Fonts fonts: - family: Source Code Pro @@ -129,7 +192,7 @@ flutter: - asset: assets/fonts/SourceCodePro-Regular.ttf - asset: assets/fonts/SourceCodePro-Bold.ttf weight: 700 - + # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/assets-and-images/#resolution-aware diff --git a/build.bat b/update_generated_files.bat similarity index 86% rename from build.bat rename to update_generated_files.bat index 0889dcf..b7e2e95 100644 --- a/build.bat +++ b/update_generated_files.bat @@ -1,4 +1,9 @@ @echo off + +pushd packages\veilid_support +call build.bat +popd + dart run build_runner build --delete-conflicting-outputs pushd lib diff --git a/update_generated_files.sh b/update_generated_files.sh new file mode 100755 index 0000000..1b618ef --- /dev/null +++ b/update_generated_files.sh @@ -0,0 +1,12 @@ +#!/bin/bash +set -e + +pushd packages/veilid_support > /dev/null +./build.sh +popd > /dev/null + +dart run build_runner build --delete-conflicting-outputs + +protoc --dart_out=lib/proto -I packages/veilid_support/lib/proto -I packages/veilid_support/lib/dht_support/proto -I lib/proto veilidchat.proto +sed -i '' 's/dht.pb.dart/package:veilid_support\/proto\/dht.pb.dart/g' lib/proto/veilidchat.pb.dart +sed -i '' 's/veilid.pb.dart/package:veilid_support\/proto\/veilid.pb.dart/g' lib/proto/veilidchat.pb.dart \ No newline at end of file diff --git a/version_bump.sh b/version_bump.sh new file mode 100755 index 0000000..9733313 --- /dev/null +++ b/version_bump.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash + +# Fail out if any step has an error +set -e + +if [ "$1" == "patch" ]; then + echo Bumping patch version + PART=patch +elif [ "$1" == "minor" ]; then + echo Bumping minor version + PART=minor +elif [ "$1" == "major" ]; then + echo Bumping major version + PART=major +elif [ "$1" == "build" ]; then + echo Bumping build code + PART=build +else + echo Unsupported part! Specify 'build', 'patch', 'minor', or 'major' + exit 1 +fi + +# Function to increment the build code +increment_buildcode() { + local current_version=$1 + local major_minor_patch=${current_version%+*} + local buildcode=${current_version#*+} + local new_buildcode=$((buildcode + 1)) + echo "${major_minor_patch}+${new_buildcode}" +} +# Function to get the current version from pubspec.yaml +get_current_version() { + awk '/^version: / { print $2 }' pubspec.yaml +} + +# Function to update the version in pubspec.yaml +update_version() { + local new_version=$1 + + if [[ "$OSTYPE" == "darwin"* ]]; then + SED_CMD="sed -i ''" + else + SED_CMD="sed -i" + fi + eval "$SED_CMD 's/version: .*/version: ${new_version}/' pubspec.yaml" +} + +current_version=$(get_current_version) + +echo "Current Version: $current_version" + +if [ "$PART" == "build" ]; then + final_version=$(increment_buildcode $current_version) +else + # Bump the major, minor, or patch version using bump2version + bump2version --current-version $current_version $PART + + new_version_base=$(get_current_version) + + buildcode=${current_version#*+} + intermediate_version="${new_version_base%+*}+${buildcode}" + + final_version=$(increment_buildcode $intermediate_version) +fi +# Update pubspec.yaml with the final version +update_version $final_version + +# Print the final version +echo "New Version: $final_version" + +#git add pubspec.yaml +#git commit -m "Bump version to $final_version" +#git tag "v$final_version" diff --git a/web/index.html b/web/index.html index a9c0bf2..99eac34 100644 --- a/web/index.html +++ b/web/index.html @@ -1,5 +1,5 @@ - + - + - + @@ -34,19 +33,16 @@ VeilidChat - - - - - diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt index 930d207..903f489 100644 --- a/windows/flutter/CMakeLists.txt +++ b/windows/flutter/CMakeLists.txt @@ -10,6 +10,11 @@ include(${EPHEMERAL_DIR}/generated_config.cmake) # https://github.com/flutter/flutter/issues/57146. set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + # === Flutter Library === set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") @@ -92,7 +97,7 @@ add_custom_command( COMMAND ${CMAKE_COMMAND} -E env ${FLUTTER_TOOL_ENVIRONMENT} "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" - windows-x64 $ + ${FLUTTER_TARGET_PLATFORM} $ VERBATIM ) add_custom_target(flutter_assemble DEPENDS diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index c453077..bb1eee2 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,23 +6,26 @@ #include "generated_plugin_registrant.h" +#include #include -#include +#include +#include #include -#include #include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSaverPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSaverPlugin")); PasteboardPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PasteboardPlugin")); - ScreenRetrieverPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("ScreenRetrieverPlugin")); + PrintingPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PrintingPlugin")); + ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); SharePlusWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); - SmartAuthPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("SmartAuthPlugin")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); VeilidPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index e705509..6cba61e 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,10 +3,11 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_saver pasteboard - screen_retriever + printing + screen_retriever_windows share_plus - smart_auth url_launcher_windows veilid window_manager