├── .github └── workflows │ └── ubuntu.yml ├── .gitignore ├── .metadata ├── CITATION.cff ├── LICENSE ├── README.md ├── analysis_options.yaml ├── android ├── .gitignore ├── app │ ├── build.gradle │ └── src │ │ ├── debug │ │ └── AndroidManifest.xml │ │ ├── main │ │ ├── AndroidManifest.xml │ │ ├── kotlin │ │ │ └── com │ │ │ │ └── example │ │ │ │ └── seemoo_lab_21_22 │ │ │ │ └── MainActivity.kt │ │ └── res │ │ │ ├── drawable-v21 │ │ │ └── launch_background.xml │ │ │ ├── drawable │ │ │ └── launch_background.xml │ │ │ ├── mipmap-hdpi │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-mdpi │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-xhdpi │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-xxhdpi │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-xxxhdpi │ │ │ └── ic_launcher.png │ │ │ ├── values-night │ │ │ └── styles.xml │ │ │ └── values │ │ │ └── styles.xml │ │ └── profile │ │ └── AndroidManifest.xml ├── build.gradle ├── gradle.properties ├── gradle │ └── wrapper │ │ └── gradle-wrapper.properties └── settings.gradle ├── assets ├── OpenHaystackIcon.png └── accessory_icons │ ├── bug_report.png │ ├── business_center.png │ ├── credit_card.png │ ├── directions_car.png │ ├── directions_walk.png │ ├── favorite.png │ ├── language.png │ ├── pedal_bike.png │ ├── pets.png │ ├── place.png │ ├── push_pin.png │ ├── redeem.png │ ├── school.png │ ├── visibility.png │ ├── vpn_key.png │ └── work.png ├── lib ├── accessory │ ├── accessory_color_selector.dart │ ├── accessory_detail.dart │ ├── accessory_dto.dart │ ├── accessory_icon.dart │ ├── accessory_icon_model.dart │ ├── accessory_icon_selector.dart │ ├── accessory_list.dart │ ├── accessory_list_item.dart │ ├── accessory_list_item_placeholder.dart │ ├── accessory_model.dart │ ├── accessory_registry.dart │ └── no_accessories.dart ├── dashboard │ ├── accessory_map_list_vert.dart │ ├── dashboard_desktop.dart │ └── dashboard_mobile.dart ├── deployment │ ├── code_block.dart │ ├── deployment_details.dart │ ├── deployment_email.dart │ ├── deployment_esp32.dart │ ├── deployment_instructions.dart │ ├── deployment_linux_hci.dart │ ├── deployment_nrf51.dart │ └── hyperlink.dart ├── ffi │ ├── bridge_generated.dart │ ├── bridge_generated.io.dart │ ├── bridge_generated.web.dart │ ├── ffi.dart │ └── ffi_web.dart ├── findMy │ ├── decrypt_reports.dart │ ├── find_my_controller.dart │ ├── models.dart │ └── reports_fetcher.dart ├── history │ ├── accessory_history.dart │ └── days_selection_slider.dart ├── item_management │ ├── accessory_color_input.dart │ ├── accessory_icon_input.dart │ ├── accessory_id_input.dart │ ├── accessory_name_input.dart │ ├── accessory_pk_input.dart │ ├── item_creation.dart │ ├── item_export.dart │ ├── item_file_import.dart │ ├── item_import.dart │ ├── item_management.dart │ ├── loading_spinner.dart │ └── new_item_action.dart ├── location │ └── location_model.dart ├── main.dart ├── map │ └── map.dart ├── placeholder │ ├── avatar_placeholder.dart │ └── text_placeholder.dart ├── preferences │ ├── preferences_page.dart │ └── user_preferences_model.dart └── splashscreen.dart ├── native ├── Cargo.lock ├── Cargo.toml └── src │ ├── api.rs │ ├── bridge_generated.io.rs │ ├── bridge_generated.rs │ ├── bridge_generated.web.rs │ └── lib.rs ├── pubspec.lock ├── pubspec.yaml ├── test └── widget_test.dart └── web ├── favicon.png ├── icons ├── Icon-192.png ├── Icon-512.png ├── Icon-maskable-192.png └── Icon-maskable-512.png ├── index.html └── manifest.json /.github/workflows/ubuntu.yml: -------------------------------------------------------------------------------- 1 | name: Release (Android, Web) 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: actions/setup-java@v2 11 | with: 12 | distribution: "zulu" 13 | java-version: "21" 14 | cache: "gradle" 15 | - uses: subosito/flutter-action@v2 16 | with: 17 | channel: stable 18 | flutter-version: 3.29.1 19 | cache: true 20 | - uses: dtolnay/rust-toolchain@master 21 | with: 22 | toolchain: nightly 23 | targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android 24 | - name: Setup Rust tools for Android build 25 | run: | 26 | cargo install cargo-ndk 27 | - name: Setup Keystore 28 | env: 29 | ANDROID_KEYSTORE: ${{ secrets.ANDROID_KEYSTORE }} 30 | ANDROID_KEY_PROPERTIES: ${{ secrets.ANDROID_KEY_PROPERTIES }} 31 | run: | 32 | echo $ANDROID_KEYSTORE | base64 --decode > ./android/app/key.jks 33 | echo $ANDROID_KEY_PROPERTIES | base64 --decode > ./android/key.properties 34 | - name: Build for Android 35 | env: 36 | SDK_REGISTRY_TOKEN: ${{ secrets.MAP_SDK_SECRET_KEY }} 37 | MAP_SDK_PUBLIC_KEY: ${{ secrets.MAP_SDK_PUBLIC_KEY }} 38 | run: | 39 | flutter pub get 40 | flutter build apk --release --dart-define=MAP_SDK_PUBLIC_KEY="$MAP_SDK_PUBLIC_KEY" 41 | - name: Upload Android Artifacts 42 | uses: actions/upload-artifact@v4 43 | with: 44 | name: android 45 | path: ./build/app/outputs/flutter-apk/app-release.apk 46 | - name: Setup Rust tools for Web build 47 | run: | 48 | rustup override set nightly 49 | rustup component add rust-src 50 | rustup target add wasm32-unknown-unknown 51 | cargo install wasm-pack 52 | - name: Build for Web 53 | env: 54 | RUSTUP_TOOLCHAIN: nightly 55 | RUSTFLAGS: -C target-feature=+atomics,+bulk-memory,+mutable-globals 56 | SDK_REGISTRY_TOKEN: ${{ secrets.MAP_SDK_SECRET_KEY }} 57 | MAP_SDK_PUBLIC_KEY: ${{ secrets.MAP_SDK_PUBLIC_KEY }} 58 | run: | 59 | wasm-pack build -t no-modules -d ./../web/pkg --no-typescript --out-name native native -- -Z build-std=std,panic_abort 60 | flutter build web --release --dart-define=MAP_SDK_PUBLIC_KEY="$MAP_SDK_PUBLIC_KEY" 61 | - name: Upload Web Artifacts 62 | uses: actions/upload-artifact@v4 63 | with: 64 | name: web 65 | path: ./build/web 66 | - name: Publish to Cloudflare Pages 67 | uses: cloudflare/pages-action@1 68 | with: 69 | apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} 70 | accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} 71 | projectName: openhaystack 72 | directory: ./build/web 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | 12 | # IntelliJ related 13 | *.iml 14 | *.ipr 15 | *.iws 16 | .idea/ 17 | 18 | # The .vscode folder contains launch configuration and tasks you configure in 19 | # VS Code which you may wish to be included in version control, so this line 20 | # is commented out by default. 21 | #.vscode/ 22 | 23 | # Flutter/Dart/Pub related 24 | **/doc/api/ 25 | **/ios/Flutter/.last_build_id 26 | .dart_tool/ 27 | .flutter-plugins 28 | .flutter-plugins-dependencies 29 | .packages 30 | .pub-cache/ 31 | .pub/ 32 | /build/ 33 | 34 | # Web related 35 | 36 | # Symbolication related 37 | app.*.symbols 38 | 39 | # Obfuscation related 40 | app.*.map.json 41 | 42 | # Android Studio will place build artifacts here 43 | /android/app/debug 44 | /android/app/profile 45 | /android/app/release 46 | 47 | # FFI 48 | jniLibs 49 | 50 | # Rust 51 | /native/target 52 | 53 | # VSCode 54 | .vscode 55 | -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: 18116933e77adc82f80866c928266a5b4f1ed645 8 | channel: stable 9 | 10 | project_type: app 11 | -------------------------------------------------------------------------------- /CITATION.cff: -------------------------------------------------------------------------------- 1 | # This CITATION.cff file was generated with cffinit. 2 | # Visit https://bit.ly/cffinit to generate yours today! 3 | 4 | cff-version: 1.2.0 5 | title: OpenHaystack 6 | message: 'If you use this software, please cite it as below.' 7 | type: software 8 | authors: 9 | - given-names: Alexander 10 | family-names: Heinrich 11 | affiliation: 'SEEMOO, TU Darmstadt' 12 | orcid: 'https://orcid.org/0000-0002-1150-1922' 13 | - given-names: Milan 14 | family-names: Stute 15 | affiliation: 'SEEMOO, TU Darmstadt' 16 | orcid: 'https://orcid.org/0000-0003-4921-8476' 17 | - given-names: Matthias 18 | family-names: Hollick 19 | affiliation: 'SEEMOO, TU Darmstadt' 20 | orcid: 'https://orcid.org/0000-0002-9163-5989' 21 | repository-code: 'https://github.com/seemoo-lab/openhaystack' 22 | abstract: >- 23 | OpenHaystack is a framework for tracking personal 24 | Bluetooth devices via Apple's massive Find My network. Use 25 | it to create your own tracking tags that you can append to 26 | physical objects (keyrings, backpacks, ...) or integrate 27 | it into other Bluetooth-capable devices such as notebooks. 28 | license: AGPL-3.0 29 | commit: 7d72fa1ac19d2a9f6dec43011be07df8976a8b02 30 | version: 0.5.3 31 | date-released: '2023-10-09' 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a fork of [SEEMOO's Openhaystack mobile app](https://github.com/seemoo-lab/openhaystack/tree/main/OpenHaystack) 2 | 3 | # Main improvements over the original project 4 | - Vector map: smoother, faster and prettier map 5 | - Performance improvements: less than 2 seconds to decrypt 2000 history locations (even on your browser!) 6 | - Server endpoint as user preference: bring your how Openhaystack server without recompiling 7 | - Better history visualization: color encoded points 8 | - Web version: try it now at https://find.willian.wang/ 9 | 10 | # About OpenHaystack 11 | OpenHaystack is a project that allows location tracking of Bluetooth Low Energy (BLE) devices over Apples Find My Network. 12 | 13 | # About the code 14 | 🚧 15 | 16 | For now, it's possible to build it by reproducing the [Github Actions workflow](https://github.com/wangwillian0/openhaystack/blob/main/.github/workflows/ubuntu.yml) 17 | 18 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # This file configures the analyzer, which statically analyzes Dart code to 2 | # check for errors, warnings, and lints. 3 | # 4 | # The issues identified by the analyzer are surfaced in the UI of Dart-enabled 5 | # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be 6 | # invoked from the command line by running `flutter analyze`. 7 | 8 | # The following line activates a set of recommended lints for Flutter apps, 9 | # packages, and plugins designed to encourage good coding practices. 10 | include: package:flutter_lints/flutter.yaml 11 | 12 | linter: 13 | # The lint rules applied to this project can be customized in the 14 | # section below to disable rules from the `package:flutter_lints/flutter.yaml` 15 | # included above or to enable additional rules. A list of all available lints 16 | # and their documentation is published at 17 | # https://dart-lang.github.io/linter/lints/index.html. 18 | # 19 | # Instead of disabling a lint rule for the entire project in the 20 | # section below, it can also be suppressed for a single line of code 21 | # or a specific dart file by using the `// ignore: name_of_lint` and 22 | # `// ignore_for_file: name_of_lint` syntax on the line or in the file 23 | # producing the lint. 24 | rules: 25 | # avoid_print: false # Uncomment to disable the `avoid_print` rule 26 | # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule 27 | 28 | # Additional information about this file can be found at 29 | # https://dart.dev/guides/language/analysis-options 30 | -------------------------------------------------------------------------------- /android/.gitignore: -------------------------------------------------------------------------------- 1 | gradle-wrapper.jar 2 | /.gradle 3 | /captures/ 4 | /gradlew 5 | /gradlew.bat 6 | /local.properties 7 | GeneratedPluginRegistrant.java 8 | 9 | # Remember to never publicly share your keystore. 10 | # See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app 11 | key.properties 12 | **/*.keystore 13 | **/*.jks 14 | -------------------------------------------------------------------------------- /android/app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "com.android.application" 3 | id "kotlin-android" 4 | id "dev.flutter.flutter-gradle-plugin" 5 | } 6 | 7 | def keystorePropertiesFile = rootProject.file("key.properties") 8 | def keystoreProperties = new Properties() 9 | keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) 10 | 11 | def localProperties = new Properties() 12 | def localPropertiesFile = rootProject.file('local.properties') 13 | if (localPropertiesFile.exists()) { 14 | localPropertiesFile.withReader('UTF-8') { reader -> 15 | localProperties.load(reader) 16 | } 17 | } 18 | 19 | def flutterVersionCode = localProperties.getProperty('flutter.versionCode') 20 | if (flutterVersionCode == null) { 21 | flutterVersionCode = '1' 22 | } 23 | 24 | def flutterVersionName = localProperties.getProperty('flutter.versionName') 25 | if (flutterVersionName == null) { 26 | flutterVersionName = '1.0' 27 | } 28 | 29 | android { 30 | compileSdkVersion 35 31 | 32 | kotlinOptions { 33 | jvmTarget = '1.8' 34 | } 35 | 36 | sourceSets { 37 | main.java.srcDirs += 'src/main/kotlin' 38 | } 39 | 40 | defaultConfig { 41 | // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). 42 | applicationId "de.seemoo.android.openhaystack" 43 | minSdkVersion 21 44 | targetSdkVersion 30 45 | versionCode flutterVersionCode.toInteger() 46 | versionName flutterVersionName 47 | } 48 | 49 | signingConfigs { 50 | release { 51 | storeFile file(keystoreProperties['storeFile']) 52 | storePassword keystoreProperties['storePassword'] 53 | keyAlias keystoreProperties['keyAlias'] 54 | keyPassword keystoreProperties['keyPassword'] 55 | } 56 | } 57 | 58 | buildTypes { 59 | release { 60 | minifyEnabled false 61 | shrinkResources false 62 | signingConfig signingConfigs.release 63 | } 64 | } 65 | } 66 | 67 | flutter { 68 | source '../..' 69 | } 70 | 71 | [ 72 | Debug: null, 73 | Profile: '--release', 74 | Release: '--release' 75 | ].each { 76 | def taskPostfix = it.key 77 | def profileMode = it.value 78 | tasks.whenTaskAdded { task -> 79 | if (task.name == "javaPreCompile$taskPostfix") { 80 | task.dependsOn "cargoBuild$taskPostfix" 81 | } 82 | } 83 | tasks.register("cargoBuild$taskPostfix", Exec) { 84 | workingDir "../../native" 85 | commandLine 'cargo', 'ndk', 86 | '-t', 'armeabi-v7a', 87 | '-t', 'arm64-v8a', 88 | '-t', 'x86_64', 89 | '-o', '../android/app/src/main/jniLibs', 'build' 90 | if (profileMode != null) { 91 | args profileMode 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /android/app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 10 | 17 | 21 | 25 | 30 | 34 | 35 | 36 | 37 | 38 | 39 | 41 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /android/app/src/main/kotlin/com/example/seemoo_lab_21_22/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package de.seemoo.android.openhaystack 2 | 3 | import io.flutter.embedding.android.FlutterActivity 4 | 5 | class MainActivity: FlutterActivity() { 6 | } 7 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-v21/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangwillian0/openhaystack/24a641649e3fdeb6145832e2b4881545e01111a3/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangwillian0/openhaystack/24a641649e3fdeb6145832e2b4881545e01111a3/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangwillian0/openhaystack/24a641649e3fdeb6145832e2b4881545e01111a3/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangwillian0/openhaystack/24a641649e3fdeb6145832e2b4881545e01111a3/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangwillian0/openhaystack/24a641649e3fdeb6145832e2b4881545e01111a3/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/values-night/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /android/app/src/profile/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | allprojects { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | } 6 | } 7 | 8 | rootProject.buildDir = '../build' 9 | subprojects { 10 | afterEvaluate { project -> 11 | if (project.hasProperty('android')) { 12 | project.android { 13 | if (namespace == null) { 14 | namespace project.group 15 | } 16 | } 17 | } 18 | } 19 | project.buildDir = "${rootProject.buildDir}/${project.name}" 20 | project.evaluationDependsOn(':app') 21 | } 22 | 23 | tasks.register("clean", Delete) { 24 | delete rootProject.buildDir 25 | } 26 | -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx1536M 2 | android.useAndroidX=true 3 | android.enableJetifier=true 4 | -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Fri Apr 28 22:51:27 BRT 2023 2 | distributionBase=GRADLE_USER_HOME 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-all.zip 4 | distributionPath=wrapper/dists 5 | zipStorePath=wrapper/dists 6 | zipStoreBase=GRADLE_USER_HOME 7 | -------------------------------------------------------------------------------- /android/settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | def flutterSdkPath = { 3 | def properties = new Properties() 4 | file("local.properties").withInputStream { properties.load(it) } 5 | def flutterSdkPath = properties.getProperty("flutter.sdk") 6 | assert flutterSdkPath != null, "flutter.sdk not set in local.properties" 7 | return flutterSdkPath 8 | }() 9 | 10 | includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") 11 | 12 | repositories { 13 | google() 14 | mavenCentral() 15 | gradlePluginPortal() 16 | } 17 | } 18 | 19 | plugins { 20 | id "dev.flutter.flutter-plugin-loader" version "1.0.0" 21 | id "com.android.application" version "8.2.1" apply false 22 | id "org.jetbrains.kotlin.android" version "1.8.20" apply false 23 | } 24 | 25 | include ":app" 26 | -------------------------------------------------------------------------------- /assets/OpenHaystackIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangwillian0/openhaystack/24a641649e3fdeb6145832e2b4881545e01111a3/assets/OpenHaystackIcon.png -------------------------------------------------------------------------------- /assets/accessory_icons/bug_report.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangwillian0/openhaystack/24a641649e3fdeb6145832e2b4881545e01111a3/assets/accessory_icons/bug_report.png -------------------------------------------------------------------------------- /assets/accessory_icons/business_center.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangwillian0/openhaystack/24a641649e3fdeb6145832e2b4881545e01111a3/assets/accessory_icons/business_center.png -------------------------------------------------------------------------------- /assets/accessory_icons/credit_card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangwillian0/openhaystack/24a641649e3fdeb6145832e2b4881545e01111a3/assets/accessory_icons/credit_card.png -------------------------------------------------------------------------------- /assets/accessory_icons/directions_car.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangwillian0/openhaystack/24a641649e3fdeb6145832e2b4881545e01111a3/assets/accessory_icons/directions_car.png -------------------------------------------------------------------------------- /assets/accessory_icons/directions_walk.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangwillian0/openhaystack/24a641649e3fdeb6145832e2b4881545e01111a3/assets/accessory_icons/directions_walk.png -------------------------------------------------------------------------------- /assets/accessory_icons/favorite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangwillian0/openhaystack/24a641649e3fdeb6145832e2b4881545e01111a3/assets/accessory_icons/favorite.png -------------------------------------------------------------------------------- /assets/accessory_icons/language.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangwillian0/openhaystack/24a641649e3fdeb6145832e2b4881545e01111a3/assets/accessory_icons/language.png -------------------------------------------------------------------------------- /assets/accessory_icons/pedal_bike.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangwillian0/openhaystack/24a641649e3fdeb6145832e2b4881545e01111a3/assets/accessory_icons/pedal_bike.png -------------------------------------------------------------------------------- /assets/accessory_icons/pets.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangwillian0/openhaystack/24a641649e3fdeb6145832e2b4881545e01111a3/assets/accessory_icons/pets.png -------------------------------------------------------------------------------- /assets/accessory_icons/place.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangwillian0/openhaystack/24a641649e3fdeb6145832e2b4881545e01111a3/assets/accessory_icons/place.png -------------------------------------------------------------------------------- /assets/accessory_icons/push_pin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangwillian0/openhaystack/24a641649e3fdeb6145832e2b4881545e01111a3/assets/accessory_icons/push_pin.png -------------------------------------------------------------------------------- /assets/accessory_icons/redeem.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangwillian0/openhaystack/24a641649e3fdeb6145832e2b4881545e01111a3/assets/accessory_icons/redeem.png -------------------------------------------------------------------------------- /assets/accessory_icons/school.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangwillian0/openhaystack/24a641649e3fdeb6145832e2b4881545e01111a3/assets/accessory_icons/school.png -------------------------------------------------------------------------------- /assets/accessory_icons/visibility.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangwillian0/openhaystack/24a641649e3fdeb6145832e2b4881545e01111a3/assets/accessory_icons/visibility.png -------------------------------------------------------------------------------- /assets/accessory_icons/vpn_key.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangwillian0/openhaystack/24a641649e3fdeb6145832e2b4881545e01111a3/assets/accessory_icons/vpn_key.png -------------------------------------------------------------------------------- /assets/accessory_icons/work.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangwillian0/openhaystack/24a641649e3fdeb6145832e2b4881545e01111a3/assets/accessory_icons/work.png -------------------------------------------------------------------------------- /lib/accessory/accessory_color_selector.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_colorpicker/flutter_colorpicker.dart'; 3 | 4 | class AccessoryColorSelector extends StatelessWidget { 5 | 6 | /// This shows a color selector. 7 | /// 8 | /// The color can be selected via a color field or by inputing explicit 9 | /// RGB values. 10 | const AccessoryColorSelector({ Key? key }) : super(key: key); 11 | 12 | /// Displays the color selector with the [initialColor] preselected. 13 | /// 14 | /// The selected color is returned if the user selects the save option. 15 | /// Otherwise the selection is discarded with a null return value. 16 | static Future showColorSelection(BuildContext context, Color initialColor) async { 17 | Color currentColor = initialColor; 18 | return await showDialog( 19 | context: context, 20 | builder: (BuildContext context) { 21 | return AlertDialog( 22 | title: const Text('Pick a color'), 23 | content: SingleChildScrollView( 24 | child: ColorPicker( 25 | hexInputBar: true, 26 | pickerColor: currentColor, 27 | onColorChanged: (Color newColor) { 28 | currentColor = newColor; 29 | }, 30 | ) 31 | ), 32 | actions: [ 33 | ElevatedButton( 34 | child: const Text('Save'), 35 | onPressed: () { 36 | Navigator.pop(context, currentColor); 37 | }, 38 | ), 39 | ], 40 | ); 41 | }, 42 | ); 43 | } 44 | 45 | @override 46 | Widget build(BuildContext context) { 47 | throw UnimplementedError(); 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /lib/accessory/accessory_detail.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:provider/provider.dart'; 3 | import 'package:openhaystack_mobile/accessory/accessory_color_selector.dart'; 4 | import 'package:openhaystack_mobile/accessory/accessory_icon.dart'; 5 | import 'package:openhaystack_mobile/accessory/accessory_icon_selector.dart'; 6 | import 'package:openhaystack_mobile/accessory/accessory_model.dart'; 7 | import 'package:openhaystack_mobile/accessory/accessory_registry.dart'; 8 | import 'package:openhaystack_mobile/item_management/accessory_name_input.dart'; 9 | 10 | class AccessoryDetail extends StatefulWidget { 11 | Accessory accessory; 12 | 13 | /// A page displaying the editable information of a specific [accessory]. 14 | /// 15 | /// This shows the editable information of a specific [accessory] and 16 | /// allows the user to edit them. 17 | AccessoryDetail({ 18 | Key? key, 19 | required this.accessory, 20 | }) : super(key: key); 21 | 22 | @override 23 | _AccessoryDetailState createState() => _AccessoryDetailState(); 24 | } 25 | 26 | class _AccessoryDetailState extends State { 27 | // An accessory storing the changed values. 28 | late Accessory newAccessory; 29 | final _formKey = GlobalKey(); 30 | 31 | @override 32 | void initState() { 33 | // Initialize changed accessory with existing accessory properties. 34 | newAccessory = widget.accessory.clone(); 35 | super.initState(); 36 | } 37 | 38 | @override 39 | Widget build(BuildContext context) { 40 | return Scaffold( 41 | appBar: AppBar( 42 | title: Text(widget.accessory.name), 43 | ), 44 | body: SingleChildScrollView( 45 | child: Form( 46 | key: _formKey, 47 | child: Column( 48 | children: [ 49 | Center( 50 | child: Stack( 51 | children: [ 52 | Padding( 53 | padding: const EdgeInsets.all(20), 54 | child: AccessoryIcon( 55 | size: 100, 56 | icon: newAccessory.icon, 57 | color: newAccessory.color, 58 | ), 59 | ), 60 | Positioned( 61 | bottom: 0, 62 | right: 0, 63 | child: Padding( 64 | padding: const EdgeInsets.all(10.0), 65 | child: Container( 66 | decoration: const BoxDecoration( 67 | color: Color.fromARGB(255, 200, 200, 200), 68 | shape: BoxShape.circle, 69 | ), 70 | child: IconButton( 71 | onPressed: () async { 72 | // Show icon selection 73 | String? selectedIcon = await AccessoryIconSelector 74 | .showIconSelection(context, newAccessory.rawIcon, newAccessory.color); 75 | if (selectedIcon != null) { 76 | setState(() { 77 | newAccessory.setIcon(selectedIcon); 78 | }); 79 | 80 | // Show color selection only when icon is selected 81 | Color? selectedColor = await AccessoryColorSelector 82 | .showColorSelection(context, newAccessory.color); 83 | if (selectedColor != null) { 84 | setState(() { 85 | newAccessory.color = selectedColor; 86 | }); 87 | } 88 | } 89 | }, 90 | icon: const Icon(Icons.edit), 91 | ), 92 | ), 93 | ), 94 | ), 95 | ], 96 | ), 97 | ), 98 | AccessoryNameInput( 99 | initialValue: newAccessory.name, 100 | onChanged: (value) { 101 | setState(() { 102 | newAccessory.name = value; 103 | }); 104 | }, 105 | ), 106 | SwitchListTile( 107 | value: newAccessory.isActive, 108 | title: const Text('Is Active'), 109 | onChanged: (checked) { 110 | setState(() { 111 | newAccessory.isActive = checked; 112 | }); 113 | }, 114 | ), 115 | SwitchListTile( 116 | value: newAccessory.isDeployed, 117 | title: const Text('Is Deployed'), 118 | onChanged: (checked) { 119 | setState(() { 120 | newAccessory.isDeployed = checked; 121 | }); 122 | }, 123 | ), 124 | ListTile( 125 | title: OutlinedButton( 126 | child: const Text('Save'), 127 | onPressed: _formKey.currentState == null || !_formKey.currentState!.validate() 128 | ? null : () { 129 | if (_formKey.currentState != null && _formKey.currentState!.validate()) { 130 | // Update accessory with changed values 131 | var accessoryRegistry = Provider.of(context, listen: false); 132 | accessoryRegistry.editAccessory(widget.accessory, newAccessory); 133 | ScaffoldMessenger.of(context).showSnackBar( 134 | const SnackBar( 135 | content: Text('Changes saved!'), 136 | ), 137 | ); 138 | } 139 | }, 140 | ), 141 | ), 142 | ListTile( 143 | title: ElevatedButton( 144 | style: ButtonStyle( 145 | backgroundColor: MaterialStateProperty.resolveWith( 146 | (Set states) { 147 | return Theme.of(context).colorScheme.error; 148 | }, 149 | ), 150 | ), 151 | child: const Text('Delete Accessory', style: TextStyle(color: Colors.white),), 152 | onPressed: () { 153 | // Delete accessory 154 | var accessoryRegistry = Provider.of(context, listen: false); 155 | accessoryRegistry.removeAccessory(widget.accessory); 156 | Navigator.pop(context); 157 | }, 158 | ), 159 | ), 160 | ], 161 | ), 162 | ), 163 | ), 164 | ); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /lib/accessory/accessory_dto.dart: -------------------------------------------------------------------------------- 1 | /// This class is used for de-/serializing data to the JSON transfer format. 2 | class AccessoryDTO { 3 | int id; 4 | List colorComponents; 5 | String name; 6 | double? lastDerivationTimestamp; 7 | String? symmetricKey; 8 | int? updateInterval; 9 | String privateKey; 10 | String icon; 11 | bool isDeployed; 12 | String colorSpaceName; 13 | bool usesDerivation; 14 | String? oldestRelevantSymmetricKey; 15 | bool isActive; 16 | 17 | /// Creates a transfer object to serialize to the JSON export format. 18 | /// 19 | /// This implements the [toJson] method used by the Dart JSON serializer. 20 | /// ```dart 21 | /// var accessoryDTO = AccessoryDTO(...); 22 | /// jsonEncode(accessoryDTO); 23 | /// ``` 24 | AccessoryDTO({ 25 | required this.id, 26 | required this.colorComponents, 27 | required this.name, 28 | this.lastDerivationTimestamp, 29 | this.symmetricKey, 30 | this.updateInterval, 31 | required this.privateKey, 32 | required this.icon, 33 | required this.isDeployed, 34 | required this.colorSpaceName, 35 | required this.usesDerivation, 36 | this.oldestRelevantSymmetricKey, 37 | required this.isActive, 38 | }); 39 | 40 | /// Creates a transfer object from deserialized JSON data. 41 | /// 42 | /// The data is only decoded and not processed further. 43 | /// 44 | /// Typically used with JSON decoder. 45 | /// ```dart 46 | /// String json = '...'; 47 | /// var accessoryDTO = AccessoryDTO.fromJSON(jsonDecode(json)); 48 | /// ``` 49 | /// 50 | /// This implements the [toJson] method used by the Dart JSON serializer. 51 | /// ```dart 52 | /// var accessoryDTO = AccessoryDTO(...); 53 | /// jsonEncode(accessoryDTO); 54 | /// ``` 55 | AccessoryDTO.fromJson(Map json) 56 | : id = json['id'], 57 | colorComponents = List.from(json['colorComponents']) 58 | .map((val) => double.parse(val.toString())).toList(), 59 | name = json['name'], 60 | lastDerivationTimestamp = json['lastDerivationTimestamp'] ?? 0, 61 | symmetricKey = json['symmetricKey'] ?? '', 62 | updateInterval = json['updateInterval'] ?? 0, 63 | privateKey = json['privateKey'], 64 | icon = json['icon'], 65 | isDeployed = json['isDeployed'], 66 | colorSpaceName = json['colorSpaceName'], 67 | usesDerivation = json['usesDerivation'] ?? false, 68 | oldestRelevantSymmetricKey = json['oldestRelevantSymmetricKey'] ?? '', 69 | isActive = json['isActive']; 70 | 71 | /// Creates a JSON map of the serialized transfer object. 72 | /// 73 | /// Typically used by JSON encoder. 74 | /// ```dart 75 | /// var accessoryDTO = AccessoryDTO(...); 76 | /// jsonEncode(accessoryDTO); 77 | /// ``` 78 | Map toJson() => usesDerivation ? { 79 | // With derivation 80 | 'id': id, 81 | 'colorComponents': colorComponents, 82 | 'name': name, 83 | 'lastDerivationTimestamp': lastDerivationTimestamp, 84 | 'symmetricKey': symmetricKey, 85 | 'updateInterval': updateInterval, 86 | 'privateKey': privateKey, 87 | 'icon': icon, 88 | 'isDeployed': isDeployed, 89 | 'colorSpaceName': colorSpaceName, 90 | 'usesDerivation': usesDerivation, 91 | 'oldestRelevantSymmetricKey': oldestRelevantSymmetricKey, 92 | 'isActive': isActive, 93 | } : { 94 | // Without derivation (skip rolling key params) 95 | 'id': id, 96 | 'colorComponents': colorComponents, 97 | 'name': name, 98 | 'privateKey': privateKey, 99 | 'icon': icon, 100 | 'isDeployed': isDeployed, 101 | 'colorSpaceName': colorSpaceName, 102 | 'usesDerivation': usesDerivation, 103 | 'isActive': isActive, 104 | }; 105 | 106 | } 107 | -------------------------------------------------------------------------------- /lib/accessory/accessory_icon.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter/foundation.dart'; 3 | 4 | class AccessoryIcon extends StatelessWidget { 5 | /// The icon to display. 6 | final IconData icon; 7 | /// The color of the surrounding ring. 8 | final Color color; 9 | /// The size of the icon. 10 | final double size; 11 | 12 | /// Displays the icon in a colored ring. 13 | /// 14 | /// The default size can be adjusted by setting the [size] parameter. 15 | const AccessoryIcon({ 16 | Key? key, 17 | this.icon = Icons.help, 18 | this.color = Colors.grey, 19 | this.size = 24, 20 | }) : super(key: key); 21 | 22 | @override 23 | Widget build(BuildContext context) { 24 | return Container( 25 | decoration: BoxDecoration( 26 | color: Theme.of(context).colorScheme.surface, 27 | shape: BoxShape.circle, 28 | border: Border.all(width: size / 6, color: color), 29 | ), 30 | child: Padding( 31 | padding: EdgeInsets.all(size / 12), 32 | child: Icon( 33 | icon, 34 | size: size, 35 | color: Theme.of(context).colorScheme.onSurface, 36 | ), 37 | ), 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /lib/accessory/accessory_icon_model.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class AccessoryIconModel { 4 | /// A list of all available icons 5 | static const List icons = [ 6 | "credit_card", "business_center", "work", "vpn_key", 7 | "place", "push_pin", "language", "school", 8 | "redeem", "directions_car", "pedal_bike", "directions_walk", 9 | "favorite", "pets", "bug_report", "visibility", 10 | ]; 11 | 12 | /// A mapping from the cupertino icon names to the material icon names. 13 | /// 14 | /// If the icons do not match, so a similar replacement is used. 15 | static const iconMapping = { 16 | 'credit_card': Icons.credit_card, 17 | 'business_center': Icons.business_center, 18 | 'work': Icons.work, 19 | 'vpn_key': Icons.vpn_key, 20 | 'place': Icons.place, 21 | 'push_pin': Icons.push_pin, 22 | 'language': Icons.language, 23 | 'school': Icons.school, 24 | 'redeem': Icons.redeem, 25 | 'directions_car': Icons.directions_car, 26 | 'pedal_bike': Icons.pedal_bike, 27 | 'directions_walk': Icons.directions_walk, 28 | 'favorite': Icons.favorite, 29 | 'pets': Icons.pets, 30 | 'bug_report': Icons.bug_report, 31 | 'visibility': Icons.visibility, 32 | }; 33 | 34 | /// Looks up the equivalent material icon for the cupertino icon [iconName]. 35 | static IconData? mapIcon(String iconName) { 36 | return iconMapping[iconName]; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /lib/accessory/accessory_icon_selector.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:openhaystack_mobile/accessory/accessory_icon_model.dart'; 5 | 6 | typedef IconChangeListener = void Function(String? newValue); 7 | 8 | class AccessoryIconSelector extends StatelessWidget { 9 | /// The existing icon used previously. 10 | final String icon; 11 | /// The existing color used previously. 12 | final Color color; 13 | /// A callback being called when the icon changes. 14 | final IconChangeListener iconChanged; 15 | 16 | /// This show an icon selector. 17 | /// 18 | /// The icon can be selected from a list of available icons. 19 | /// The icons are handled by the cupertino icon names. 20 | const AccessoryIconSelector({ 21 | Key? key, 22 | required this.icon, 23 | required this.color, 24 | required this.iconChanged, 25 | }) : super(key: key); 26 | 27 | /// Displays the icon selector with the [currentIcon] preselected in the [highlighColor]. 28 | /// 29 | /// The selected icon as a cupertino icon name is returned if the user selects an icon. 30 | /// Otherwise the selection is discarded and a null value is returned. 31 | static Future showIconSelection(BuildContext context, String currentIcon, Color highlighColor) async { 32 | return await showDialog( 33 | context: context, 34 | builder: (BuildContext context) { 35 | return LayoutBuilder( 36 | builder: (context, constraints) => Dialog( 37 | child: GridView.count( 38 | primary: false, 39 | padding: const EdgeInsets.all(20), 40 | crossAxisSpacing: 10, 41 | mainAxisSpacing: 10, 42 | shrinkWrap: true, 43 | crossAxisCount: min((constraints.maxWidth / 80).floor(), 8), 44 | semanticChildCount: AccessoryIconModel.icons.length, 45 | children: AccessoryIconModel.icons 46 | .map((value) => IconButton( 47 | icon: Icon(AccessoryIconModel.mapIcon(value)), 48 | color: value == currentIcon ? highlighColor : null, 49 | onPressed: () { Navigator.pop(context, value); }, 50 | )).toList(), 51 | ), 52 | ), 53 | ); 54 | } 55 | ); 56 | } 57 | 58 | @override 59 | Widget build(BuildContext context) { 60 | return Container( 61 | decoration: const BoxDecoration( 62 | color: Color.fromARGB(255, 200, 200, 200), 63 | shape: BoxShape.circle, 64 | ), 65 | child: IconButton( 66 | onPressed: () async { 67 | String? selectedIcon = await showIconSelection(context, icon, color); 68 | if (selectedIcon != null) { 69 | iconChanged(selectedIcon); 70 | } 71 | }, 72 | icon: Icon(AccessoryIconModel.mapIcon(icon)), 73 | ), 74 | ); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /lib/accessory/accessory_list.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter/foundation.dart'; 5 | import 'package:flutter_slidable/flutter_slidable.dart'; 6 | import 'package:maps_launcher/maps_launcher.dart'; 7 | import 'package:provider/provider.dart'; 8 | import 'package:mapbox_gl/mapbox_gl.dart'; 9 | import 'package:geolocator/geolocator.dart'; 10 | import 'package:openhaystack_mobile/accessory/accessory_list_item.dart'; 11 | import 'package:openhaystack_mobile/accessory/accessory_list_item_placeholder.dart'; 12 | import 'package:openhaystack_mobile/accessory/accessory_registry.dart'; 13 | import 'package:openhaystack_mobile/accessory/no_accessories.dart'; 14 | import 'package:openhaystack_mobile/history/accessory_history.dart'; 15 | import 'package:openhaystack_mobile/location/location_model.dart'; 16 | 17 | class AccessoryList extends StatefulWidget { 18 | final AsyncCallback loadLocationUpdates; 19 | final void Function(LatLng point)? centerOnPoint; 20 | 21 | /// Display a location overview all accessories in a concise list form. 22 | /// 23 | /// For each accessory the name and last known locaiton information is shown. 24 | /// Uses the accessories in the [AccessoryRegistry]. 25 | const AccessoryList({ 26 | Key? key, 27 | required this.loadLocationUpdates, 28 | this.centerOnPoint, 29 | }): super(key: key); 30 | 31 | @override 32 | _AccessoryListState createState() => _AccessoryListState(); 33 | } 34 | 35 | class _AccessoryListState extends State { 36 | 37 | @override 38 | Widget build(BuildContext context) { 39 | return Consumer2( 40 | builder: (context, accessoryRegistry, locationModel, child) { 41 | var accessories = accessoryRegistry.accessories; 42 | 43 | // Show placeholder while accessories are loading 44 | if (accessoryRegistry.loading){ 45 | return LayoutBuilder( 46 | builder: (context, constraints) { 47 | // Show as many accessory placeholder fitting into the vertical space. 48 | // Minimum one, maximum 6 placeholders 49 | var nrOfEntries = min(max((constraints.maxHeight / 64).floor(), 1), 6); 50 | List placeholderList = []; 51 | for (int i = 0; i < nrOfEntries; i++) { 52 | placeholderList.add(const AccessoryListItemPlaceholder()); 53 | } 54 | return Scrollbar( 55 | child: ListView( 56 | children: placeholderList, 57 | ), 58 | ); 59 | } 60 | ); 61 | } 62 | 63 | if (accessories.isEmpty) { 64 | return const NoAccessoriesPlaceholder(); 65 | } 66 | 67 | // TODO: Refresh Indicator for desktop 68 | // Use pull to refresh method 69 | return SlidableAutoCloseBehavior(child: 70 | RefreshIndicator( 71 | onRefresh: widget.loadLocationUpdates, 72 | child: Scrollbar( 73 | child: ListView( 74 | children: accessories.map((accessory) { 75 | // Calculate distance from users devices location 76 | Widget? trailing; 77 | if (locationModel.here != null && accessory.lastLocation != null) { 78 | final double km = GeolocatorPlatform.instance.distanceBetween( 79 | locationModel.here!.latitude, locationModel.here!.longitude, 80 | accessory.lastLocation!.latitude, accessory.lastLocation!.longitude 81 | ) / 1000; 82 | trailing = Text('${km.toStringAsFixed(3)} km'); 83 | } 84 | // Get human readable location 85 | return Slidable( 86 | endActionPane: ActionPane( 87 | motion: const DrawerMotion(), 88 | children: [ 89 | if (accessory.isDeployed) SlidableAction( 90 | onPressed: (context) async { 91 | if (accessory.lastLocation != null && accessory.isDeployed) { 92 | var loc = accessory.lastLocation!; 93 | await MapsLauncher.launchCoordinates( 94 | loc.latitude, loc.longitude, accessory.name); 95 | } 96 | }, 97 | backgroundColor: Colors.blue, 98 | foregroundColor: Colors.white, 99 | icon: Icons.directions, 100 | label: 'Navigate', 101 | ), 102 | if (accessory.isDeployed) SlidableAction( 103 | onPressed: (context) { 104 | Navigator.push( 105 | context, 106 | MaterialPageRoute(builder: (context) => AccessoryHistory( 107 | accessory: accessory, 108 | )), 109 | ); 110 | }, 111 | backgroundColor: Colors.orange, 112 | foregroundColor: Colors.white, 113 | icon: Icons.history, 114 | label: 'History', 115 | ), 116 | if (!accessory.isDeployed) SlidableAction( 117 | onPressed: (context) { 118 | var accessoryRegistry = Provider.of(context, listen: false); 119 | var newAccessory = accessory.clone(); 120 | newAccessory.isDeployed = true; 121 | accessoryRegistry.editAccessory(accessory, newAccessory); 122 | }, 123 | backgroundColor: Colors.green, 124 | foregroundColor: Colors.white, 125 | icon: Icons.upload_file, 126 | label: 'Deploy', 127 | ), 128 | ], 129 | ), 130 | child: Builder( 131 | builder: (context) { 132 | return AccessoryListItem( 133 | accessory: accessory, 134 | distance: trailing, 135 | herePlace: locationModel.herePlace, 136 | onTap: () { 137 | var lastLocation = accessory.lastLocation; 138 | if (lastLocation != null) { 139 | widget.centerOnPoint?.call(lastLocation); 140 | } 141 | }, 142 | onLongPress: Slidable.of(context)?.openEndActionPane, 143 | ); 144 | } 145 | ), 146 | ); 147 | }).toList(), 148 | ), 149 | ), 150 | ), 151 | ); 152 | }, 153 | ); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /lib/accessory/accessory_list_item.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:geocoding/geocoding.dart'; 3 | import 'package:openhaystack_mobile/accessory/accessory_icon.dart'; 4 | import 'package:openhaystack_mobile/accessory/accessory_model.dart'; 5 | import 'package:intl/intl.dart'; 6 | 7 | class AccessoryListItem extends StatelessWidget { 8 | /// The accessory to display the information for. 9 | final Accessory accessory; 10 | /// A trailing distance information widget. 11 | final Widget? distance; 12 | /// Address information about the accessories location. 13 | final Placemark? herePlace; 14 | final VoidCallback onTap; 15 | final VoidCallback? onLongPress; 16 | 17 | /// Displays the location of an accessory as a concise list item. 18 | /// 19 | /// Shows the icon and name of the accessory, as well as the current 20 | /// location and distance to the user's location (if known; `distance != null`) 21 | const AccessoryListItem({ 22 | Key? key, 23 | required this.accessory, 24 | required this.onTap, 25 | this.onLongPress, 26 | this.distance, 27 | this.herePlace, 28 | }) : super(key: key); 29 | 30 | @override 31 | Widget build(BuildContext context) { 32 | return FutureBuilder( 33 | future: accessory.place, 34 | builder: (BuildContext context, AsyncSnapshot snapshot) { 35 | // Format the location of the accessory. Use in this order: 36 | // * Address if known 37 | // * Coordinates (latitude & longitude) if known 38 | // * `Unknown` if unknown 39 | String locationString = accessory.lastLocation != null 40 | ? '${accessory.lastLocation!.latitude}, ${accessory.lastLocation!.longitude}' 41 | : 'Unknown'; 42 | if (snapshot.hasData && snapshot.data != null) { 43 | Placemark place = snapshot.data!; 44 | locationString = '${place.locality}, ${place.administrativeArea}'; 45 | if (herePlace != null && herePlace!.country != place.country) { 46 | locationString = '${place.locality}, ${place.country}'; 47 | } 48 | } 49 | // Format published date in a human readable way 50 | String? dateString = accessory.datePublished != null 51 | ? ' · ${DateFormat('dd.MM.yyyy HH:mm').format(accessory.datePublished!)}' 52 | : ''; 53 | return ListTile( 54 | onTap: onTap, 55 | onLongPress: onLongPress, 56 | title: Text( 57 | accessory.name + (accessory.isDeployed ? '' : ' (not deployed)'), 58 | style: TextStyle( 59 | color: accessory.isDeployed 60 | ? Theme.of(context).colorScheme.onSurface 61 | : Theme.of(context).disabledColor, 62 | ), 63 | ), 64 | subtitle: Text(locationString + dateString), 65 | trailing: distance, 66 | dense: true, 67 | leading: AccessoryIcon( 68 | icon: accessory.icon, 69 | color: accessory.color, 70 | ), 71 | ); 72 | }, 73 | ); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /lib/accessory/accessory_list_item_placeholder.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:openhaystack_mobile/accessory/accessory_list_item.dart'; 3 | import 'package:openhaystack_mobile/placeholder/avatar_placeholder.dart'; 4 | import 'package:openhaystack_mobile/placeholder/text_placeholder.dart'; 5 | 6 | class AccessoryListItemPlaceholder extends StatelessWidget { 7 | 8 | /// A placeholder for an [AccessoryListItem] showing a loading animation. 9 | const AccessoryListItemPlaceholder({ 10 | Key? key, 11 | }) : super(key: key); 12 | 13 | @override 14 | Widget build(BuildContext context) { 15 | // Uses a similar layout to the actual accessory list item 16 | return const ListTile( 17 | title: TextPlaceholder(), 18 | subtitle: TextPlaceholder(), 19 | dense: true, 20 | leading: AvatarPlaceholder(), 21 | trailing: TextPlaceholder(width: 60), 22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /lib/accessory/accessory_model.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:geocoding/geocoding.dart'; 3 | import 'package:mapbox_gl/mapbox_gl.dart'; 4 | import 'package:openhaystack_mobile/accessory/accessory_icon_model.dart'; 5 | import 'package:openhaystack_mobile/findMy/find_my_controller.dart'; 6 | import 'package:openhaystack_mobile/location/location_model.dart'; 7 | 8 | class Pair { 9 | final T1 a; 10 | final T2 b; 11 | 12 | Pair(this.a, this.b); 13 | } 14 | 15 | 16 | const defaultIcon = Icons.push_pin; 17 | 18 | 19 | class Accessory { 20 | /// The ID of the accessory key. 21 | String id; 22 | /// A hash of the public key. 23 | /// An identifier for the private key stored separately in the key store. 24 | String hashedPublicKey; 25 | /// If the accessory uses rolling keys. 26 | bool usesDerivation; 27 | 28 | // Parameters for rolling keys (only relevant is usesDerivation == true) 29 | String? symmetricKey; 30 | double? lastDerivationTimestamp; 31 | int? updateInterval; 32 | String? oldestRelevantSymmetricKey; 33 | 34 | /// The display name of the accessory. 35 | String name; 36 | /// The display icon of the accessory. 37 | String iconString; 38 | /// The display color of the accessory. 39 | Color color; 40 | 41 | /// If the accessory is active. 42 | bool isActive; 43 | /// If the accessory is already deployed 44 | /// (and could therefore send locations). 45 | bool isDeployed; 46 | 47 | /// The timestamp of the last known location 48 | /// (null if no location known). 49 | DateTime? datePublished; 50 | /// The last known locations coordinates 51 | /// (null if no location known). 52 | LatLng? _lastLocation; 53 | 54 | /// A list of known locations over time. 55 | List> locationHistory = []; 56 | 57 | /// Stores address information about the current location. 58 | Future place = Future.value(null); 59 | 60 | 61 | /// Creates an accessory with the given properties. 62 | Accessory({ 63 | required this.id, 64 | required this.name, 65 | required this.hashedPublicKey, 66 | required this.datePublished, 67 | this.isActive = false, 68 | this.isDeployed = false, 69 | LatLng? lastLocation, 70 | String icon = 'push_pin', 71 | this.color = Colors.grey, 72 | this.usesDerivation = false, 73 | this.symmetricKey, 74 | this.lastDerivationTimestamp, 75 | this.updateInterval, 76 | this.oldestRelevantSymmetricKey, 77 | }): iconString = icon, _lastLocation = lastLocation, super() { 78 | _init(); 79 | } 80 | 81 | void _init() { 82 | if (_lastLocation != null) { 83 | place = LocationModel.getAddress(_lastLocation!); 84 | } 85 | } 86 | 87 | /// Creates a new accessory with exactly the same properties of this accessory. 88 | Accessory clone() { 89 | return Accessory( 90 | datePublished: datePublished, 91 | id: id, 92 | name: name, 93 | hashedPublicKey: hashedPublicKey, 94 | color: color, 95 | icon: iconString, 96 | isActive: isActive, 97 | isDeployed: isDeployed, 98 | lastLocation: lastLocation, 99 | usesDerivation: usesDerivation, 100 | symmetricKey: symmetricKey, 101 | lastDerivationTimestamp: lastDerivationTimestamp, 102 | updateInterval: updateInterval, 103 | oldestRelevantSymmetricKey: oldestRelevantSymmetricKey, 104 | ); 105 | } 106 | 107 | /// Updates the properties of this accessor with the new values of the [newAccessory]. 108 | void update(Accessory newAccessory) { 109 | datePublished = newAccessory.datePublished; 110 | id = newAccessory.id; 111 | name = newAccessory.name; 112 | hashedPublicKey = newAccessory.hashedPublicKey; 113 | color = newAccessory.color; 114 | iconString = newAccessory.iconString; 115 | isActive = newAccessory.isActive; 116 | isDeployed = newAccessory.isDeployed; 117 | lastLocation = newAccessory.lastLocation; 118 | } 119 | 120 | /// The last known location of the accessory. 121 | LatLng? get lastLocation { 122 | return _lastLocation; 123 | } 124 | 125 | /// The last known location of the accessory. 126 | set lastLocation(LatLng? newLocation) { 127 | _lastLocation = newLocation; 128 | if (_lastLocation != null) { 129 | place = LocationModel.getAddress(_lastLocation!); 130 | } 131 | } 132 | 133 | /// The display icon of the accessory. 134 | IconData get icon { 135 | IconData? icon = AccessoryIconModel.mapIcon(iconString); 136 | return icon ?? defaultIcon; 137 | } 138 | 139 | /// The cupertino icon name. 140 | String get rawIcon { 141 | return iconString; 142 | } 143 | 144 | /// The display icon of the accessory. 145 | setIcon (String icon) { 146 | iconString = icon; 147 | } 148 | 149 | /// Creates an accessory from deserialized JSON data. 150 | /// 151 | /// Uses the same format as in [toJson] 152 | /// 153 | /// Typically used with JSON decoder. 154 | /// ```dart 155 | /// String json = '...'; 156 | /// var accessoryDTO = Accessory.fromJSON(jsonDecode(json)); 157 | /// ``` 158 | Accessory.fromJson(Map json) 159 | : id = json['id'], 160 | name = json['name'], 161 | hashedPublicKey = json['hashedPublicKey'], 162 | datePublished = json['datePublished'] != null 163 | ? DateTime.fromMillisecondsSinceEpoch(json['datePublished']) : null, 164 | _lastLocation = json['latitude'] != null && json['longitude'] != null 165 | ? LatLng(json['latitude'].toDouble(), json['longitude'].toDouble()) : null, 166 | isActive = json['isActive'], 167 | isDeployed = json['isDeployed'], 168 | iconString = json['icon'], 169 | color = Color(int.parse(json['color'], radix: 16)), 170 | usesDerivation = json['usesDerivation'] ?? false, 171 | symmetricKey = json['symmetricKey'], 172 | lastDerivationTimestamp = json['lastDerivationTimestamp'], 173 | updateInterval = json['updateInterval'], 174 | oldestRelevantSymmetricKey = json['oldestRelevantSymmetricKey'] { 175 | _init(); 176 | } 177 | 178 | /// Creates a JSON map of the serialized accessory. 179 | /// 180 | /// Uses the same format as in [Accessory.fromJson]. 181 | /// 182 | /// Typically used by JSON encoder. 183 | /// ```dart 184 | /// var accessory = Accessory(...); 185 | /// jsonEncode(accessory); 186 | /// ``` 187 | Map toJson() => { 188 | 'id': id, 189 | 'name': name, 190 | 'hashedPublicKey': hashedPublicKey, 191 | 'datePublished': datePublished?.millisecondsSinceEpoch, 192 | 'latitude': _lastLocation?.latitude, 193 | 'longitude': _lastLocation?.longitude, 194 | 'isActive': isActive, 195 | 'isDeployed': isDeployed, 196 | 'icon': iconString, 197 | 'color': color.toString().split('(0x')[1].split(')')[0], 198 | 'usesDerivation': usesDerivation, 199 | 'symmetricKey': symmetricKey, 200 | 'lastDerivationTimestamp': lastDerivationTimestamp, 201 | 'updateInterval': updateInterval, 202 | 'oldestRelevantSymmetricKey': oldestRelevantSymmetricKey, 203 | }; 204 | 205 | /// Returns the Base64 encoded hash of the advertisement key 206 | /// (used to fetch location reports). 207 | Future getHashedAdvertisementKey() async { 208 | var keyPair = await FindMyController.getKeyPair(hashedPublicKey); 209 | return keyPair.getHashedAdvertisementKey(); 210 | } 211 | 212 | /// Returns the Base64 encoded advertisement key 213 | /// (sent out by the accessory via BLE). 214 | Future getAdvertisementKey() async { 215 | var keyPair = await FindMyController.getKeyPair(hashedPublicKey); 216 | return keyPair.getBase64AdvertisementKey(); 217 | } 218 | 219 | /// Returns the Base64 encoded private key. 220 | Future getPrivateKey() async { 221 | var keyPair = await FindMyController.getKeyPair(hashedPublicKey); 222 | return keyPair.getBase64PrivateKey(); 223 | } 224 | 225 | } 226 | -------------------------------------------------------------------------------- /lib/accessory/accessory_registry.dart: -------------------------------------------------------------------------------- 1 | import 'dart:collection'; 2 | import 'dart:convert'; 3 | import 'package:flutter/foundation.dart'; 4 | import 'package:flutter/material.dart'; 5 | import 'package:flutter_secure_storage/flutter_secure_storage.dart'; 6 | import 'package:openhaystack_mobile/accessory/accessory_model.dart'; 7 | import 'package:mapbox_gl/mapbox_gl.dart'; 8 | import 'package:openhaystack_mobile/findMy/find_my_controller.dart'; 9 | import 'package:openhaystack_mobile/findMy/models.dart'; 10 | 11 | const accessoryStorageKey = 'ACCESSORIES'; 12 | 13 | class AccessoryRegistry extends ChangeNotifier { 14 | 15 | final _storage = const FlutterSecureStorage(); 16 | final _findMyController = FindMyController(); 17 | List _accessories = []; 18 | bool loading = false; 19 | bool initialLoadFinished = false; 20 | 21 | /// Creates the accessory registry. 22 | /// 23 | /// This is used to manage the accessories of the user. 24 | AccessoryRegistry() : super(); 25 | 26 | /// A list of the user's accessories. 27 | UnmodifiableListView get accessories => UnmodifiableListView(_accessories); 28 | 29 | /// Loads the user's accessories from persistent storage. 30 | Future loadAccessories() async { 31 | loading = true; 32 | String? serialized = await _storage.read(key: accessoryStorageKey); 33 | if (serialized != null) { 34 | List accessoryJson = json.decode(serialized); 35 | List loadedAccessories = 36 | accessoryJson.map((val) => Accessory.fromJson(val)).toList(); 37 | _accessories = loadedAccessories; 38 | } else { 39 | _accessories = []; 40 | } 41 | 42 | // For Debugging: 43 | // await overwriteEverythingWithDemoDataForDebugging(); 44 | 45 | loading = false; 46 | 47 | notifyListeners(); 48 | } 49 | 50 | /// __USE ONLY FOR DEBUGGING PURPOSES__ 51 | /// 52 | /// __ALL PERSISTENT DATA WILL BE LOST!__ 53 | /// 54 | /// Overwrites all accessories in this registry with demo data for testing. 55 | Future overwriteEverythingWithDemoDataForDebugging() async { 56 | // Delete everything to start with a fresh set of demo accessories 57 | await _storage.deleteAll(); 58 | 59 | // Load demo accessories 60 | List demoAccessories = [ 61 | Accessory(hashedPublicKey: 'TrnHrAM0ZrFSDeq1NN7ppmh0zYJotYiO09alVVF1mPI=', 62 | id: '-5952179461995674635', name: 'Raspberry Pi', color: Colors.green, 63 | datePublished: DateTime.fromMillisecondsSinceEpoch(1636390931651), 64 | icon: 'redeem', lastLocation: LatLng(-23.559389, -46.731839)), 65 | Accessory(hashedPublicKey: 'TrnHrAM0ZrFSDeq1NN7ppmh0zYJotYiO09alVVF1mPI=', 66 | id: '-5952179461995674635', name: 'My Bag', color: Colors.blue, 67 | datePublished: DateTime.fromMillisecondsSinceEpoch(1636390931651), 68 | icon: 'business_center', lastLocation: LatLng(-23.559389, -46.731839)), 69 | Accessory(hashedPublicKey: 'TrnHrAM0ZrFSDeq1NN7ppmh0zYJotYiO09alVVF1mPI=', 70 | id: '-5952179461995674635', name: 'Car', color: Colors.red, 71 | datePublished: DateTime.fromMillisecondsSinceEpoch(1636390931651), 72 | icon: 'directions_car', lastLocation: LatLng(-23.559389, -46.731839)), 73 | ]; 74 | _accessories = demoAccessories; 75 | 76 | // Store demo accessories for later use 77 | await _storeAccessories(); 78 | 79 | // Import private key for demo accessories 80 | // Public key hash is TrnHrAM0ZrFSDeq1NN7ppmh0zYJotYiO09alVVF1mPI= 81 | await FindMyController.importKeyPair('siykvOCIEQRVDwrbjyZUXuBwsMi0Htm7IBmBIg=='); 82 | } 83 | 84 | /// Fetches new location reports and matches them to their accessory. 85 | Future loadLocationReports() async { 86 | List>> runningLocationRequests = []; 87 | 88 | // request location updates for all accessories simultaneously 89 | List currentAccessories = accessories; 90 | for (var i = 0; i < currentAccessories.length; i++) { 91 | var accessory = currentAccessories.elementAt(i); 92 | 93 | var keyPair = await FindMyController.getKeyPair(accessory.hashedPublicKey); 94 | var locationRequest = FindMyController.computeResults(keyPair); 95 | runningLocationRequests.add(locationRequest); 96 | } 97 | 98 | // wait for location updates to succeed and update state afterwards 99 | var reportsForAccessories = await Future.wait(runningLocationRequests); 100 | for (var i = 0; i < currentAccessories.length; i++) { 101 | var accessory = currentAccessories.elementAt(i); 102 | var reports = reportsForAccessories.elementAt(i); 103 | 104 | debugPrint("Found ${reports.length} reports for accessory '${accessory.name}'"); 105 | 106 | accessory.locationHistory = reports 107 | .where((report) => report.latitude.abs() <= 90 && report.longitude.abs() < 90 ) 108 | .map((report) => Pair( 109 | LatLng(report.latitude, report.longitude), 110 | report.timestamp ?? report.published, 111 | )) 112 | .toList(); 113 | 114 | reports.sort((a, b) => (b.timestamp ?? DateTime(0)).compareTo(a.timestamp ?? DateTime(0))); 115 | 116 | if (reports.isNotEmpty) { 117 | var lastReport = reports.first; 118 | accessory.lastLocation = LatLng(lastReport.latitude, lastReport.longitude); 119 | accessory.datePublished = lastReport.timestamp ?? lastReport.published; 120 | } 121 | } 122 | 123 | // Store updated lastLocation and datePublished for accessories 124 | _storeAccessories(); 125 | 126 | initialLoadFinished = true; 127 | notifyListeners(); 128 | } 129 | 130 | /// Stores the user's accessories in persistent storage. 131 | Future _storeAccessories() async { 132 | List jsonList = _accessories.map(jsonEncode).toList(); 133 | await _storage.write(key: accessoryStorageKey, value: jsonList.toString()); 134 | } 135 | 136 | /// Adds a new accessory to this registry. 137 | void addAccessory(Accessory accessory) { 138 | _accessories.add(accessory); 139 | _storeAccessories(); 140 | notifyListeners(); 141 | } 142 | 143 | /// Removes [accessory] from this registry. 144 | void removeAccessory(Accessory accessory) { 145 | _accessories.remove(accessory); 146 | // TODO: remove private key from keychain 147 | _storeAccessories(); 148 | notifyListeners(); 149 | } 150 | 151 | /// Updates [oldAccessory] with the values from [newAccessory]. 152 | void editAccessory(Accessory oldAccessory, Accessory newAccessory) { 153 | oldAccessory.update(newAccessory); 154 | _storeAccessories(); 155 | notifyListeners(); 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /lib/accessory/no_accessories.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:openhaystack_mobile/item_management/new_item_action.dart'; 3 | 4 | class NoAccessoriesPlaceholder extends StatelessWidget { 5 | 6 | /// Displays a message that no accessories are present. 7 | /// 8 | /// Allows the user to quickly add a new accessory. 9 | const NoAccessoriesPlaceholder({ Key? key }) : super(key: key); 10 | 11 | @override 12 | Widget build(BuildContext context) { 13 | return Center( 14 | child: Column( 15 | mainAxisAlignment: MainAxisAlignment.center, 16 | children: const [ 17 | Text( 18 | 'There\'s Nothing Here Yet\nAdd an accessory to get started.', 19 | style: TextStyle( 20 | fontSize: 20, 21 | color: Colors.grey, 22 | ), 23 | textAlign: TextAlign.center, 24 | ), 25 | NewKeyAction(mini: true), 26 | ], 27 | ), 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /lib/dashboard/accessory_map_list_vert.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/foundation.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:provider/provider.dart'; 4 | import 'package:openhaystack_mobile/accessory/accessory_list.dart'; 5 | import 'package:openhaystack_mobile/accessory/accessory_registry.dart'; 6 | import 'package:openhaystack_mobile/location/location_model.dart'; 7 | import 'package:openhaystack_mobile/map/map.dart'; 8 | import 'package:mapbox_gl/mapbox_gl.dart'; 9 | 10 | class AccessoryMapListVertical extends StatefulWidget { 11 | final AsyncCallback loadLocationUpdates; 12 | 13 | /// Displays a map view and the accessory list in a vertical alignment. 14 | const AccessoryMapListVertical({ 15 | Key? key, 16 | required this.loadLocationUpdates, 17 | }) : super(key: key); 18 | 19 | @override 20 | State createState() => _AccessoryMapListVerticalState(); 21 | } 22 | 23 | class _AccessoryMapListVerticalState extends State { 24 | MapboxMapController? _mapController; 25 | 26 | void _centerPoint(LatLng point) { 27 | _mapController?.moveCamera( 28 | CameraUpdate.newCameraPosition( 29 | CameraPosition( 30 | target: point, 31 | zoom: 15.0, 32 | ), 33 | ) 34 | ); 35 | } 36 | 37 | @override 38 | Widget build(BuildContext context) { 39 | return Consumer2( 40 | builder: (BuildContext context, AccessoryRegistry accessoryRegistry, LocationModel locationModel, Widget? child) { 41 | return Column( 42 | children: [ 43 | Flexible( 44 | fit: FlexFit.tight, 45 | child: AccessoryMap( 46 | onMapCreatedCallback: (controller) { 47 | _mapController = controller; 48 | }, 49 | ), 50 | ), 51 | Flexible( 52 | fit: FlexFit.tight, 53 | child: AccessoryList( 54 | loadLocationUpdates: widget.loadLocationUpdates, 55 | centerOnPoint: _centerPoint, 56 | ), 57 | ), 58 | ], 59 | ); 60 | }, 61 | ); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /lib/dashboard/dashboard_desktop.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:provider/provider.dart'; 3 | import 'package:openhaystack_mobile/accessory/accessory_list.dart'; 4 | import 'package:openhaystack_mobile/accessory/accessory_registry.dart'; 5 | import 'package:openhaystack_mobile/location/location_model.dart'; 6 | import 'package:openhaystack_mobile/map/map.dart'; 7 | import 'package:openhaystack_mobile/preferences/preferences_page.dart'; 8 | import 'package:openhaystack_mobile/preferences/user_preferences_model.dart'; 9 | 10 | class DashboardDesktop extends StatefulWidget { 11 | 12 | /// Displays the layout for the desktop view of the app. 13 | /// 14 | /// The layout is optimized for horizontally aligned larger screens 15 | /// on desktop devices. 16 | const DashboardDesktop({ Key? key }) : super(key: key); 17 | 18 | @override 19 | _DashboardDesktopState createState() => _DashboardDesktopState(); 20 | } 21 | 22 | class _DashboardDesktopState extends State { 23 | 24 | @override 25 | void initState() { 26 | super.initState(); 27 | 28 | // Initialize models and preferences 29 | var userPreferences = Provider.of(context, listen: false); 30 | var locationModel = Provider.of(context, listen: false); 31 | var locationPreferenceKnown = userPreferences.locationPreferenceKnown ?? false; 32 | var locationAccessWanted = userPreferences.locationAccessWanted ?? false; 33 | if (!locationPreferenceKnown || locationAccessWanted) { 34 | locationModel.requestLocationUpdates(); 35 | } 36 | 37 | loadLocationUpdates(); 38 | } 39 | 40 | /// Fetch locaiton updates for all accessories. 41 | Future loadLocationUpdates() async { 42 | var accessoryRegistry = Provider.of(context, listen: false); 43 | await accessoryRegistry.loadLocationReports(); 44 | } 45 | 46 | @override 47 | Widget build(BuildContext context) { 48 | return Scaffold( 49 | body: Row( 50 | children: [ 51 | SizedBox( 52 | width: 400, 53 | child: Column( 54 | children: [ 55 | AppBar( 56 | title: const Text('OpenHaystack'), 57 | leading: IconButton( 58 | onPressed: () { /* reload */ }, 59 | icon: const Icon(Icons.menu), 60 | ), 61 | actions: [ 62 | IconButton( 63 | onPressed: () { 64 | Navigator.push( 65 | context, 66 | MaterialPageRoute(builder: (context) => const PreferencesPage()), 67 | ); 68 | }, 69 | icon: const Icon(Icons.settings), 70 | ), 71 | ], 72 | ), 73 | const Padding( 74 | padding: EdgeInsets.all(5), 75 | child: Text('My Accessories') 76 | ), 77 | Expanded( 78 | child: AccessoryList( 79 | loadLocationUpdates: loadLocationUpdates, 80 | ), 81 | ), 82 | ], 83 | ), 84 | ), 85 | const Expanded( 86 | child: AccessoryMap(), 87 | ), 88 | ], 89 | ), 90 | ); 91 | } 92 | 93 | } 94 | -------------------------------------------------------------------------------- /lib/dashboard/dashboard_mobile.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:provider/provider.dart'; 3 | import 'package:openhaystack_mobile/accessory/accessory_registry.dart'; 4 | import 'package:openhaystack_mobile/dashboard/accessory_map_list_vert.dart'; 5 | import 'package:openhaystack_mobile/item_management/item_management.dart'; 6 | import 'package:openhaystack_mobile/item_management/new_item_action.dart'; 7 | import 'package:openhaystack_mobile/location/location_model.dart'; 8 | import 'package:openhaystack_mobile/preferences/preferences_page.dart'; 9 | import 'package:openhaystack_mobile/preferences/user_preferences_model.dart'; 10 | 11 | class DashboardMobile extends StatefulWidget { 12 | 13 | /// Displays the layout for the mobile view of the app. 14 | /// 15 | /// The layout is optimized for a vertically aligned small screens. 16 | /// The functionality is structured in a bottom tab bar for easy access 17 | /// on mobile devices. 18 | const DashboardMobile({ Key? key }) : super(key: key); 19 | 20 | @override 21 | _DashboardMobileState createState() => _DashboardMobileState(); 22 | } 23 | 24 | class _DashboardMobileState extends State { 25 | 26 | /// A list of the tabs displayed in the bottom tab bar. 27 | late final List> _tabs = [ 28 | { 29 | 'title': 'My Accessories', 30 | 'body': (ctx) => AccessoryMapListVertical( 31 | loadLocationUpdates: loadLocationUpdates, 32 | ), 33 | 'icon': Icons.place, 34 | 'label': 'Map', 35 | }, 36 | { 37 | 'title': 'My Accessories', 38 | 'body': (ctx) => const KeyManagement(), 39 | 'icon': Icons.style, 40 | 'label': 'Accessories', 41 | 'actionButton': (ctx) => const NewKeyAction(), 42 | }, 43 | ]; 44 | 45 | @override 46 | void initState() { 47 | super.initState(); 48 | 49 | // Initialize models and preferences 50 | var userPreferences = Provider.of(context, listen: false); 51 | var locationModel = Provider.of(context, listen: false); 52 | var locationPreferenceKnown = userPreferences.locationPreferenceKnown ?? false; 53 | var locationAccessWanted = userPreferences.locationAccessWanted ?? false; 54 | if (!locationPreferenceKnown || locationAccessWanted) { 55 | locationModel.requestLocationUpdates(); 56 | } 57 | 58 | // Load new location reports on app start 59 | loadLocationUpdates(); 60 | } 61 | 62 | /// Fetch locaiton updates for all accessories. 63 | Future loadLocationUpdates() async { 64 | var accessoryRegistry = Provider.of(context, listen: false); 65 | try { 66 | await accessoryRegistry.loadLocationReports(); 67 | } catch (e) { 68 | ScaffoldMessenger.of(context).showSnackBar( 69 | SnackBar( 70 | backgroundColor: Theme.of(context).colorScheme.error, 71 | content: Text( 72 | 'Could not find location reports. Try again later.', 73 | style: TextStyle( 74 | color: Theme.of(context).colorScheme.onError, 75 | ), 76 | ), 77 | ), 78 | ); 79 | } 80 | } 81 | 82 | /// The selected tab index. 83 | int _selectedIndex = 0; 84 | /// Updates the currently displayed tab to [index]. 85 | void _onItemTapped(int index) { 86 | setState(() { 87 | _selectedIndex = index; 88 | }); 89 | } 90 | 91 | @override 92 | Widget build(BuildContext context) { 93 | return Scaffold( 94 | appBar: AppBar( 95 | title: const Text('My Accessories'), 96 | actions: [ 97 | IconButton( 98 | onPressed: () { 99 | Navigator.push( 100 | context, 101 | MaterialPageRoute(builder: (context) => const PreferencesPage()), 102 | ); 103 | }, 104 | icon: const Icon(Icons.settings), 105 | ), 106 | ], 107 | ), 108 | body: _tabs[_selectedIndex]['body'](context), 109 | bottomNavigationBar: BottomNavigationBar( 110 | items: _tabs.map((tab) => BottomNavigationBarItem( 111 | icon: Icon(tab['icon']), 112 | label: tab['label'], 113 | )).toList(), 114 | currentIndex: _selectedIndex, 115 | selectedItemColor: Theme.of(context).indicatorColor, 116 | onTap: _onItemTapped, 117 | ), 118 | floatingActionButton: _tabs[_selectedIndex]['actionButton']?.call(context), 119 | ); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /lib/deployment/code_block.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter/services.dart'; 3 | 4 | class CodeBlock extends StatelessWidget { 5 | String text; 6 | 7 | /// Displays a code block that can easily copied by the user. 8 | CodeBlock({ 9 | Key? key, 10 | required this.text, 11 | }) : super(key: key); 12 | 13 | @override 14 | Widget build(BuildContext context) { 15 | return Padding( 16 | padding: const EdgeInsets.symmetric(vertical: 8.0), 17 | child: Stack( 18 | children: [ 19 | Container( 20 | width: double.infinity, 21 | constraints: const BoxConstraints(minHeight: 50), 22 | decoration: BoxDecoration( 23 | borderRadius: const BorderRadius.all(Radius.circular(10)), 24 | color: Theme.of(context).colorScheme.background, 25 | ), 26 | padding: const EdgeInsets.all(5), 27 | child: SelectableText(text), 28 | ), 29 | Positioned( 30 | top: 0, 31 | right: 5, 32 | child: OutlinedButton( 33 | child: const Text('Copy'), 34 | onPressed: () { 35 | Clipboard.setData(ClipboardData(text: text)); 36 | }, 37 | ), 38 | ), 39 | ], 40 | ), 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /lib/deployment/deployment_details.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class DeploymentDetails extends StatefulWidget { 4 | /// The steps required to deploy on this target. 5 | List steps; 6 | /// The name of the deployment target. 7 | String title; 8 | 9 | /// Describes a generic step-by-step deployment for a special hardware target. 10 | /// 11 | /// The actual steps depend on the target platform and are provided in [steps]. 12 | DeploymentDetails({ 13 | Key? key, 14 | required this.title, 15 | required this.steps, 16 | }) : super(key: key); 17 | 18 | @override 19 | _DeploymentDetailsState createState() => _DeploymentDetailsState(); 20 | } 21 | 22 | class _DeploymentDetailsState extends State { 23 | /// The index of the currently displayed step. 24 | int _index = 0; 25 | 26 | @override 27 | Widget build(BuildContext context) { 28 | var stepCount = widget.steps.length; 29 | return Scaffold( 30 | appBar: AppBar( 31 | title: Text(widget.title), 32 | ), 33 | body: SafeArea( 34 | child: Stepper( 35 | currentStep: _index, 36 | controlsBuilder: (BuildContext context, ControlsDetails details) { 37 | String continueText = _index < stepCount - 1 ? 'CONTINUE' : 'FINISH'; 38 | return Row( 39 | children: [ 40 | ElevatedButton( 41 | style: ElevatedButton.styleFrom(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(1))), 42 | onPressed: details.onStepContinue, 43 | child: Text(continueText), 44 | ), 45 | if (_index > 0) TextButton( 46 | onPressed: details.onStepCancel, 47 | child: const Text('BACK'), 48 | ), 49 | ], 50 | ); 51 | }, 52 | onStepCancel: () { 53 | // Back button clicked 54 | if (_index == 0) { 55 | // Cancel deployment and return 56 | Navigator.pop(context); 57 | } 58 | else if (_index > 0) { 59 | setState(() { 60 | _index -= 1; 61 | }); 62 | } 63 | }, 64 | onStepContinue: () { 65 | // Continue button clicked 66 | if (_index == stepCount - 1) { 67 | // TODO: Mark accessory as deployed 68 | // Deployment finished 69 | Navigator.pop(context); 70 | Navigator.pop(context); 71 | } else { 72 | setState(() { 73 | _index += 1; 74 | }); 75 | } 76 | }, 77 | onStepTapped: (int index) { 78 | setState(() { 79 | _index = index; 80 | }); 81 | }, 82 | steps: widget.steps, 83 | ), 84 | ), 85 | ); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /lib/deployment/deployment_email.dart: -------------------------------------------------------------------------------- 1 | class DeploymentEmail { 2 | static const _mailtoLink = 3 | 'mailto:?subject=Open%20Haystack%20Deplyoment%20Instructions&body='; 4 | 5 | static const _welcomeMessage = 'OpenHaystack Deployment Guide\n\n' 6 | 'This is the deployment guide for your recently created OpenHaystack accessory. ' 7 | 'The next step is to deploy the generated cryptographic key to a compatible ' 8 | 'Bluetooth device.\n\n'; 9 | 10 | static const _finishedMessage = 11 | '\n\nThe device now sends out Bluetooth advertisements. ' 12 | 'It can take up to an hour for the location updates to appear in the app.\n'; 13 | 14 | static String getMicrobitDeploymentEmail(String advertisementKey) { 15 | String mailContent = 'nRF51822 Deployment:\n\n' 16 | 'Requirements\n' 17 | 'To build the firmware the GNU Arm Embedded Toolchain is required.\n\n' 18 | 'Download\n' 19 | 'Download the firmware source code from GitHub and navigate to the ' 20 | 'given folder.\n' 21 | 'https://github.com/seemoo-lab/openhaystack\n' 22 | 'git clone https://github.com/seemoo-lab/openhaystack.git && ' 23 | 'cd openhaystack/Firmware/Microbit_v1\n\n' 24 | 'Build\n' 25 | 'Replace the public_key in main.c (initially ' 26 | 'OFFLINEFINEINGPUBLICKEYHERE!) with the actual advertisement key. ' 27 | 'Then execute make to create the firmware. You can export your ' 28 | 'advertisement key directly from the OpenHaystack app.\n' 29 | 'static char public_key[28] = $advertisementKey;\n' 30 | 'make\n\n' 31 | 'Firmware Deployment\n' 32 | 'If the firmware is built successfully it can be deployed to the ' 33 | 'microcontroller with the following command. (Please fill in the ' 34 | 'volume of your microcontroller) \n' 35 | 'make install DEPLOY_PATH=/Volumes/MICROBIT'; 36 | 37 | return _mailtoLink + 38 | Uri.encodeComponent(_welcomeMessage) + 39 | Uri.encodeComponent(mailContent) + 40 | Uri.encodeComponent(_finishedMessage); 41 | } 42 | 43 | static String getESP32DeploymentEmail(String advertisementKey) { 44 | String mailContent = 'Espressif ESP32 Deployment: \n\n' 45 | 'Requirements\n' 46 | 'To build the firmware for the ESP32 Espressif\'s IoT Development ' 47 | 'Framework (ESP-IDF) is required. Additionally Python 3 and the venv ' 48 | 'module need to be installed.\n\n' 49 | 'Download\n' 50 | 'Download the firmware source code from GitHub and navigate to the ' 51 | 'given folder.\n' 52 | 'https://github.com/seemoo-lab/openhaystack\n' 53 | 'git clone https://github.com/seemoo-lab/openhaystack.git ' 54 | '&& cd openhaystack/Firmware/ESP32\n\n' 55 | 'Build\n' 56 | 'Execute the ESP-IDF build command to create the ESP32 firmware.\n' 57 | 'idf.py build\n\n' 58 | 'Firmware Deployment\n' 59 | 'If the firmware is built successfully it can be flashed onto the ' 60 | 'ESP32. This action is performed by the flash_esp32.sh script that ' 61 | 'is provided with the advertisement key of the newly created accessory.\n' 62 | 'Please fill in the serial port of your microcontroller.\n' 63 | 'You can export your advertisement key directly from the ' 64 | 'OpenHaystack app.\n' 65 | './flash_esp32.sh -p /dev/yourSerialPort $advertisementKey'; 66 | 67 | return _mailtoLink + 68 | Uri.encodeComponent(_welcomeMessage) + 69 | Uri.encodeComponent(mailContent) + 70 | Uri.encodeComponent(_finishedMessage); 71 | } 72 | 73 | static String getLinuxHCIDeploymentEmail(String advertisementKey) { 74 | String mailContent = 'Linux HCI Deployment:\n\n' 75 | 'Requirements\n' 76 | 'Install the hcitool software on a Bluetooth Low Energy Linux device, ' 77 | 'for example a Raspberry Pi. Additionally Pyhton 3 needs to be ' 78 | 'installed.\n\n' 79 | 'Download\n' 80 | 'Next download the python script that configures the HCI tool to ' 81 | 'send out BLE advertisements.\n' 82 | 'https://raw.githubusercontent.com/seemoo-lab/openhaystack/main/Firmware/Linux_HCI/HCI.py\n' 83 | 'curl -o HCI.py https://raw.githubusercontent.com/seemoo-lab/openhaystack/main/Firmware/Linux_HCI/HCI.py\n\n' 84 | 'Usage\n' 85 | 'To start the BLE advertisements run the script.\n' 86 | 'You can export your advertisement key directly from the ' 87 | 'OpenHaystack app.\n' 88 | 'sudo python3 HCI.py --key $advertisementKey'; 89 | 90 | return _mailtoLink + 91 | Uri.encodeComponent(_welcomeMessage) + 92 | Uri.encodeComponent(mailContent) + 93 | Uri.encodeComponent(_finishedMessage); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /lib/deployment/deployment_esp32.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:openhaystack_mobile/deployment/code_block.dart'; 3 | import 'package:openhaystack_mobile/deployment/deployment_details.dart'; 4 | import 'package:openhaystack_mobile/deployment/hyperlink.dart'; 5 | 6 | class DeploymentInstructionsESP32 extends StatelessWidget { 7 | String advertisementKey; 8 | 9 | /// Displays a deployment guide for the ESP32 platform. 10 | DeploymentInstructionsESP32({ 11 | Key? key, 12 | this.advertisementKey = '', 13 | }) : super(key: key); 14 | 15 | @override 16 | Widget build(BuildContext context) { 17 | return DeploymentDetails( 18 | title: 'ESP32 Deployment', 19 | steps: [ 20 | const Step( 21 | title: Text('Requirements'), 22 | content: Text('To build the firmware for the ESP32 Espressif\'s ' 23 | 'IoT Development Framework (ESP-IDF) is required. Additionally ' 24 | 'Python 3 and the venv module need to be installed.'), 25 | ), 26 | Step( 27 | title: const Text('Download'), 28 | content: Column( 29 | children: [ 30 | const Text('Download the firmware source code from GitHub ' 31 | 'and navigate to the given folder.'), 32 | Hyperlink(target: 'https://github.com/seemoo-lab/openhaystack'), 33 | CodeBlock(text: 'git clone https://github.com/seemoo-lab/openhaystack.git && cd openhaystack/Firmware/ESP32'), 34 | ], 35 | ), 36 | ), 37 | Step( 38 | title: const Text('Build'), 39 | content: Column( 40 | children: [ 41 | const Text('Execute the ESP-IDF build command to create the ESP32 firmware.'), 42 | CodeBlock(text: 'idf.py build'), 43 | ], 44 | ), 45 | ), 46 | Step( 47 | title: const Text('Firmware Deployment'), 48 | content: Column( 49 | children: [ 50 | const Text('If the firmware is built successfully it can ' 51 | 'be flashed onto the ESP32. This action is performed by ' 52 | 'the flash_esp32.sh script that is provided with the ' 53 | 'advertisement key of the newly created accessory.'), 54 | const Text( 55 | 'Please fill in the serial port of your microcontroller.', 56 | style: TextStyle( 57 | fontWeight: FontWeight.bold, 58 | ), 59 | ), 60 | CodeBlock(text: './flash_esp32.sh -p /dev/yourSerialPort "$advertisementKey"'), 61 | ], 62 | ), 63 | ), 64 | ], 65 | ); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /lib/deployment/deployment_linux_hci.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:openhaystack_mobile/deployment/code_block.dart'; 3 | import 'package:openhaystack_mobile/deployment/deployment_details.dart'; 4 | import 'package:openhaystack_mobile/deployment/hyperlink.dart'; 5 | 6 | class DeploymentInstructionsLinux extends StatelessWidget { 7 | String advertisementKey; 8 | 9 | /// Displays a deployment guide for the generic Linux HCI platform. 10 | DeploymentInstructionsLinux({ 11 | Key? key, 12 | this.advertisementKey = '', 13 | }) : super(key: key); 14 | 15 | @override 16 | Widget build(BuildContext context) { 17 | return DeploymentDetails( 18 | title: 'Linux HCI Deployment', 19 | steps: [ 20 | const Step( 21 | title: Text('Requirements'), 22 | content: Text('Install the hcitool software on a Bluetooth ' 23 | 'Low Energy Linux device, for example a Raspberry Pi. ' 24 | 'Additionally Pyhton 3 needs to be installed.'), 25 | ), 26 | Step( 27 | title: const Text('Download'), 28 | content: Column( 29 | children: [ 30 | const Text('Next download the python script that ' 31 | 'configures the HCI tool to send out BLE advertisements.'), 32 | Hyperlink(target: 'https://raw.githubusercontent.com/seemoo-lab/openhaystack/main/Firmware/Linux_HCI/HCI.py'), 33 | CodeBlock(text: 'curl -o HCI.py https://raw.githubusercontent.com/seemoo-lab/openhaystack/main/Firmware/Linux_HCI/HCI.py'), 34 | ], 35 | ), 36 | ), 37 | Step( 38 | title: const Text('Usage'), 39 | content: Column( 40 | children: [ 41 | const Text('To start the BLE advertisements run the script.'), 42 | CodeBlock(text: 'sudo python3 HCI.py --key $advertisementKey'), 43 | ], 44 | ), 45 | ), 46 | ], 47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /lib/deployment/deployment_nrf51.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:openhaystack_mobile/deployment/code_block.dart'; 3 | import 'package:openhaystack_mobile/deployment/deployment_details.dart'; 4 | import 'package:openhaystack_mobile/deployment/hyperlink.dart'; 5 | 6 | class DeploymentInstructionsNRF51 extends StatelessWidget { 7 | String advertisementKey; 8 | 9 | /// Displays a deployment guide for the NRF51 platform. 10 | DeploymentInstructionsNRF51({ 11 | Key? key, 12 | this.advertisementKey = '', 13 | }) : super(key: key); 14 | 15 | @override 16 | Widget build(BuildContext context) { 17 | return DeploymentDetails( 18 | title: 'nRF51822 Deployment', 19 | steps: [ 20 | const Step( 21 | title: Text('Requirements'), 22 | content: Text('To build the firmware the GNU Arm Embedded ' 23 | 'Toolchain is required.'), 24 | ), 25 | Step( 26 | title: const Text('Download'), 27 | content: Column( 28 | children: [ 29 | const Text('Download the firmware source code from GitHub ' 30 | 'and navigate to the given folder.'), 31 | Hyperlink(target: 'https://github.com/seemoo-lab/openhaystack'), 32 | CodeBlock(text: 'git clone https://github.com/seemoo-lab/openhaystack.git && cd openhaystack/Firmware/Microbit_v1'), 33 | ], 34 | ), 35 | ), 36 | Step( 37 | title: const Text('Build'), 38 | content: Column( 39 | children: [ 40 | const Text('Replace the public_key in main.c (initially ' 41 | 'OFFLINEFINEINGPUBLICKEYHERE!) with the actual ' 42 | 'advertisement key. Then execute make to create the ' 43 | 'firmware.'), 44 | CodeBlock(text: 'static char public_key[28] = "$advertisementKey";'), 45 | CodeBlock(text: 'make'), 46 | ], 47 | ), 48 | ), 49 | Step( 50 | title: const Text('Firmware Deployment'), 51 | content: Column( 52 | children: [ 53 | const Text('If the firmware is built successfully it can ' 54 | 'be deployed to the microcontroller with the following ' 55 | 'command.'), 56 | const Text( 57 | 'Please fill in the volume of your microcontroller.', 58 | style: TextStyle( 59 | fontWeight: FontWeight.bold, 60 | ), 61 | ), 62 | 63 | CodeBlock(text: 'make install DEPLOY_PATH=/Volumes/MICROBIT'), 64 | ], 65 | ), 66 | ), 67 | ], 68 | ); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /lib/deployment/hyperlink.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:url_launcher/url_launcher.dart'; 3 | 4 | class Hyperlink extends StatelessWidget { 5 | /// The target url to open. 6 | String target; 7 | /// The display text of the hyperlink. Default is [target]. 8 | String _text; 9 | 10 | /// Displays a hyperlink that can be opened by a tap. 11 | Hyperlink({ 12 | Key? key, 13 | required this.target, 14 | text, 15 | }) : _text = text ?? target, super(key: key); 16 | 17 | @override 18 | Widget build(BuildContext context) { 19 | return InkWell( 20 | child: Text(_text, 21 | style: const TextStyle( 22 | color: Colors.blue, 23 | decoration: TextDecoration.underline, 24 | ), 25 | ), 26 | onTap: () { 27 | launch(target); 28 | }, 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /lib/ffi/bridge_generated.dart: -------------------------------------------------------------------------------- 1 | // AUTO GENERATED FILE, DO NOT EDIT. 2 | // Generated by `flutter_rust_bridge`@ 1.82.6. 3 | // ignore_for_file: non_constant_identifier_names, unused_element, duplicate_ignore, directives_ordering, curly_braces_in_flow_control_structures, unnecessary_lambdas, slash_for_doc_comments, prefer_const_literals_to_create_immutables, implicit_dynamic_list_literal, duplicate_import, unused_import, unnecessary_import, prefer_single_quotes, prefer_const_constructors, use_super_parameters, always_use_package_imports, annotate_overrides, invalid_use_of_protected_member, constant_identifier_names, invalid_use_of_internal_member, prefer_is_empty, unnecessary_const 4 | 5 | import 'dart:convert'; 6 | import 'dart:async'; 7 | import 'package:meta/meta.dart'; 8 | import 'package:flutter_rust_bridge/flutter_rust_bridge.dart'; 9 | import 'package:uuid/uuid.dart'; 10 | 11 | import 'dart:convert'; 12 | import 'dart:async'; 13 | import 'package:meta/meta.dart'; 14 | import 'package:flutter_rust_bridge/flutter_rust_bridge.dart'; 15 | import 'package:uuid/uuid.dart'; 16 | import 'bridge_generated.io.dart' 17 | if (dart.library.html) 'bridge_generated.web.dart'; 18 | 19 | abstract class Native { 20 | Future ecdh( 21 | {required Uint8List publicKeyBlob, 22 | required Uint8List privateKey, 23 | dynamic hint}); 24 | 25 | FlutterRustBridgeTaskConstMeta get kEcdhConstMeta; 26 | } 27 | 28 | class NativeImpl implements Native { 29 | final NativePlatform _platform; 30 | factory NativeImpl(ExternalLibrary dylib) => 31 | NativeImpl.raw(NativePlatform(dylib)); 32 | 33 | /// Only valid on web/WASM platforms. 34 | factory NativeImpl.wasm(FutureOr module) => 35 | NativeImpl(module as ExternalLibrary); 36 | NativeImpl.raw(this._platform); 37 | Future ecdh( 38 | {required Uint8List publicKeyBlob, 39 | required Uint8List privateKey, 40 | dynamic hint}) { 41 | var arg0 = _platform.api2wire_uint_8_list(publicKeyBlob); 42 | var arg1 = _platform.api2wire_uint_8_list(privateKey); 43 | return _platform.executeNormal(FlutterRustBridgeTask( 44 | callFfi: (port_) => _platform.inner.wire_ecdh(port_, arg0, arg1), 45 | parseSuccessData: _wire2api_uint_8_list, 46 | parseErrorData: null, 47 | constMeta: kEcdhConstMeta, 48 | argValues: [publicKeyBlob, privateKey], 49 | hint: hint, 50 | )); 51 | } 52 | 53 | FlutterRustBridgeTaskConstMeta get kEcdhConstMeta => 54 | const FlutterRustBridgeTaskConstMeta( 55 | debugName: "ecdh", 56 | argNames: ["publicKeyBlob", "privateKey"], 57 | ); 58 | 59 | void dispose() { 60 | _platform.dispose(); 61 | } 62 | // Section: wire2api 63 | 64 | int _wire2api_u8(dynamic raw) { 65 | return raw as int; 66 | } 67 | 68 | Uint8List _wire2api_uint_8_list(dynamic raw) { 69 | return raw as Uint8List; 70 | } 71 | } 72 | 73 | // Section: api2wire 74 | 75 | @protected 76 | int api2wire_u8(int raw) { 77 | return raw; 78 | } 79 | 80 | // Section: finalizer 81 | -------------------------------------------------------------------------------- /lib/ffi/bridge_generated.io.dart: -------------------------------------------------------------------------------- 1 | // AUTO GENERATED FILE, DO NOT EDIT. 2 | // Generated by `flutter_rust_bridge`@ 1.72.1. 3 | // ignore_for_file: non_constant_identifier_names, unused_element, duplicate_ignore, directives_ordering, curly_braces_in_flow_control_structures, unnecessary_lambdas, slash_for_doc_comments, prefer_const_literals_to_create_immutables, implicit_dynamic_list_literal, duplicate_import, unused_import, unnecessary_import, prefer_single_quotes, prefer_const_constructors, use_super_parameters, always_use_package_imports, annotate_overrides, invalid_use_of_protected_member, constant_identifier_names, invalid_use_of_internal_member, prefer_is_empty, unnecessary_const 4 | 5 | import 'dart:convert'; 6 | import 'dart:async'; 7 | import 'package:meta/meta.dart'; 8 | import 'package:flutter_rust_bridge/flutter_rust_bridge.dart'; 9 | import 'package:uuid/uuid.dart'; 10 | import 'bridge_generated.dart'; 11 | export 'bridge_generated.dart'; 12 | import 'dart:ffi' as ffi; 13 | 14 | class NativePlatform extends FlutterRustBridgeBase { 15 | NativePlatform(ffi.DynamicLibrary dylib) : super(NativeWire(dylib)); 16 | 17 | // Section: api2wire 18 | 19 | @protected 20 | ffi.Pointer api2wire_uint_8_list(Uint8List raw) { 21 | final ans = inner.new_uint_8_list_0(raw.length); 22 | ans.ref.ptr.asTypedList(raw.length).setAll(0, raw); 23 | return ans; 24 | } 25 | // Section: finalizer 26 | 27 | // Section: api_fill_to_wire 28 | } 29 | 30 | // ignore_for_file: camel_case_types, non_constant_identifier_names, avoid_positional_boolean_parameters, annotate_overrides, constant_identifier_names 31 | 32 | // AUTO GENERATED FILE, DO NOT EDIT. 33 | // 34 | // Generated by `package:ffigen`. 35 | // ignore_for_file: type=lint 36 | 37 | /// generated by flutter_rust_bridge 38 | class NativeWire implements FlutterRustBridgeWireBase { 39 | @internal 40 | late final dartApi = DartApiDl(init_frb_dart_api_dl); 41 | 42 | /// Holds the symbol lookup function. 43 | final ffi.Pointer Function(String symbolName) 44 | _lookup; 45 | 46 | /// The symbols are looked up in [dynamicLibrary]. 47 | NativeWire(ffi.DynamicLibrary dynamicLibrary) 48 | : _lookup = dynamicLibrary.lookup; 49 | 50 | /// The symbols are looked up with [lookup]. 51 | NativeWire.fromLookup( 52 | ffi.Pointer Function(String symbolName) 53 | lookup) 54 | : _lookup = lookup; 55 | 56 | void store_dart_post_cobject( 57 | DartPostCObjectFnType ptr, 58 | ) { 59 | return _store_dart_post_cobject( 60 | ptr, 61 | ); 62 | } 63 | 64 | late final _store_dart_post_cobjectPtr = 65 | _lookup>( 66 | 'store_dart_post_cobject'); 67 | late final _store_dart_post_cobject = _store_dart_post_cobjectPtr 68 | .asFunction(); 69 | 70 | Object get_dart_object( 71 | int ptr, 72 | ) { 73 | return _get_dart_object( 74 | ptr, 75 | ); 76 | } 77 | 78 | late final _get_dart_objectPtr = 79 | _lookup>( 80 | 'get_dart_object'); 81 | late final _get_dart_object = 82 | _get_dart_objectPtr.asFunction(); 83 | 84 | void drop_dart_object( 85 | int ptr, 86 | ) { 87 | return _drop_dart_object( 88 | ptr, 89 | ); 90 | } 91 | 92 | late final _drop_dart_objectPtr = 93 | _lookup>( 94 | 'drop_dart_object'); 95 | late final _drop_dart_object = 96 | _drop_dart_objectPtr.asFunction(); 97 | 98 | int new_dart_opaque( 99 | Object handle, 100 | ) { 101 | return _new_dart_opaque( 102 | handle, 103 | ); 104 | } 105 | 106 | late final _new_dart_opaquePtr = 107 | _lookup>( 108 | 'new_dart_opaque'); 109 | late final _new_dart_opaque = 110 | _new_dart_opaquePtr.asFunction(); 111 | 112 | int init_frb_dart_api_dl( 113 | ffi.Pointer obj, 114 | ) { 115 | return _init_frb_dart_api_dl( 116 | obj, 117 | ); 118 | } 119 | 120 | late final _init_frb_dart_api_dlPtr = 121 | _lookup)>>( 122 | 'init_frb_dart_api_dl'); 123 | late final _init_frb_dart_api_dl = _init_frb_dart_api_dlPtr 124 | .asFunction)>(); 125 | 126 | void wire_ecdh( 127 | int port_, 128 | ffi.Pointer public_key_blob, 129 | ffi.Pointer private_key, 130 | ) { 131 | return _wire_ecdh( 132 | port_, 133 | public_key_blob, 134 | private_key, 135 | ); 136 | } 137 | 138 | late final _wire_ecdhPtr = _lookup< 139 | ffi.NativeFunction< 140 | ffi.Void Function(ffi.Int64, ffi.Pointer, 141 | ffi.Pointer)>>('wire_ecdh'); 142 | late final _wire_ecdh = _wire_ecdhPtr.asFunction< 143 | void Function( 144 | int, ffi.Pointer, ffi.Pointer)>(); 145 | 146 | ffi.Pointer new_uint_8_list_0( 147 | int len, 148 | ) { 149 | return _new_uint_8_list_0( 150 | len, 151 | ); 152 | } 153 | 154 | late final _new_uint_8_list_0Ptr = _lookup< 155 | ffi.NativeFunction< 156 | ffi.Pointer Function( 157 | ffi.Int32)>>('new_uint_8_list_0'); 158 | late final _new_uint_8_list_0 = _new_uint_8_list_0Ptr 159 | .asFunction Function(int)>(); 160 | 161 | void free_WireSyncReturn( 162 | WireSyncReturn ptr, 163 | ) { 164 | return _free_WireSyncReturn( 165 | ptr, 166 | ); 167 | } 168 | 169 | late final _free_WireSyncReturnPtr = 170 | _lookup>( 171 | 'free_WireSyncReturn'); 172 | late final _free_WireSyncReturn = 173 | _free_WireSyncReturnPtr.asFunction(); 174 | } 175 | 176 | class _Dart_Handle extends ffi.Opaque {} 177 | 178 | class wire_uint_8_list extends ffi.Struct { 179 | external ffi.Pointer ptr; 180 | 181 | @ffi.Int32() 182 | external int len; 183 | } 184 | 185 | typedef DartPostCObjectFnType = ffi.Pointer< 186 | ffi.NativeFunction< 187 | ffi.Bool Function(DartPort port_id, ffi.Pointer message)>>; 188 | typedef DartPort = ffi.Int64; 189 | -------------------------------------------------------------------------------- /lib/ffi/bridge_generated.web.dart: -------------------------------------------------------------------------------- 1 | // AUTO GENERATED FILE, DO NOT EDIT. 2 | // Generated by `flutter_rust_bridge`@ 1.82.6. 3 | // ignore_for_file: non_constant_identifier_names, unused_element, duplicate_ignore, directives_ordering, curly_braces_in_flow_control_structures, unnecessary_lambdas, slash_for_doc_comments, prefer_const_literals_to_create_immutables, implicit_dynamic_list_literal, duplicate_import, unused_import, unnecessary_import, prefer_single_quotes, prefer_const_constructors, use_super_parameters, always_use_package_imports, annotate_overrides, invalid_use_of_protected_member, constant_identifier_names, invalid_use_of_internal_member, prefer_is_empty, unnecessary_const 4 | 5 | import 'dart:convert'; 6 | import 'dart:async'; 7 | import 'package:meta/meta.dart'; 8 | import 'package:flutter_rust_bridge/flutter_rust_bridge.dart'; 9 | import 'package:uuid/uuid.dart'; 10 | import 'bridge_generated.dart'; 11 | export 'bridge_generated.dart'; 12 | 13 | class NativePlatform extends FlutterRustBridgeBase 14 | with FlutterRustBridgeSetupMixin { 15 | NativePlatform(FutureOr dylib) : super(NativeWire(dylib)) { 16 | setupMixinConstructor(); 17 | } 18 | Future setup() => inner.init; 19 | 20 | // Section: api2wire 21 | 22 | @protected 23 | Uint8List api2wire_uint_8_list(Uint8List raw) { 24 | return raw; 25 | } 26 | // Section: finalizer 27 | } 28 | 29 | // Section: WASM wire module 30 | 31 | @JS('wasm_bindgen') 32 | external NativeWasmModule get wasmModule; 33 | 34 | @JS() 35 | @anonymous 36 | class NativeWasmModule implements WasmModule { 37 | external Object /* Promise */ call([String? moduleName]); 38 | external NativeWasmModule bind(dynamic thisArg, String moduleName); 39 | external dynamic /* void */ wire_ecdh( 40 | NativePortType port_, Uint8List public_key_blob, Uint8List private_key); 41 | } 42 | 43 | // Section: WASM wire connector 44 | 45 | class NativeWire extends FlutterRustBridgeWasmWireBase { 46 | NativeWire(FutureOr module) 47 | : super(WasmModule.cast(module)); 48 | 49 | void wire_ecdh(NativePortType port_, Uint8List public_key_blob, 50 | Uint8List private_key) => 51 | wasmModule.wire_ecdh(port_, public_key_blob, private_key); 52 | } 53 | -------------------------------------------------------------------------------- /lib/ffi/ffi.dart: -------------------------------------------------------------------------------- 1 | // This file initializes the dynamic library and connects it with the stub 2 | // generated by flutter_rust_bridge_codegen. 3 | 4 | import 'dart:ffi'; 5 | 6 | import 'bridge_generated.dart'; 7 | 8 | // Re-export the bridge so it is only necessary to import this file. 9 | export 'bridge_generated.dart'; 10 | import 'dart:io' as io; 11 | 12 | const _root = 'native'; 13 | 14 | // On MacOS, the dynamic library is not bundled with the binary, 15 | // but rather directly **linked** against the binary. 16 | final _dylib = io.Platform.isWindows ? '$_root.dll' : 'lib$_root.so'; 17 | 18 | final Native api = NativeImpl(io.Platform.isIOS || io.Platform.isMacOS 19 | ? DynamicLibrary.executable() 20 | : DynamicLibrary.open(_dylib)); 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /lib/ffi/ffi_web.dart: -------------------------------------------------------------------------------- 1 | 2 | import 'package:flutter_rust_bridge/flutter_rust_bridge.dart'; 3 | import 'bridge_generated.web.dart'; 4 | 5 | const _root = 'pkg/native'; 6 | 7 | final api = NativeImpl.wasm( 8 | WasmModule.initialize(kind: const Modules.noModules(root: _root)), 9 | ); -------------------------------------------------------------------------------- /lib/findMy/decrypt_reports.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:isolate'; 3 | import 'dart:typed_data'; 4 | import 'package:collection/collection.dart'; 5 | 6 | import 'package:flutter/foundation.dart'; 7 | import 'package:pointycastle/export.dart'; 8 | import 'package:pointycastle/src/utils.dart' as pc_utils; 9 | import 'package:openhaystack_mobile/findMy/models.dart'; 10 | import 'package:openhaystack_mobile/ffi/ffi.dart' 11 | if (dart.library.html) 'package:openhaystack_mobile/ffi/ffi_web.dart'; 12 | 13 | class DecryptReports { 14 | /// Decrypts a given [FindMyReport] with the given private key. 15 | static Future> decryptReports(List reports, Uint8List privateKeyBytes) async { 16 | final curveDomainParam = ECCurve_secp224r1(); 17 | 18 | final ephemeralKeys = reports.map((report) { 19 | final payloadData = report.payload; 20 | final ephemeralKeyBytes = payloadData.sublist(payloadData.length - 16 - 10 - 57, payloadData.length - 16 - 10); 21 | return ephemeralKeyBytes; 22 | }).toList(); 23 | 24 | late final List sharedKeys; 25 | 26 | try { 27 | debugPrint("Trying native ECDH"); 28 | final ephemeralKeyBlob = Uint8List.fromList(ephemeralKeys.expand((element) => element).toList()); 29 | final sharedKeyBlob = await api.ecdh(publicKeyBlob: ephemeralKeyBlob, privateKey: privateKeyBytes); 30 | final keySize = (sharedKeyBlob.length / ephemeralKeys.length).ceil(); 31 | sharedKeys = [ 32 | for (var i = 0; i < sharedKeyBlob.length; i += keySize) 33 | sharedKeyBlob.sublist(i, i + keySize < sharedKeyBlob.length ? i + keySize : sharedKeyBlob.length), 34 | ]; 35 | } 36 | catch (e) { 37 | debugPrint("Native ECDH failed: $e"); 38 | debugPrint("Falling back to pure Dart ECDH on single thread!"); 39 | final privateKey = ECPrivateKey( 40 | pc_utils.decodeBigIntWithSign(1, privateKeyBytes), 41 | curveDomainParam); 42 | sharedKeys = ephemeralKeys.map((ephemeralKey) { 43 | final decodePoint = curveDomainParam.curve.decodePoint(ephemeralKey); 44 | final ephemeralPublicKey = ECPublicKey(decodePoint, curveDomainParam); 45 | 46 | final sharedKeyBytes = _ecdh(ephemeralPublicKey, privateKey); 47 | return sharedKeyBytes; 48 | }).toList(); 49 | } 50 | 51 | final decryptedLocations = reports.mapIndexed((index, report) { 52 | final derivedKey = _kdf(sharedKeys[index], ephemeralKeys[index]); 53 | final payloadData = report.payload; 54 | _decodeTimeAndConfidence(payloadData, report); 55 | final encData = payloadData.sublist(payloadData.length - 16 - 10, payloadData.length - 16); 56 | final tag = payloadData.sublist(payloadData.length - 16, payloadData.length); 57 | final decryptedPayload = _decryptPayload(encData, derivedKey, tag); 58 | final locationReport = _decodePayload(decryptedPayload, report); 59 | return locationReport; 60 | }).toList(); 61 | 62 | return decryptedLocations; 63 | } 64 | 65 | /// Decodes the unencrypted timestamp and confidence 66 | static void _decodeTimeAndConfidence(Uint8List payloadData, FindMyReport report) { 67 | final seenTimeStamp = payloadData.sublist(0, 4).buffer.asByteData() 68 | .getInt32(0, Endian.big); 69 | final timestamp = DateTime(2001).add(Duration(seconds: seenTimeStamp)); 70 | final confidence = payloadData.elementAt(4); 71 | report.timestamp = timestamp; 72 | report.confidence = confidence; 73 | } 74 | 75 | /// Performs an Elliptic Curve Diffie-Hellman with the given keys. 76 | /// Returns the derived raw key data. 77 | static Uint8List _ecdh(ECPublicKey ephemeralPublicKey, ECPrivateKey privateKey) { 78 | final sharedKey = ephemeralPublicKey.Q! * privateKey.d; 79 | final sharedKeyBytes = pc_utils.encodeBigIntAsUnsigned( 80 | sharedKey!.x!.toBigInteger()!); 81 | debugPrint("Shared Key (shared secret): ${base64Encode(sharedKeyBytes)}"); 82 | 83 | return sharedKeyBytes; 84 | } 85 | 86 | /// Decodes the raw decrypted payload and constructs and returns 87 | /// the resulting [FindMyLocationReport]. 88 | static FindMyLocationReport _decodePayload( 89 | Uint8List payload, FindMyReport report) { 90 | 91 | final latitude = payload.buffer.asByteData(0, 4).getInt32(0, Endian.big); 92 | final longitude = payload.buffer.asByteData(4, 4).getInt32(0, Endian.big); 93 | final accuracy = payload.buffer.asByteData(8, 1).getUint8(0); 94 | 95 | final latitudeDec = latitude / 10000000.0; 96 | final longitudeDec = longitude / 10000000.0; 97 | 98 | return FindMyLocationReport(latitudeDec, longitudeDec, accuracy, 99 | report.datePublished, report.timestamp, report.confidence); 100 | } 101 | 102 | /// Decrypts the given cipher text with the key data using an AES-GCM block cipher. 103 | /// Returns the decrypted raw data. 104 | static Uint8List _decryptPayload( 105 | Uint8List cipherText, Uint8List symmetricKey, Uint8List tag) { 106 | final decryptionKey = symmetricKey.sublist(0, 16); 107 | final iv = symmetricKey.sublist(16, symmetricKey.length); 108 | 109 | final aesGcm = GCMBlockCipher(AESEngine()) 110 | ..init(false, AEADParameters(KeyParameter(decryptionKey), 111 | tag.lengthInBytes * 8, iv, tag)); 112 | 113 | final plainText = Uint8List(cipherText.length); 114 | var offset = 0; 115 | while (offset < cipherText.length) { 116 | offset += aesGcm.processBlock(cipherText, offset, plainText, offset); 117 | } 118 | 119 | assert(offset == cipherText.length); 120 | return plainText; 121 | } 122 | 123 | /// ANSI X.963 key derivation to calculate the actual (symmetric) advertisement 124 | /// key and returns the raw key data. 125 | static Uint8List _kdf(Uint8List secret, Uint8List ephemeralKey) { 126 | var shaDigest = SHA256Digest(); 127 | if (secret.length < 28) { 128 | var pad = Uint8List(28 - secret.length); 129 | shaDigest.update(pad, 0, pad.length); 130 | } 131 | shaDigest.update(secret, 0, secret.length); 132 | 133 | var counter = 1; 134 | var counterData = ByteData(4)..setUint32(0, counter); 135 | var counterDataBytes = counterData.buffer.asUint8List(); 136 | shaDigest.update(counterDataBytes, 0, counterDataBytes.lengthInBytes); 137 | 138 | shaDigest.update(ephemeralKey, 0, ephemeralKey.lengthInBytes); 139 | 140 | Uint8List out = Uint8List(shaDigest.digestSize); 141 | shaDigest.doFinal(out, 0); 142 | 143 | debugPrint("Derived key: ${base64Encode(out)}"); 144 | return out; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /lib/findMy/find_my_controller.dart: -------------------------------------------------------------------------------- 1 | import 'dart:collection'; 2 | import 'dart:convert'; 3 | import 'dart:isolate'; 4 | import 'dart:typed_data'; 5 | 6 | import 'package:flutter/foundation.dart'; 7 | import 'package:flutter_secure_storage/flutter_secure_storage.dart'; 8 | import 'package:pointycastle/export.dart'; 9 | import 'package:pointycastle/src/platform_check/platform_check.dart'; 10 | import 'package:pointycastle/src/utils.dart' as pc_utils; 11 | import 'package:openhaystack_mobile/findMy/decrypt_reports.dart'; 12 | import 'package:openhaystack_mobile/findMy/models.dart'; 13 | import 'package:openhaystack_mobile/findMy/reports_fetcher.dart'; 14 | import 'package:shared_preferences/shared_preferences.dart'; 15 | import 'package:openhaystack_mobile/preferences/user_preferences_model.dart'; 16 | 17 | class FindMyController { 18 | static const _storage = FlutterSecureStorage(); 19 | static final ECCurve_secp224r1 _curveParams = ECCurve_secp224r1(); 20 | static HashMap _keyCache = HashMap(); 21 | 22 | /// Starts a new [Isolate], fetches and decrypts all location reports 23 | /// for the given [FindMyKeyPair]. 24 | /// Returns a list of [FindMyLocationReport]'s. 25 | static Future> computeResults(FindMyKeyPair keyPair) async{ 26 | await _loadPrivateKey(keyPair); 27 | var prefs = await SharedPreferences.getInstance(); 28 | final seemooEndpoint = prefs.getString(serverAddressKey) ?? "https://add-your-proxy-server-here/getLocationReports"; 29 | return compute(_getListedReportResults, [keyPair, seemooEndpoint]); 30 | } 31 | 32 | /// Fetches and decrypts each location report in a separate [Isolate] 33 | /// for the given [FindMyKeyPair] from apples FindMy Network. 34 | /// Each report is decrypted in a separate [Isolate]. 35 | /// Returns a list of [FindMyLocationReport]. 36 | static Future> _getListedReportResults(List args) async { 37 | FindMyKeyPair keyPair = args[0]; 38 | String seemooEndpoint = args[1]; 39 | final jsonReports = await ReportsFetcher.fetchLocationReports(keyPair.getHashedAdvertisementKey(), seemooEndpoint); 40 | final decryptedLocations = await _decryptReports(jsonReports, keyPair, keyPair.privateKeyBase64!); 41 | return decryptedLocations; 42 | } 43 | 44 | /// Loads the private key from the local cache or secure storage and adds it 45 | /// to the given [FindMyKeyPair]. 46 | static Future _loadPrivateKey(FindMyKeyPair keyPair) async { 47 | String? privateKey; 48 | if (!_keyCache.containsKey(keyPair.hashedPublicKey)) { 49 | privateKey = await _storage.read(key: keyPair.hashedPublicKey); 50 | final newKey = _keyCache.putIfAbsent(keyPair.hashedPublicKey, () => privateKey); 51 | assert(newKey == privateKey); 52 | } else { 53 | privateKey = _keyCache[keyPair.hashedPublicKey]; 54 | } 55 | keyPair.privateKeyBase64 = privateKey!; 56 | } 57 | 58 | /// Derives an [ECPublicKey] from a given [ECPrivateKey] on the given curve. 59 | static ECPublicKey _derivePublicKey(ECPrivateKey privateKey) { 60 | final pk = _curveParams.G * privateKey.d; 61 | final publicKey = ECPublicKey(pk, _curveParams); 62 | debugPrint("Point Data: ${base64Encode(publicKey.Q!.getEncoded(false))}"); 63 | 64 | return publicKey; 65 | } 66 | 67 | /// Decrypts the encrypted reports with the given list of [FindMyKeyPair] and private key. 68 | /// Returns the list of decrypted reports as a list of [FindMyLocationReport]. 69 | static Future> _decryptReports(List jsonRerportList, FindMyKeyPair keyPair, String privateKey) async { 70 | final reportChunk = jsonRerportList.map((jsonReport) { 71 | assert (jsonReport["id"]! == keyPair.getHashedAdvertisementKey(), 72 | "Returned FindMyReport hashed key != requested hashed key"); 73 | 74 | final unixTimestampInMillis = jsonReport["datePublished"]; 75 | final datePublished = DateTime.fromMillisecondsSinceEpoch(unixTimestampInMillis); 76 | 77 | final report = FindMyReport( 78 | datePublished, 79 | base64Decode(jsonReport["payload"]), 80 | keyPair.getHashedAdvertisementKey(), 81 | jsonReport["statusCode"]); 82 | 83 | return report; 84 | }).toList(); 85 | 86 | final decryptedReports = await DecryptReports.decryptReports(reportChunk, base64Decode(privateKey)); 87 | 88 | return decryptedReports; 89 | } 90 | 91 | /// Returns the to the base64 encoded given hashed public key 92 | /// corresponding [FindMyKeyPair] from the local [FlutterSecureStorage]. 93 | static Future getKeyPair(String base64HashedPublicKey) async { 94 | final privateKeyBase64 = await _storage.read(key: base64HashedPublicKey); 95 | 96 | ECPrivateKey privateKey = ECPrivateKey( 97 | pc_utils.decodeBigIntWithSign(1, base64Decode(privateKeyBase64!)), _curveParams); 98 | ECPublicKey publicKey = _derivePublicKey(privateKey); 99 | 100 | return FindMyKeyPair(publicKey, base64HashedPublicKey, privateKey, DateTime.now(), -1); 101 | } 102 | 103 | /// Imports a base64 encoded private key to the local [FlutterSecureStorage]. 104 | /// Returns a [FindMyKeyPair] containing the corresponding [ECPublicKey]. 105 | static Future importKeyPair(String privateKeyBase64) async { 106 | final privateKeyBytes = base64Decode(privateKeyBase64); 107 | final ECPrivateKey privateKey = ECPrivateKey( 108 | pc_utils.decodeBigIntWithSign(1, privateKeyBytes), _curveParams); 109 | final ECPublicKey publicKey = _derivePublicKey(privateKey); 110 | final hashedPublicKey = getHashedPublicKey(publicKey: publicKey); 111 | final keyPair = FindMyKeyPair( 112 | publicKey, 113 | hashedPublicKey, 114 | privateKey, 115 | DateTime.now(), 116 | -1); 117 | 118 | await _storage.write(key: hashedPublicKey, value: keyPair.getBase64PrivateKey()); 119 | 120 | return keyPair; 121 | } 122 | 123 | /// Generates a [ECCurve_secp224r1] keypair. 124 | /// Returns the newly generated keypair as a [FindMyKeyPair] object. 125 | static Future generateKeyPair() async { 126 | final ecCurve = ECCurve_secp224r1(); 127 | final secureRandom = SecureRandom('Fortuna') 128 | ..seed(KeyParameter( 129 | Platform.instance.platformEntropySource().getBytes(32))); 130 | ECKeyGenerator keyGen = ECKeyGenerator() 131 | ..init(ParametersWithRandom(ECKeyGeneratorParameters(ecCurve), secureRandom)); 132 | 133 | final newKeyPair = keyGen.generateKeyPair(); 134 | final ECPublicKey publicKey = newKeyPair.publicKey as ECPublicKey; 135 | final ECPrivateKey privateKey = newKeyPair.privateKey as ECPrivateKey; 136 | final hashedKey = getHashedPublicKey(publicKey: publicKey); 137 | final keyPair = FindMyKeyPair(publicKey, hashedKey, privateKey, DateTime.now(), -1); 138 | await _storage.write(key: hashedKey, value: keyPair.getBase64PrivateKey()); 139 | 140 | return keyPair; 141 | } 142 | 143 | /// Returns hashed, base64 encoded public key for given [publicKeyBytes] 144 | /// or for an [ECPublicKey] object [publicKey], if [publicKeyBytes] equals null. 145 | /// Returns the base64 encoded hashed public key as a [String]. 146 | static String getHashedPublicKey({Uint8List? publicKeyBytes, ECPublicKey? publicKey}) { 147 | var pkBytes = publicKeyBytes ?? publicKey!.Q!.getEncoded(false); 148 | final shaDigest = SHA256Digest(); 149 | shaDigest.update(pkBytes, 0, pkBytes.lengthInBytes); 150 | Uint8List out = Uint8List(shaDigest.digestSize); 151 | shaDigest.doFinal(out, 0); 152 | return base64Encode(out); 153 | } 154 | } -------------------------------------------------------------------------------- /lib/findMy/models.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:typed_data'; 3 | 4 | import 'package:pointycastle/ecc/api.dart'; 5 | import 'package:pointycastle/src/utils.dart' as pc_utils; 6 | import 'package:openhaystack_mobile/findMy/find_my_controller.dart'; 7 | 8 | /// Represents a decrypted FindMyReport. 9 | class FindMyLocationReport { 10 | double latitude; 11 | double longitude; 12 | int accuracy; 13 | DateTime published; 14 | DateTime? timestamp; 15 | int? confidence; 16 | 17 | FindMyLocationReport(this.latitude, this.longitude, this.accuracy, 18 | this.published, this.timestamp, this.confidence); 19 | 20 | Location get location => Location(latitude, longitude); 21 | } 22 | 23 | class Location { 24 | double latitude; 25 | double longitude; 26 | 27 | Location(this.latitude, this.longitude); 28 | } 29 | 30 | /// FindMy report returned by the FindMy Network 31 | class FindMyReport { 32 | DateTime datePublished; 33 | Uint8List payload; 34 | String id; 35 | int statusCode; 36 | 37 | int? confidence; 38 | DateTime? timestamp; 39 | 40 | FindMyReport(this.datePublished, this.payload, this.id, this.statusCode); 41 | 42 | FindMyReport.completeInit(this.datePublished, this.payload, this.id, this.statusCode, 43 | this.confidence, this.timestamp); 44 | 45 | } 46 | 47 | class FindMyKeyPair { 48 | final ECPublicKey _publicKey; 49 | final ECPrivateKey _privateKey; 50 | final String hashedPublicKey; 51 | String? privateKeyBase64; 52 | 53 | /// Time when this key was used to send BLE advertisements 54 | DateTime startTime; 55 | /// Duration from start time how long the key was used to send BLE advertisements 56 | double duration; 57 | 58 | FindMyKeyPair(this._publicKey, this.hashedPublicKey, this._privateKey, this.startTime, 59 | this.duration); 60 | 61 | String getBase64PublicKey() { 62 | return base64Encode(_publicKey.Q!.getEncoded(false)); 63 | } 64 | 65 | String getBase64PrivateKey() { 66 | return base64Encode(pc_utils.encodeBigIntAsUnsigned(_privateKey.d!)); 67 | } 68 | 69 | String getBase64AdvertisementKey() { 70 | return base64Encode(_getAdvertisementKey()); 71 | } 72 | 73 | Uint8List _getAdvertisementKey() { 74 | var pkBytes = _publicKey.Q!.getEncoded(true); 75 | //Drop first byte to get the 28byte version 76 | var key = pkBytes.sublist(1, pkBytes.length); 77 | return key; 78 | } 79 | 80 | String getHashedAdvertisementKey() { 81 | var key = _getAdvertisementKey(); 82 | return FindMyController.getHashedPublicKey(publicKeyBytes: key); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /lib/findMy/reports_fetcher.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:http/http.dart' as http; 4 | 5 | class ReportsFetcher { 6 | /// Fetches the location reports corresponding to the given hashed advertisement 7 | /// key. 8 | /// Throws [Exception] if no answer was received. 9 | static Future fetchLocationReports(String hashedAdvertisementKey, String seemooEndpoint) async { 10 | final response = await http.post(Uri.parse(seemooEndpoint), 11 | headers: { 12 | "Content-Type": "application/json", 13 | }, 14 | body: jsonEncode({ 15 | "ids": [hashedAdvertisementKey], 16 | })); 17 | 18 | if (response.statusCode == 200) { 19 | return await jsonDecode(response.body)["results"]; 20 | } else { 21 | throw Exception("Failed to fetch location reports with statusCode:${response.statusCode}\n\n Response:\n${response}"); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /lib/history/accessory_history.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:openhaystack_mobile/accessory/accessory_model.dart'; 3 | import 'package:openhaystack_mobile/history/days_selection_slider.dart'; 4 | import 'package:mapbox_gl/mapbox_gl.dart'; 5 | 6 | class AccessoryHistory extends StatefulWidget { 7 | final Accessory accessory; 8 | 9 | /// Shows previous locations of a specific [accessory] on a map. 10 | /// The locations are connected by a chronological line. 11 | /// The number of days to go back can be adjusted with a slider. 12 | const AccessoryHistory({ 13 | Key? key, 14 | required this.accessory, 15 | }) : super(key: key); 16 | 17 | @override 18 | _AccessoryHistoryState createState() => _AccessoryHistoryState(); 19 | } 20 | 21 | class _AccessoryHistoryState extends State { 22 | MapboxMapController? _mapController; 23 | 24 | double numberOfDays = 7; 25 | 26 | @override 27 | void initState() { 28 | super.initState(); 29 | } 30 | 31 | void _onMapCreated(MapboxMapController controller) { 32 | _mapController = controller; 33 | } 34 | 35 | void _updateMarkers() { 36 | _mapController?.removeCircles(_mapController?.circles ?? []); 37 | 38 | var now = DateTime.now(); 39 | 40 | var options = widget.accessory.locationHistory 41 | .where ((entry) => entry.b.isAfter(now.subtract(Duration(days: numberOfDays.round())))) 42 | .map((entry) => CircleOptions( 43 | geometry: entry.a, 44 | circleRadius: 6, 45 | circleColor: Color.lerp( 46 | Colors.red, Colors.blue, 47 | now.difference(entry.b).inSeconds / (numberOfDays * 24 * 60 * 60) 48 | )!.toHexStringRGB(), 49 | )).toList(); 50 | 51 | var data = widget.accessory.locationHistory 52 | .where ((entry) => entry.b.isAfter(now.subtract(Duration(days: numberOfDays.round())))) 53 | .map((entry) => { 54 | 'time': entry.b, 55 | }).toList(); 56 | 57 | _mapController?.addCircles(options, data); 58 | } 59 | 60 | void _onStyleLoaded() { 61 | _mapController?.moveCamera( 62 | CameraUpdate.newLatLngBounds( 63 | LatLngBounds( 64 | southwest: LatLng( 65 | widget.accessory.locationHistory.map((entry) => entry.a.latitude).reduce((value, element) => value < element ? value : element), 66 | widget.accessory.locationHistory.map((entry) => entry.a.longitude).reduce((value, element) => value < element ? value : element), 67 | ), 68 | northeast: LatLng( 69 | widget.accessory.locationHistory.map((entry) => entry.a.latitude).reduce((value, element) => value > element ? value : element), 70 | widget.accessory.locationHistory.map((entry) => entry.a.longitude).reduce((value, element) => value > element ? value : element), 71 | ), 72 | ), 73 | left: 25, top: 25, right: 25, bottom: 25, 74 | ), 75 | ); 76 | 77 | _mapController!.onCircleTapped.add(_onCircleTapped); 78 | 79 | _updateMarkers(); 80 | } 81 | 82 | void _onCircleTapped(Circle circle) { 83 | final originalCircleColor = circle.options.circleColor; 84 | _mapController!.updateCircle(circle, CircleOptions(circleColor: Colors.green.toHexStringRGB())); 85 | 86 | final snackBar = SnackBar( 87 | content: Text( 88 | '${circle.data!['time'].toLocal().toString().substring(0, 19)}\n' 89 | 'Lat: ${circle.options.geometry!.latitude}\n' 90 | 'Lng: ${circle.options.geometry!.longitude}', 91 | style: const TextStyle(color: Colors.white), 92 | textAlign: TextAlign.center, 93 | ), 94 | backgroundColor: Theme.of(context).primaryColor); 95 | ScaffoldMessenger.of(context).clearSnackBars(); 96 | ScaffoldMessenger.of(context).showSnackBar(snackBar).closed.then((_) { 97 | _mapController!.updateCircle(circle, CircleOptions(circleColor: originalCircleColor)); 98 | }); 99 | } 100 | 101 | @override 102 | Widget build(BuildContext context) { 103 | return Scaffold( 104 | appBar: AppBar( 105 | title: Text(widget.accessory.name), 106 | ), 107 | body: SafeArea( 108 | child: Column( 109 | children: [ 110 | Flexible( 111 | flex: 3, 112 | fit: FlexFit.tight, 113 | child: MapboxMap( 114 | accessToken: const String.fromEnvironment("MAP_SDK_PUBLIC_KEY"), 115 | onMapCreated: _onMapCreated, 116 | onStyleLoadedCallback: _onStyleLoaded, 117 | initialCameraPosition: const CameraPosition( 118 | target: LatLng(-23.559389, -46.731839), 119 | zoom: 13.0, 120 | ), 121 | styleString: Theme.of(context).brightness == Brightness.dark ? MapboxStyles.DARK : MapboxStyles.LIGHT, 122 | ), 123 | ), 124 | Flexible( 125 | flex: 1, 126 | fit: FlexFit.tight, 127 | child: DaysSelectionSlider( 128 | numberOfDays: numberOfDays, 129 | onChanged: (double newValue) { 130 | setState(() { 131 | numberOfDays = newValue; 132 | _updateMarkers(); 133 | }); 134 | }, 135 | ), 136 | ), 137 | ], 138 | ), 139 | ), 140 | ); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /lib/history/days_selection_slider.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class DaysSelectionSlider extends StatefulWidget { 4 | 5 | /// The number of days currently selected. 6 | double numberOfDays; 7 | /// A callback listening for value changes. 8 | ValueChanged onChanged; 9 | 10 | /// Display a slider that allows to define how many days to go back 11 | /// (range 1 to 7). 12 | DaysSelectionSlider({ 13 | Key? key, 14 | required this.numberOfDays, 15 | required this.onChanged, 16 | }) : super(key: key); 17 | 18 | @override 19 | _DaysSelectionSliderState createState() => _DaysSelectionSliderState(); 20 | } 21 | 22 | class _DaysSelectionSliderState extends State { 23 | @override 24 | Widget build(BuildContext context) { 25 | return Padding( 26 | padding: const EdgeInsets.all(12.0), 27 | child: Column( 28 | children: [ 29 | const Center( 30 | child: Text( 31 | 'How many days back?', 32 | style: TextStyle(fontSize: 20), 33 | ), 34 | ), 35 | Row( 36 | children: [ 37 | const Text('1', style: TextStyle(fontWeight: FontWeight.bold)), 38 | Expanded( 39 | child: Slider( 40 | value: widget.numberOfDays, 41 | min: 1, 42 | max: 7, 43 | label: '${widget.numberOfDays.round()}', 44 | divisions: 6, 45 | onChanged: widget.onChanged, 46 | ), 47 | ), 48 | const Text('7', style: TextStyle(fontWeight: FontWeight.bold)), 49 | ], 50 | ), 51 | ], 52 | ), 53 | ); 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /lib/item_management/accessory_color_input.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:openhaystack_mobile/accessory/accessory_color_selector.dart'; 3 | 4 | class AccessoryColorInput extends StatelessWidget { 5 | /// The inititial color value 6 | Color color; 7 | /// Callback called when the color is changed. Parameter is null 8 | /// if color did not change 9 | ValueChanged changeListener; 10 | 11 | /// Displays a color selection input that previews the current selection. 12 | AccessoryColorInput({ 13 | Key? key, 14 | required this.color, 15 | required this.changeListener, 16 | }) : super(key: key); 17 | 18 | @override 19 | Widget build(BuildContext context) { 20 | return ListTile( 21 | title: Row( 22 | children: [ 23 | const Text('Color: '), 24 | Icon( 25 | Icons.circle, 26 | color: color, 27 | ), 28 | const Spacer(), 29 | OutlinedButton( 30 | child: const Text('Change'), 31 | onPressed: () async { 32 | Color? selectedColor = await AccessoryColorSelector 33 | .showColorSelection(context, color); 34 | changeListener(selectedColor); 35 | }, 36 | ), 37 | ], 38 | ), 39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /lib/item_management/accessory_icon_input.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:openhaystack_mobile/accessory/accessory_icon_selector.dart'; 3 | 4 | class AccessoryIconInput extends StatelessWidget { 5 | /// The initial icon 6 | IconData initialIcon; 7 | /// The original icon name 8 | String iconString; 9 | /// The color of the icon 10 | Color color; 11 | /// Callback called when the icon is changed. Parameter is null 12 | /// if icon did not change 13 | ValueChanged changeListener; 14 | 15 | /// Displays an icon selection input that previews the current selection. 16 | AccessoryIconInput({ 17 | Key? key, 18 | required this.initialIcon, 19 | required this.iconString, 20 | required this.color, 21 | required this.changeListener, 22 | }) : super(key: key); 23 | 24 | @override 25 | Widget build(BuildContext context) { 26 | return ListTile( 27 | title: Row( 28 | children: [ 29 | const Text('Icon: '), 30 | Icon(initialIcon), 31 | const Spacer(), 32 | OutlinedButton( 33 | child: const Text('Change'), 34 | onPressed: () async { 35 | String? selectedIcon = await AccessoryIconSelector 36 | .showIconSelection(context, iconString, color); 37 | changeListener(selectedIcon); 38 | }, 39 | ), 40 | ], 41 | ), 42 | ); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /lib/item_management/accessory_id_input.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class AccessoryIdInput extends StatelessWidget { 4 | ValueChanged changeListener; 5 | 6 | /// Displays an input field with validation for an accessory ID. 7 | AccessoryIdInput({ 8 | Key? key, 9 | required this.changeListener, 10 | }) : super(key: key); 11 | 12 | @override 13 | Widget build(BuildContext context) { 14 | return Padding( 15 | padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0), 16 | child: TextFormField( 17 | decoration: const InputDecoration( 18 | labelText: 'ID', 19 | ), 20 | validator: (value) { 21 | if (value == null) { 22 | return 'ID must be provided.'; 23 | } 24 | int? parsed = int.tryParse(value); 25 | if (parsed == null) { 26 | return 'ID must be an integer value.'; 27 | } 28 | return null; 29 | }, 30 | onSaved: changeListener, 31 | ), 32 | ); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /lib/item_management/accessory_name_input.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class AccessoryNameInput extends StatelessWidget { 4 | ValueChanged? onSaved; 5 | ValueChanged? onChanged; 6 | /// The initial accessory name 7 | String? initialValue; 8 | 9 | /// Displays an input field with validation for an accessory name. 10 | AccessoryNameInput({ 11 | Key? key, 12 | this.onSaved, 13 | this.initialValue, 14 | this.onChanged, 15 | }) : super(key: key); 16 | 17 | @override 18 | Widget build(BuildContext context) { 19 | return Padding( 20 | padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0), 21 | child: TextFormField( 22 | decoration: const InputDecoration( 23 | labelText: 'Name', 24 | ), 25 | validator: (value) { 26 | if (value == null) { 27 | return 'Name must be provided.'; 28 | } 29 | if (value.isEmpty || value.length > 30) { 30 | return 'Name must be a non empty string of max length 30.'; 31 | } 32 | return null; 33 | }, 34 | onSaved: onSaved, 35 | onChanged: onChanged, 36 | initialValue: initialValue, 37 | ), 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /lib/item_management/accessory_pk_input.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | class AccessoryPrivateKeyInput extends StatelessWidget { 6 | ValueChanged changeListener; 7 | 8 | /// Displays an input field with validation for a Base64 encoded accessory private key. 9 | AccessoryPrivateKeyInput({ 10 | Key? key, 11 | required this.changeListener, 12 | }) : super(key: key); 13 | 14 | @override 15 | Widget build(BuildContext context) { 16 | return Padding( 17 | padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0), 18 | child: TextFormField( 19 | decoration: const InputDecoration( 20 | hintText: 'SGVsbG8gV29ybGQhCg==', 21 | labelText: 'Private Key (Base64)', 22 | ), 23 | validator: (value) { 24 | if (value == null || value.isEmpty) { 25 | return 'Private key must be provided.'; 26 | } 27 | try { 28 | var removeEscaping = value 29 | .replaceAll('\\', '').replaceAll('\n', ''); 30 | base64Decode(removeEscaping); 31 | } catch (e) { 32 | return 'Value must be valid base64 key.'; 33 | } 34 | return null; 35 | }, 36 | onSaved: (newValue) => 37 | changeListener(newValue?.replaceAll('\\', '').replaceAll('\n', '')), 38 | ), 39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /lib/item_management/item_creation.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:provider/provider.dart'; 3 | import 'package:openhaystack_mobile/accessory/accessory_model.dart'; 4 | import 'package:openhaystack_mobile/accessory/accessory_registry.dart'; 5 | import 'package:openhaystack_mobile/findMy/find_my_controller.dart'; 6 | import 'package:openhaystack_mobile/item_management/accessory_color_input.dart'; 7 | import 'package:openhaystack_mobile/item_management/accessory_icon_input.dart'; 8 | import 'package:openhaystack_mobile/item_management/accessory_name_input.dart'; 9 | import 'package:openhaystack_mobile/deployment/deployment_instructions.dart'; 10 | 11 | class AccessoryGeneration extends StatefulWidget { 12 | 13 | /// Displays a page to create a new accessory. 14 | /// 15 | /// The parameters of the new accessory can be input in text fields. 16 | const AccessoryGeneration({ Key? key }) : super(key: key); 17 | 18 | @override 19 | _AccessoryGenerationState createState() => _AccessoryGenerationState(); 20 | } 21 | 22 | class _AccessoryGenerationState extends State { 23 | 24 | /// Stores the properties of the new accessory. 25 | Accessory newAccessory = Accessory( 26 | id: '', 27 | name: '', 28 | hashedPublicKey: '', 29 | datePublished: DateTime.now(), 30 | ); 31 | 32 | /// Stores the advertisement key of the newly created accessory. 33 | String? advertisementKey; 34 | 35 | final _formKey = GlobalKey(); 36 | 37 | /// Creates a new accessory with a new key pair. 38 | Future createAccessory(BuildContext context) async { 39 | if (_formKey.currentState != null) { 40 | if (_formKey.currentState!.validate()) { 41 | _formKey.currentState!.save(); 42 | 43 | var keyPair = await FindMyController.generateKeyPair(); 44 | advertisementKey = keyPair.getBase64AdvertisementKey(); 45 | newAccessory.hashedPublicKey = keyPair.hashedPublicKey; 46 | AccessoryRegistry accessoryRegistry = Provider.of(context, listen: false); 47 | accessoryRegistry.addAccessory(newAccessory); 48 | return true; 49 | } 50 | } 51 | return false; 52 | } 53 | 54 | @override 55 | Widget build(BuildContext context) { 56 | return Scaffold( 57 | appBar: AppBar( 58 | title: const Text('Create new Accessory'), 59 | ), 60 | body: SingleChildScrollView( 61 | child: Form( 62 | key: _formKey, 63 | child: Column( 64 | children: [ 65 | AccessoryNameInput( 66 | onSaved: (name) => setState(() { 67 | newAccessory.name = name!; 68 | }), 69 | ), 70 | AccessoryIconInput( 71 | initialIcon: newAccessory.icon, 72 | iconString: newAccessory.rawIcon, 73 | color: newAccessory.color, 74 | changeListener: (String? selectedIcon) { 75 | if (selectedIcon != null) { 76 | setState(() { 77 | newAccessory.setIcon(selectedIcon); 78 | }); 79 | } 80 | }, 81 | ), 82 | AccessoryColorInput( 83 | color: newAccessory.color, 84 | changeListener: (Color? selectedColor) { 85 | if (selectedColor != null) { 86 | setState(() { 87 | newAccessory.color = selectedColor; 88 | }); 89 | } 90 | }, 91 | ), 92 | const ListTile( 93 | title: Text('A secure key pair will be generated for you automatically.'), 94 | ), 95 | SwitchListTile( 96 | value: newAccessory.isActive, 97 | title: const Text('Is Active'), 98 | onChanged: (checked) { 99 | setState(() { 100 | newAccessory.isActive = checked; 101 | }); 102 | }, 103 | ), 104 | SwitchListTile( 105 | value: newAccessory.isDeployed, 106 | title: const Text('Is Deployed'), 107 | onChanged: (checked) { 108 | setState(() { 109 | newAccessory.isDeployed = checked; 110 | }); 111 | }, 112 | ), 113 | ListTile( 114 | title: OutlinedButton( 115 | child: const Text('Create only'), 116 | onPressed: () async { 117 | var created = await createAccessory(context); 118 | if (created) { 119 | Navigator.pop(context); 120 | } 121 | }, 122 | ), 123 | ), 124 | ListTile( 125 | title: ElevatedButton( 126 | child: const Text('Create and Deploy'), 127 | onPressed: () async { 128 | var created = await createAccessory(context); 129 | if (created) { 130 | Navigator.pushReplacement( 131 | context, 132 | MaterialPageRoute(builder: (context) => DeploymentInstructions( 133 | advertisementKey: advertisementKey ?? '', 134 | )), 135 | ); 136 | } 137 | }, 138 | ), 139 | ), 140 | ], 141 | ), 142 | ), 143 | ), 144 | ); 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /lib/item_management/item_export.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:io'; 3 | 4 | import 'package:flutter/material.dart'; 5 | import 'package:flutter/foundation.dart'; 6 | import 'package:path_provider/path_provider.dart'; 7 | import 'package:provider/provider.dart'; 8 | import 'package:openhaystack_mobile/accessory/accessory_dto.dart'; 9 | import 'package:openhaystack_mobile/accessory/accessory_model.dart'; 10 | import 'package:openhaystack_mobile/accessory/accessory_registry.dart'; 11 | import 'package:share_plus/share_plus.dart'; 12 | 13 | class ItemExportMenu extends StatelessWidget { 14 | /// The accessory to export from 15 | Accessory accessory; 16 | 17 | /// Displays a bottom sheet with export options. 18 | /// 19 | /// The accessory can be exported to a JSON file or the 20 | /// key parameters can be exported separately. 21 | ItemExportMenu({ 22 | Key? key, 23 | required this.accessory, 24 | }) : super(key: key); 25 | 26 | /// Shows the export options for the [accessory]. 27 | void showKeyExportSheet(BuildContext context, Accessory accessory) { 28 | showModalBottomSheet(context: context, builder: (BuildContext context) { 29 | return SafeArea( 30 | child: ListView( 31 | physics: const NeverScrollableScrollPhysics(), 32 | shrinkWrap: true, 33 | children: [ 34 | ListTile( 35 | trailing: IconButton( 36 | onPressed: () { 37 | _showKeyExplanationAlert(context); 38 | }, 39 | icon: const Icon(Icons.info), 40 | ), 41 | ), 42 | ListTile( 43 | title: const Text('Export All Accessories (JSON)'), 44 | onTap: () async { 45 | var accessories = Provider.of(context, listen: false).accessories; 46 | await _exportAccessoriesAsJSON(accessories); 47 | Navigator.pop(context); 48 | }, 49 | ), 50 | ListTile( 51 | title: const Text('Export Accessory (JSON)'), 52 | onTap: () async { 53 | await _exportAccessoriesAsJSON([accessory]); 54 | Navigator.pop(context); 55 | }, 56 | ), 57 | ListTile( 58 | title: const Text('Export Hashed Advertisement Key (Base64)'), 59 | onTap: () async { 60 | var advertisementKey = await accessory.getHashedAdvertisementKey(); 61 | Share.share(advertisementKey); 62 | Navigator.pop(context); 63 | }, 64 | ), 65 | ListTile( 66 | title: const Text('Export Advertisement Key (Base64)'), 67 | onTap: () async { 68 | var advertisementKey = await accessory.getAdvertisementKey(); 69 | Share.share(advertisementKey); 70 | Navigator.pop(context); 71 | }, 72 | ), 73 | ListTile( 74 | title: const Text('Export Private Key (Base64)'), 75 | onTap: () async { 76 | var privateKey = await accessory.getPrivateKey(); 77 | Share.share(privateKey); 78 | Navigator.pop(context); 79 | }, 80 | ), 81 | ], 82 | ), 83 | ); 84 | }); 85 | } 86 | 87 | /// Export the serialized [accessories] as a JSON file. 88 | /// 89 | /// The OpenHaystack export format is used for interoperability with 90 | /// the desktop app. 91 | Future _exportAccessoriesAsJSON(List accessories) async { 92 | // Create temporary directory to store export file 93 | Directory tempDir = await getTemporaryDirectory(); 94 | String path = tempDir.path; 95 | // Convert accessories to export format 96 | List exportAccessories = []; 97 | for (Accessory accessory in accessories) { 98 | String privateKey = await accessory.getPrivateKey(); 99 | exportAccessories.add(AccessoryDTO( 100 | id: int.tryParse(accessory.id) ?? 0, 101 | colorComponents: [ 102 | accessory.color.red / 255, 103 | accessory.color.green / 255, 104 | accessory.color.blue / 255, 105 | accessory.color.opacity, 106 | ], 107 | name: accessory.name, 108 | lastDerivationTimestamp: accessory.lastDerivationTimestamp, 109 | symmetricKey: accessory.symmetricKey, 110 | updateInterval: accessory.updateInterval, 111 | privateKey: privateKey, 112 | icon: accessory.rawIcon, 113 | isDeployed: accessory.isDeployed, 114 | colorSpaceName: 'kCGColorSpaceSRGB', 115 | usesDerivation: accessory.usesDerivation, 116 | oldestRelevantSymmetricKey: accessory.oldestRelevantSymmetricKey, 117 | isActive: accessory.isActive, 118 | )); 119 | } 120 | // Create file and write accessories as json 121 | const filename = 'accessories.json'; 122 | File file = File('$path/$filename'); 123 | JsonEncoder encoder = const JsonEncoder.withIndent(' '); // format output 124 | String encodedAccessories = encoder.convert(exportAccessories); 125 | await file.writeAsString(encodedAccessories); 126 | // Share export file over os share dialog 127 | Share.shareFiles( 128 | [file.path], 129 | mimeTypes: ['application/json'], 130 | subject: filename, 131 | ); 132 | } 133 | 134 | /// Show an explanation how the different key types are used. 135 | Future _showKeyExplanationAlert(BuildContext context) async { 136 | return showDialog( 137 | context: context, 138 | builder: (BuildContext context) { 139 | return AlertDialog( 140 | title: const Text('Key Overview'), 141 | content: SingleChildScrollView( 142 | child: ListBody( 143 | children: const [ 144 | Text('Private Key:', style: TextStyle(fontWeight: FontWeight.bold)), 145 | Text('Secret key used for location report decryption.'), 146 | Text('Advertisement Key:', style: TextStyle(fontWeight: FontWeight.bold)), 147 | Text('Shortened public key sent out over Bluetooth.'), 148 | Text('Hashed Advertisement Key:', style: TextStyle(fontWeight: FontWeight.bold)), 149 | Text('Used to retrieve location reports from the server'), 150 | Text('Accessory:', style: TextStyle(fontWeight: FontWeight.bold)), 151 | Text('A file containing all information about the accessory.'), 152 | ], 153 | ), 154 | ), 155 | actions: [ 156 | TextButton( 157 | child: const Text('Close'), 158 | onPressed: () { 159 | Navigator.of(context).pop(); 160 | }, 161 | ), 162 | ], 163 | ); 164 | }, 165 | ); 166 | } 167 | 168 | @override 169 | Widget build(BuildContext context) { 170 | return IconButton( 171 | onPressed: () { 172 | showKeyExportSheet(context, accessory); 173 | }, 174 | icon: const Icon(Icons.open_in_new), 175 | ); 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /lib/item_management/item_file_import.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:io'; 3 | 4 | import 'package:flutter/material.dart'; 5 | import 'package:provider/provider.dart'; 6 | import 'package:openhaystack_mobile/accessory/accessory_dto.dart'; 7 | import 'package:openhaystack_mobile/accessory/accessory_icon_model.dart'; 8 | import 'package:openhaystack_mobile/accessory/accessory_model.dart'; 9 | import 'package:openhaystack_mobile/accessory/accessory_registry.dart'; 10 | import 'package:openhaystack_mobile/findMy/find_my_controller.dart'; 11 | import 'package:openhaystack_mobile/item_management/loading_spinner.dart'; 12 | 13 | class ItemFileImport extends StatefulWidget { 14 | /// The path to the file to import from. 15 | final String filePath; 16 | 17 | /// Lets the user select which accessories to import from a file. 18 | /// 19 | /// Displays the accessories contained in the import file. 20 | /// The user can then select the accessories to import. 21 | const ItemFileImport({ 22 | Key? key, 23 | required this.filePath, 24 | }) : super(key: key); 25 | 26 | @override 27 | _ItemFileImportState createState() => _ItemFileImportState(); 28 | } 29 | 30 | class _ItemFileImportState extends State { 31 | /// The accessory information stored in the file 32 | List? accessories; 33 | /// Stores which accessories are selected. 34 | List? selected; 35 | /// Stores which accessory details are expanded 36 | List? expanded; 37 | 38 | /// Flag if the passed file can not be imported. 39 | bool hasError = false; 40 | /// Stores the reason for the error condition. 41 | String? errorText; 42 | 43 | @override 44 | void initState() { 45 | super.initState(); 46 | 47 | _initStateAsync(widget.filePath); 48 | } 49 | 50 | void _initStateAsync(String filePath) async { 51 | var isValidPath = await _validateFilePath(filePath); 52 | 53 | if (!isValidPath) { 54 | setState(() { 55 | hasError = true; 56 | errorText = 'Invalid file path. Please select another file.'; 57 | }); 58 | 59 | return; 60 | } 61 | 62 | // Parse the JSON file and read all contained accessories 63 | try { 64 | var accessoryDTOs = await _parseAccessories(filePath); 65 | 66 | setState(() { 67 | accessories = accessoryDTOs; 68 | selected = accessoryDTOs.map((_) => true).toList(); 69 | expanded = accessoryDTOs.map((_) => false).toList(); 70 | }); 71 | } catch (e) { 72 | setState(() { 73 | hasError = true; 74 | errorText = 'Could not parse JSON file. Please check if the file is formatted correctly.'; 75 | }); 76 | } 77 | } 78 | 79 | /// Validate that the file path is a valid path and the file exists. 80 | Future _validateFilePath(String filePath) async { 81 | if (filePath.isEmpty) { 82 | return false; 83 | } 84 | File file = File(filePath); 85 | var fileExists = await file.exists(); 86 | 87 | return fileExists; 88 | } 89 | 90 | /// Parse the JSON encoded accessories from the file stored at [filePath]. 91 | Future> _parseAccessories(String filePath) async { 92 | File file = File(filePath); 93 | String encodedContent = await file.readAsString(); 94 | 95 | List content = jsonDecode(encodedContent); 96 | var accessoryDTOs = content 97 | .map((json) => AccessoryDTO.fromJson(json)) 98 | .toList(); 99 | 100 | return accessoryDTOs; 101 | } 102 | 103 | /// Import the selected accessories. 104 | Future _importSelectedAccessories() async { 105 | if (accessories == null) { 106 | return; // File not parsed. Do nothing. 107 | } 108 | 109 | var registry = Provider.of(context, listen: false); 110 | 111 | for (var i = 0; i < accessories!.length; i++) { 112 | var accessoryDTO = accessories![i]; 113 | var shouldImport = selected?[i] ?? false; 114 | 115 | if (shouldImport) { 116 | await _importAccessory(registry, accessoryDTO); 117 | } 118 | } 119 | 120 | var nrOfImports = selected?.fold(0, 121 | (previousValue, element) => element ? previousValue + 1 : previousValue) ?? 0; 122 | if (nrOfImports > 0) { 123 | var snackbar = SnackBar( 124 | content: Text('Successfully imported ${nrOfImports.toString()} accessories.'), 125 | ); 126 | ScaffoldMessenger.of(context).showSnackBar(snackbar); 127 | } 128 | } 129 | 130 | /// Import a specific [accessory] by converting the DTO to the internal representation. 131 | Future _importAccessory(AccessoryRegistry registry, AccessoryDTO accessoryDTO) async { 132 | Color color = Colors.grey; 133 | if (accessoryDTO.colorSpaceName == 'kCGColorSpaceSRGB' && accessoryDTO.colorComponents.length == 4) { 134 | var colors = accessoryDTO.colorComponents; 135 | int red = (colors[0] * 255).round(); 136 | int green = (colors[1] * 255).round(); 137 | int blue = (colors[2] * 255).round(); 138 | double opacity = colors[3]; 139 | color = Color.fromRGBO(red, green, blue, opacity); 140 | } 141 | 142 | String icon = 'push_pin'; 143 | if (AccessoryIconModel.icons.contains(accessoryDTO.icon)) { 144 | icon = accessoryDTO.icon; 145 | } 146 | 147 | var keyPair = await FindMyController.importKeyPair(accessoryDTO.privateKey); 148 | 149 | Accessory newAccessory = Accessory( 150 | datePublished: DateTime.now(), 151 | hashedPublicKey: keyPair.hashedPublicKey, 152 | id: accessoryDTO.id.toString(), 153 | name: accessoryDTO.name, 154 | color: color, 155 | icon: icon, 156 | isActive: accessoryDTO.isActive, 157 | isDeployed: accessoryDTO.isDeployed, 158 | lastLocation: null, 159 | lastDerivationTimestamp: accessoryDTO.lastDerivationTimestamp, 160 | symmetricKey: accessoryDTO.symmetricKey, 161 | updateInterval: accessoryDTO.updateInterval, 162 | usesDerivation: accessoryDTO.usesDerivation, 163 | oldestRelevantSymmetricKey: accessoryDTO.oldestRelevantSymmetricKey, 164 | ); 165 | 166 | registry.addAccessory(newAccessory); 167 | } 168 | 169 | @override 170 | Widget build(BuildContext context) { 171 | if (hasError) { 172 | return _buildScaffold(Padding( 173 | padding: const EdgeInsets.all(16.0), 174 | child: Column( 175 | children: [ 176 | Text( 177 | 'An error occured.', 178 | style: Theme.of(context).textTheme.titleLarge, 179 | ), 180 | Padding( 181 | padding: const EdgeInsets.only(top: 8.0), 182 | child: Text(errorText ?? 'An unknown error occured. Please try again.'), 183 | ), 184 | ], 185 | ), 186 | )); 187 | } 188 | 189 | if (accessories == null) { 190 | return _buildScaffold(const LoadingSpinner()); 191 | } 192 | 193 | return _buildScaffold( 194 | SingleChildScrollView( 195 | child: ExpansionPanelList( 196 | expansionCallback: (int index, bool isExpanded) { 197 | setState(() { 198 | expanded?[index] = !isExpanded; 199 | }); 200 | }, 201 | children: accessories?.asMap().map((idx, accessory) => MapEntry(idx, ExpansionPanel( 202 | headerBuilder: (BuildContext context, bool isExpanded) 203 | => ListTile( 204 | leading: Checkbox( 205 | value: selected?[idx] ?? false, 206 | onChanged: (newState) { 207 | if (newState != null) { 208 | setState(() { 209 | selected?[idx] = newState; 210 | }); 211 | } 212 | }), 213 | title: Text(accessory.name), 214 | ), 215 | body: Padding( 216 | padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 8.0), 217 | child: Column( 218 | children: [ 219 | _buildProperty('ID', accessory.id.toString()), 220 | _buildProperty('Name', accessory.name), 221 | _buildProperty('Color', accessory.colorComponents.toString()), 222 | _buildProperty('Icon', accessory.icon), 223 | _buildProperty('privateKey', accessory.privateKey.replaceRange( 224 | 4, 225 | accessory.privateKey.length - 4, 226 | '*'*(accessory.privateKey.length - 8), 227 | )), 228 | _buildProperty('isActive', accessory.isActive.toString()), 229 | _buildProperty('isDeployed', accessory.isDeployed.toString()), 230 | _buildProperty('usesDerivation', accessory.usesDerivation.toString()), 231 | ], 232 | ), 233 | ), 234 | isExpanded: expanded?[idx] ?? false, 235 | ))).values.toList() ?? [], 236 | ), 237 | ), 238 | ); 239 | } 240 | 241 | /// Display a key-value property. 242 | Widget _buildProperty(String key, String value) { 243 | return Row( 244 | crossAxisAlignment: CrossAxisAlignment.start, 245 | children: [ 246 | Text( 247 | '$key: ', 248 | style: const TextStyle(fontWeight: FontWeight.bold), 249 | ), 250 | Flexible(child: Text(value)), 251 | ], 252 | ); 253 | } 254 | 255 | /// Surround the [body] widget with a [Scaffold] widget. 256 | Widget _buildScaffold(Widget body) { 257 | return Scaffold( 258 | appBar: AppBar( 259 | title: const Text('Select Accessories'), 260 | actions: [ 261 | TextButton( 262 | onPressed: () { 263 | if (accessories != null) { 264 | _importSelectedAccessories(); 265 | Navigator.pop(context); 266 | } 267 | }, 268 | child: Text( 269 | 'Import', 270 | style: TextStyle( 271 | color: accessories == null ? Colors.grey : Colors.white, 272 | ), 273 | ), 274 | ), 275 | ], 276 | ), 277 | body: SafeArea(child: body), 278 | ); 279 | } 280 | } 281 | -------------------------------------------------------------------------------- /lib/item_management/item_import.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:provider/provider.dart'; 3 | import 'package:openhaystack_mobile/accessory/accessory_model.dart'; 4 | import 'package:openhaystack_mobile/accessory/accessory_registry.dart'; 5 | import 'package:openhaystack_mobile/findMy/find_my_controller.dart'; 6 | import 'package:openhaystack_mobile/item_management/accessory_color_input.dart'; 7 | import 'package:openhaystack_mobile/item_management/accessory_icon_input.dart'; 8 | import 'package:openhaystack_mobile/item_management/accessory_id_input.dart'; 9 | import 'package:openhaystack_mobile/item_management/accessory_name_input.dart'; 10 | import 'package:openhaystack_mobile/item_management/accessory_pk_input.dart'; 11 | 12 | class AccessoryImport extends StatefulWidget { 13 | 14 | /// Displays an input form to manually import an accessory. 15 | const AccessoryImport({Key? key}) : super(key: key); 16 | 17 | @override 18 | State createState() => _AccessoryImportState(); 19 | } 20 | 21 | class _AccessoryImportState extends State { 22 | 23 | /// Stores the properties of the accessory to import. 24 | Accessory newAccessory = Accessory( 25 | id: '', 26 | name: '', 27 | hashedPublicKey: '', 28 | datePublished: DateTime.now(), 29 | ); 30 | String privateKey = ''; 31 | 32 | final _formKey = GlobalKey(); 33 | 34 | /// Imports the private key to the key store. 35 | Future importKey(BuildContext context) async { 36 | if (_formKey.currentState != null) { 37 | if (_formKey.currentState!.validate()) { 38 | _formKey.currentState!.save(); 39 | try { 40 | var keyPair = await FindMyController.importKeyPair(privateKey); 41 | newAccessory.hashedPublicKey = keyPair.hashedPublicKey; 42 | } catch (e) { 43 | ScaffoldMessenger.of(context).showSnackBar( 44 | const SnackBar( 45 | content: Text('Key import failed. Check if private key is correct.'), 46 | ), 47 | ); 48 | } 49 | var keyPair = await FindMyController.importKeyPair(privateKey); 50 | newAccessory.hashedPublicKey = keyPair.hashedPublicKey; 51 | AccessoryRegistry accessoryRegistry = Provider.of(context, listen: false); 52 | accessoryRegistry.addAccessory(newAccessory); 53 | Navigator.pop(context); 54 | } 55 | } 56 | } 57 | 58 | @override 59 | Widget build(BuildContext context) { 60 | return Scaffold( 61 | appBar: AppBar( 62 | title: const Text('Import Accessory'), 63 | ), 64 | body: SingleChildScrollView( 65 | child: Form( 66 | key: _formKey, 67 | child: Column( 68 | children: [ 69 | const ListTile( 70 | title: Text('Please enter the accessory parameters. They can be found in the exported accessory file.'), 71 | ), 72 | AccessoryIdInput( 73 | changeListener: (id) => setState(() { 74 | newAccessory.id = id!; 75 | }), 76 | ), 77 | AccessoryNameInput( 78 | onSaved: (name) => setState(() { 79 | newAccessory.name = name!; 80 | }), 81 | ), 82 | AccessoryIconInput( 83 | initialIcon: newAccessory.icon, 84 | iconString: newAccessory.rawIcon, 85 | color: newAccessory.color, 86 | changeListener: (String? selectedIcon) { 87 | if (selectedIcon != null) { 88 | setState(() { 89 | newAccessory.setIcon(selectedIcon); 90 | }); 91 | } 92 | }, 93 | ), 94 | AccessoryColorInput( 95 | color: newAccessory.color, 96 | changeListener: (Color? selectedColor) { 97 | if (selectedColor != null) { 98 | setState(() { 99 | newAccessory.color = selectedColor; 100 | }); 101 | } 102 | }, 103 | ), 104 | AccessoryPrivateKeyInput( 105 | changeListener: (String? privateKeyVal) async { 106 | if (privateKeyVal != null) { 107 | setState(() { 108 | privateKey = privateKeyVal; 109 | }); 110 | } 111 | }, 112 | ), 113 | SwitchListTile( 114 | value: newAccessory.isActive, 115 | title: const Text('Is Active'), 116 | onChanged: (checked) { 117 | setState(() { 118 | newAccessory.isActive = checked; 119 | }); 120 | }, 121 | ), 122 | SwitchListTile( 123 | value: newAccessory.isDeployed, 124 | title: const Text('Is Deployed'), 125 | onChanged: (checked) { 126 | setState(() { 127 | newAccessory.isDeployed = checked; 128 | }); 129 | }, 130 | ), 131 | ListTile( 132 | title: ElevatedButton( 133 | child: const Text('Import'), 134 | onPressed: () => importKey(context), 135 | ), 136 | ), 137 | ], 138 | ), 139 | ), 140 | ), 141 | ); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /lib/item_management/item_management.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:provider/provider.dart'; 3 | import 'package:openhaystack_mobile/accessory/accessory_detail.dart'; 4 | import 'package:openhaystack_mobile/accessory/accessory_icon.dart'; 5 | import 'package:openhaystack_mobile/accessory/no_accessories.dart'; 6 | import 'package:openhaystack_mobile/item_management/item_export.dart'; 7 | import 'package:openhaystack_mobile/accessory/accessory_registry.dart'; 8 | import 'package:intl/intl.dart'; 9 | 10 | class KeyManagement extends StatelessWidget { 11 | 12 | /// Displays a list of all accessories. 13 | /// 14 | /// Each accessory can be exported and is linked to a detail page. 15 | const KeyManagement({ 16 | Key? key, 17 | }) : super(key: key); 18 | 19 | @override 20 | Widget build(BuildContext context) { 21 | return Consumer( 22 | builder: (context, accessoryRegistry, child) { 23 | var accessories = accessoryRegistry.accessories; 24 | 25 | if (accessories.isEmpty) { 26 | return const NoAccessoriesPlaceholder(); 27 | } 28 | 29 | return Scrollbar( 30 | child: ListView( 31 | children: accessories.map((accessory) { 32 | String lastSeen = accessory.datePublished != null 33 | ? DateFormat('dd.MM.yyyy HH:mm').format(accessory.datePublished!) 34 | : 'Unknown'; 35 | return ListTile( 36 | onTap: () { 37 | Navigator.push( 38 | context, 39 | MaterialPageRoute(builder: (context) => AccessoryDetail( 40 | accessory: accessory, 41 | )), 42 | ); 43 | }, 44 | dense: true, 45 | title: Text(accessory.name), 46 | subtitle: Text('Last seen: ' + lastSeen), 47 | leading: AccessoryIcon( 48 | icon: accessory.icon, 49 | color: accessory.color, 50 | ), 51 | trailing: ItemExportMenu(accessory: accessory), 52 | ); 53 | }).toList(), 54 | ), 55 | ); 56 | }, 57 | ); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /lib/item_management/loading_spinner.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class LoadingSpinner extends StatelessWidget { 4 | 5 | /// Displays a centered loading spinner. 6 | const LoadingSpinner({ Key? key }) : super(key: key); 7 | 8 | @override 9 | Widget build(BuildContext context) { 10 | return Row( 11 | mainAxisAlignment: MainAxisAlignment.center, 12 | children: [Padding( 13 | padding: const EdgeInsets.only(top: 20), 14 | child: CircularProgressIndicator( 15 | color: Theme.of(context).primaryColor, 16 | semanticsLabel: 'Loading. Please wait.', 17 | ), 18 | )], 19 | ); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /lib/item_management/new_item_action.dart: -------------------------------------------------------------------------------- 1 | import 'package:file_picker/file_picker.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:openhaystack_mobile/item_management/item_creation.dart'; 4 | import 'package:openhaystack_mobile/item_management/item_file_import.dart'; 5 | import 'package:openhaystack_mobile/item_management/item_import.dart'; 6 | 7 | class NewKeyAction extends StatelessWidget { 8 | /// If the action button is small. 9 | final bool mini; 10 | 11 | /// Displays a floating button used to access the accessory creation menu. 12 | /// 13 | /// A new accessory can be created or an existing one imported manually. 14 | const NewKeyAction({ 15 | Key? key, 16 | this.mini = false, 17 | }) : super(key: key); 18 | 19 | /// Display a bottom sheet with creation options. 20 | void showCreationSheet(BuildContext context) { 21 | showModalBottomSheet(context: context, builder: (BuildContext context) { 22 | return SafeArea( 23 | child: ListView( 24 | shrinkWrap: true, 25 | children: [ 26 | ListTile( 27 | title: const Text('Import Accessory'), 28 | leading: const Icon(Icons.import_export), 29 | onTap: () { 30 | Navigator.pushReplacement( 31 | context, 32 | MaterialPageRoute(builder: (context) => const AccessoryImport()), 33 | ); 34 | }, 35 | ), 36 | ListTile( 37 | title: const Text('Import from JSON File'), 38 | leading: const Icon(Icons.description), 39 | onTap: () async { 40 | FilePickerResult? result = await FilePicker.platform.pickFiles( 41 | allowMultiple: false, 42 | type: FileType.custom, 43 | allowedExtensions: ['json'], 44 | dialogTitle: 'Select accessory configuration', 45 | ); 46 | 47 | if (result != null && result.paths.isNotEmpty) { 48 | // File selected, dialog not canceled 49 | String? filePath = result.paths[0]; 50 | 51 | if (filePath != null) { 52 | Navigator.pushReplacement(context, MaterialPageRoute( 53 | builder: (context) => ItemFileImport(filePath: filePath), 54 | )); 55 | } 56 | } 57 | }, 58 | ), 59 | ListTile( 60 | title: const Text('Create new Accessory'), 61 | leading: const Icon(Icons.add_box), 62 | onTap: () { 63 | Navigator.pushReplacement( 64 | context, 65 | MaterialPageRoute(builder: (context) => const AccessoryGeneration()), 66 | ); 67 | }, 68 | ), 69 | ], 70 | ), 71 | ); 72 | }); 73 | } 74 | 75 | @override 76 | Widget build(BuildContext context) { 77 | return FloatingActionButton( 78 | mini: mini, 79 | onPressed: () { 80 | showCreationSheet(context); 81 | }, 82 | tooltip: 'Create', 83 | child: const Icon(Icons.add), 84 | ); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /lib/location/location_model.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter/services.dart'; 5 | import 'package:geocoding/geocoding.dart' as geocode; 6 | import 'package:mapbox_gl/mapbox_gl.dart'; 7 | import 'package:geolocator/geolocator.dart'; 8 | 9 | class LocationModel extends ChangeNotifier { 10 | LatLng? here; 11 | geocode.Placemark? herePlace; 12 | StreamSubscription? locationStream; 13 | bool initialLocationSet = false; 14 | 15 | /// Requests access to the device location from the user. 16 | /// 17 | /// Initializes the location services and requests location 18 | /// access from the user if not granged. 19 | /// Returns if location access was granted. 20 | Future requestLocationAccess() async { 21 | // Enable location service 22 | var serviceEnabled = await Geolocator.isLocationServiceEnabled(); 23 | if (!serviceEnabled) { 24 | debugPrint('Location services are disabled.'); 25 | return false; 26 | } 27 | 28 | // Request location access from user if not permanently denied or already granted 29 | var permissionGranted = await Geolocator.checkPermission(); 30 | if (permissionGranted == LocationPermission.denied) { 31 | permissionGranted = await Geolocator.requestPermission(); 32 | if (permissionGranted == LocationPermission.denied) { 33 | debugPrint('Location access is denied.'); 34 | return false; 35 | } 36 | } 37 | 38 | return true; 39 | } 40 | 41 | /// Requests location updates from the platform. 42 | /// 43 | /// Listeners will be notified about locaiton changes. 44 | Future requestLocationUpdates() async { 45 | var permissionGranted = await requestLocationAccess(); 46 | if (permissionGranted) { 47 | 48 | const LocationSettings locationSettings = LocationSettings( 49 | accuracy: LocationAccuracy.high, 50 | distanceFilter: 100, 51 | ); 52 | 53 | // Handle future location updates 54 | locationStream ??= Geolocator.getPositionStream(locationSettings: locationSettings).listen(_updateLocation); 55 | 56 | // Fetch the current location 57 | var locationData = await Geolocator.getCurrentPosition(); 58 | _updateLocation(locationData); 59 | } else { 60 | initialLocationSet = true; 61 | if (locationStream != null) { 62 | locationStream?.cancel(); 63 | locationStream = null; 64 | } 65 | _removeCurrentLocation(); 66 | notifyListeners(); 67 | } 68 | } 69 | 70 | /// Updates the current location if new location data is available. 71 | /// 72 | /// Additionally updates the current address information to match 73 | /// the new location. 74 | void _updateLocation(Position? locationData) { 75 | if (locationData != null) { 76 | // debugPrint('Locaiton here: ${locationData.latitude!}, ${locationData.longitude!}'); 77 | here = LatLng(locationData.latitude, locationData.longitude); 78 | initialLocationSet = true; 79 | getAddress(here!) 80 | .then((value) { 81 | herePlace = value; 82 | notifyListeners(); 83 | }); 84 | } else { 85 | debugPrint('Received invalid location data: $locationData'); 86 | } 87 | notifyListeners(); 88 | } 89 | 90 | /// Cancels the listening for location updates. 91 | void cancelLocationUpdates() { 92 | if (locationStream != null) { 93 | locationStream?.cancel(); 94 | locationStream = null; 95 | } 96 | _removeCurrentLocation(); 97 | notifyListeners(); 98 | } 99 | 100 | /// Resets the currently stored location and address information 101 | void _removeCurrentLocation() { 102 | here = null; 103 | herePlace = null; 104 | } 105 | 106 | /// Returns the address for a given geolocation (latitude & longitude). 107 | /// 108 | /// Only works on mobile platforms with their local APIs. 109 | static Future getAddress(LatLng? location) async { 110 | if (location == null) { 111 | return null; 112 | } 113 | double lat = location.latitude; 114 | double lng = location.longitude; 115 | 116 | try { 117 | List placemarks = await geocode.placemarkFromCoordinates(lat, lng); 118 | return placemarks.first; 119 | } on MissingPluginException { 120 | return null; 121 | } on PlatformException { 122 | return null; 123 | } 124 | } 125 | 126 | } 127 | -------------------------------------------------------------------------------- /lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io'; 3 | 4 | import 'package:flutter/material.dart'; 5 | import 'package:provider/provider.dart'; 6 | import 'package:openhaystack_mobile/dashboard/dashboard_desktop.dart'; 7 | import 'package:openhaystack_mobile/dashboard/dashboard_mobile.dart'; 8 | import 'package:openhaystack_mobile/accessory/accessory_registry.dart'; 9 | import 'package:openhaystack_mobile/item_management/item_file_import.dart'; 10 | import 'package:openhaystack_mobile/location/location_model.dart'; 11 | import 'package:openhaystack_mobile/preferences/user_preferences_model.dart'; 12 | import 'package:openhaystack_mobile/splashscreen.dart'; 13 | 14 | void main() { 15 | runApp(const MyApp()); 16 | } 17 | 18 | class MyApp extends StatelessWidget { 19 | const MyApp({Key? key}) : super(key: key); 20 | 21 | @override 22 | Widget build(BuildContext context) { 23 | return MultiProvider( 24 | providers: [ 25 | ChangeNotifierProvider(create: (ctx) => AccessoryRegistry()), 26 | ChangeNotifierProvider(create: (ctx) => UserPreferences()), 27 | ChangeNotifierProvider(create: (ctx) => LocationModel()), 28 | ], 29 | child: MaterialApp( 30 | title: 'OpenHaystack', 31 | theme: ThemeData( 32 | primarySwatch: Colors.blue, 33 | ), 34 | darkTheme: ThemeData.dark(), 35 | home: const AppLayout(), 36 | ), 37 | ); 38 | } 39 | } 40 | 41 | class AppLayout extends StatefulWidget { 42 | const AppLayout({Key? key}) : super(key: key); 43 | 44 | @override 45 | State createState() => _AppLayoutState(); 46 | } 47 | 48 | class _AppLayoutState extends State { 49 | StreamSubscription? _intentDataStreamSubscription; 50 | 51 | @override 52 | initState() { 53 | super.initState(); 54 | 55 | var accessoryRegistry = Provider.of(context, listen: false); 56 | accessoryRegistry.loadAccessories(); 57 | } 58 | 59 | @override 60 | void dispose() { 61 | _intentDataStreamSubscription?.cancel(); 62 | super.dispose(); 63 | } 64 | 65 | @override 66 | void didChangeDependencies() { 67 | // Precache logo for faster load times (e.g. on the splash screen) 68 | precacheImage(const AssetImage('assets/OpenHaystackIcon.png'), context); 69 | super.didChangeDependencies(); 70 | } 71 | 72 | 73 | @override 74 | Widget build(BuildContext context) { 75 | bool isInitialized = context.watch().initialized; 76 | bool isLoading = context.watch().loading; 77 | if (!isInitialized || isLoading) { 78 | return const Splashscreen(); 79 | } 80 | 81 | Size screenSize = MediaQuery.of(context).size; 82 | Orientation orientation = MediaQuery.of(context).orientation; 83 | 84 | // TODO: More advanced media query handling 85 | if (screenSize.width < 800) { 86 | return const DashboardMobile(); 87 | } else { 88 | return const DashboardMobile(); 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /lib/map/map.dart: -------------------------------------------------------------------------------- 1 | import 'dart:collection'; 2 | 3 | import 'package:flutter/foundation.dart'; 4 | import 'package:flutter/material.dart'; 5 | import 'package:flutter/services.dart'; 6 | import 'package:mapbox_gl/mapbox_gl.dart'; 7 | import 'package:provider/provider.dart'; 8 | import 'package:openhaystack_mobile/accessory/accessory_model.dart'; 9 | import 'package:openhaystack_mobile/accessory/accessory_registry.dart'; 10 | import 'package:openhaystack_mobile/location/location_model.dart'; 11 | 12 | class AccessoryMap extends StatefulWidget { 13 | final Function(MapboxMapController)? onMapCreatedCallback; 14 | 15 | /// Displays a map with all accessories at their latest position. 16 | const AccessoryMap({ 17 | Key? key, 18 | this.onMapCreatedCallback, 19 | }): super(key: key); 20 | 21 | @override 22 | _AccessoryMapState createState() => _AccessoryMapState(); 23 | } 24 | 25 | class _AccessoryMapState extends State { 26 | MapboxMapController? _mapController; 27 | void Function()? cancelLocationUpdates; 28 | void Function()? cancelAccessoryUpdates; 29 | bool accessoryInitialized = false; 30 | bool mapStyleLoaded = false; 31 | 32 | @override 33 | void initState() { 34 | super.initState(); 35 | } 36 | 37 | @override 38 | void dispose() { 39 | super.dispose(); 40 | 41 | cancelLocationUpdates?.call(); 42 | cancelAccessoryUpdates?.call(); 43 | } 44 | 45 | void fitToContent(List accessories, LatLng? hereLocation) async { 46 | // Delay to prevent race conditions 47 | await Future.delayed(const Duration(milliseconds: 500)); 48 | 49 | List points = [ 50 | ...accessories 51 | .where((accessory) => accessory.lastLocation != null) 52 | .map((accessory) => accessory.lastLocation!), 53 | if (hereLocation != null) hereLocation, 54 | ].toList(); 55 | 56 | _mapController?.moveCamera( 57 | CameraUpdate.newLatLngBounds( 58 | LatLngBounds( 59 | southwest: LatLng( 60 | points.map((point) => point.latitude).reduce((value, element) => value < element ? value : element) - 0.003, 61 | points.map((point) => point.longitude).reduce((value, element) => value < element ? value : element) - 0.003, 62 | ), 63 | northeast: LatLng( 64 | points.map((point) => point.latitude).reduce((value, element) => value > element ? value : element) + 0.003, 65 | points.map((point) => point.longitude).reduce((value, element) => value > element ? value : element) + 0.003, 66 | ), 67 | ), 68 | left: 25, top: 25, right: 25, bottom: 25, 69 | ), 70 | ); 71 | } 72 | 73 | onMapCreated(MapboxMapController controller, UnmodifiableListView accessories, LocationModel locationModel) { 74 | _mapController = controller; 75 | widget.onMapCreatedCallback!(controller); 76 | if (!accessoryInitialized) { 77 | fitToContent(accessories, locationModel.here); 78 | } 79 | } 80 | 81 | /// Adds an asset image to the currently displayed style 82 | Future addImageFromAsset(MapboxMapController controller, String name, String assetName) async { 83 | final ByteData bytes = await rootBundle.load(assetName); 84 | final Uint8List list = bytes.buffer.asUint8List(); 85 | return controller.addImage(name, list); 86 | } 87 | 88 | updateMarkers(MapboxMapController controller, UnmodifiableListView accessories) async { 89 | mapStyleLoaded = true; 90 | controller.removeCircles(controller.circles); 91 | controller.removeSymbols(controller.symbols); 92 | 93 | Set iconStrings = accessories.map((accessory) => accessory.iconString).toSet(); 94 | for (String iconString in iconStrings) { 95 | // to convert from svg to RGBA png use `convert -background "rgba(0,0,0,0)" $f png32:${f%.*}.png` 96 | await addImageFromAsset(controller, iconString, "assets/accessory_icons/$iconString.png"); 97 | } 98 | 99 | controller.addCircles( 100 | accessories 101 | .where((accessory) => accessory.lastLocation != null) 102 | .map((accessory) => CircleOptions( 103 | geometry: accessory.lastLocation!, 104 | circleRadius: 12, 105 | circleColor: "#FFFFFF", 106 | circleStrokeColor: accessory.color.toHexStringRGB(), 107 | circleStrokeWidth: 4, 108 | )) 109 | .toList(), 110 | ); 111 | controller.addSymbols( 112 | accessories 113 | .where((accessory) => accessory.lastLocation != null) 114 | .map((accessory) => SymbolOptions( 115 | geometry: accessory.lastLocation!, 116 | iconImage: accessory.iconString, 117 | iconSize: 0.425 * MediaQuery.of(context).devicePixelRatio, 118 | textField: accessory.name, 119 | textColor: "#000000", 120 | textOffset: const Offset(0, 1.5), 121 | )) 122 | .toList(), 123 | ); 124 | } 125 | 126 | 127 | @override 128 | Widget build(BuildContext context) { 129 | return Consumer2( 130 | builder: (BuildContext context, AccessoryRegistry accessoryRegistry, LocationModel locationModel, Widget? child) { 131 | // Zoom map to fit all accessories on first accessory update 132 | var accessories = accessoryRegistry.accessories; 133 | if (!accessoryInitialized && accessoryRegistry.initialLoadFinished) { 134 | fitToContent(accessories, locationModel.here); 135 | accessoryInitialized = true; 136 | } 137 | 138 | if (mapStyleLoaded) { 139 | updateMarkers(_mapController!, accessories); 140 | } 141 | 142 | return MapboxMap( 143 | myLocationEnabled: true, 144 | accessToken: const String.fromEnvironment("MAP_SDK_PUBLIC_KEY"), 145 | onMapCreated: (controller) => onMapCreated(controller, accessories, locationModel), 146 | onStyleLoadedCallback: () => updateMarkers(_mapController!, accessories), 147 | initialCameraPosition: CameraPosition( 148 | target: locationModel.here ?? const LatLng(-23.559389, -46.731839), 149 | zoom: 13.0, 150 | ), 151 | // styleString: Theme.of(context).brightness == Brightness.dark ? MapboxStyles.DARK : MapboxStyles.LIGHT, 152 | annotationOrder: const [ 153 | AnnotationType.circle, 154 | AnnotationType.symbol 155 | ], 156 | ); 157 | } 158 | ); 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /lib/placeholder/avatar_placeholder.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class AvatarPlaceholder extends StatelessWidget { 4 | final double size; 5 | 6 | /// Displays a placeholder for the actual avatar, occupying the same layout space. 7 | const AvatarPlaceholder({ 8 | Key? key, 9 | this.size = 24, 10 | }) : super(key: key); 11 | 12 | @override 13 | Widget build(BuildContext context) { 14 | return Container( 15 | width: size * 3 / 2, 16 | height: size * 3 / 2, 17 | decoration: const BoxDecoration( 18 | color: Color.fromARGB(255, 200, 200, 200), 19 | shape: BoxShape.circle, 20 | ), 21 | ); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /lib/placeholder/text_placeholder.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class TextPlaceholder extends StatefulWidget { 4 | final double maxWidth; 5 | final double? width; 6 | final double? height; 7 | final bool animated; 8 | 9 | /// Displays a placeholder for the actual text, occupying the same layout space. 10 | /// 11 | /// An optional loading animation is provided. 12 | const TextPlaceholder({ 13 | Key? key, 14 | this.maxWidth = double.infinity, 15 | this.width, 16 | this.height = 10, 17 | this.animated = true, 18 | }) : super(key: key); 19 | 20 | @override 21 | _TextPlaceholderState createState() => _TextPlaceholderState(); 22 | } 23 | 24 | class _TextPlaceholderState extends State with SingleTickerProviderStateMixin{ 25 | late Animation animation; 26 | late AnimationController controller; 27 | 28 | @override 29 | void initState() { 30 | super.initState(); 31 | 32 | controller = AnimationController( 33 | vsync: this, 34 | duration: const Duration(seconds: 1), 35 | ); 36 | animation = Tween(begin: 0, end: 1).animate(controller) 37 | ..addListener(() { 38 | setState(() {}); // Trigger UI update with current value 39 | }) 40 | ..addStatusListener((status) { 41 | if (status == AnimationStatus.completed) { 42 | controller.reverse(); 43 | } else if (status == AnimationStatus.dismissed) { 44 | controller.forward(); 45 | } 46 | }); 47 | 48 | controller.forward(); 49 | } 50 | 51 | @override 52 | void dispose() { 53 | controller.dispose(); 54 | super.dispose(); 55 | } 56 | 57 | @override 58 | Widget build(BuildContext context) { 59 | return Container( 60 | constraints: BoxConstraints(maxWidth: widget.maxWidth), 61 | height: widget.height, 62 | width: widget.width, 63 | decoration: BoxDecoration( 64 | gradient: widget.animated ? LinearGradient( 65 | begin: Alignment.centerLeft, 66 | end: Alignment.centerRight, 67 | stops: [0.0, animation.value, 1.0], 68 | colors: const [Color.fromARGB(255, 200, 200, 200), Color.fromARGB(255, 230, 230, 230), Color.fromARGB(255, 200, 200, 200)], 69 | ): null, 70 | color: widget.animated ? null : const Color.fromARGB(255, 200, 200, 200), 71 | borderRadius: const BorderRadius.all(Radius.circular(8)), 72 | ), 73 | ); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /lib/preferences/preferences_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:provider/provider.dart'; 3 | import 'package:openhaystack_mobile/location/location_model.dart'; 4 | import 'package:openhaystack_mobile/preferences/user_preferences_model.dart'; 5 | 6 | class PreferencesPage extends StatefulWidget { 7 | 8 | /// Displays this preferences page with information about the app. 9 | const PreferencesPage({ Key? key }) : super(key: key); 10 | 11 | @override 12 | _PreferencesPageState createState() => _PreferencesPageState(); 13 | } 14 | 15 | class _PreferencesPageState extends State { 16 | @override 17 | Widget build(BuildContext context) { 18 | return Scaffold( 19 | appBar: AppBar( 20 | title: const Text('Settings'), 21 | ), 22 | body: Consumer( 23 | builder: (BuildContext context, UserPreferences prefs, Widget? child) { 24 | return Center( 25 | child: Container( 26 | constraints: const BoxConstraints(maxWidth: 500), 27 | child: ListView( 28 | children: [ 29 | SwitchListTile( 30 | title: const Text('Show this devices location'), 31 | value: !prefs.locationPreferenceKnown! || (prefs.locationAccessWanted ?? true), 32 | onChanged: (showLocation) { 33 | prefs.setLocationPreference(showLocation); 34 | var locationModel = Provider.of(context, listen: false); 35 | if (showLocation) { 36 | locationModel.requestLocationUpdates(); 37 | } else { 38 | locationModel.cancelLocationUpdates(); 39 | } 40 | }, 41 | ), 42 | TextField( 43 | controller: TextEditingController(text: prefs.serverAddress), 44 | decoration: const InputDecoration( 45 | labelText: 'Server Address', 46 | ), 47 | onChanged: (value) { 48 | prefs.setServerPreference(value); 49 | }, 50 | ), 51 | ListTile( 52 | title: TextButton( 53 | child: const Text('About'), 54 | onPressed: () => showAboutDialog( 55 | context: context, 56 | ), 57 | ), 58 | ), 59 | ], 60 | ), 61 | ), 62 | ); 63 | }, 64 | ), 65 | ); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /lib/preferences/user_preferences_model.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/foundation.dart'; 2 | import 'package:shared_preferences/shared_preferences.dart'; 3 | 4 | const introductionShownKey = 'INTRODUCTION_SHOWN'; 5 | const locationPreferenceKnownKey = 'LOCATION_PREFERENCE_KNOWN'; 6 | const locationAccessWantedKey = 'LOCATION_PREFERENCE_WANTED'; 7 | const serverAddressKey = '_seemooEndpoint'; 8 | 9 | class UserPreferences extends ChangeNotifier { 10 | 11 | /// If these settings are initialized. 12 | bool initialized = false; 13 | /// The shared preferences storage. 14 | SharedPreferences? _prefs; 15 | 16 | /// Manages information about the users preferences. 17 | UserPreferences() { 18 | _initializeAsync(); 19 | } 20 | 21 | /// Initialize shared preferences access 22 | void _initializeAsync() async { 23 | _prefs = await SharedPreferences.getInstance(); 24 | 25 | // For Debugging: 26 | // await prefs.clear(); 27 | 28 | initialized = true; 29 | notifyListeners(); 30 | } 31 | 32 | /// Returns if the introduction should be shown. 33 | bool? shouldShowIntroduction() { 34 | if (_prefs == null) { 35 | return null; 36 | } else { 37 | if (!_prefs!.containsKey(introductionShownKey)) { 38 | return true; // Initial start of the app 39 | } 40 | return _prefs?.getBool(introductionShownKey); 41 | } 42 | } 43 | 44 | /// Returns if the user's locaiton preference is known. 45 | bool? get locationPreferenceKnown { 46 | return _prefs?.getBool(locationPreferenceKnownKey) ?? false; 47 | } 48 | 49 | /// Returns if the user desires location access. 50 | bool? get locationAccessWanted { 51 | return _prefs?.getBool(locationAccessWantedKey); 52 | } 53 | 54 | /// Returns the server address. 55 | String? get serverAddress { 56 | return _prefs?.getString(serverAddressKey); 57 | } 58 | 59 | /// Updates the location access preference of the user. 60 | Future setLocationPreference(bool locationAccessWanted) async { 61 | _prefs ??= await SharedPreferences.getInstance(); 62 | var success = await _prefs!.setBool(locationPreferenceKnownKey, true); 63 | if (!success) { 64 | return Future.value(false); 65 | } else { 66 | var result = await _prefs!.setBool(locationAccessWantedKey, locationAccessWanted); 67 | notifyListeners(); 68 | return result; 69 | } 70 | 71 | } 72 | 73 | /// Updates the server preference of the user. 74 | Future setServerPreference(String serverUrl) async { 75 | _prefs ??= await SharedPreferences.getInstance(); 76 | return _prefs!.setString(serverAddressKey, serverUrl); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /lib/splashscreen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class Splashscreen extends StatelessWidget { 4 | 5 | /// Display a fullscreen splashscreen to cover loading times. 6 | const Splashscreen({ Key? key }) : super(key: key); 7 | 8 | @override 9 | Widget build(BuildContext context) { 10 | Size screenSize = MediaQuery.of(context).size; 11 | Orientation orientation = MediaQuery.of(context).orientation; 12 | 13 | var maxScreen = orientation == Orientation.portrait ? screenSize.width : screenSize.height; 14 | var maxSize = maxScreen * 0.4; 15 | 16 | return Scaffold( 17 | body: Center( 18 | child: Container( 19 | constraints: BoxConstraints(maxWidth: maxSize, maxHeight: maxSize), 20 | // TODO: Update app icon accordingly (https://docs.flutter.dev/development/ui/assets-and-images#platform-assets) 21 | child: const Image( 22 | width: 1800, 23 | image: AssetImage('assets/OpenHaystackIcon.png')), 24 | ), 25 | ), 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /native/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "native" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [lib] 7 | crate-type = ["staticlib", "cdylib", "rlib"] 8 | 9 | [dependencies] 10 | flutter_rust_bridge = "^1.78.6" 11 | p224 = "^0.13.2" 12 | getrandom = "^0.2.14" 13 | rayon = "1.10.0" 14 | wasm-bindgen = "0.2.92" 15 | 16 | [features] 17 | default = ["p224/ecdh", "getrandom/js"] 18 | 19 | [profile.release] 20 | opt-level = 3 21 | lto = true 22 | 23 | [package.metadata.wasm-pack.profile.release] 24 | wasm-opt = ["-O4"] 25 | 26 | -------------------------------------------------------------------------------- /native/src/api.rs: -------------------------------------------------------------------------------- 1 | use p224::{SecretKey, PublicKey, ecdh::diffie_hellman}; 2 | use rayon::prelude::*; 3 | use std::sync::{Arc, Mutex}; 4 | 5 | const PRIVATE_LEN : usize = 28; 6 | const PUBLIC_LEN : usize = 57; 7 | 8 | pub fn ecdh(public_key_blob : Vec, private_key : Vec) -> Vec { 9 | let num_keys = public_key_blob.len() / PUBLIC_LEN; 10 | let vec_shared_secret = Arc::new(Mutex::new(vec![0u8; num_keys*PRIVATE_LEN])); 11 | 12 | let private_key = SecretKey::from_slice(&private_key).unwrap(); 13 | let secret_scalar = private_key.to_nonzero_scalar(); 14 | 15 | (0..num_keys).into_par_iter().for_each(|i| { 16 | let start = i * PUBLIC_LEN; 17 | let end = start + PUBLIC_LEN; 18 | let public_key = PublicKey::from_sec1_bytes(&public_key_blob[start..end]).unwrap(); 19 | let public_affine = public_key.as_affine(); 20 | 21 | let shared_secret = diffie_hellman(secret_scalar, public_affine); 22 | let shared_secret_ref = shared_secret.raw_secret_bytes().as_ref(); 23 | 24 | let start = i * PRIVATE_LEN; 25 | let end = start + PRIVATE_LEN; 26 | 27 | let mut vec_shared_secret = vec_shared_secret.lock().unwrap(); 28 | vec_shared_secret[start..end].copy_from_slice(shared_secret_ref); 29 | }); 30 | 31 | Arc::try_unwrap(vec_shared_secret).unwrap().into_inner().unwrap() 32 | } 33 | -------------------------------------------------------------------------------- /native/src/bridge_generated.io.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | // Section: wire functions 3 | 4 | #[no_mangle] 5 | pub extern "C" fn wire_ecdh( 6 | port_: i64, 7 | public_key_blob: *mut wire_uint_8_list, 8 | private_key: *mut wire_uint_8_list, 9 | ) { 10 | wire_ecdh_impl(port_, public_key_blob, private_key) 11 | } 12 | 13 | // Section: allocate functions 14 | 15 | #[no_mangle] 16 | pub extern "C" fn new_uint_8_list_0(len: i32) -> *mut wire_uint_8_list { 17 | let ans = wire_uint_8_list { 18 | ptr: support::new_leak_vec_ptr(Default::default(), len), 19 | len, 20 | }; 21 | support::new_leak_box_ptr(ans) 22 | } 23 | 24 | // Section: related functions 25 | 26 | // Section: impl Wire2Api 27 | 28 | impl Wire2Api> for *mut wire_uint_8_list { 29 | fn wire2api(self) -> Vec { 30 | unsafe { 31 | let wrap = support::box_from_leak_ptr(self); 32 | support::vec_from_leak_ptr(wrap.ptr, wrap.len) 33 | } 34 | } 35 | } 36 | // Section: wire structs 37 | 38 | #[repr(C)] 39 | #[derive(Clone)] 40 | pub struct wire_uint_8_list { 41 | ptr: *mut u8, 42 | len: i32, 43 | } 44 | 45 | // Section: impl NewWithNullPtr 46 | 47 | pub trait NewWithNullPtr { 48 | fn new_with_null_ptr() -> Self; 49 | } 50 | 51 | impl NewWithNullPtr for *mut T { 52 | fn new_with_null_ptr() -> Self { 53 | std::ptr::null_mut() 54 | } 55 | } 56 | 57 | // Section: sync execution mode utility 58 | 59 | #[no_mangle] 60 | pub extern "C" fn free_WireSyncReturn(ptr: support::WireSyncReturn) { 61 | unsafe { 62 | let _ = support::box_from_leak_ptr(ptr); 63 | }; 64 | } 65 | -------------------------------------------------------------------------------- /native/src/bridge_generated.rs: -------------------------------------------------------------------------------- 1 | #![allow( 2 | non_camel_case_types, 3 | unused, 4 | clippy::redundant_closure, 5 | clippy::useless_conversion, 6 | clippy::unit_arg, 7 | clippy::double_parens, 8 | non_snake_case, 9 | clippy::too_many_arguments 10 | )] 11 | // AUTO GENERATED FILE, DO NOT EDIT. 12 | // Generated by `flutter_rust_bridge`@ 1.82.6. 13 | 14 | use crate::api::*; 15 | use core::panic::UnwindSafe; 16 | use flutter_rust_bridge::rust2dart::IntoIntoDart; 17 | use flutter_rust_bridge::*; 18 | use std::ffi::c_void; 19 | use std::sync::Arc; 20 | 21 | // Section: imports 22 | 23 | // Section: wire functions 24 | 25 | fn wire_ecdh_impl( 26 | port_: MessagePort, 27 | public_key_blob: impl Wire2Api> + UnwindSafe, 28 | private_key: impl Wire2Api> + UnwindSafe, 29 | ) { 30 | FLUTTER_RUST_BRIDGE_HANDLER.wrap::<_, _, _, Vec, _>( 31 | WrapInfo { 32 | debug_name: "ecdh", 33 | port: Some(port_), 34 | mode: FfiCallMode::Normal, 35 | }, 36 | move || { 37 | let api_public_key_blob = public_key_blob.wire2api(); 38 | let api_private_key = private_key.wire2api(); 39 | move |task_callback| Result::<_, ()>::Ok(ecdh(api_public_key_blob, api_private_key)) 40 | }, 41 | ) 42 | } 43 | // Section: wrapper structs 44 | 45 | // Section: static checks 46 | 47 | // Section: allocate functions 48 | 49 | // Section: related functions 50 | 51 | // Section: impl Wire2Api 52 | 53 | pub trait Wire2Api { 54 | fn wire2api(self) -> T; 55 | } 56 | 57 | impl Wire2Api> for *mut S 58 | where 59 | *mut S: Wire2Api, 60 | { 61 | fn wire2api(self) -> Option { 62 | (!self.is_null()).then(|| self.wire2api()) 63 | } 64 | } 65 | impl Wire2Api for u8 { 66 | fn wire2api(self) -> u8 { 67 | self 68 | } 69 | } 70 | 71 | // Section: impl IntoDart 72 | 73 | // Section: executor 74 | 75 | support::lazy_static! { 76 | pub static ref FLUTTER_RUST_BRIDGE_HANDLER: support::DefaultHandler = Default::default(); 77 | } 78 | 79 | /// cbindgen:ignore 80 | #[cfg(target_family = "wasm")] 81 | #[path = "bridge_generated.web.rs"] 82 | mod web; 83 | #[cfg(target_family = "wasm")] 84 | pub use self::web::*; 85 | 86 | #[cfg(not(target_family = "wasm"))] 87 | #[path = "bridge_generated.io.rs"] 88 | mod io; 89 | #[cfg(not(target_family = "wasm"))] 90 | pub use self::io::*; 91 | -------------------------------------------------------------------------------- /native/src/bridge_generated.web.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | // Section: wire functions 3 | 4 | #[wasm_bindgen] 5 | pub fn wire_ecdh(port_: MessagePort, public_key_blob: Box<[u8]>, private_key: Box<[u8]>) { 6 | wire_ecdh_impl(port_, public_key_blob, private_key) 7 | } 8 | 9 | // Section: allocate functions 10 | 11 | // Section: related functions 12 | 13 | // Section: impl Wire2Api 14 | 15 | impl Wire2Api> for Box<[u8]> { 16 | fn wire2api(self) -> Vec { 17 | self.into_vec() 18 | } 19 | } 20 | // Section: impl Wire2Api for JsValue 21 | 22 | impl Wire2Api> for JsValue 23 | where 24 | JsValue: Wire2Api, 25 | { 26 | fn wire2api(self) -> Option { 27 | (!self.is_null() && !self.is_undefined()).then(|| self.wire2api()) 28 | } 29 | } 30 | impl Wire2Api for JsValue { 31 | fn wire2api(self) -> u8 { 32 | self.unchecked_into_f64() as _ 33 | } 34 | } 35 | impl Wire2Api> for JsValue { 36 | fn wire2api(self) -> Vec { 37 | self.unchecked_into::().to_vec().into() 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /native/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod bridge_generated; /* AUTO INJECTED BY flutter_rust_bridge. This line may not be accurate, and you can change it according to your needs. */ 2 | mod api; 3 | pub fn add(left: usize, right: usize) -> usize { 4 | left + right 5 | } 6 | 7 | #[cfg(test)] 8 | mod tests { 9 | use super::*; 10 | 11 | #[test] 12 | fn it_works() { 13 | let result = add(2, 2); 14 | assert_eq!(result, 4); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: openhaystack_mobile 2 | description: OpenHaystack Mobile 3 | 4 | # The following line prevents the package from being accidentally published to 5 | # pub.dev using `flutter pub publish`. This is preferred for private packages. 6 | publish_to: "none" # Remove this line if you wish to publish to pub.dev 7 | 8 | # The following defines the version and build number for your application. 9 | # A version number is three numbers separated by dots, like 1.2.43 10 | # followed by an optional build number separated by a +. 11 | # Both the version and the builder number may be overridden in flutter 12 | # build by specifying --build-name and --build-number, respectively. 13 | # In Android, build-name is used as versionName while build-number used as versionCode. 14 | # Read more about Android versioning at https://developer.android.com/studio/publish/versioning 15 | # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. 16 | # Read more about iOS versioning at 17 | # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html 18 | version: 1.0.0+1 19 | 20 | environment: 21 | sdk: ">=2.13.0 <3.0.0" 22 | 23 | # Dependencies specify other packages that your package needs in order to work. 24 | # To automatically upgrade your package dependencies to the latest versions 25 | # consider running `flutter pub upgrade --major-versions`. Alternatively, 26 | # dependencies can be manually updated by changing the version numbers below to 27 | # the latest version available on pub.dev. To see which dependencies have newer 28 | # versions available, run `flutter pub outdated`. 29 | dependencies: 30 | flutter: 31 | sdk: flutter 32 | 33 | # UI 34 | flutter_colorpicker: ^1.0.3 35 | flutter_launcher_icons: ^0.13.0 36 | flutter_slidable: ^3.1.0 37 | 38 | # Networking 39 | http: ^1.2.1 40 | 41 | # Cryptography 42 | # latest version of pointy castle for crypto functions 43 | pointycastle: ^3.4.0 44 | 45 | # State Management 46 | provider: ^6.0.1 47 | 48 | # Location 49 | mapbox_gl: 50 | git: 51 | url: https://github.com/flutter-mapbox-gl/maps.git 52 | ref: master 53 | intl: ^0.19.0 54 | geolocator: 9.0.2 55 | geocoding: ^3.0.0 56 | 57 | # Storage 58 | shared_preferences: ^2.0.9 59 | flutter_secure_storage: ^9.0.0 60 | file_picker: ^8.0.0+1 61 | 62 | # Sharing 63 | share_plus: ^8.0.0 64 | url_launcher: ^6.0.17 65 | path_provider: ^2.0.8 66 | maps_launcher: ^2.0.1 67 | ffi: ^2.1.2 68 | flutter_rust_bridge: ^1.78.6 69 | 70 | # The following adds the Cupertino Icons font to your application. 71 | # Use with the CupertinoIcons class for iOS style icons. 72 | #cupertino_icons: ^1.0.2 73 | 74 | dev_dependencies: 75 | flutter_test: 76 | sdk: flutter 77 | 78 | # The "flutter_lints" package below contains a set of recommended lints to 79 | # encourage good coding practices. The lint set provided by the package is 80 | # activated in the `analysis_options.yaml` file located at the root of your 81 | # package. See that file for information about deactivating specific lint 82 | # rules and activating additional ones. 83 | flutter_lints: ^3.0.2 84 | ffigen: ^11.0.0 85 | 86 | # Configuration for flutter_launcher_icons 87 | flutter_icons: 88 | android: true 89 | ios: true 90 | image_path: "assets/OpenHaystackIcon.png" 91 | 92 | # For information on the generic Dart part of this file, see the 93 | # following page: https://dart.dev/tools/pub/pubspec 94 | 95 | # The following section is specific to Flutter. 96 | flutter: 97 | # The following line ensures that the Material Icons font is 98 | # included with your application, so that you can use the icons in 99 | # the material Icons class. 100 | uses-material-design: true 101 | 102 | # To add assets to your application, add an assets section, like this: 103 | # assets: 104 | # - images/a_dot_burr.jpeg 105 | # - images/a_dot_ham.jpeg 106 | assets: 107 | - assets/ 108 | - assets/accessory_icons/ 109 | 110 | # An image asset can refer to one or more resolution-specific "variants", see 111 | # https://flutter.dev/assets-and-images/#resolution-aware. 112 | 113 | # For details regarding adding assets from package dependencies, see 114 | # https://flutter.dev/assets-and-images/#from-packages 115 | 116 | # To add custom fonts to your application, add a fonts section here, 117 | # in this "flutter" section. Each entry in this list should have a 118 | # "family" key with the font family name, and a "fonts" key with a 119 | # list giving the asset and other descriptors for the font. For 120 | # example: 121 | # fonts: 122 | # - family: Schyler 123 | # fonts: 124 | # - asset: fonts/Schyler-Regular.ttf 125 | # - asset: fonts/Schyler-Italic.ttf 126 | # style: italic 127 | # - family: Trajan Pro 128 | # fonts: 129 | # - asset: fonts/TrajanPro.ttf 130 | # - asset: fonts/TrajanPro_Bold.ttf 131 | # weight: 700 132 | # 133 | # For details regarding fonts from package dependencies, 134 | # see https://flutter.dev/custom-fonts/#from-packages 135 | -------------------------------------------------------------------------------- /test/widget_test.dart: -------------------------------------------------------------------------------- 1 | // This is a basic Flutter widget test. 2 | // 3 | // To perform an interaction with a widget in your test, use the WidgetTester 4 | // utility that Flutter provides. For example, you can send tap and scroll 5 | // gestures. You can also use WidgetTester to find child widgets in the widget 6 | // tree, read text, and verify that the values of widget properties are correct. 7 | 8 | import 'package:flutter/material.dart'; 9 | import 'package:flutter_test/flutter_test.dart'; 10 | 11 | import 'package:openhaystack_mobile/main.dart'; 12 | 13 | void main() { 14 | testWidgets('Counter increments smoke test', (WidgetTester tester) async { 15 | // Build our app and trigger a frame. 16 | await tester.pumpWidget(const MyApp()); 17 | 18 | // Verify that our counter starts at 0. 19 | expect(find.text('0'), findsOneWidget); 20 | expect(find.text('1'), findsNothing); 21 | 22 | // Tap the '+' icon and trigger a frame. 23 | await tester.tap(find.byIcon(Icons.add)); 24 | await tester.pump(); 25 | 26 | // Verify that our counter has incremented. 27 | expect(find.text('0'), findsNothing); 28 | expect(find.text('1'), findsOneWidget); 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /web/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangwillian0/openhaystack/24a641649e3fdeb6145832e2b4881545e01111a3/web/favicon.png -------------------------------------------------------------------------------- /web/icons/Icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangwillian0/openhaystack/24a641649e3fdeb6145832e2b4881545e01111a3/web/icons/Icon-192.png -------------------------------------------------------------------------------- /web/icons/Icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangwillian0/openhaystack/24a641649e3fdeb6145832e2b4881545e01111a3/web/icons/Icon-512.png -------------------------------------------------------------------------------- /web/icons/Icon-maskable-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangwillian0/openhaystack/24a641649e3fdeb6145832e2b4881545e01111a3/web/icons/Icon-maskable-192.png -------------------------------------------------------------------------------- /web/icons/Icon-maskable-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangwillian0/openhaystack/24a641649e3fdeb6145832e2b4881545e01111a3/web/icons/Icon-maskable-512.png -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | OpenHaystack Mobile 34 | 35 | 36 | 37 | 40 | 104 | 105 | 106 | -------------------------------------------------------------------------------- /web/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "openhaystack_mobile", 3 | "short_name": "openhaystack_mobile", 4 | "start_url": ".", 5 | "display": "standalone", 6 | "background_color": "#0175C2", 7 | "theme_color": "#0175C2", 8 | "description": "OpenHaystack2.0", 9 | "orientation": "portrait-primary", 10 | "prefer_related_applications": false, 11 | "icons": [ 12 | { 13 | "src": "icons/Icon-192.png", 14 | "sizes": "192x192", 15 | "type": "image/png" 16 | }, 17 | { 18 | "src": "icons/Icon-512.png", 19 | "sizes": "512x512", 20 | "type": "image/png" 21 | }, 22 | { 23 | "src": "icons/Icon-maskable-192.png", 24 | "sizes": "192x192", 25 | "type": "image/png", 26 | "purpose": "maskable" 27 | }, 28 | { 29 | "src": "icons/Icon-maskable-512.png", 30 | "sizes": "512x512", 31 | "type": "image/png", 32 | "purpose": "maskable" 33 | } 34 | ] 35 | } 36 | --------------------------------------------------------------------------------