├── .dockerignore ├── .env-example ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── DEVELOPMENT.md ├── Dockerfile.bolik-api ├── LICENSE ├── Makefile ├── README.md ├── RELEASE.md ├── app ├── .gitignore ├── .metadata ├── LICENSE ├── README.md ├── analysis_options.yaml ├── android │ ├── .gitignore │ ├── app │ │ ├── build.gradle │ │ └── src │ │ │ ├── debug │ │ │ └── AndroidManifest.xml │ │ │ ├── main │ │ │ ├── AndroidManifest.xml │ │ │ ├── kotlin │ │ │ │ └── tech │ │ │ │ │ └── bolik │ │ │ │ │ └── timeline │ │ │ │ │ └── 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 │ └── images │ │ ├── dog_icon.png │ │ └── dog_icon_android.png ├── ios │ ├── .gitignore │ ├── Flutter │ │ ├── AppFrameworkInfo.plist │ │ ├── Debug.xcconfig │ │ └── Release.xcconfig │ ├── Podfile │ ├── Podfile.lock │ ├── Runner.xcodeproj │ │ ├── project.pbxproj │ │ ├── project.xcworkspace │ │ │ ├── contents.xcworkspacedata │ │ │ └── xcshareddata │ │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ │ └── WorkspaceSettings.xcsettings │ │ └── xcshareddata │ │ │ └── xcschemes │ │ │ └── Runner.xcscheme │ ├── Runner.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── WorkspaceSettings.xcsettings │ └── Runner │ │ ├── AppDelegate.swift │ │ ├── Assets.xcassets │ │ ├── AppIcon.appiconset │ │ │ ├── Contents.json │ │ │ ├── Icon-App-1024x1024@1x.png │ │ │ ├── Icon-App-20x20@1x.png │ │ │ ├── Icon-App-20x20@2x.png │ │ │ ├── Icon-App-20x20@3x.png │ │ │ ├── Icon-App-29x29@1x.png │ │ │ ├── Icon-App-29x29@2x.png │ │ │ ├── Icon-App-29x29@3x.png │ │ │ ├── Icon-App-40x40@1x.png │ │ │ ├── Icon-App-40x40@2x.png │ │ │ ├── Icon-App-40x40@3x.png │ │ │ ├── Icon-App-50x50@1x.png │ │ │ ├── Icon-App-50x50@2x.png │ │ │ ├── Icon-App-57x57@1x.png │ │ │ ├── Icon-App-57x57@2x.png │ │ │ ├── Icon-App-60x60@2x.png │ │ │ ├── Icon-App-60x60@3x.png │ │ │ ├── Icon-App-72x72@1x.png │ │ │ ├── Icon-App-72x72@2x.png │ │ │ ├── Icon-App-76x76@1x.png │ │ │ ├── Icon-App-76x76@2x.png │ │ │ └── Icon-App-83.5x83.5@2x.png │ │ └── LaunchImage.imageset │ │ │ ├── Contents.json │ │ │ ├── LaunchImage.png │ │ │ ├── LaunchImage@2x.png │ │ │ ├── LaunchImage@3x.png │ │ │ └── README.md │ │ ├── Base.lproj │ │ ├── LaunchScreen.storyboard │ │ └── Main.storyboard │ │ ├── Info.plist │ │ ├── Runner-Bridging-Header.h │ │ └── bridge_generated.h ├── lib │ ├── bridge_ext.dart │ ├── bridge_generated.dart │ ├── bridge_generated.freezed.dart │ ├── common │ │ └── models │ │ │ ├── app_state.dart │ │ │ ├── dispatcher.dart │ │ │ ├── logger.dart │ │ │ ├── phase_info.dart │ │ │ └── pre_account_state.dart │ ├── features │ │ ├── account │ │ │ ├── account_page.dart │ │ │ └── contacts.dart │ │ ├── account_setup │ │ │ ├── account_setup_page.dart │ │ │ └── sdk_error_page.dart │ │ ├── card │ │ │ ├── block_row.dart │ │ │ ├── bottom_bar.dart │ │ │ ├── card_edit.dart │ │ │ ├── card_page.dart │ │ │ ├── collaborators.dart │ │ │ ├── file_preview.dart │ │ │ ├── image_preview.dart │ │ │ ├── labels.dart │ │ │ ├── media_page.dart │ │ │ ├── move_to_bin.dart │ │ │ └── toolbar.dart │ │ ├── connect_device │ │ │ └── connect_device_page.dart │ │ ├── import │ │ │ ├── export_page.dart │ │ │ └── import_page.dart │ │ ├── labels │ │ │ └── acc_labels.dart │ │ ├── log_view │ │ │ └── log_view.dart │ │ ├── notifications │ │ │ └── notifications_page.dart │ │ └── timeline │ │ │ ├── card_preview.dart │ │ │ ├── sidebar.dart │ │ │ └── timeline_page.dart │ ├── main.dart │ └── routes.dart ├── linux │ ├── .gitignore │ ├── CMakeLists.txt │ ├── flutter │ │ ├── CMakeLists.txt │ │ ├── generated_plugin_registrant.cc │ │ ├── generated_plugin_registrant.h │ │ └── generated_plugins.cmake │ ├── main.cc │ ├── my_application.cc │ ├── my_application.h │ └── rust.cmake ├── macos │ ├── .gitignore │ ├── Flutter │ │ ├── Flutter-Debug.xcconfig │ │ ├── Flutter-Release.xcconfig │ │ └── GeneratedPluginRegistrant.swift │ ├── Podfile │ ├── Podfile.lock │ ├── Runner.xcodeproj │ │ ├── project.pbxproj │ │ ├── project.xcworkspace │ │ │ └── xcshareddata │ │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ │ └── WorkspaceSettings.xcsettings │ │ └── xcshareddata │ │ │ └── xcschemes │ │ │ └── Runner.xcscheme │ ├── Runner.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── Runner │ │ ├── AppDelegate.swift │ │ ├── Assets.xcassets │ │ └── AppIcon.appiconset │ │ │ ├── Contents.json │ │ │ ├── app_icon_1024.png │ │ │ ├── app_icon_128.png │ │ │ ├── app_icon_16.png │ │ │ ├── app_icon_256.png │ │ │ ├── app_icon_32.png │ │ │ ├── app_icon_512.png │ │ │ └── app_icon_64.png │ │ ├── Base.lproj │ │ └── MainMenu.xib │ │ ├── Configs │ │ ├── AppInfo.xcconfig │ │ ├── Debug.xcconfig │ │ ├── Release.xcconfig │ │ └── Warnings.xcconfig │ │ ├── DebugProfile.entitlements │ │ ├── Info.plist │ │ ├── MainFlutterWindow.swift │ │ ├── Release.entitlements │ │ └── bridge_generated.h ├── native │ ├── Cargo.toml │ ├── LICENSE │ ├── native.xcodeproj │ │ └── project.pbxproj │ └── src │ │ ├── api.rs │ │ ├── bridge_generated.io.rs │ │ ├── bridge_generated.rs │ │ ├── lib.rs │ │ └── qr.rs ├── pubspec.lock ├── pubspec.yaml └── test │ ├── common │ └── models │ │ └── dispatcher_test.dart │ └── widget_test.dart ├── bolik_chain ├── Cargo.toml └── src │ ├── device.rs │ ├── lib.rs │ └── signature_chain.rs ├── bolik_proto ├── Cargo.toml ├── build.rs ├── proto │ └── sync.proto └── src │ └── lib.rs ├── bolik_sdk ├── Cargo.toml └── src │ ├── account.rs │ ├── account │ ├── acc_atom.rs │ ├── acc_view.rs │ ├── notifications.rs │ └── profile.rs │ ├── background.rs │ ├── blobs.rs │ ├── blobs │ └── blobs_atom.rs │ ├── client.rs │ ├── db.rs │ ├── db │ └── migrations.rs │ ├── device.rs │ ├── device │ └── device_atom.rs │ ├── documents.rs │ ├── documents │ ├── docs_atom.rs │ ├── sync_docs_atom.rs │ └── yrs_util.rs │ ├── export.rs │ ├── import.rs │ ├── import │ ├── v1.rs │ └── v2.rs │ ├── input.rs │ ├── lib.rs │ ├── mailbox.rs │ ├── mailbox │ └── mailbox_atom.rs │ ├── output.rs │ ├── registry.rs │ ├── sdk.rs │ ├── secret_group.rs │ ├── secret_group │ └── group_atom.rs │ ├── secrets.rs │ ├── signature_chain.rs │ └── timeline │ ├── acl_doc.rs │ ├── card.rs │ ├── mod.rs │ └── timeline_atom.rs ├── bolik_server ├── .gitignore ├── Cargo.toml └── src │ ├── account.rs │ ├── blobs.rs │ ├── device.rs │ ├── docs.rs │ ├── error.rs │ ├── lib.rs │ ├── mailbox.rs │ ├── main.rs │ ├── migration.rs │ ├── mls.rs │ ├── router.rs │ └── state.rs ├── bolik_tests ├── Cargo.toml ├── src │ └── lib.rs └── tests │ ├── multiple_accounts_test.rs │ ├── multiple_devices_test.rs │ └── single_device_test.rs ├── common └── migrations │ ├── Cargo.toml │ └── src │ └── lib.rs ├── fly-bolik-api.toml ├── litefs.yml └── test_data ├── bolik_export_1 ├── 2022-10-07T07:35:23 (45941d).md └── Files │ └── hello world (version 2460e6).txt ├── bolik_export_2 ├── 2022-12-26T13:06:46 (9aa6b4).md └── Files │ └── hello-world (version 9aa6b4).txt ├── device-A.bundles ├── device-B.bundles ├── device-C.bundles ├── device-D.bundles └── device-E.bundles /.dockerignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | *.log 4 | tmp/ 5 | 6 | # Rust builds 7 | target 8 | 9 | # Dev database 10 | bolik_server/timeline.db* 11 | 12 | # Locally stored blobs 13 | bolik_server/blobs 14 | 15 | notes 16 | 17 | # Flutter app (except our native module) 18 | app 19 | !app/native 20 | -------------------------------------------------------------------------------- /.env-example: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ############################################# 4 | # Application 5 | ############################################# 6 | 7 | export DEFAULT_BOLIK_HOST=http://127.0.0.1/api 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | *.log 4 | tmp/ 5 | 6 | # Rust builds 7 | target 8 | 9 | # Dev database 10 | bolik_server/timeline.db* 11 | 12 | # Locally stored blobs 13 | bolik_server/blobs 14 | 15 | notes 16 | .env 17 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["app/native", "bolik_chain", "bolik_sdk", "bolik_proto", "bolik_server", "common/migrations", "bolik_tests"] 3 | 4 | [workspace.dependencies] 5 | bs58 = "^0.4" 6 | # openmls = "0.4" 7 | # openmls_rust_crypto = "0.1" 8 | # openmls_traits = "0.1" 9 | openmls = { git = "https://github.com/zaynetro/openmls", branch = "bolik-branch" } 10 | openmls_rust_crypto = { git = "https://github.com/zaynetro/openmls", branch = "bolik-branch" } 11 | openmls_traits = { git = "https://github.com/zaynetro/openmls", branch = "bolik-branch" } 12 | seahash = "4.1" 13 | tracing = "^0.1" 14 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | # Development setup 2 | 3 | ## Requirements 4 | 5 | * Flutter 6 | * Rust nightly 7 | 8 | 9 | ## Icons 10 | 11 | * Icons are generated using https://pub.dev/packages/flutter_launcher_icons 12 | * Configuration is at the bottom of `app/pubspec.yaml` 13 | 14 | ``` 15 | cd app 16 | flutter pub get 17 | flutter pub run flutter_launcher_icons 18 | ``` 19 | 20 | 21 | ## Generate Flutter Rust bridge bindings 22 | 23 | From `app` directory. 24 | 25 | ``` 26 | flutter_rust_bridge_codegen \ 27 | -r native/src/api.rs \ 28 | -d lib/bridge_generated.dart \ 29 | -c ios/Runner/bridge_generated.h \ 30 | -e macos/Runner/ 31 | ``` 32 | 33 | On Fedora Silverblue: 34 | 35 | ```fish 36 | set -x CPATH "$(clang -v 2>&1 | grep "Selected GCC installation" | rev | cut -d' ' -f1 | rev)/include" 37 | 38 | flutter_rust_bridge_codegen --llvm-path /usr/lib64/libclang.so.14.0.0 \ 39 | -r native/src/api.rs \ 40 | -d lib/bridge_generated.dart \ 41 | -c ios/Runner/bridge_generated.h \ 42 | -e macos/Runner/ 43 | ``` 44 | 45 | 46 | ## iOS 47 | 48 | ``` 49 | cd app 50 | flutter create --platforms=ios . 51 | cargo install -f cargo-xcode 52 | cargo install -f flutter_rust_bridge_codegen@1.59.0 53 | rustup target add aarch64-apple-ios 54 | ``` 55 | 56 | * Follow Flutter Rust bridge setup for iOS[^frb-ios] 57 | * Modify how Xcode strips symbols[^ios-symbols] 58 | * In Xcode, go to **Target Runner > Build Settings > Strip Style**. 59 | * Change from **All Symbols** to **Non-Global Symbols**. 60 | 61 | 62 | [^frb-ios]: https://cjycode.com/flutter_rust_bridge/integrate/ios.html 63 | [^ios-symbols]: https://docs.flutter.dev/development/platform-integration/ios/c-interop#stripping-ios-symbols 64 | 65 | 66 | ## Mac 67 | 68 | ``` 69 | cd app 70 | flutter create --platforms=macos . 71 | cargo install -f cargo-xcode 72 | cargo install -f flutter_rust_bridge_codegen@1.59.0 73 | ``` 74 | 75 | * Follow Flutter Rust bridge setup for Mac[^frb-ios] 76 | * Instead of dylib as suggested in the guide I am linking static lib (just like on iOS) 77 | * Under **Signing & Capabilities** 78 | * Check "Outgoing connections" for both Debug and Release profiles 79 | * Modify how Xcode strips symbols[^ios-symbols] 80 | * In Xcode, go to **Target Runner > Build Settings > Strip Style**. 81 | * Change from **All Symbols** to **Non-Global Symbols**. 82 | 83 | 84 | ## Android 85 | 86 | ``` 87 | cd app 88 | flutter create --platforms=android . 89 | cargo install -f cargo-ndk 90 | cargo install -f flutter_rust_bridge_codegen@1.59.0 91 | rustup target add \ 92 | aarch64-linux-android \ 93 | armv7-linux-androideabi \ 94 | x86_64-linux-android 95 | ``` 96 | 97 | * In Android Studio also install: 98 | * Android SDK Command-line Tools 99 | * NDK 100 | 101 | * Follow Flutter Rust bridge setup for Android[^frb-android] 102 | * Or see their sample project[^frb-template] 103 | * Add to `~/.gradle/gradle.properties` (using absolute path is a requirement): 104 | `ANDROID_NDK=/var/home/roman/Android/Sdk/ndk/25.0.8775105` 105 | Mac: `ANDROID_NDK=/Users/roman/Library/Android/sdk/ndk/25.1.8937393` 106 | 107 | ### Running on physical Android device 108 | 109 | You have two options: 110 | 111 | 1. Either clear debug signing config not to use release keystore 112 | 2. Or set up keystore (see "Android: First time setup" section in `RELEASE.md`) 113 | 114 | [^frb-android]: https://cjycode.com/flutter_rust_bridge/integrate/android_tasks.html 115 | [^frb-template]: https://github.com/Desdaemon/flutter_rust_bridge_template/blob/main/android/app/build.gradle 116 | -------------------------------------------------------------------------------- /Dockerfile.bolik-api: -------------------------------------------------------------------------------- 1 | FROM lukemathwalker/cargo-chef:latest-rust-1 AS chef 2 | WORKDIR app 3 | 4 | 5 | # Compute recipe.json (our dependencies) 6 | FROM chef AS planner 7 | COPY . . 8 | RUN cargo chef prepare --recipe-path recipe.json 9 | 10 | 11 | # Build 12 | FROM chef AS builder 13 | COPY --from=planner /app/recipe.json recipe.json 14 | 15 | RUN apt-get update 16 | # OpenSSL dependencies 17 | RUN apt-get install -y pkg-config libssl-dev make 18 | # Protobuf 19 | # RUN apt-get install -y protobuf-compiler libprotobuf-dev 20 | # Debian bullseye comes with too old protoc version 21 | RUN apt-get install -y curl unzip \ 22 | && mkdir -p /tmp/protoc \ 23 | && cd /tmp/protoc \ 24 | && curl -Lo protoc.zip https://github.com/protocolbuffers/protobuf/releases/download/v21.9/protoc-21.9-linux-x86_64.zip \ 25 | && unzip protoc.zip \ 26 | && chmod +x bin/protoc \ 27 | && cp bin/protoc /usr/bin/protoc 28 | RUN protoc --version 29 | 30 | # Build dependencies - this is the caching Docker layer! 31 | RUN cargo chef cook --release --recipe-path recipe.json 32 | # Build application 33 | COPY . . 34 | RUN cd bolik_server && cargo build --release 35 | 36 | 37 | # Runtime 38 | FROM debian:bullseye-slim AS runtime 39 | 40 | RUN apt-get update 41 | RUN apt-get -y install sqlite3 ca-certificates fuse 42 | 43 | WORKDIR app 44 | 45 | ADD litefs.yml /etc/litefs.yml 46 | 47 | COPY --from=flyio/litefs:0.3 /usr/local/bin/litefs /usr/local/bin/litefs 48 | COPY --from=builder /app/target/release/bolik_server /usr/local/bin/bolik_server 49 | 50 | CMD litefs mount -- bolik_server 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Bolik Oy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := help 2 | 3 | .PHONY: help 4 | # From: http://disq.us/p/16327nq 5 | help: ## This help. 6 | @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) 7 | 8 | 9 | .PHONY: codegen 10 | codegen: ## Generate Rust and Dart glue code 11 | flutter_rust_bridge_codegen \ 12 | -r native/src/api.rs \ 13 | -d lib/bridge_generated.dart \ 14 | -c ios/Runner/bridge_generated.h \ 15 | -e macos/Runner/ 16 | 17 | .PHONY: flutter-upgrade-deps 18 | flutter-upgrade-deps: ## Upgrade Flutter dependencies 19 | (cd app && flutter pub upgrade --major-versions) 20 | (cd app && flutter pub upgrade) 21 | 22 | .PHONY: flutter-run-linux 23 | flutter-run-linux: ## Run Flutter app on Linux host 24 | (cd app && flutter run -d linux) 25 | 26 | .PHONY: flutter-run-linux-alt 27 | flutter-run-linux-alt: ## Run Flutter app on Linux host (different account) 28 | (cd app && flutter run --dart-define=BOLIK_APP_SUPPORT_PATH=${HOME}/.local/share/tech.bolik.timeline-alt -d linux) 29 | 30 | .PHONY: flutter-run-mac 31 | flutter-run-mac: ## Run Flutter app on Mac host 32 | (cd app && flutter run -d macos) 33 | 34 | .PHONY: flutter-run-mac-alt 35 | flutter-run-mac-alt: ## Run Flutter app on Mac host (different account) 36 | (cd app && flutter run --dart-define=BOLIK_APP_SUPPORT_PATH=${HOME}/Library/Containers/tech.bolik.timelineapp/Data/Library/Application\ Support/tech.bolik.timeline-alt -d macos) 37 | 38 | .PHONY: deploy 39 | deploy: ## Deploy bolik server 40 | echo "Deploying bolik server..." 41 | fly deploy --config fly-bolik-api.toml --dockerfile Dockerfile.bolik-api -r waw 42 | 43 | .PHONY: logs 44 | logs: ## Tail server logs 45 | fly logs -a bolik-api 46 | 47 | .PHONY: server-run 48 | server-run: export S3_BUCKET=bolik-bucket 49 | server-run: export S3_ENDPOINT=s3.localhost 50 | server-run: export AWS_ACCESS_KEY_ID=key-id 51 | server-run: export AWS_SECRET_ACCESS_KEY=access-key 52 | server-run: ## Run local server 53 | (cd bolik_server && cargo run) 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bolik monorepo 2 | 3 | Bolik Timeline is [local-first](https://www.inkandswitch.com/local-first/) software for keeping notes and files. 4 | 5 | > This repo contains **alpha-quality software**. This means that we are expecting breaking changes and potential data loss. 6 | 7 | 8 | ## Features 9 | 10 | Bolik Timeline is an application for managing personal documents like notes, photos and memories. It supports offline editing, is end-to-end encrypted and is open source unlike other popular solutions. Timeline is an attempt to organize your private life under a single app. 11 | 12 | * A chronological timeline of your notes and files. 13 | * End-to-end encryption with [OpenMLS](https://github.com/openmls/openmls) and ChaCha20Poly1305. 14 | * Offline editing support by leveraging [Yrs](https://github.com/y-crdt/y-crdt/) CRDT. 15 | * Multi-device synchronization. 16 | * Selective sharing: Share only the content you want while keeping the rest private and in the same place. 17 | * Access your data from every major operating system: Android, iOS, Mac and Linux (Windows planned). 18 | * No lock-in. Fully open source. At any time you can export your data to Markdown files. 19 | 20 | Read an [introductory blog post](https://www.zaynetro.com/post/how-to-build-e2ee-local-first-app/) for more details. 21 | 22 | ## How to install? 23 | 24 | You can join an open beta on Android, iOS and Mac. 25 | 26 | * iOS and Mac: 27 | * Android: 28 | 29 | For Linux you will need to build yourself. Windows is not supported for the time being. 30 | 31 | ### Run yourself 32 | 33 | * Install Rust nightly and Flutter 34 | * `cd app && flutter run` 35 | 36 | 37 | ## Repo structure 38 | 39 | * `app`: Cross-platform Flutter application. 40 | * `app/native`: FFI module, bridge Flutter with SDK. 41 | * `bolik_chain`: Signature chain. 42 | * `bolik_proto`: Protobuf definitions. 43 | * `bolik_sdk`: Client SDK. 44 | * `bolik_server`: Server. 45 | * `bolik_tests`: Integration tests. 46 | 47 | 48 | ## License 49 | 50 | Flutter application (`app`) and Flutter FFI module (`app/native`) are released under GPL license. All other projects are MIT licensed. 51 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Release 2 | 3 | ## Server 4 | 5 | Deploying to fly.io is just: 6 | 7 | * `make deploy` 8 | 9 | ### Server: First time setup 10 | 11 | * Create a volume: https://fly.io/docs/reference/volumes/ 12 | * `fly volumes create bolik_api_db --region waw --size 3 -a bolik-api` 13 | * Create 3GB volume in Warsaw 14 | * Mount volume to your app in `fly-bolik-api.toml` under `[mounts]` section. 15 | * Set secrets with `fly secrets set -a bolik-api AWS_KEY=one` 16 | 17 | 18 | ## Android 19 | 20 | All instructions are relative to `app` directory. 21 | 22 | * Increment version in `pubspec.yaml` 23 | * Increment version before '+' and after. Number after '+' is called version code. 24 | * If prev is `1.0.0+1` then next is `1.0.1+2` 25 | * Commit the change 26 | * Make sure we build for all Android targets 27 | * See `cargoBuild` task in `android/app/build.gradle` 28 | * `flutter build appbundle` 29 | * Upload application bundle from `build/app/outputs/bundle/release/app-release.aab` to Play Console. 30 | 31 | ### Android: First time setup 32 | 33 | * https://docs.flutter.dev/deployment/android 34 | 35 | * Generate signing key with `keytool` 36 | * Set up `android/key.properties` (do not commit) 37 | ``` 38 | storePassword= 39 | keyPassword= 40 | keyAlias=upload 41 | storeFile=/Users/roman/upload-keystore.jks 42 | ``` 43 | * Modify `android/app/build.gradle` to load signing key and use release signing config 44 | 45 | 46 | ## iOS 47 | 48 | All instructions are relative to `app` directory. 49 | 50 | * Increment version in `pubspec.yaml` 51 | * Increment version before '+' and after. Number after '+' is called version code. 52 | * If prev is `1.0.0+1` then next is `1.0.1+2` 53 | * Commit the change 54 | * `flutter build ipa` 55 | * Open Transporter mac app and upload `build/ios/ipa/*.ipa` 56 | * Wait for Apple to build the app (they will send an email with build results) 57 | * Distribute the build to testers in https://appstoreconnect.apple.com 58 | 59 | 60 | ## Mac 61 | 62 | All instructions are relative to `app` directory. 63 | 64 | * Increment version in `pubspec.yaml` 65 | * Increment version before '+' and after. Number after '+' is called version code. 66 | * If prev is `1.0.0+1` then next is `1.0.1+2` 67 | * Commit the change 68 | * `flutter build macos` 69 | * `open macos/Runner.xcworkspace` and "Product --> Archive" 70 | * It is important to open `.xcworkspace` and not `.xcodeproj`![^mac-xcworkspace] 71 | * Create an archive "Product --> Archive", ignore Xcode Cloud. 72 | * If XCode can't find cargo then you need to extend PATH env var 73 | * In `native/native.xcodeproj/project.pbxproj` find a line that starts with ` script = ` 74 | * That line will contain PATH override. Include the directory where cargo is installed. 75 | * "Validate App" *(this step can be skipped as it will be done in "Distribute App" step)* 76 | * Automatically manage signing 77 | * "Distribute App" 78 | * App Store Connect 79 | * Upload 80 | * Wait for Apple to build the app (they will send an email with build results) 81 | * Distribute the build to testers in https://appstoreconnect.apple.com 82 | 83 | [^mac-xcworkspace]: https://github.com/flutter/flutter/issues/114314#issuecomment-1315911977 84 | 85 | ### Mac Tips 86 | 87 | * You can see existing archives in XCode: "Window --> Organizer" 88 | 89 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | migrate_working_dir/ 12 | 13 | # IntelliJ related 14 | *.iml 15 | *.ipr 16 | *.iws 17 | .idea/ 18 | 19 | # The .vscode folder contains launch configuration and tasks you configure in 20 | # VS Code which you may wish to be included in version control, so this line 21 | # is commented out by default. 22 | #.vscode/ 23 | 24 | # Flutter/Dart/Pub related 25 | **/doc/api/ 26 | **/ios/Flutter/.last_build_id 27 | .dart_tool/ 28 | .flutter-plugins 29 | .flutter-plugins-dependencies 30 | .packages 31 | .pub-cache/ 32 | .pub/ 33 | /build/ 34 | 35 | # Web related 36 | lib/generated_plugin_registrant.dart 37 | 38 | # Symbolication related 39 | app.*.symbols 40 | 41 | # Obfuscation related 42 | app.*.map.json 43 | 44 | # Android Studio will place build artifacts here 45 | /android/app/debug 46 | /android/app/profile 47 | /android/app/release 48 | 49 | # User preferences for XCode native module 50 | /native/native.xcodeproj/xcuserdata 51 | -------------------------------------------------------------------------------- /app/.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. 5 | 6 | version: 7 | revision: 135454af32477f815a7525073027a3ff9eff1bfd 8 | channel: stable 9 | 10 | project_type: app 11 | 12 | # Tracks metadata for the flutter migrate command 13 | migration: 14 | platforms: 15 | - platform: root 16 | create_revision: 135454af32477f815a7525073027a3ff9eff1bfd 17 | base_revision: 135454af32477f815a7525073027a3ff9eff1bfd 18 | - platform: macos 19 | create_revision: 135454af32477f815a7525073027a3ff9eff1bfd 20 | base_revision: 135454af32477f815a7525073027a3ff9eff1bfd 21 | 22 | # User provided section 23 | 24 | # List of Local paths (relative to this file) that should be 25 | # ignored by the migrate tool. 26 | # 27 | # Files that are not part of the templates will be ignored by default. 28 | unmanaged_files: 29 | - 'lib/main.dart' 30 | - 'ios/Runner.xcodeproj/project.pbxproj' 31 | -------------------------------------------------------------------------------- /app/README.md: -------------------------------------------------------------------------------- 1 | # timeline 2 | 3 | A new Flutter project. 4 | 5 | ## Getting Started 6 | 7 | This project is a starting point for a Flutter application. 8 | 9 | A few resources to get you started if this is your first Flutter project: 10 | 11 | - [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) 12 | - [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) 13 | 14 | For help getting started with Flutter development, view the 15 | [online documentation](https://docs.flutter.dev/), which offers tutorials, 16 | samples, guidance on mobile development, and a full API reference. 17 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | 15 | app/src/main/jniLibs 16 | -------------------------------------------------------------------------------- /app/android/app/build.gradle: -------------------------------------------------------------------------------- 1 | def localProperties = new Properties() 2 | def localPropertiesFile = rootProject.file('local.properties') 3 | if (localPropertiesFile.exists()) { 4 | localPropertiesFile.withReader('UTF-8') { reader -> 5 | localProperties.load(reader) 6 | } 7 | } 8 | 9 | def flutterRoot = localProperties.getProperty('flutter.sdk') 10 | if (flutterRoot == null) { 11 | throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") 12 | } 13 | 14 | def flutterVersionCode = localProperties.getProperty('flutter.versionCode') 15 | if (flutterVersionCode == null) { 16 | flutterVersionCode = '1' 17 | } 18 | 19 | def flutterVersionName = localProperties.getProperty('flutter.versionName') 20 | if (flutterVersionName == null) { 21 | flutterVersionName = '1.0' 22 | } 23 | 24 | apply plugin: 'com.android.application' 25 | apply plugin: 'kotlin-android' 26 | apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" 27 | 28 | def keystoreProperties = new Properties() 29 | def keystorePropertiesFile = rootProject.file('key.properties') 30 | if (keystorePropertiesFile.exists()) { 31 | keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) 32 | } 33 | 34 | android { 35 | // compileSdkVersion flutter.compileSdkVersion 36 | compileSdkVersion 33 37 | ndkVersion flutter.ndkVersion 38 | 39 | compileOptions { 40 | sourceCompatibility JavaVersion.VERSION_1_8 41 | targetCompatibility JavaVersion.VERSION_1_8 42 | } 43 | 44 | kotlinOptions { 45 | jvmTarget = '1.8' 46 | } 47 | 48 | sourceSets { 49 | main.java.srcDirs += 'src/main/kotlin' 50 | } 51 | 52 | defaultConfig { 53 | applicationId "tech.bolik.timeline" 54 | // You can update the following values to match your application needs. 55 | // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration. 56 | // minSdkVersion flutter.minSdkVersion 57 | minSdkVersion 21 58 | targetSdkVersion flutter.targetSdkVersion 59 | versionCode flutterVersionCode.toInteger() 60 | versionName flutterVersionName 61 | } 62 | 63 | signingConfigs { 64 | release { 65 | keyAlias keystoreProperties['keyAlias'] 66 | keyPassword keystoreProperties['keyPassword'] 67 | storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null 68 | storePassword keystoreProperties['storePassword'] 69 | } 70 | } 71 | buildTypes { 72 | debug { 73 | applicationIdSuffix ".beta" 74 | } 75 | 76 | release { 77 | signingConfig signingConfigs.release 78 | ndk { 79 | debugSymbolLevel 'SYMBOL_TABLE' 80 | } 81 | } 82 | } 83 | } 84 | 85 | flutter { 86 | source '../..' 87 | } 88 | 89 | dependencies { 90 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" 91 | } 92 | 93 | // Build Rust code: 94 | // - http://cjycode.com/flutter_rust_bridge/integrate/android_tasks.html 95 | // - https://github.com/Desdaemon/flutter_rust_bridge_template/blob/main/android/app/build.gradle 96 | [ 97 | Debug: null, 98 | Profile: '--release', 99 | Release: '--release' 100 | ].each { 101 | def taskPostfix = it.key 102 | def profileMode = it.value 103 | tasks.whenTaskAdded { task -> 104 | if (task.name == "javaPreCompile$taskPostfix") { 105 | task.dependsOn "cargoBuild$taskPostfix" 106 | } 107 | } 108 | tasks.register("cargoBuild$taskPostfix", Exec) { 109 | // My Pixel is arm64-v8a so limit arch for now 110 | // Check with ~/Android/Sdk/platform-tools/adb shell getprop ro.product.cpu.abi 111 | 112 | workingDir "../../native" 113 | environment "ANDROID_NDK_HOME", "$ANDROID_NDK" 114 | commandLine 'cargo', 'ndk', 115 | // the 2 ABIs below are used by real Android devices 116 | '-t', 'armeabi-v7a', 117 | '-t', 'arm64-v8a', 118 | // the below 2 ABIs are usually used for Android simulators, 119 | // add or remove these ABIs as needed. 120 | // '-t', 'x86', 121 | '-t', 'x86_64', 122 | '-o', '../android/app/src/main/jniLibs', 'build' 123 | if (profileMode != null) { 124 | args profileMode 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /app/android/app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /app/android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 7 | 15 | 19 | 23 | 24 | 25 | 26 | 27 | 28 | 30 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /app/android/app/src/main/kotlin/tech/bolik/timeline/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package tech.bolik.timeline 2 | 3 | import io.flutter.embedding.android.FlutterActivity 4 | 5 | class MainActivity: FlutterActivity() { 6 | } 7 | -------------------------------------------------------------------------------- /app/android/app/src/main/res/drawable-v21/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /app/android/app/src/main/res/drawable/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaynetro/timeline/f83b9f5d5e53f78ccdced44efd78c32647920b42/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaynetro/timeline/f83b9f5d5e53f78ccdced44efd78c32647920b42/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaynetro/timeline/f83b9f5d5e53f78ccdced44efd78c32647920b42/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaynetro/timeline/f83b9f5d5e53f78ccdced44efd78c32647920b42/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaynetro/timeline/f83b9f5d5e53f78ccdced44efd78c32647920b42/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/android/app/src/main/res/values-night/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /app/android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /app/android/app/src/profile/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /app/android/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext.kotlin_version = '1.8.0' 3 | repositories { 4 | google() 5 | mavenCentral() 6 | } 7 | 8 | dependencies { 9 | classpath 'com.android.tools.build:gradle:7.1.2' 10 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 11 | } 12 | } 13 | 14 | allprojects { 15 | repositories { 16 | google() 17 | mavenCentral() 18 | } 19 | } 20 | 21 | rootProject.buildDir = '../build' 22 | subprojects { 23 | project.buildDir = "${rootProject.buildDir}/${project.name}" 24 | } 25 | subprojects { 26 | project.evaluationDependsOn(':app') 27 | } 28 | 29 | task clean(type: Delete) { 30 | delete rootProject.buildDir 31 | } 32 | -------------------------------------------------------------------------------- /app/android/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx1536M 2 | android.useAndroidX=true 3 | android.enableJetifier=true 4 | -------------------------------------------------------------------------------- /app/android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Fri Jun 23 08:50:38 CEST 2017 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip 7 | -------------------------------------------------------------------------------- /app/android/settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | 3 | def localPropertiesFile = new File(rootProject.projectDir, "local.properties") 4 | def properties = new Properties() 5 | 6 | assert localPropertiesFile.exists() 7 | localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } 8 | 9 | def flutterSdkPath = properties.getProperty("flutter.sdk") 10 | assert flutterSdkPath != null, "flutter.sdk not set in local.properties" 11 | apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" 12 | -------------------------------------------------------------------------------- /app/assets/images/dog_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaynetro/timeline/f83b9f5d5e53f78ccdced44efd78c32647920b42/app/assets/images/dog_icon.png -------------------------------------------------------------------------------- /app/assets/images/dog_icon_android.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaynetro/timeline/f83b9f5d5e53f78ccdced44efd78c32647920b42/app/assets/images/dog_icon_android.png -------------------------------------------------------------------------------- /app/ios/.gitignore: -------------------------------------------------------------------------------- 1 | **/dgph 2 | *.mode1v3 3 | *.mode2v3 4 | *.moved-aside 5 | *.pbxuser 6 | *.perspectivev3 7 | **/*sync/ 8 | .sconsign.dblite 9 | .tags* 10 | **/.vagrant/ 11 | **/DerivedData/ 12 | Icon? 13 | **/Pods/ 14 | **/.symlinks/ 15 | profile 16 | xcuserdata 17 | **/.generated/ 18 | Flutter/App.framework 19 | Flutter/Flutter.framework 20 | Flutter/Flutter.podspec 21 | Flutter/Generated.xcconfig 22 | Flutter/ephemeral/ 23 | Flutter/app.flx 24 | Flutter/app.zip 25 | Flutter/flutter_assets/ 26 | Flutter/flutter_export_environment.sh 27 | ServiceDefinitions.json 28 | Runner/GeneratedPluginRegistrant.* 29 | 30 | # Exceptions to above rules. 31 | !default.mode1v3 32 | !default.mode2v3 33 | !default.pbxuser 34 | !default.perspectivev3 35 | -------------------------------------------------------------------------------- /app/ios/Flutter/AppFrameworkInfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | App 9 | CFBundleIdentifier 10 | io.flutter.flutter.app 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | App 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1.0 23 | MinimumOSVersion 24 | 11.0 25 | 26 | 27 | -------------------------------------------------------------------------------- /app/ios/Flutter/Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /app/ios/Flutter/Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /app/ios/Podfile: -------------------------------------------------------------------------------- 1 | # Uncomment this line to define a global platform for your project 2 | # platform :ios, '11.0' 3 | 4 | # CocoaPods analytics sends network stats synchronously affecting flutter build latency. 5 | ENV['COCOAPODS_DISABLE_STATS'] = 'true' 6 | 7 | project 'Runner', { 8 | 'Debug' => :debug, 9 | 'Profile' => :release, 10 | 'Release' => :release, 11 | } 12 | 13 | def flutter_root 14 | generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) 15 | unless File.exist?(generated_xcode_build_settings_path) 16 | raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" 17 | end 18 | 19 | File.foreach(generated_xcode_build_settings_path) do |line| 20 | matches = line.match(/FLUTTER_ROOT\=(.*)/) 21 | return matches[1].strip if matches 22 | end 23 | raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" 24 | end 25 | 26 | require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) 27 | 28 | flutter_ios_podfile_setup 29 | 30 | target 'Runner' do 31 | use_frameworks! 32 | use_modular_headers! 33 | 34 | flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) 35 | end 36 | 37 | post_install do |installer| 38 | installer.pods_project.targets.each do |target| 39 | flutter_additional_ios_build_settings(target) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /app/ios/Podfile.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - camera_avfoundation (0.0.1): 3 | - Flutter 4 | - device_info_plus (0.0.1): 5 | - Flutter 6 | - DKImagePickerController/Core (4.3.4): 7 | - DKImagePickerController/ImageDataManager 8 | - DKImagePickerController/Resource 9 | - DKImagePickerController/ImageDataManager (4.3.4) 10 | - DKImagePickerController/PhotoGallery (4.3.4): 11 | - DKImagePickerController/Core 12 | - DKPhotoGallery 13 | - DKImagePickerController/Resource (4.3.4) 14 | - DKPhotoGallery (0.0.17): 15 | - DKPhotoGallery/Core (= 0.0.17) 16 | - DKPhotoGallery/Model (= 0.0.17) 17 | - DKPhotoGallery/Preview (= 0.0.17) 18 | - DKPhotoGallery/Resource (= 0.0.17) 19 | - SDWebImage 20 | - SwiftyGif 21 | - DKPhotoGallery/Core (0.0.17): 22 | - DKPhotoGallery/Model 23 | - DKPhotoGallery/Preview 24 | - SDWebImage 25 | - SwiftyGif 26 | - DKPhotoGallery/Model (0.0.17): 27 | - SDWebImage 28 | - SwiftyGif 29 | - DKPhotoGallery/Preview (0.0.17): 30 | - DKPhotoGallery/Model 31 | - DKPhotoGallery/Resource 32 | - SDWebImage 33 | - SwiftyGif 34 | - DKPhotoGallery/Resource (0.0.17): 35 | - SDWebImage 36 | - SwiftyGif 37 | - file_picker (0.0.1): 38 | - DKImagePickerController/PhotoGallery 39 | - Flutter 40 | - Flutter (1.0.0) 41 | - open_filex (0.0.2): 42 | - Flutter 43 | - path_provider_foundation (0.0.1): 44 | - Flutter 45 | - FlutterMacOS 46 | - SDWebImage (5.14.3): 47 | - SDWebImage/Core (= 5.14.3) 48 | - SDWebImage/Core (5.14.3) 49 | - SwiftyGif (5.4.3) 50 | - url_launcher_ios (0.0.1): 51 | - Flutter 52 | 53 | DEPENDENCIES: 54 | - camera_avfoundation (from `.symlinks/plugins/camera_avfoundation/ios`) 55 | - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) 56 | - file_picker (from `.symlinks/plugins/file_picker/ios`) 57 | - Flutter (from `Flutter`) 58 | - open_filex (from `.symlinks/plugins/open_filex/ios`) 59 | - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/ios`) 60 | - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) 61 | 62 | SPEC REPOS: 63 | trunk: 64 | - DKImagePickerController 65 | - DKPhotoGallery 66 | - SDWebImage 67 | - SwiftyGif 68 | 69 | EXTERNAL SOURCES: 70 | camera_avfoundation: 71 | :path: ".symlinks/plugins/camera_avfoundation/ios" 72 | device_info_plus: 73 | :path: ".symlinks/plugins/device_info_plus/ios" 74 | file_picker: 75 | :path: ".symlinks/plugins/file_picker/ios" 76 | Flutter: 77 | :path: Flutter 78 | open_filex: 79 | :path: ".symlinks/plugins/open_filex/ios" 80 | path_provider_foundation: 81 | :path: ".symlinks/plugins/path_provider_foundation/ios" 82 | url_launcher_ios: 83 | :path: ".symlinks/plugins/url_launcher_ios/ios" 84 | 85 | SPEC CHECKSUMS: 86 | camera_avfoundation: 07c77549ea54ad95d8581be86617c094a46280d9 87 | device_info_plus: e5c5da33f982a436e103237c0c85f9031142abed 88 | DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac 89 | DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 90 | file_picker: ce3938a0df3cc1ef404671531facef740d03f920 91 | Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 92 | open_filex: 6e26e659846ec990262224a12ef1c528bb4edbe4 93 | path_provider_foundation: 37748e03f12783f9de2cb2c4eadfaa25fe6d4852 94 | SDWebImage: 9c36e66c8ce4620b41a7407698dda44211a96764 95 | SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780 96 | url_launcher_ios: ae1517e5e344f5544fb090b079e11f399dfbe4d2 97 | 98 | PODFILE CHECKSUM: ef19549a9bc3046e7bb7d2fab4d021637c0c58a3 99 | 100 | COCOAPODS: 1.11.3 101 | -------------------------------------------------------------------------------- /app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 37 | 38 | 39 | 40 | 41 | 42 | 52 | 54 | 60 | 61 | 62 | 63 | 69 | 71 | 77 | 78 | 79 | 80 | 82 | 83 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /app/ios/Runner.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /app/ios/Runner/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Flutter 3 | 4 | @UIApplicationMain 5 | @objc class AppDelegate: FlutterAppDelegate { 6 | override func application( 7 | _ application: UIApplication, 8 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 9 | ) -> Bool { 10 | // We need to show XCode that that code is used so that it is not stripped. 11 | // http://cjycode.com/flutter_rust_bridge/integrate/ios_headers.html 12 | let dummy = dummy_method_to_enforce_bundling() 13 | NSLog("Dummy: %d", dummy) 14 | GeneratedPluginRegistrant.register(with: self) 15 | return super.application(application, didFinishLaunchingWithOptions: launchOptions) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "20x20", 5 | "idiom" : "iphone", 6 | "filename" : "Icon-App-20x20@2x.png", 7 | "scale" : "2x" 8 | }, 9 | { 10 | "size" : "20x20", 11 | "idiom" : "iphone", 12 | "filename" : "Icon-App-20x20@3x.png", 13 | "scale" : "3x" 14 | }, 15 | { 16 | "size" : "29x29", 17 | "idiom" : "iphone", 18 | "filename" : "Icon-App-29x29@1x.png", 19 | "scale" : "1x" 20 | }, 21 | { 22 | "size" : "29x29", 23 | "idiom" : "iphone", 24 | "filename" : "Icon-App-29x29@2x.png", 25 | "scale" : "2x" 26 | }, 27 | { 28 | "size" : "29x29", 29 | "idiom" : "iphone", 30 | "filename" : "Icon-App-29x29@3x.png", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "size" : "40x40", 35 | "idiom" : "iphone", 36 | "filename" : "Icon-App-40x40@2x.png", 37 | "scale" : "2x" 38 | }, 39 | { 40 | "size" : "40x40", 41 | "idiom" : "iphone", 42 | "filename" : "Icon-App-40x40@3x.png", 43 | "scale" : "3x" 44 | }, 45 | { 46 | "size" : "60x60", 47 | "idiom" : "iphone", 48 | "filename" : "Icon-App-60x60@2x.png", 49 | "scale" : "2x" 50 | }, 51 | { 52 | "size" : "60x60", 53 | "idiom" : "iphone", 54 | "filename" : "Icon-App-60x60@3x.png", 55 | "scale" : "3x" 56 | }, 57 | { 58 | "size" : "20x20", 59 | "idiom" : "ipad", 60 | "filename" : "Icon-App-20x20@1x.png", 61 | "scale" : "1x" 62 | }, 63 | { 64 | "size" : "20x20", 65 | "idiom" : "ipad", 66 | "filename" : "Icon-App-20x20@2x.png", 67 | "scale" : "2x" 68 | }, 69 | { 70 | "size" : "29x29", 71 | "idiom" : "ipad", 72 | "filename" : "Icon-App-29x29@1x.png", 73 | "scale" : "1x" 74 | }, 75 | { 76 | "size" : "29x29", 77 | "idiom" : "ipad", 78 | "filename" : "Icon-App-29x29@2x.png", 79 | "scale" : "2x" 80 | }, 81 | { 82 | "size" : "40x40", 83 | "idiom" : "ipad", 84 | "filename" : "Icon-App-40x40@1x.png", 85 | "scale" : "1x" 86 | }, 87 | { 88 | "size" : "40x40", 89 | "idiom" : "ipad", 90 | "filename" : "Icon-App-40x40@2x.png", 91 | "scale" : "2x" 92 | }, 93 | { 94 | "size" : "76x76", 95 | "idiom" : "ipad", 96 | "filename" : "Icon-App-76x76@1x.png", 97 | "scale" : "1x" 98 | }, 99 | { 100 | "size" : "76x76", 101 | "idiom" : "ipad", 102 | "filename" : "Icon-App-76x76@2x.png", 103 | "scale" : "2x" 104 | }, 105 | { 106 | "size" : "83.5x83.5", 107 | "idiom" : "ipad", 108 | "filename" : "Icon-App-83.5x83.5@2x.png", 109 | "scale" : "2x" 110 | }, 111 | { 112 | "size" : "1024x1024", 113 | "idiom" : "ios-marketing", 114 | "filename" : "Icon-App-1024x1024@1x.png", 115 | "scale" : "1x" 116 | } 117 | ], 118 | "info" : { 119 | "version" : 1, 120 | "author" : "xcode" 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaynetro/timeline/f83b9f5d5e53f78ccdced44efd78c32647920b42/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaynetro/timeline/f83b9f5d5e53f78ccdced44efd78c32647920b42/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaynetro/timeline/f83b9f5d5e53f78ccdced44efd78c32647920b42/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaynetro/timeline/f83b9f5d5e53f78ccdced44efd78c32647920b42/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaynetro/timeline/f83b9f5d5e53f78ccdced44efd78c32647920b42/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaynetro/timeline/f83b9f5d5e53f78ccdced44efd78c32647920b42/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaynetro/timeline/f83b9f5d5e53f78ccdced44efd78c32647920b42/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaynetro/timeline/f83b9f5d5e53f78ccdced44efd78c32647920b42/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaynetro/timeline/f83b9f5d5e53f78ccdced44efd78c32647920b42/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaynetro/timeline/f83b9f5d5e53f78ccdced44efd78c32647920b42/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaynetro/timeline/f83b9f5d5e53f78ccdced44efd78c32647920b42/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaynetro/timeline/f83b9f5d5e53f78ccdced44efd78c32647920b42/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaynetro/timeline/f83b9f5d5e53f78ccdced44efd78c32647920b42/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaynetro/timeline/f83b9f5d5e53f78ccdced44efd78c32647920b42/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaynetro/timeline/f83b9f5d5e53f78ccdced44efd78c32647920b42/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaynetro/timeline/f83b9f5d5e53f78ccdced44efd78c32647920b42/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaynetro/timeline/f83b9f5d5e53f78ccdced44efd78c32647920b42/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaynetro/timeline/f83b9f5d5e53f78ccdced44efd78c32647920b42/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaynetro/timeline/f83b9f5d5e53f78ccdced44efd78c32647920b42/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaynetro/timeline/f83b9f5d5e53f78ccdced44efd78c32647920b42/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaynetro/timeline/f83b9f5d5e53f78ccdced44efd78c32647920b42/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "LaunchImage.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "LaunchImage@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "LaunchImage@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaynetro/timeline/f83b9f5d5e53f78ccdced44efd78c32647920b42/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaynetro/timeline/f83b9f5d5e53f78ccdced44efd78c32647920b42/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaynetro/timeline/f83b9f5d5e53f78ccdced44efd78c32647920b42/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md: -------------------------------------------------------------------------------- 1 | # Launch Screen Assets 2 | 3 | You can customize the launch screen with your own desired assets by replacing the image files in this directory. 4 | 5 | You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. -------------------------------------------------------------------------------- /app/ios/Runner/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /app/ios/Runner/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /app/ios/Runner/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CADisableMinimumFrameDurationOnPhone 6 | 7 | CFBundleDevelopmentRegion 8 | $(DEVELOPMENT_LANGUAGE) 9 | CFBundleDisplayName 10 | Bolik Timeline 11 | CFBundleExecutable 12 | $(EXECUTABLE_NAME) 13 | CFBundleIdentifier 14 | $(PRODUCT_BUNDLE_IDENTIFIER) 15 | CFBundleInfoDictionaryVersion 16 | 6.0 17 | CFBundleName 18 | app 19 | CFBundlePackageType 20 | APPL 21 | CFBundleShortVersionString 22 | $(FLUTTER_BUILD_NAME) 23 | CFBundleSignature 24 | ???? 25 | CFBundleVersion 26 | $(FLUTTER_BUILD_NUMBER) 27 | LSRequiresIPhoneOS 28 | 29 | NSCameraUsageDescription 30 | Users could scan a QR code to link devices together. 31 | NSMicrophoneUsageDescription 32 | Users could scan a QR code to link devices together. (No audio is recorded) 33 | NSPhotoLibraryUsageDescription 34 | Users could select media files to be stored on their timeline. 35 | UIApplicationSupportsIndirectInputEvents 36 | 37 | UISupportsDocumentBrowser 38 | 39 | UILaunchStoryboardName 40 | LaunchScreen 41 | UIMainStoryboardFile 42 | Main 43 | UISupportedInterfaceOrientations 44 | 45 | UIInterfaceOrientationPortrait 46 | UIInterfaceOrientationLandscapeLeft 47 | UIInterfaceOrientationLandscapeRight 48 | 49 | UISupportedInterfaceOrientations~ipad 50 | 51 | UIInterfaceOrientationPortrait 52 | UIInterfaceOrientationPortraitUpsideDown 53 | UIInterfaceOrientationLandscapeLeft 54 | UIInterfaceOrientationLandscapeRight 55 | 56 | UIViewControllerBasedStatusBarAppearance 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /app/ios/Runner/Runner-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | #import "GeneratedPluginRegistrant.h" 2 | #import "bridge_generated.h" 3 | -------------------------------------------------------------------------------- /app/lib/bridge_ext.dart: -------------------------------------------------------------------------------- 1 | import 'package:timeline/bridge_generated.dart'; 2 | 3 | extension CardViewExt on CardView { 4 | DateTime get createdAt => 5 | DateTime.fromMillisecondsSinceEpoch(createdAtSec * 1000, isUtc: true); 6 | 7 | DateTime get editedAt => 8 | DateTime.fromMillisecondsSinceEpoch(editedAtSec * 1000, isUtc: true); 9 | 10 | List accLabels(AccView acc) { 11 | final knownLabels = {for (var label in acc.labels) label.id: label}; 12 | return labels.map((l) => knownLabels[l.id]).whereType().toList() 13 | ..sort((a, b) => a.name.compareTo(b.name)); 14 | } 15 | } 16 | 17 | extension AccViewExt on AccView { 18 | DateTime get createdAt => 19 | DateTime.fromMillisecondsSinceEpoch(createdAtSec * 1000, isUtc: true); 20 | } 21 | 22 | extension CardTextAttrsExt on CardTextAttrs { 23 | Map toJson() { 24 | return { 25 | 'bold': bold, 26 | 'italic': italic, 27 | 'link': link, 28 | 'checked': checked, 29 | 'heading': heading, 30 | 'block': block, 31 | }; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/lib/common/models/dispatcher.dart: -------------------------------------------------------------------------------- 1 | import 'package:timeline/bridge_generated.dart'; 2 | import 'package:timeline/common/models/phase_info.dart'; 3 | 4 | /// Global event dispatcher. All components are supposed to dispatch their events via this class. 5 | class AppEventDispatcher { 6 | final List<_Listener> _listeners = []; 7 | 8 | void dispatch(OutputEvent event) { 9 | for (var listener in List.from(_listeners)) { 10 | try { 11 | if (listener.test(event)) { 12 | listener.callback(event); 13 | } 14 | } catch (e) { 15 | logger.warn("Failed to push event: $e"); 16 | } 17 | } 18 | } 19 | 20 | void addListener(Function(T) callback) { 21 | _listeners.add(_Listener( 22 | (event) => event is T, 23 | (event) => callback(event as T), 24 | callback.hashCode, 25 | )); 26 | } 27 | 28 | void removeListener(Function(T) callback) { 29 | _listeners.removeWhere( 30 | (listener) => listener.callbackHashCode == callback.hashCode); 31 | } 32 | } 33 | 34 | class _Listener { 35 | final bool Function(OutputEvent) test; 36 | final Function(OutputEvent) callback; 37 | final int callbackHashCode; 38 | 39 | _Listener(this.test, this.callback, this.callbackHashCode); 40 | } 41 | -------------------------------------------------------------------------------- /app/lib/common/models/logger.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:flutter/foundation.dart'; 4 | import 'package:path/path.dart' as p; 5 | 6 | enum LogLevel { 7 | trace(0), 8 | debug(1), 9 | info(2), 10 | warn(3), 11 | error(4); 12 | 13 | final int order; 14 | const LogLevel(this.order); 15 | } 16 | 17 | const _maxFileSize = 1024 * 1024 * 5 /* MBs */; 18 | 19 | class Logger { 20 | final LogLevel _level; 21 | final List _listeners = []; 22 | 23 | Logger(this._level, {bool stdOutput = false}) { 24 | if (stdOutput) { 25 | _listeners.add(_StdOutput()); 26 | } 27 | } 28 | 29 | void addFileOutput(String directory) { 30 | final logFilePath = p.join(directory, "app.log"); 31 | info('Using $logFilePath for logs'); 32 | final file = File(logFilePath); 33 | 34 | var mode = FileMode.writeOnlyAppend; 35 | var logSize = 0; 36 | if (file.existsSync()) { 37 | logSize = file.lengthSync(); 38 | if (logSize > _maxFileSize) { 39 | mode = FileMode.writeOnly; 40 | } 41 | } 42 | 43 | _listeners.add(FileLogOutput(logFilePath, file, mode)); 44 | if (mode == FileMode.writeOnly) { 45 | info("Truncated log file automatically (file_size(before)=$logSize)"); 46 | } 47 | } 48 | 49 | FileLogOutput? get fileOutput { 50 | for (var listener in _listeners) { 51 | if (listener is FileLogOutput) { 52 | return listener; 53 | } 54 | } 55 | return null; 56 | } 57 | 58 | Future truncateFile() async { 59 | await fileOutput?._truncate(); 60 | debug('Truncated log file'); 61 | } 62 | 63 | void debug(String line) => _flutterEvent(LogLevel.debug, line); 64 | void info(String line) => _flutterEvent(LogLevel.info, line); 65 | void warn(String line) => _flutterEvent(LogLevel.warn, line); 66 | void error(String line) => _flutterEvent(LogLevel.error, line); 67 | 68 | // An log event coming from Rust SDK 69 | void nativeEvent(String line) { 70 | final level = levelFromLine(line) ?? LogLevel.trace; 71 | _event(LogEvent(level, line, hasNewLine: true)); 72 | } 73 | 74 | void _flutterEvent(LogLevel level, String line) { 75 | final now = DateTime.now().toUtc().toIso8601String(); 76 | final paddedLevel = level.name.toUpperCase().padLeft(5); 77 | final text = '$now $paddedLevel flutter: $line'; 78 | _event(LogEvent(level, text)); 79 | } 80 | 81 | void _event(LogEvent event) { 82 | if (event.level.order < _level.order) { 83 | return; 84 | } 85 | 86 | for (var listener in _listeners) { 87 | try { 88 | listener.event(event); 89 | } catch (e) { 90 | debugPrint('Failed to push log event: $e'); 91 | } 92 | } 93 | } 94 | 95 | static LogLevel? levelFromLine(String line) { 96 | if (line.contains(" ERROR ")) { 97 | return LogLevel.error; 98 | } else if (line.contains(" WARN ")) { 99 | return LogLevel.warn; 100 | } else if (line.contains(" INFO ")) { 101 | return LogLevel.info; 102 | } else if (line.contains(" DEBUG ")) { 103 | return LogLevel.debug; 104 | } else if (line.contains(" TRACE ")) { 105 | return LogLevel.trace; 106 | } else { 107 | return null; 108 | } 109 | } 110 | } 111 | 112 | class LogEvent { 113 | final LogLevel level; 114 | final String line; 115 | final bool hasNewLine; 116 | 117 | LogEvent(this.level, this.line, {this.hasNewLine = false}); 118 | } 119 | 120 | abstract class LogOutput { 121 | void event(LogEvent event); 122 | void destroy() {} 123 | } 124 | 125 | class _StdOutput extends LogOutput { 126 | @override 127 | void event(LogEvent event) { 128 | if (Platform.isAndroid) { 129 | debugPrint(event.line); 130 | } else { 131 | stdout.write(event.line); 132 | if (!event.hasNewLine) { 133 | stdout.writeln(); 134 | } 135 | } 136 | } 137 | } 138 | 139 | class FileLogOutput extends LogOutput { 140 | final String filePath; 141 | final File file; 142 | IOSink? _sink; 143 | 144 | FileLogOutput(this.filePath, this.file, FileMode mode) 145 | : _sink = file.openWrite(mode: mode); 146 | 147 | @override 148 | void event(LogEvent event) { 149 | _sink?.write(event.line); 150 | if (!event.hasNewLine) { 151 | _sink?.writeln(); 152 | } 153 | } 154 | 155 | // Clear current log file and start appending 156 | Future _truncate() async { 157 | final oldSink = _sink; 158 | _sink = null; 159 | 160 | await oldSink?.close(); 161 | _sink = file.openWrite(mode: FileMode.writeOnly); 162 | } 163 | 164 | @override 165 | void destroy() async { 166 | await _sink?.flush(); 167 | await _sink?.close(); 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /app/lib/common/models/pre_account_state.dart: -------------------------------------------------------------------------------- 1 | import 'package:timeline/bridge_generated.dart'; 2 | import 'package:timeline/common/models/dispatcher.dart'; 3 | 4 | /// State of the app before account is created. 5 | class PreAccountState { 6 | final AppEventDispatcher dispatcher; 7 | final Native native; 8 | 9 | PreAccountState(this.dispatcher, this.native); 10 | 11 | Future createAccount(String? name) async { 12 | final view = await native.createAccount(name: name); 13 | dispatcher.dispatch(OutputEvent.postAccount(accView: view)); 14 | } 15 | 16 | Future getDeviceShare() async { 17 | return native.getDeviceShare(); 18 | } 19 | 20 | syncBackend() { 21 | native.sync(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/lib/features/account_setup/sdk_error_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:timeline/routes.dart'; 3 | 4 | class SdkErrorPage extends StatelessWidget { 5 | const SdkErrorPage({Key? key}) : super(key: key); 6 | 7 | @override 8 | Widget build(BuildContext context) { 9 | return Scaffold( 10 | appBar: AppBar( 11 | elevation: 0, 12 | actions: [ 13 | PopupMenuButton( 14 | tooltip: 'Options', 15 | onSelected: (action) { 16 | if (action == 'logs') { 17 | Navigator.pushNamed(context, BolikRoutes.logs); 18 | } 19 | }, 20 | itemBuilder: (context) => [ 21 | const PopupMenuItem( 22 | value: 'logs', 23 | child: Text('Logs'), 24 | ), 25 | ], 26 | ), 27 | ], 28 | ), 29 | body: const Padding( 30 | padding: EdgeInsets.symmetric(horizontal: 16), 31 | child: Center( 32 | child: Text( 33 | 'Fatal error... Sometimes things fail. Please, restart the app.', 34 | style: TextStyle(fontSize: 18), 35 | ), 36 | ), 37 | ), 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /app/lib/features/card/block_row.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class CardBlockRow extends StatelessWidget { 4 | final Widget child; 5 | final double maxContentWidth; 6 | 7 | const CardBlockRow( 8 | {super.key, required this.child, required this.maxContentWidth}); 9 | 10 | @override 11 | Widget build(BuildContext context) { 12 | return Padding( 13 | padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), 14 | child: ConstrainedBox( 15 | constraints: BoxConstraints(maxWidth: maxContentWidth), 16 | child: Align(alignment: Alignment.topLeft, child: child), 17 | ), 18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/lib/features/card/file_preview.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:open_filex/open_filex.dart'; 5 | import 'package:provider/provider.dart'; 6 | import 'package:timeline/bridge_generated.dart'; 7 | import 'package:timeline/common/models/app_state.dart'; 8 | import 'package:timeline/features/card/card_edit.dart'; 9 | import 'package:url_launcher/url_launcher.dart'; 10 | 11 | class CardFilePreview extends StatefulWidget { 12 | final CardFile file; 13 | 14 | const CardFilePreview(this.file, {Key? key}) : super(key: key); 15 | 16 | @override 17 | State createState() => _FilePreviewState(); 18 | } 19 | 20 | class _FilePreviewState extends State { 21 | bool opening = false; 22 | bool _shouldOpen = false; 23 | 24 | _openFile(String filePath) { 25 | _shouldOpen = false; 26 | if (!opening) { 27 | setState(() => opening = true); 28 | Future.delayed(const Duration(seconds: 5)).then((_) { 29 | if (mounted) { 30 | setState(() => opening = false); 31 | } 32 | }); 33 | 34 | if (Platform.isAndroid || Platform.isIOS) { 35 | OpenFilex.open(filePath, linuxByProcess: true); 36 | } else { 37 | launchUrl(Uri.parse('file:$filePath')); 38 | } 39 | } 40 | } 41 | 42 | @override 43 | Widget build(BuildContext context) { 44 | final blob = context.select( 45 | (value) => value.blobs[widget.file.blobId]); 46 | final cardEdit = context.read(); 47 | final accEdit = context.read(); 48 | final readonly = cardEdit.readonly(accEdit.view); 49 | 50 | Widget icon = const Icon(Icons.attachment); 51 | final downloading = blob?.downloading == true; 52 | if (downloading) { 53 | icon = const CircularProgressIndicator(); 54 | } else if (opening) { 55 | icon = const Icon(Icons.hourglass_empty); 56 | } 57 | 58 | // File is downloaded --> open 59 | if (blob?.path != null && _shouldOpen) { 60 | WidgetsBinding.instance.addPostFrameCallback((_) { 61 | _openFile(blob!.path!); 62 | }); 63 | } 64 | 65 | final card = Card( 66 | child: ListTile( 67 | onTap: () { 68 | // Remove focus from the editor 69 | // FocusManager.instance.primaryFocus?.unfocus(); 70 | 71 | if (downloading || opening) { 72 | return; 73 | } 74 | 75 | // We have the path --> open 76 | if (blob?.path != null) { 77 | _openFile(blob!.path!); 78 | return; 79 | } 80 | 81 | // Download the file and open it after 82 | final cardBlobs = context.read(); 83 | _shouldOpen = true; 84 | cardBlobs.downloadFile(widget.file); 85 | }, 86 | leading: icon, 87 | title: Text(widget.file.name ?? 'No name'), 88 | subtitle: Text(_fileSizeDisplay(widget.file.sizeBytes)), 89 | trailing: readonly 90 | ? null 91 | : PopupMenuButton( 92 | tooltip: 'Options', 93 | onSelected: (action) { 94 | final cardEdit = context.read(); 95 | if (action == 'delete') { 96 | cardEdit.removeFile(widget.file); 97 | } 98 | }, 99 | itemBuilder: (context) => const [ 100 | PopupMenuItem(value: 'delete', child: Text('Delete')), 101 | ], 102 | ), 103 | ), 104 | ); 105 | 106 | return GestureDetector( 107 | onTapUp: (details) { 108 | // Add this handler to prevent tap being forwared to the editor 109 | }, 110 | child: card, 111 | ); 112 | } 113 | } 114 | 115 | String _fileSizeDisplay(int sizeBytes) { 116 | const divider = 1024; 117 | if (sizeBytes < divider) { 118 | return '$sizeBytes B'; 119 | } 120 | 121 | final kBytes = sizeBytes / divider; 122 | if (kBytes < divider) { 123 | return '${kBytes.toStringAsFixed(1)} KB'; 124 | } 125 | 126 | final mBytes = kBytes / divider; 127 | if (mBytes < divider) { 128 | return '${mBytes.toStringAsFixed(1)} MB'; 129 | } 130 | 131 | final gBytes = kBytes / divider; 132 | return '${gBytes.toStringAsFixed(1)} GB'; 133 | } 134 | -------------------------------------------------------------------------------- /app/lib/features/card/image_preview.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:path/path.dart' as p; 5 | import 'package:provider/provider.dart'; 6 | import 'package:timeline/bridge_generated.dart'; 7 | import 'package:timeline/features/card/card_edit.dart'; 8 | import 'package:timeline/features/card/media_page.dart'; 9 | 10 | class CardImagePreview extends StatefulWidget { 11 | final CardFile file; 12 | final double columnWidth; 13 | 14 | const CardImagePreview( 15 | {super.key, required this.file, required this.columnWidth}); 16 | 17 | @override 18 | State createState() => _CardImagePreviewState(); 19 | } 20 | 21 | class _CardImagePreviewState extends State { 22 | double _imageScale = 1.0; 23 | 24 | @override 25 | void initState() { 26 | super.initState(); 27 | _loadFile(); 28 | } 29 | 30 | Future _loadFile() async { 31 | final cardBlobs = context.read(); 32 | cardBlobs.downloadFile(widget.file); 33 | } 34 | 35 | @override 36 | Widget build(BuildContext context) { 37 | final blob = context.select( 38 | (value) => value.blobs[widget.file.blobId]); 39 | final cardEdit = context.read(); 40 | 41 | ImageProvider? provider; 42 | if (blob?.path != null) { 43 | // Display original file 44 | final width = widget.columnWidth * 2.2; 45 | provider = 46 | ResizeImage(FileImage(File(blob!.path!)), width: width.toInt()); 47 | } 48 | 49 | if (provider == null) { 50 | return Container( 51 | color: Colors.grey[200], 52 | child: Stack( 53 | fit: StackFit.expand, 54 | alignment: Alignment.center, 55 | children: [ 56 | Icon(Icons.image, size: 32, color: Colors.grey[800]), 57 | const Positioned( 58 | top: 16, 59 | right: 16, 60 | child: SizedBox( 61 | width: 20, 62 | height: 20, 63 | child: CircularProgressIndicator(strokeWidth: 2), 64 | ), 65 | ), 66 | ], 67 | ), 68 | ); 69 | } 70 | 71 | final effectiveImageScale = _imageScale; 72 | return InkWell( 73 | onHover: (hovered) { 74 | setState(() => _imageScale = hovered ? 1.2 : 1.0); 75 | }, 76 | onTapUp: (details) { 77 | // Add this handler to prevent tap being forwared to the editor 78 | }, 79 | onTap: () async { 80 | // Remove focus from the editor 81 | // FocusManager.instance.primaryFocus?.unfocus(); 82 | 83 | final cardBlobs = context.read(); 84 | 85 | if (blob?.path != null) { 86 | final provider = FileImage(File(blob!.path!)); 87 | await precacheImage(provider, context); 88 | } 89 | 90 | Navigator.push( 91 | context, 92 | MaterialPageRoute( 93 | builder: (context) => MultiProvider( 94 | providers: [ 95 | ChangeNotifierProvider.value(value: cardEdit), 96 | ChangeNotifierProvider.value(value: cardBlobs), 97 | ], 98 | child: MediaPage(selectedBlobId: widget.file.blobId), 99 | ), 100 | )); 101 | 102 | // Remove focus from the editor 103 | // FocusManager.instance.primaryFocus?.unfocus(); 104 | }, 105 | child: Stack( 106 | fit: StackFit.expand, 107 | children: [ 108 | Container( 109 | clipBehavior: Clip.hardEdge, 110 | decoration: const BoxDecoration(), 111 | child: AnimatedScale( 112 | scale: effectiveImageScale, 113 | duration: const Duration(milliseconds: 700), 114 | child: Hero( 115 | tag: widget.file.blobId, 116 | child: Image( 117 | image: provider, 118 | height: widget.columnWidth, 119 | semanticLabel: widget.file.name, 120 | fit: BoxFit.cover, 121 | gaplessPlayback: true, 122 | ), 123 | ), 124 | ), 125 | ), 126 | ], 127 | ), 128 | ); 129 | } 130 | } 131 | 132 | bool isImageFile(String? name) { 133 | if (name == null) return false; 134 | final ext = p.extension(name).toLowerCase(); 135 | final supported = ['.jpg', '.jpeg', '.gif', '.png', '.webp']; 136 | if (!Platform.isLinux) { 137 | supported.add('.heic'); 138 | } 139 | 140 | return supported.contains(ext); 141 | } 142 | -------------------------------------------------------------------------------- /app/lib/features/card/labels.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:provider/provider.dart'; 3 | import 'package:timeline/common/models/app_state.dart'; 4 | import 'package:timeline/features/card/card_edit.dart'; 5 | import 'package:timeline/routes.dart'; 6 | 7 | class CardLabelsPicker extends StatefulWidget { 8 | const CardLabelsPicker({super.key}); 9 | 10 | @override 11 | State createState() => _CardLabelsPickerState(); 12 | } 13 | 14 | class _CardLabelsPickerState extends State { 15 | final _textController = TextEditingController(); 16 | 17 | @override 18 | void dispose() { 19 | _textController.dispose(); 20 | super.dispose(); 21 | } 22 | 23 | @override 24 | Widget build(BuildContext context) { 25 | final accEdit = context.watch(); 26 | final cardEdit = context.watch(); 27 | 28 | final accountLabels = accEdit.view.labels; 29 | final text = _textController.text; 30 | final textFilter = text.toLowerCase(); 31 | final labelMissing = 32 | accountLabels.where((l) => l.name.toLowerCase() == textFilter).isEmpty; 33 | final showAddLabel = text.isNotEmpty && labelMissing; 34 | final filteredLabels = 35 | accountLabels.where((l) => l.name.toLowerCase().contains(textFilter)); 36 | 37 | final theme = Theme.of(context); 38 | 39 | return Scaffold( 40 | appBar: AppBar( 41 | leading: IconButton( 42 | onPressed: () { 43 | BolikRoutes.goBack(); 44 | }, 45 | tooltip: 'Cancel', 46 | icon: const Icon(Icons.close), 47 | ), 48 | title: TextField( 49 | controller: _textController, 50 | decoration: const InputDecoration( 51 | hintText: 'Enter label name', 52 | ), 53 | onChanged: (content) { 54 | // Rebuild the widget to filter list items 55 | setState(() {}); 56 | }, 57 | textCapitalization: TextCapitalization.sentences, 58 | ), 59 | ), 60 | body: Column( 61 | children: [ 62 | if (showAddLabel) 63 | ListTile( 64 | onTap: () async { 65 | _textController.text = ''; 66 | final label = await accEdit.createLabel(name: text); 67 | cardEdit.addLabel(label.id); 68 | }, 69 | leading: Icon(Icons.add, color: theme.colorScheme.primary), 70 | title: Text( 71 | 'Create "$text"', 72 | style: TextStyle(color: theme.colorScheme.primary), 73 | ), 74 | hoverColor: theme.colorScheme.primary.withOpacity(0.08), 75 | ), 76 | const SizedBox(height: 8), 77 | Expanded( 78 | child: ListView( 79 | // shrinkWrap: true, 80 | children: filteredLabels 81 | .map((l) => CheckboxListTile( 82 | title: Text(l.name), 83 | secondary: const Icon(Icons.label_outline), 84 | value: cardEdit.card.labels 85 | .any((cardLabel) => cardLabel.id == l.id), 86 | onChanged: (checked) { 87 | checked == true 88 | ? cardEdit.addLabel(l.id) 89 | : cardEdit.removeLabel(l.id); 90 | })) 91 | .toList(), 92 | ), 93 | ), 94 | ], 95 | ), 96 | ); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /app/lib/features/card/move_to_bin.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:provider/provider.dart'; 3 | import 'package:timeline/bridge_generated.dart'; 4 | import 'package:timeline/common/models/app_state.dart'; 5 | import 'package:timeline/features/card/card_edit.dart'; 6 | 7 | class MoveToBinDialog extends StatefulWidget { 8 | const MoveToBinDialog({super.key}); 9 | 10 | @override 11 | State createState() => _MoveToBinDialogState(); 12 | } 13 | 14 | class _MoveToBinDialogState extends State { 15 | bool deleteAll = false; 16 | 17 | @override 18 | Widget build(BuildContext context) { 19 | final cardEdit = context.read(); 20 | final accEdit = context.read(); 21 | final acl = cardEdit.card.acl; 22 | final sharedWithOthers = acl.accounts.length > 1; 23 | final isAdmin = acl.accounts.any((e) => 24 | (e.accountId == accEdit.view.id) && (e.rights == AclRights.Admin)); 25 | 26 | return AlertDialog( 27 | title: const Text("Delete card"), 28 | content: Column( 29 | crossAxisAlignment: CrossAxisAlignment.stretch, 30 | mainAxisSize: MainAxisSize.min, 31 | children: [ 32 | const Padding( 33 | padding: EdgeInsets.symmetric(horizontal: 8), 34 | child: Text('Are you sure you want to delete this card?'), 35 | ), 36 | const SizedBox(height: 16), 37 | if (sharedWithOthers && isAdmin) 38 | InkWell( 39 | onTap: () { 40 | setState(() => deleteAll = !deleteAll); 41 | }, 42 | hoverColor: Colors.transparent, 43 | child: Row(children: [ 44 | Checkbox( 45 | value: deleteAll, 46 | onChanged: (value) { 47 | setState(() => deleteAll = value ?? false); 48 | }), 49 | const SizedBox(width: 8), 50 | const Flexible(child: Text('Also delete for other accounts')), 51 | ]), 52 | ), 53 | ], 54 | ), 55 | actions: [ 56 | TextButton( 57 | onPressed: () { 58 | Navigator.pop(context); 59 | }, 60 | child: const Text('Cancel'), 61 | ), 62 | TextButton( 63 | onPressed: () async { 64 | final cardEdit = context.read(); 65 | final appState = context.read(); 66 | if (deleteAll) { 67 | await cardEdit.moveToBinAll(); 68 | } else { 69 | await cardEdit.moveToBin(); 70 | } 71 | appState.timeline.refresh(); 72 | 73 | if (!mounted) return; 74 | // Close dialog 75 | Navigator.pop(context); 76 | // Return to timeline page 77 | Navigator.pop(context); 78 | 79 | ScaffoldMessenger.of(context).showSnackBar( 80 | const SnackBar(content: Text('Card moved to bin'))); 81 | }, 82 | child: const Text('Delete'), 83 | ), 84 | ], 85 | ); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /app/lib/features/labels/acc_labels.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:provider/provider.dart'; 3 | import 'package:timeline/common/models/app_state.dart'; 4 | import 'package:timeline/routes.dart'; 5 | 6 | class AccLabelsPage extends StatefulWidget { 7 | const AccLabelsPage({super.key}); 8 | 9 | @override 10 | State createState() => _AccLabelsPageState(); 11 | } 12 | 13 | class _AccLabelsPageState extends State { 14 | @override 15 | Widget build(BuildContext context) { 16 | final accEdit = context.watch(); 17 | final accountLabels = accEdit.view.labels; 18 | 19 | return Scaffold( 20 | appBar: AppBar( 21 | leading: IconButton( 22 | onPressed: () { 23 | BolikRoutes.goBack(); 24 | }, 25 | tooltip: 'Cancel', 26 | icon: const Icon(Icons.close), 27 | ), 28 | title: const Text('Edit labels'), 29 | ), 30 | body: ListView( 31 | children: accountLabels 32 | .map( 33 | (l) => ListTile( 34 | leading: const Icon(Icons.label_outline), 35 | title: Text(l.name), 36 | trailing: IconButton( 37 | onPressed: () async { 38 | accEdit.deleteLabel(labelId: l.id); 39 | }, 40 | icon: const Icon(Icons.delete), 41 | ), 42 | ), 43 | ) 44 | .toList(), 45 | ), 46 | ); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:provider/provider.dart'; 3 | import 'package:timeline/bridge_generated.dart'; 4 | import 'package:timeline/common/models/dispatcher.dart'; 5 | import 'package:timeline/common/models/phase_info.dart'; 6 | import 'package:timeline/routes.dart'; 7 | 8 | void main() async { 9 | WidgetsFlutterBinding.ensureInitialized(); 10 | 11 | final info = await setupDevice(AppEventDispatcher()); 12 | runApp(BolikApp(info: info)); 13 | } 14 | 15 | class BolikApp extends StatefulWidget { 16 | final DevicePhaseInfo info; 17 | const BolikApp({super.key, required this.info}); 18 | 19 | @override 20 | State createState() => _BolikAppState(); 21 | } 22 | 23 | class _BolikAppState extends State { 24 | @override 25 | void initState() { 26 | super.initState(); 27 | widget.info.dispatcher.addListener(_eventListener); 28 | } 29 | 30 | @override 31 | void dispose() { 32 | super.dispose(); 33 | widget.info.dispatcher.removeListener(_eventListener); 34 | } 35 | 36 | void _eventListener(OutputEvent event) async { 37 | if (event is OutputEvent_PostAccount) { 38 | if (widget.info.appState.value == null) { 39 | widget.info.onPostAccount(event.accView); 40 | 41 | // Replace all routes with a timeline page 42 | BolikRoutes.rootNav.currentState! 43 | .pushNamedAndRemoveUntil(BolikRoutes.timeline, (route) => false); 44 | } 45 | } else if (event is OutputEvent_LogOut) { 46 | await widget.info.onLogout(); 47 | 48 | // Replace all routes with a index page 49 | BolikRoutes.rootNav.currentState! 50 | .pushNamedAndRemoveUntil(BolikRoutes.index, (route) => false); 51 | } 52 | } 53 | 54 | Widget _injectProviders({required Widget child}) { 55 | return MultiProvider(providers: [ 56 | ValueListenableProvider.value(value: widget.info.preAccount), 57 | ValueListenableProvider.value(value: widget.info.appState), 58 | ChangeNotifierProvider.value(value: widget.info.account), 59 | ], child: child); 60 | } 61 | 62 | @override 63 | Widget build(BuildContext context) { 64 | String initialRoute = BolikRoutes.index; 65 | if (widget.info.sdkFatalError) { 66 | initialRoute = BolikRoutes.sdkError; 67 | } else if (widget.info.appState.value != null) { 68 | initialRoute = BolikRoutes.timeline; 69 | } 70 | 71 | return _injectProviders( 72 | child: MaterialApp( 73 | title: 'Bolik Timeline', 74 | theme: ThemeData( 75 | colorSchemeSeed: Colors.orange, 76 | useMaterial3: true, 77 | ), 78 | initialRoute: initialRoute, 79 | navigatorKey: BolikRoutes.rootNav, 80 | onGenerateInitialRoutes: (initialRoute) { 81 | final route = rootOnGenerateRoute(RouteSettings(name: initialRoute))!; 82 | return [route]; 83 | }, 84 | onGenerateRoute: rootOnGenerateRoute, 85 | ), 86 | ); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /app/linux/.gitignore: -------------------------------------------------------------------------------- 1 | flutter/ephemeral 2 | -------------------------------------------------------------------------------- /app/linux/flutter/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | # This file controls Flutter-level build steps. It should not be edited. 2 | cmake_minimum_required(VERSION 3.10) 3 | 4 | set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") 5 | 6 | # Configuration provided via flutter tool. 7 | include(${EPHEMERAL_DIR}/generated_config.cmake) 8 | 9 | # TODO: Move the rest of this into files in ephemeral. See 10 | # https://github.com/flutter/flutter/issues/57146. 11 | 12 | # Serves the same purpose as list(TRANSFORM ... PREPEND ...), 13 | # which isn't available in 3.10. 14 | function(list_prepend LIST_NAME PREFIX) 15 | set(NEW_LIST "") 16 | foreach(element ${${LIST_NAME}}) 17 | list(APPEND NEW_LIST "${PREFIX}${element}") 18 | endforeach(element) 19 | set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) 20 | endfunction() 21 | 22 | # === Flutter Library === 23 | # System-level dependencies. 24 | find_package(PkgConfig REQUIRED) 25 | pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) 26 | pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) 27 | pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) 28 | 29 | set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") 30 | 31 | # Published to parent scope for install step. 32 | set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) 33 | set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) 34 | set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) 35 | set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) 36 | 37 | list(APPEND FLUTTER_LIBRARY_HEADERS 38 | "fl_basic_message_channel.h" 39 | "fl_binary_codec.h" 40 | "fl_binary_messenger.h" 41 | "fl_dart_project.h" 42 | "fl_engine.h" 43 | "fl_json_message_codec.h" 44 | "fl_json_method_codec.h" 45 | "fl_message_codec.h" 46 | "fl_method_call.h" 47 | "fl_method_channel.h" 48 | "fl_method_codec.h" 49 | "fl_method_response.h" 50 | "fl_plugin_registrar.h" 51 | "fl_plugin_registry.h" 52 | "fl_standard_message_codec.h" 53 | "fl_standard_method_codec.h" 54 | "fl_string_codec.h" 55 | "fl_value.h" 56 | "fl_view.h" 57 | "flutter_linux.h" 58 | ) 59 | list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") 60 | add_library(flutter INTERFACE) 61 | target_include_directories(flutter INTERFACE 62 | "${EPHEMERAL_DIR}" 63 | ) 64 | target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") 65 | target_link_libraries(flutter INTERFACE 66 | PkgConfig::GTK 67 | PkgConfig::GLIB 68 | PkgConfig::GIO 69 | ) 70 | add_dependencies(flutter flutter_assemble) 71 | 72 | # === Flutter tool backend === 73 | # _phony_ is a non-existent file to force this command to run every time, 74 | # since currently there's no way to get a full input/output list from the 75 | # flutter tool. 76 | add_custom_command( 77 | OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} 78 | ${CMAKE_CURRENT_BINARY_DIR}/_phony_ 79 | COMMAND ${CMAKE_COMMAND} -E env 80 | ${FLUTTER_TOOL_ENVIRONMENT} 81 | "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" 82 | ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} 83 | VERBATIM 84 | ) 85 | add_custom_target(flutter_assemble DEPENDS 86 | "${FLUTTER_LIBRARY}" 87 | ${FLUTTER_LIBRARY_HEADERS} 88 | ) 89 | -------------------------------------------------------------------------------- /app/linux/flutter/generated_plugin_registrant.cc: -------------------------------------------------------------------------------- 1 | // 2 | // Generated file. Do not edit. 3 | // 4 | 5 | // clang-format off 6 | 7 | #include "generated_plugin_registrant.h" 8 | 9 | #include 10 | 11 | void fl_register_plugins(FlPluginRegistry* registry) { 12 | g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = 13 | fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); 14 | url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); 15 | } 16 | -------------------------------------------------------------------------------- /app/linux/flutter/generated_plugin_registrant.h: -------------------------------------------------------------------------------- 1 | // 2 | // Generated file. Do not edit. 3 | // 4 | 5 | // clang-format off 6 | 7 | #ifndef GENERATED_PLUGIN_REGISTRANT_ 8 | #define GENERATED_PLUGIN_REGISTRANT_ 9 | 10 | #include 11 | 12 | // Registers Flutter plugins. 13 | void fl_register_plugins(FlPluginRegistry* registry); 14 | 15 | #endif // GENERATED_PLUGIN_REGISTRANT_ 16 | -------------------------------------------------------------------------------- /app/linux/flutter/generated_plugins.cmake: -------------------------------------------------------------------------------- 1 | # 2 | # Generated file, do not edit. 3 | # 4 | 5 | list(APPEND FLUTTER_PLUGIN_LIST 6 | url_launcher_linux 7 | ) 8 | 9 | list(APPEND FLUTTER_FFI_PLUGIN_LIST 10 | ) 11 | 12 | set(PLUGIN_BUNDLED_LIBRARIES) 13 | 14 | foreach(plugin ${FLUTTER_PLUGIN_LIST}) 15 | add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) 16 | target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) 17 | list(APPEND PLUGIN_BUNDLED_LIBRARIES $) 18 | list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) 19 | endforeach(plugin) 20 | 21 | foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) 22 | add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) 23 | list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) 24 | endforeach(ffi_plugin) 25 | -------------------------------------------------------------------------------- /app/linux/main.cc: -------------------------------------------------------------------------------- 1 | #include "my_application.h" 2 | 3 | int main(int argc, char** argv) { 4 | g_autoptr(MyApplication) app = my_application_new(); 5 | return g_application_run(G_APPLICATION(app), argc, argv); 6 | } 7 | -------------------------------------------------------------------------------- /app/linux/my_application.cc: -------------------------------------------------------------------------------- 1 | #include "my_application.h" 2 | 3 | #include 4 | #ifdef GDK_WINDOWING_X11 5 | #include 6 | #endif 7 | 8 | #include "flutter/generated_plugin_registrant.h" 9 | 10 | struct _MyApplication { 11 | GtkApplication parent_instance; 12 | char** dart_entrypoint_arguments; 13 | }; 14 | 15 | G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) 16 | 17 | // Implements GApplication::activate. 18 | static void my_application_activate(GApplication* application) { 19 | MyApplication* self = MY_APPLICATION(application); 20 | GtkWindow* window = 21 | GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); 22 | 23 | // Use a header bar when running in GNOME as this is the common style used 24 | // by applications and is the setup most users will be using (e.g. Ubuntu 25 | // desktop). 26 | // If running on X and not using GNOME then just use a traditional title bar 27 | // in case the window manager does more exotic layout, e.g. tiling. 28 | // If running on Wayland assume the header bar will work (may need changing 29 | // if future cases occur). 30 | gboolean use_header_bar = TRUE; 31 | #ifdef GDK_WINDOWING_X11 32 | GdkScreen* screen = gtk_window_get_screen(window); 33 | if (GDK_IS_X11_SCREEN(screen)) { 34 | const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); 35 | if (g_strcmp0(wm_name, "GNOME Shell") != 0) { 36 | use_header_bar = FALSE; 37 | } 38 | } 39 | #endif 40 | if (use_header_bar) { 41 | GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); 42 | gtk_widget_show(GTK_WIDGET(header_bar)); 43 | gtk_header_bar_set_title(header_bar, "timeline"); 44 | gtk_header_bar_set_show_close_button(header_bar, TRUE); 45 | gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); 46 | } else { 47 | gtk_window_set_title(window, "timeline"); 48 | } 49 | 50 | gtk_window_set_default_size(window, 1280, 720); 51 | gtk_widget_show(GTK_WIDGET(window)); 52 | 53 | g_autoptr(FlDartProject) project = fl_dart_project_new(); 54 | fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); 55 | 56 | FlView* view = fl_view_new(project); 57 | gtk_widget_show(GTK_WIDGET(view)); 58 | gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); 59 | 60 | fl_register_plugins(FL_PLUGIN_REGISTRY(view)); 61 | 62 | gtk_widget_grab_focus(GTK_WIDGET(view)); 63 | } 64 | 65 | // Implements GApplication::local_command_line. 66 | static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { 67 | MyApplication* self = MY_APPLICATION(application); 68 | // Strip out the first argument as it is the binary name. 69 | self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); 70 | 71 | g_autoptr(GError) error = nullptr; 72 | if (!g_application_register(application, nullptr, &error)) { 73 | g_warning("Failed to register: %s", error->message); 74 | *exit_status = 1; 75 | return TRUE; 76 | } 77 | 78 | g_application_activate(application); 79 | *exit_status = 0; 80 | 81 | return TRUE; 82 | } 83 | 84 | // Implements GObject::dispose. 85 | static void my_application_dispose(GObject* object) { 86 | MyApplication* self = MY_APPLICATION(object); 87 | g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); 88 | G_OBJECT_CLASS(my_application_parent_class)->dispose(object); 89 | } 90 | 91 | static void my_application_class_init(MyApplicationClass* klass) { 92 | G_APPLICATION_CLASS(klass)->activate = my_application_activate; 93 | G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; 94 | G_OBJECT_CLASS(klass)->dispose = my_application_dispose; 95 | } 96 | 97 | static void my_application_init(MyApplication* self) {} 98 | 99 | MyApplication* my_application_new() { 100 | return MY_APPLICATION(g_object_new(my_application_get_type(), 101 | "application-id", APPLICATION_ID, 102 | "flags", G_APPLICATION_NON_UNIQUE, 103 | nullptr)); 104 | } 105 | -------------------------------------------------------------------------------- /app/linux/my_application.h: -------------------------------------------------------------------------------- 1 | #ifndef FLUTTER_MY_APPLICATION_H_ 2 | #define FLUTTER_MY_APPLICATION_H_ 3 | 4 | #include 5 | 6 | G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, 7 | GtkApplication) 8 | 9 | /** 10 | * my_application_new: 11 | * 12 | * Creates a new Flutter-based application. 13 | * 14 | * Returns: a new #MyApplication. 15 | */ 16 | MyApplication* my_application_new(); 17 | 18 | #endif // FLUTTER_MY_APPLICATION_H_ 19 | -------------------------------------------------------------------------------- /app/linux/rust.cmake: -------------------------------------------------------------------------------- 1 | # We include Corrosion inline here, but ideally in a project with 2 | # many dependencies we would need to install Corrosion on the system. 3 | # See instructions on https://github.com/AndrewGaspar/corrosion#cmake-install 4 | # Once done, uncomment this line: 5 | # find_package(Corrosion REQUIRED) 6 | 7 | # Install Corrosion 8 | include(FetchContent) 9 | FetchContent_Declare( 10 | Corrosion 11 | GIT_REPOSITORY https://github.com/AndrewGaspar/corrosion.git 12 | #GIT_TAG origin/master 13 | GIT_TAG v0.3.0 14 | ) 15 | FetchContent_MakeAvailable(Corrosion) 16 | 17 | set(Rust_TOOLCHAIN "nightly-x86_64-unknown-linux-gnu") 18 | corrosion_import_crate(MANIFEST_PATH ../native/Cargo.toml) 19 | 20 | # Flutter-specific 21 | set(CRATE_NAME "native") 22 | target_link_libraries(${BINARY_NAME} PRIVATE ${CRATE_NAME}) 23 | list(APPEND PLUGIN_BUNDLED_LIBRARIES $) 24 | -------------------------------------------------------------------------------- /app/macos/.gitignore: -------------------------------------------------------------------------------- 1 | # Flutter-related 2 | **/Flutter/ephemeral/ 3 | **/Pods/ 4 | 5 | # Xcode-related 6 | **/dgph 7 | **/xcuserdata/ 8 | -------------------------------------------------------------------------------- /app/macos/Flutter/Flutter-Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" 2 | #include "ephemeral/Flutter-Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /app/macos/Flutter/Flutter-Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" 2 | #include "ephemeral/Flutter-Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /app/macos/Flutter/GeneratedPluginRegistrant.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Generated file. Do not edit. 3 | // 4 | 5 | import FlutterMacOS 6 | import Foundation 7 | 8 | import device_info_plus 9 | import path_provider_foundation 10 | import url_launcher_macos 11 | 12 | func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { 13 | DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) 14 | PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) 15 | UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) 16 | } 17 | -------------------------------------------------------------------------------- /app/macos/Podfile: -------------------------------------------------------------------------------- 1 | platform :osx, '10.14' 2 | 3 | # CocoaPods analytics sends network stats synchronously affecting flutter build latency. 4 | ENV['COCOAPODS_DISABLE_STATS'] = 'true' 5 | 6 | project 'Runner', { 7 | 'Debug' => :debug, 8 | 'Profile' => :release, 9 | 'Release' => :release, 10 | } 11 | 12 | def flutter_root 13 | generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) 14 | unless File.exist?(generated_xcode_build_settings_path) 15 | raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" 16 | end 17 | 18 | File.foreach(generated_xcode_build_settings_path) do |line| 19 | matches = line.match(/FLUTTER_ROOT\=(.*)/) 20 | return matches[1].strip if matches 21 | end 22 | raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" 23 | end 24 | 25 | require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) 26 | 27 | flutter_macos_podfile_setup 28 | 29 | target 'Runner' do 30 | use_frameworks! 31 | use_modular_headers! 32 | 33 | flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) 34 | end 35 | 36 | post_install do |installer| 37 | installer.pods_project.targets.each do |target| 38 | flutter_additional_macos_build_settings(target) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /app/macos/Podfile.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - device_info_plus (0.0.1): 3 | - FlutterMacOS 4 | - FlutterMacOS (1.0.0) 5 | - path_provider_foundation (0.0.1): 6 | - Flutter 7 | - FlutterMacOS 8 | - url_launcher_macos (0.0.1): 9 | - FlutterMacOS 10 | 11 | DEPENDENCIES: 12 | - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) 13 | - FlutterMacOS (from `Flutter/ephemeral`) 14 | - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/macos`) 15 | - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) 16 | 17 | EXTERNAL SOURCES: 18 | device_info_plus: 19 | :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos 20 | FlutterMacOS: 21 | :path: Flutter/ephemeral 22 | path_provider_foundation: 23 | :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/macos 24 | url_launcher_macos: 25 | :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos 26 | 27 | SPEC CHECKSUMS: 28 | device_info_plus: 5401765fde0b8d062a2f8eb65510fb17e77cf07f 29 | FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 30 | path_provider_foundation: 37748e03f12783f9de2cb2c4eadfaa25fe6d4852 31 | url_launcher_macos: c04e4fa86382d4f94f6b38f14625708be3ae52e2 32 | 33 | PODFILE CHECKSUM: 353c8bcc5d5b0994e508d035b5431cfe18c1dea7 34 | 35 | COCOAPODS: 1.11.3 36 | -------------------------------------------------------------------------------- /app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 37 | 38 | 39 | 40 | 41 | 42 | 52 | 54 | 60 | 61 | 62 | 63 | 69 | 71 | 77 | 78 | 79 | 80 | 82 | 83 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /app/macos/Runner.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /app/macos/Runner/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | import FlutterMacOS 3 | 4 | @NSApplicationMain 5 | class AppDelegate: FlutterAppDelegate { 6 | override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { 7 | // We need to show XCode that that code is used so that it is not stripped. 8 | // http://cjycode.com/flutter_rust_bridge/integrate/ios_headers.html 9 | let dummy = dummy_method_to_enforce_bundling() 10 | print(dummy) 11 | 12 | return true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "version": 1, 4 | "author": "xcode" 5 | }, 6 | "images": [ 7 | { 8 | "size": "16x16", 9 | "idiom": "mac", 10 | "filename": "app_icon_16.png", 11 | "scale": "1x" 12 | }, 13 | { 14 | "size": "16x16", 15 | "idiom": "mac", 16 | "filename": "app_icon_32.png", 17 | "scale": "2x" 18 | }, 19 | { 20 | "size": "32x32", 21 | "idiom": "mac", 22 | "filename": "app_icon_32.png", 23 | "scale": "1x" 24 | }, 25 | { 26 | "size": "32x32", 27 | "idiom": "mac", 28 | "filename": "app_icon_64.png", 29 | "scale": "2x" 30 | }, 31 | { 32 | "size": "128x128", 33 | "idiom": "mac", 34 | "filename": "app_icon_128.png", 35 | "scale": "1x" 36 | }, 37 | { 38 | "size": "128x128", 39 | "idiom": "mac", 40 | "filename": "app_icon_256.png", 41 | "scale": "2x" 42 | }, 43 | { 44 | "size": "256x256", 45 | "idiom": "mac", 46 | "filename": "app_icon_256.png", 47 | "scale": "1x" 48 | }, 49 | { 50 | "size": "256x256", 51 | "idiom": "mac", 52 | "filename": "app_icon_512.png", 53 | "scale": "2x" 54 | }, 55 | { 56 | "size": "512x512", 57 | "idiom": "mac", 58 | "filename": "app_icon_512.png", 59 | "scale": "1x" 60 | }, 61 | { 62 | "size": "512x512", 63 | "idiom": "mac", 64 | "filename": "app_icon_1024.png", 65 | "scale": "2x" 66 | } 67 | ] 68 | } -------------------------------------------------------------------------------- /app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaynetro/timeline/f83b9f5d5e53f78ccdced44efd78c32647920b42/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png -------------------------------------------------------------------------------- /app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaynetro/timeline/f83b9f5d5e53f78ccdced44efd78c32647920b42/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png -------------------------------------------------------------------------------- /app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaynetro/timeline/f83b9f5d5e53f78ccdced44efd78c32647920b42/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png -------------------------------------------------------------------------------- /app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaynetro/timeline/f83b9f5d5e53f78ccdced44efd78c32647920b42/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png -------------------------------------------------------------------------------- /app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaynetro/timeline/f83b9f5d5e53f78ccdced44efd78c32647920b42/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png -------------------------------------------------------------------------------- /app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaynetro/timeline/f83b9f5d5e53f78ccdced44efd78c32647920b42/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png -------------------------------------------------------------------------------- /app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaynetro/timeline/f83b9f5d5e53f78ccdced44efd78c32647920b42/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png -------------------------------------------------------------------------------- /app/macos/Runner/Configs/AppInfo.xcconfig: -------------------------------------------------------------------------------- 1 | // Application-level settings for the Runner target. 2 | // 3 | // This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the 4 | // future. If not, the values below would default to using the project name when this becomes a 5 | // 'flutter create' template. 6 | 7 | // The application's name. By default this is also the title of the Flutter window. 8 | PRODUCT_NAME = Bolik Timeline 9 | 10 | // The application's bundle identifier 11 | PRODUCT_BUNDLE_IDENTIFIER = tech.bolik.timelineapp 12 | 13 | // The copyright displayed in application information 14 | PRODUCT_COPYRIGHT = Copyright © 2023 tech.bolik. All rights reserved. 15 | -------------------------------------------------------------------------------- /app/macos/Runner/Configs/Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include "../../Flutter/Flutter-Debug.xcconfig" 2 | #include "Warnings.xcconfig" 3 | -------------------------------------------------------------------------------- /app/macos/Runner/Configs/Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include "../../Flutter/Flutter-Release.xcconfig" 2 | #include "Warnings.xcconfig" 3 | -------------------------------------------------------------------------------- /app/macos/Runner/Configs/Warnings.xcconfig: -------------------------------------------------------------------------------- 1 | WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings 2 | GCC_WARN_UNDECLARED_SELECTOR = YES 3 | CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES 4 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE 5 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES 6 | CLANG_WARN_PRAGMA_PACK = YES 7 | CLANG_WARN_STRICT_PROTOTYPES = YES 8 | CLANG_WARN_COMMA = YES 9 | GCC_WARN_STRICT_SELECTOR_MATCH = YES 10 | CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES 11 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES 12 | GCC_WARN_SHADOW = YES 13 | CLANG_WARN_UNREACHABLE_CODE = YES 14 | -------------------------------------------------------------------------------- /app/macos/Runner/DebugProfile.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.cs.allow-jit 8 | 9 | com.apple.security.network.client 10 | 11 | com.apple.security.network.server 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /app/macos/Runner/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIconFile 10 | 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | $(FLUTTER_BUILD_NAME) 21 | CFBundleVersion 22 | $(FLUTTER_BUILD_NUMBER) 23 | LSMinimumSystemVersion 24 | $(MACOSX_DEPLOYMENT_TARGET) 25 | NSHumanReadableCopyright 26 | $(PRODUCT_COPYRIGHT) 27 | NSMainNibFile 28 | MainMenu 29 | NSPrincipalClass 30 | NSApplication 31 | LSApplicationCategoryType 32 | public.app-category.productivity 33 | 34 | 35 | -------------------------------------------------------------------------------- /app/macos/Runner/MainFlutterWindow.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | import FlutterMacOS 3 | 4 | class MainFlutterWindow: NSWindow { 5 | override func awakeFromNib() { 6 | let flutterViewController = FlutterViewController.init() 7 | let windowFrame = self.frame 8 | self.contentViewController = flutterViewController 9 | self.setFrame(windowFrame, display: true) 10 | 11 | RegisterGeneratedPlugins(registry: flutterViewController) 12 | 13 | super.awakeFromNib() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/macos/Runner/Release.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.network.client 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /app/native/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "native" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [lib] 9 | crate-type = ["staticlib", "cdylib"] 10 | 11 | [dependencies] 12 | anyhow = "1" 13 | bolik_sdk = { path = "../../bolik_sdk" } 14 | bolik_proto = { path = "../../bolik_proto" } 15 | # Override blake3 dependency from multihash to use only Rust code. 16 | blake3 = { version = "*", features = ["pure"] } 17 | chrono = "^0.4" 18 | flutter_rust_bridge = "1.65.0" 19 | image = { version = "0.24", default-features = false, features = ["jpeg"] } 20 | rqrr = "0.5" 21 | tokio = { version = "1", features = ["rt", "sync"] } 22 | tracing = { workspace = true } 23 | tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "std"], default-features = false } 24 | # Try to override dependency of a dependency for easier cross-compiling 25 | # openssl = { version = "0.10", features = ["vendored"] } 26 | -------------------------------------------------------------------------------- /app/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 | 3 | mod api; 4 | mod qr; 5 | -------------------------------------------------------------------------------- /app/native/src/qr.rs: -------------------------------------------------------------------------------- 1 | use std::io::Cursor; 2 | 3 | use anyhow::{bail, Result}; 4 | use image::io::Reader as ImageReader; 5 | use image::ImageFormat; 6 | use image::{DynamicImage, GrayImage, RgbaImage}; 7 | 8 | use crate::api::PixelFormat; 9 | 10 | pub fn scan_qr_code( 11 | width: u32, 12 | height: u32, 13 | format: PixelFormat, 14 | buf: Vec, 15 | ) -> Result> { 16 | let img = match format { 17 | PixelFormat::BGRA8888 => read_bgra8888(width, height, buf)?, 18 | PixelFormat::JPEG => read_jpeg(buf)?, 19 | }; 20 | find_qr_code(img.to_luma8()) 21 | } 22 | 23 | fn find_qr_code(img: GrayImage) -> Result> { 24 | let mut img = rqrr::PreparedImage::prepare(img); 25 | let grids = img.detect_grids(); 26 | if grids.is_empty() { 27 | Ok(None) 28 | } else { 29 | let (_meta, content) = grids[0].decode()?; 30 | Ok(Some(content)) 31 | } 32 | } 33 | 34 | // https://users.rust-lang.org/t/converting-a-bgra-u8-to-rgb-u8-n-for-images/67938/8?u=cad97 35 | fn convert_bgra(width: u32, height: u32, mut bgra: Vec) -> Option { 36 | for src in bgra.chunks_exact_mut(4) { 37 | let (blue, green, red, alpha) = (src[0], src[1], src[2], src[3]); 38 | src[0] = red; 39 | src[1] = green; 40 | src[2] = blue; 41 | src[3] = alpha; 42 | } 43 | RgbaImage::from_raw(width, height, bgra) 44 | } 45 | 46 | fn read_bgra8888(width: u32, height: u32, buf: Vec) -> Result { 47 | let buf_len = buf.len(); 48 | if buf_len % 4 != 0 { 49 | bail!("Incorrect buf len={} for BGRA8888", buf.len()); 50 | } 51 | let Some(img) = convert_bgra(width, height, buf) else { 52 | bail!("Incorrect buf len={} for BGRA8888", buf_len); 53 | }; 54 | Ok(DynamicImage::ImageRgba8(img)) 55 | } 56 | 57 | fn read_jpeg(buf: Vec) -> Result { 58 | let img = ImageReader::with_format(Cursor::new(buf), ImageFormat::Jpeg).decode()?; 59 | Ok(img) 60 | } 61 | -------------------------------------------------------------------------------- /app/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: timeline 2 | description: Bolik Timeline 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.21+21 19 | 20 | environment: 21 | sdk: ">=2.18.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 | 34 | characters: ^1.2.1 35 | # The following adds the Cupertino Icons font to your application. 36 | # Use with the CupertinoIcons class for iOS style icons. 37 | cupertino_icons: ^1.0.2 38 | flutter_rust_bridge: 1.65.0 39 | freezed_annotation: ^2.0.3 40 | provider: ^6.0.3 41 | file_picker: ^5.2.4 42 | path_provider: ^2.0.10 43 | intl: ^0.17.0 44 | ffi: ^2.0.1 45 | device_info_plus: ^8.0.0 46 | path: ^1.8.0 47 | url_launcher: ^6.1.5 48 | open_filex: ^4.3.1 49 | fleather: ^1.6.0 50 | quill_delta: 3.0.0-nullsafety.2 51 | qr_flutter: ^4.0.0 52 | camera: ^0.10.1 53 | 54 | dev_dependencies: 55 | test: ^1.21.1 56 | flutter_test: 57 | sdk: flutter 58 | 59 | # The "flutter_lints" package below contains a set of recommended lints to 60 | # encourage good coding practices. The lint set provided by the package is 61 | # activated in the `analysis_options.yaml` file located at the root of your 62 | # package. See that file for information about deactivating specific lint 63 | # rules and activating additional ones. 64 | flutter_lints: ^2.0.0 65 | build_runner: ^2.2.0 66 | freezed: ^2.1.0+1 67 | ffigen: ^7.2.4 68 | flutter_launcher_icons: ^0.11.0 69 | 70 | 71 | # For information on the generic Dart part of this file, see the 72 | # following page: https://dart.dev/tools/pub/pubspec 73 | 74 | # The following section is specific to Flutter packages. 75 | flutter: 76 | 77 | # The following line ensures that the Material Icons font is 78 | # included with your application, so that you can use the icons in 79 | # the material Icons class. 80 | uses-material-design: true 81 | 82 | # To add assets to your application, add an assets section, like this: 83 | # assets: 84 | # - images/a_dot_burr.jpeg 85 | # - images/a_dot_ham.jpeg 86 | 87 | # An image asset can refer to one or more resolution-specific "variants", see 88 | # https://flutter.dev/assets-and-images/#resolution-aware 89 | 90 | # For details regarding adding assets from package dependencies, see 91 | # https://flutter.dev/assets-and-images/#from-packages 92 | 93 | # To add custom fonts to your application, add a fonts section here, 94 | # in this "flutter" section. Each entry in this list should have a 95 | # "family" key with the font family name, and a "fonts" key with a 96 | # list giving the asset and other descriptors for the font. For 97 | # example: 98 | # fonts: 99 | # - family: Schyler 100 | # fonts: 101 | # - asset: fonts/Schyler-Regular.ttf 102 | # - asset: fonts/Schyler-Italic.ttf 103 | # style: italic 104 | # - family: Trajan Pro 105 | # fonts: 106 | # - asset: fonts/TrajanPro.ttf 107 | # - asset: fonts/TrajanPro_Bold.ttf 108 | # weight: 700 109 | # 110 | # For details regarding fonts from package dependencies, 111 | # see https://flutter.dev/custom-fonts/#from-packages 112 | 113 | 114 | flutter_icons: 115 | image_path: "assets/images/dog_icon.png" 116 | min_sdk_android: 21 117 | android: true 118 | image_path_android: "assets/images/dog_icon_android.png" 119 | ios: true 120 | remove_alpha_ios: true 121 | web: 122 | generate: false 123 | windows: 124 | generate: false 125 | macos: 126 | generate: true 127 | -------------------------------------------------------------------------------- /app/test/common/models/dispatcher_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:test/test.dart'; 2 | import 'package:timeline/bridge_generated.dart'; 3 | import 'package:timeline/common/models/dispatcher.dart'; 4 | 5 | void main() { 6 | group('App events', () { 7 | test('Dispatcher', () async { 8 | final dispatcher = AppEventDispatcher(); 9 | final calls = []; 10 | 11 | dispatcher.addListener((event) => 12 | calls.add("OutputEvent_DocUpdated(${event.field0.docId})")); 13 | dispatcher.addListener( 14 | (event) => calls.add("$event")); 15 | dispatcher.dispatch(OutputEvent_DocUpdated(DocUpdatedEvent(docId: 'a'))); 16 | dispatcher.dispatch(const OutputEvent_TimelineUpdated()); 17 | 18 | expect( 19 | calls, 20 | equals([ 21 | "OutputEvent_DocUpdated(a)", 22 | "OutputEvent.timelineUpdated()", 23 | ])); 24 | }); 25 | 26 | test('Dispatcher generic', () async { 27 | final dispatcher = AppEventDispatcher(); 28 | final calls = []; 29 | 30 | dispatcher.addListener((event) => calls.add("$event")); 31 | dispatcher.dispatch(OutputEvent_DocUpdated(DocUpdatedEvent(docId: 'a'))); 32 | dispatcher.dispatch(const OutputEvent_TimelineUpdated()); 33 | 34 | expect( 35 | calls, 36 | equals([ 37 | "OutputEvent.docUpdated(field0: Instance of 'DocUpdatedEvent')", 38 | "OutputEvent.timelineUpdated()", 39 | ])); 40 | }); 41 | 42 | test('Dispatcher remove listener', () async { 43 | final dispatcher = AppEventDispatcher(); 44 | final calls = []; 45 | callbackA(event) => calls.add("A: $event"); 46 | callbackB(event) => calls.add("B: $event"); 47 | 48 | dispatcher.addListener(callbackA); 49 | dispatcher.addListener(callbackB); 50 | dispatcher.dispatch(const OutputEvent_TimelineUpdated()); 51 | expect( 52 | calls, 53 | equals([ 54 | "A: OutputEvent.timelineUpdated()", 55 | "B: OutputEvent.timelineUpdated()", 56 | ])); 57 | calls.clear(); 58 | 59 | dispatcher.removeListener(callbackA); 60 | dispatcher.dispatch(const OutputEvent_TimelineUpdated()); 61 | expect( 62 | calls, 63 | equals([ 64 | "B: OutputEvent.timelineUpdated()", 65 | ])); 66 | }); 67 | }); 68 | } 69 | -------------------------------------------------------------------------------- /app/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 in the flutter_test package. 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:timeline/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 BolikApp()); 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 | -------------------------------------------------------------------------------- /bolik_chain/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "bolik_chain" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | bolik_proto = { path = "../bolik_proto" } 10 | bs58 = { workspace = true } 11 | multihash = { version = "^0.16", default-features = false, features = ["std", "multihash-impl", "blake3"] } 12 | openmls = { workspace = true } 13 | openmls_traits = { workspace = true } 14 | thiserror = "1" 15 | tls_codec = "^0.2" 16 | -------------------------------------------------------------------------------- /bolik_chain/src/device.rs: -------------------------------------------------------------------------------- 1 | use openmls::prelude::{Credential, TlsSerializeTrait}; 2 | use thiserror::Error; 3 | 4 | pub fn get_device_id(credential: &Credential) -> Result { 5 | let device_id = id_from_key( 6 | credential 7 | .signature_key() 8 | .tls_serialize_detached() 9 | .map_err(DeviceError::KeyPackageDecode)? 10 | .as_slice(), 11 | ); 12 | Ok(device_id) 13 | } 14 | 15 | /// Encode bytes into base58 string 16 | pub (crate) fn id_from_key(k: &[u8]) -> String { 17 | bs58::encode(k).into_string() 18 | } 19 | 20 | #[derive(Error, Debug)] 21 | pub enum DeviceError { 22 | #[error("KeyPackageDecode: {0}")] 23 | KeyPackageDecode(tls_codec::Error), 24 | } 25 | -------------------------------------------------------------------------------- /bolik_chain/src/lib.rs: -------------------------------------------------------------------------------- 1 | 2 | mod device; 3 | mod signature_chain; 4 | 5 | pub use signature_chain::*; 6 | -------------------------------------------------------------------------------- /bolik_proto/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "bolik_proto" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | prost = "0.11" 10 | prost-types = "0.11" 11 | # protobuf = "3.0" 12 | 13 | [build-dependencies] 14 | prost-build = "0.11" 15 | # protobuf-codegen = "3.0" 16 | # protoc-bin-vendored = "3.0" 17 | -------------------------------------------------------------------------------- /bolik_proto/build.rs: -------------------------------------------------------------------------------- 1 | use std::io::Result; 2 | 3 | fn main() -> Result<()> { 4 | prost_build::compile_protos(&["proto/sync.proto"], &["proto/"])?; 5 | 6 | // protobuf_codegen::Codegen::new() 7 | // // Use `protoc` parser, optional. 8 | // .protoc() 9 | // // Use `protoc-bin-vendored` bundled protoc command, optional. 10 | // .protoc_path(&protoc_bin_vendored::protoc_bin_path().unwrap()) 11 | // // All inputs and imports from the inputs must reside in `includes` directories. 12 | // .includes(&["proto"]) 13 | // // Inputs must reside in some of include paths. 14 | // .input("proto/timeline.proto") 15 | // .input("proto/sync.proto") 16 | // // Specify output directory relative to Cargo output directory. 17 | // .cargo_out_dir("protos2") 18 | // .run_from_script(); 19 | Ok(()) 20 | } 21 | -------------------------------------------------------------------------------- /bolik_proto/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod sync { 2 | include!(concat!(env!("OUT_DIR"), "/bolik_sync.rs")); 3 | } 4 | 5 | pub use prost; 6 | pub use prost_types; 7 | -------------------------------------------------------------------------------- /bolik_sdk/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "bolik_sdk" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | anyhow = "1" 10 | async-stream = "0.3" 11 | async-trait = "0.1" 12 | bolik_chain = { path = "../bolik_chain" } 13 | bolik_migrations = { path = "../common/migrations" } 14 | bolik_proto = { path = "../bolik_proto" } 15 | bs58 = { workspace = true } 16 | bytes = "^1.1" 17 | chacha20poly1305 = { version = "0.10", default-features = false, features = ["alloc", "getrandom", "rand_core", "stream"] } 18 | chrono = "^0.4" 19 | kamadak-exif = "0.5" 20 | image = { version = "0.24", default-features = false, features = ["gif", "jpeg", "png"] } 21 | multihash = { version = "^0.16", default-features = false, features = ["std", "multihash-impl", "sha2", "blake3"] } 22 | openmls = { workspace = true } 23 | openmls_rust_crypto = { workspace = true} 24 | openmls_traits = { workspace = true } 25 | percent-encoding = "2" 26 | prost = { version = "0.11", default-features = false, features = ["prost-derive"] } 27 | pulldown-cmark = { version = "0.9", default-features = false } 28 | rand = { version = "^0.8", features = ["std"] } 29 | serde = { version = "1", features = ["derive"] } 30 | serde_json = "1" 31 | tokio = { version = "1", features = ["rt-multi-thread", "sync", "time", "macros", "fs"] } 32 | tokio-stream = "^0.1" 33 | tokio-util = { version = "^0.7", features = ["io"] } 34 | reqwest = { version = "^0.11", default-features = false, features = ["gzip", "stream", "native-tls-vendored"] } 35 | seahash = { workspace = true } 36 | tracing = { workspace = true } 37 | thiserror = "1" 38 | uuid = { version = "1", features = ["v4", "fast-rng"] } 39 | lib0 = "0.14.1" 40 | yrs = "0.14.1" 41 | 42 | # As an option to Flutter we can use: 43 | # keyring = "1.2" 44 | 45 | [dev-dependencies] 46 | pretty_assertions = "1.3" 47 | seahash = "4.1" 48 | tempfile = "3" 49 | tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "std", "ansi"], default-features = false } 50 | -------------------------------------------------------------------------------- /bolik_sdk/src/account.rs: -------------------------------------------------------------------------------- 1 | mod acc_atom; 2 | mod acc_view; 3 | mod notifications; 4 | mod profile; 5 | 6 | pub use acc_atom::{AccNotification, AccountAtom, AccountDevice}; 7 | pub use acc_view::{AccContact, AccDevice, AccLabel, AccView}; 8 | pub use notifications::{AccNotifications, NotificationStatus}; 9 | pub use profile::ProfileView; 10 | -------------------------------------------------------------------------------- /bolik_sdk/src/account/notifications.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use lib0::any::Any; 3 | use yrs::{Map, ReadTxn, Transact}; 4 | 5 | pub struct AccNotifications {} 6 | 7 | impl AccNotifications { 8 | const IDS: &'static str = "ids"; 9 | 10 | pub fn init(client_id: yrs::block::ClientID) -> yrs::Doc { 11 | yrs::Doc::with_options(yrs::Options { 12 | client_id, 13 | offset_kind: yrs::OffsetKind::Utf32, 14 | ..Default::default() 15 | }) 16 | } 17 | 18 | /// Accept notification. 19 | pub fn accept(doc: &yrs::Doc, id: String) { 20 | let ids = doc.get_or_insert_map(Self::IDS); 21 | let txn = &mut doc.transact_mut(); 22 | ids.insert(txn, id, true); 23 | } 24 | 25 | /// Ignore notification. 26 | pub fn ignore(doc: &yrs::Doc, id: String) { 27 | let ids = doc.get_or_insert_map(Self::IDS); 28 | let txn = &mut doc.transact_mut(); 29 | ids.insert(txn, id, false); 30 | } 31 | 32 | pub fn status(doc: &yrs::Doc, id: &str) -> NotificationStatus { 33 | let txn = &doc.transact(); 34 | let v = txn 35 | .get_map(Self::IDS) 36 | .and_then(|m| m.get(txn, id)) 37 | .and_then(|v| { 38 | if let yrs::types::Value::Any(Any::Bool(b)) = v { 39 | Some(b) 40 | } else { 41 | None 42 | } 43 | }); 44 | match v { 45 | Some(true) => NotificationStatus::Accepted, 46 | Some(false) => NotificationStatus::Ignored, 47 | None => NotificationStatus::Missing, 48 | } 49 | } 50 | 51 | /// Iterate over notification ids and apply a function to each entry. 52 | pub fn iter_ids( 53 | doc: &yrs::Doc, 54 | f: impl Fn(&str, NotificationStatus) -> Result<()>, 55 | ) -> Result<()> { 56 | let txn = &doc.transact(); 57 | if let Some(map) = txn.get_map(Self::IDS) { 58 | for (notification_id, v) in map.iter(txn) { 59 | let status = match v { 60 | yrs::types::Value::Any(Any::Bool(true)) => NotificationStatus::Accepted, 61 | yrs::types::Value::Any(Any::Bool(false)) => NotificationStatus::Ignored, 62 | _ => { 63 | continue; 64 | } 65 | }; 66 | 67 | f(notification_id, status)?; 68 | } 69 | } 70 | 71 | Ok(()) 72 | } 73 | } 74 | 75 | #[derive(Debug, PartialEq)] 76 | pub enum NotificationStatus { 77 | Missing, 78 | Accepted, 79 | Ignored, 80 | } 81 | -------------------------------------------------------------------------------- /bolik_sdk/src/account/profile.rs: -------------------------------------------------------------------------------- 1 | use yrs::{Map, ReadTxn, Transact}; 2 | 3 | use crate::documents::{DbDocRow, DbDocRowMeta}; 4 | 5 | /// Profile is a public part of account 6 | pub struct ProfileView { 7 | pub account_id: String, 8 | pub name: String, 9 | } 10 | 11 | impl ProfileView { 12 | const FIELDS: &'static str = "fields"; 13 | const NAME: &'static str = "name"; 14 | 15 | pub fn init(client_id: yrs::block::ClientID) -> yrs::Doc { 16 | yrs::Doc::with_options(yrs::Options { 17 | client_id, 18 | offset_kind: yrs::OffsetKind::Utf32, 19 | ..Default::default() 20 | }) 21 | } 22 | 23 | pub fn from_db(row: DbDocRow) -> (Self, yrs::Doc) { 24 | let name = Self::get_name(&row); 25 | 26 | ( 27 | Self { 28 | account_id: row.meta.id.split('/').next().unwrap().into(), 29 | name, 30 | }, 31 | row.yrs, 32 | ) 33 | } 34 | 35 | pub fn set_name(doc: &yrs::Doc, name: String) { 36 | let fields = doc.get_or_insert_map(Self::FIELDS); 37 | let txn = &mut doc.transact_mut(); 38 | fields.insert(txn, Self::NAME, name); 39 | } 40 | 41 | pub fn default_name(account_id: &str) -> String { 42 | let short_id: String = account_id.chars().take(6).collect(); 43 | format!("Account #{}", short_id.to_lowercase()) 44 | } 45 | 46 | pub fn get_name(row: &DbDocRow) -> String { 47 | let txn = &row.yrs.transact(); 48 | txn.get_map(Self::FIELDS) 49 | .and_then(|m| m.get(txn, Self::NAME).map(|v| v.to_string(txn))) 50 | .unwrap_or_else(|| Self::default_name(&row.meta.id)) 51 | } 52 | 53 | pub fn get_account_id(meta: &DbDocRowMeta) -> Option { 54 | let mut parts = meta.id.split('/'); 55 | match (parts.next(), parts.next()) { 56 | (Some(id), Some("profile")) => Some(id.into()), 57 | _ => None, 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /bolik_sdk/src/background.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use anyhow::{Context, Result}; 4 | use tracing::instrument; 5 | 6 | use crate::{ 7 | client::Client, 8 | output::OutputEvent, 9 | registry::{Registry, WithInTxn, WithTimelineAtom}, 10 | timeline::card::{CardFile, CardView}, 11 | }; 12 | 13 | pub enum BackgroundInput { 14 | Sync, 15 | EmptyBin, 16 | ProcessFiles(CardView, tokio::sync::oneshot::Sender<()>), 17 | DownloadFile { 18 | card_id: String, 19 | card_file: CardFile, 20 | }, 21 | } 22 | 23 | impl Display for BackgroundInput { 24 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 25 | match self { 26 | Self::Sync => f.write_str("Sync"), 27 | Self::EmptyBin => f.write_str("EmptyBin"), 28 | Self::ProcessFiles(card, _) => { 29 | f.write_fmt(format_args!("ProcessFiles(card_id={})", card.id)) 30 | } 31 | Self::DownloadFile { card_id, card_file } => f.write_fmt(format_args!( 32 | "DownloadFile(blob_id={} device_id={} doc_id={})", 33 | card_file.blob_id, card_file.device_id, card_id 34 | )), 35 | } 36 | } 37 | } 38 | 39 | pub struct BackgroundTask { 40 | debug_name: String, 41 | registry: Registry, 42 | } 43 | 44 | #[instrument(name = "bg", skip_all, fields(d = task.debug_name))] 45 | pub async fn run( 46 | mut task: BackgroundTask, 47 | mut rx: tokio::sync::mpsc::Receiver, 48 | ) { 49 | while let Some(input) = rx.recv().await { 50 | let input_str = format!("{}", input); 51 | 52 | if let Err(err) = task.process(input).await { 53 | tracing::warn!("Failed to process input ({}): {:?}", input_str, err); 54 | } 55 | } 56 | } 57 | 58 | impl BackgroundTask 59 | where 60 | C: Client, 61 | { 62 | pub fn new(registry: Registry, debug_name: String) -> Self { 63 | Self { 64 | registry, 65 | debug_name, 66 | } 67 | } 68 | 69 | async fn process(&mut self, input: BackgroundInput) -> Result<()> { 70 | match input { 71 | BackgroundInput::Sync => match self.sync().await { 72 | Ok(_) => { 73 | self.broadcast(OutputEvent::Synced)?; 74 | } 75 | Err(err) => { 76 | self.broadcast(OutputEvent::SyncFailed)?; 77 | return Err(err); 78 | } 79 | }, 80 | BackgroundInput::EmptyBin => self.empty_bin()?, 81 | BackgroundInput::ProcessFiles(card, sender) => { 82 | self.process_files(card).await?; 83 | let _ = sender.send(()); 84 | } 85 | BackgroundInput::DownloadFile { card_id, card_file } => { 86 | let ctx = self.registry.db_ctx(); 87 | let card = ctx.in_txn(|ctx_tx| ctx_tx.timeline().get_card(ctx_tx, &card_id))?; 88 | match self.registry.blobs.download(&ctx, &card, &card_file).await { 89 | Ok(path) => { 90 | self.broadcast(OutputEvent::DownloadCompleted { 91 | blob_id: card_file.blob_id, 92 | device_id: card_file.device_id, 93 | path, 94 | })?; 95 | } 96 | Err(err) => { 97 | self.broadcast(OutputEvent::DownloadFailed { 98 | blob_id: card_file.blob_id, 99 | })?; 100 | return Err(err); 101 | } 102 | } 103 | } 104 | }; 105 | Ok(()) 106 | } 107 | 108 | fn broadcast(&self, event: OutputEvent) -> Result<()> { 109 | self.registry.broadcast.send(event)?; 110 | Ok(()) 111 | } 112 | 113 | async fn sync(&self) -> Result<()> { 114 | let ctx = self.registry.db_ctx(); 115 | 116 | // Mailbox 117 | self.registry.mailbox.sync(&ctx).await?; 118 | 119 | // Docs 120 | self.registry 121 | .sync_docs 122 | .sync(&ctx) 123 | .await 124 | .context("Sync docs")?; 125 | 126 | Ok(()) 127 | } 128 | 129 | fn empty_bin(&self) -> Result<()> { 130 | self.registry.in_txn(|ctx, r| { 131 | if r.account.get_account_id(ctx).is_some() { 132 | r.timeline.empty_bin(ctx, None)?; 133 | } 134 | Ok(()) 135 | })?; 136 | Ok(()) 137 | } 138 | 139 | async fn process_files(&self, card: CardView) -> Result<()> { 140 | let ctx = self.registry.db_ctx(); 141 | let res = ctx.in_txn(|ctx_tx| self.registry.timeline.generate_thumbnail(ctx_tx, &card))?; 142 | if res.card_changes > 0 { 143 | let _ = self.broadcast(OutputEvent::DocUpdated { doc_id: card.id }); 144 | } 145 | Ok(()) 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /bolik_sdk/src/db.rs: -------------------------------------------------------------------------------- 1 | use std::sync::{Arc, Mutex}; 2 | 3 | use bolik_migrations::rusqlite::{ 4 | self, 5 | types::{FromSql, FromSqlError, FromSqlResult, ToSqlOutput, Value, ValueRef}, 6 | Connection, ToSql, 7 | }; 8 | 9 | use crate::secrets::DbCipher; 10 | 11 | pub mod migrations; 12 | 13 | #[derive(Clone)] 14 | pub struct Db { 15 | pub conn: Arc>, 16 | pub db_cipher: DbCipher, 17 | } 18 | 19 | /// Read JSON array of strings from the database. 20 | pub struct StringListReadColumn(pub Vec); 21 | 22 | impl FromSql for StringListReadColumn { 23 | fn column_result(value: ValueRef<'_>) -> FromSqlResult { 24 | if let ValueRef::Text(t) = value { 25 | let list: Vec = 26 | serde_json::from_slice(t).map_err(|err| FromSqlError::Other(Box::new(err)))?; 27 | Ok(StringListReadColumn(list)) 28 | } else { 29 | Err(FromSqlError::InvalidType) 30 | } 31 | } 32 | } 33 | 34 | /// Write JSON array of strings to the database. 35 | pub struct StringListWriteColumn<'a>(pub &'a [String]); 36 | 37 | impl<'a> ToSql for StringListWriteColumn<'a> { 38 | fn to_sql(&self) -> rusqlite::Result> { 39 | let t = serde_json::to_string(self.0) 40 | .map_err(|err| rusqlite::Error::ToSqlConversionFailure(Box::new(err)))?; 41 | Ok(ToSqlOutput::Owned(Value::Text(t))) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /bolik_sdk/src/device.rs: -------------------------------------------------------------------------------- 1 | use std::hash::Hasher; 2 | 3 | use anyhow::Result; 4 | use bolik_migrations::rusqlite::Connection; 5 | use bolik_proto::sync::DeviceShareMessage; 6 | use openmls::prelude::{Credential, KeyPackage, TlsDeserializeTrait, TlsSerializeTrait}; 7 | 8 | mod device_atom; 9 | 10 | pub use device_atom::{DeviceAtom, DeviceCtx}; 11 | use prost::Message; 12 | use seahash::SeaHasher; 13 | 14 | use crate::secrets; 15 | 16 | pub struct DeviceSettings { 17 | pub device_id: String, 18 | pub device_name: String, 19 | pub account_id: Option, 20 | } 21 | 22 | pub fn query_device_settings(conn: &Connection) -> Result { 23 | let settings = conn.query_row( 24 | "SELECT device_id, device_name, account_id FROM device_settings", 25 | [], 26 | |row| { 27 | Ok(DeviceSettings { 28 | device_id: row.get(0)?, 29 | device_name: row.get(1)?, 30 | account_id: row.get(2)?, 31 | }) 32 | }, 33 | )?; 34 | Ok(settings) 35 | } 36 | 37 | fn yrs_client_id(device_id: &str) -> yrs::block::ClientID { 38 | // NOTE: 39 | // Despite Yrs' ClientID being u64 the lib supports only u53 due to compatibility with JS. 40 | // In JS MAX_SAFE_INTEGER is 9_007_199_254_740_991. 41 | // Hence we need to convert the number back and forth. 42 | // NOTE: Atm, Yrs actually supports only u32 client ID and u53 is planned: https://github.com/y-crdt/y-crdt/issues/110 43 | 44 | let mut hasher = SeaHasher::new(); 45 | hasher.write(device_id.as_bytes()); 46 | let hash = hasher.finish(); 47 | let id = hash as u32; 48 | id as u64 49 | } 50 | 51 | pub struct DeviceShare { 52 | pub key_package: KeyPackage, 53 | pub device_name: String, 54 | } 55 | 56 | impl DeviceShare { 57 | pub fn parse(share: &str) -> Result { 58 | let share_bytes = secrets::key_from_id(&share)?; 59 | let message = DeviceShareMessage::decode(share_bytes.as_slice())?; 60 | let package = KeyPackage::tls_deserialize(&mut message.key_package.as_slice())?; 61 | Ok(DeviceShare { 62 | key_package: package, 63 | device_name: message.device_name, 64 | }) 65 | } 66 | } 67 | 68 | pub fn get_device_id(credential: &Credential) -> Result { 69 | Ok(secrets::id_from_key( 70 | get_credential_id_bytes(credential)?.as_slice(), 71 | )) 72 | } 73 | 74 | pub fn get_credential_id_bytes(credential: &Credential) -> Result> { 75 | let bytes = credential.signature_key().tls_serialize_detached()?; 76 | Ok(bytes) 77 | } 78 | -------------------------------------------------------------------------------- /bolik_sdk/src/documents.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use bolik_migrations::rusqlite::{params, Connection}; 3 | use chacha20poly1305::{ChaCha20Poly1305, KeyInit}; 4 | use chrono::{DateTime, Utc}; 5 | use yrs::{updates::decoder::Decode, ReadTxn, StateVector, Transact, Update}; 6 | 7 | mod docs_atom; 8 | mod sync_docs_atom; 9 | pub mod yrs_util; 10 | 11 | pub use docs_atom::{DocsAtom, DocsCtx}; 12 | pub use sync_docs_atom::SyncDocsAtom; 13 | 14 | /// Mark moved to bin cards 15 | pub const BIN_LABEL_ID: &str = "bolik-bin"; 16 | /// A helper label to allow us to find not deleted cards. 17 | pub(crate) const ALL_LABEL_ID: &str = "bolik-all"; 18 | 19 | pub struct DbDocRow { 20 | pub meta: DbDocRowMeta, 21 | pub yrs: yrs::Doc, 22 | pub acl: yrs::Doc, 23 | } 24 | 25 | #[derive(Debug, Clone)] 26 | pub struct DbDocRowMeta { 27 | pub id: String, 28 | pub author_device_id: String, 29 | pub counter: u64, 30 | /// [bolik_proto::sync::doc_payload::DocSchema] 31 | pub schema: i32, 32 | pub created_at: DateTime, 33 | pub edited_at: DateTime, 34 | } 35 | 36 | pub fn merge_yrs_docs(source: &yrs::Doc, updates: &[u8]) -> Result<()> { 37 | let mut txn = source.transact_mut(); 38 | let u = Update::decode_v2(updates)?; 39 | txn.apply_update(u); 40 | Ok(()) 41 | } 42 | 43 | pub fn build_yrs_doc(yrs_client_id: yrs::block::ClientID, data: &[u8]) -> Result { 44 | let doc = yrs::Doc::with_options(yrs::Options { 45 | client_id: yrs_client_id, 46 | offset_kind: yrs::OffsetKind::Utf32, 47 | ..Default::default() 48 | }); 49 | merge_yrs_docs(&doc, data)?; 50 | Ok(doc) 51 | } 52 | 53 | pub fn encode_yrs_doc(doc: &yrs::Doc) -> Vec { 54 | let txn = doc.transact(); 55 | txn.encode_state_as_update_v2(&StateVector::default()) 56 | } 57 | 58 | fn save(conn: &Connection, row: &DbDocRow) -> Result<()> { 59 | let data = encode_yrs_doc(&row.yrs); 60 | let acl_data = encode_yrs_doc(&row.acl); 61 | 62 | conn.execute( 63 | r#" 64 | INSERT INTO documents (id, data, acl_data, author_device_id, counter, created_at, edited_at, schema) 65 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) 66 | ON CONFLICT (id) DO UPDATE 67 | SET data = excluded.data, 68 | acl_data = excluded.acl_data, 69 | author_device_id = excluded.author_device_id, 70 | counter = excluded.counter, 71 | edited_at = excluded.edited_at"#, 72 | params![ 73 | row.meta.id, 74 | data, 75 | acl_data, 76 | row.meta.author_device_id, 77 | row.meta.counter, 78 | row.meta.created_at, 79 | row.meta.edited_at, 80 | row.meta.schema, 81 | ], 82 | )?; 83 | 84 | Ok(()) 85 | } 86 | 87 | pub(crate) fn delete_row(conn: &Connection, doc_id: &str) -> Result<()> { 88 | conn.execute("DELETE FROM documents WHERE id = ?", [doc_id])?; 89 | conn.execute("DELETE FROM card_index WHERE id = ?", [doc_id])?; 90 | Ok(()) 91 | } 92 | 93 | #[derive(Clone)] 94 | pub struct DocSecretRow { 95 | pub id: String, 96 | /// Secret value 97 | pub key: Vec, 98 | pub account_ids: Vec, 99 | pub doc_id: Option, 100 | pub algorithm: i32, 101 | pub created_at: DateTime, 102 | /// Time after which this secret should no longer be used. 103 | pub obsolete_at: DateTime, 104 | } 105 | 106 | pub struct DocSecret { 107 | pub id: String, 108 | pub cipher: ChaCha20Poly1305, 109 | } 110 | 111 | impl DocSecret { 112 | pub fn new(id: &str, bytes: &[u8]) -> Self { 113 | let key = chacha20poly1305::Key::from_slice(bytes); 114 | Self { 115 | id: id.to_string(), 116 | cipher: ChaCha20Poly1305::new(key), 117 | } 118 | } 119 | } 120 | 121 | impl From for DocSecret { 122 | fn from(row: DocSecretRow) -> Self { 123 | let key = chacha20poly1305::Key::from_slice(row.key.as_ref()); 124 | Self { 125 | id: row.id, 126 | cipher: ChaCha20Poly1305::new(key), 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /bolik_sdk/src/documents/yrs_util.rs: -------------------------------------------------------------------------------- 1 | use lib0::any::Any; 2 | 3 | pub fn bytes_from_yrs(value: yrs::types::Value) -> Option> { 4 | if let yrs::types::Value::Any(Any::Buffer(bytes)) = value { 5 | Some(bytes.to_vec()) 6 | } else { 7 | None 8 | } 9 | } 10 | 11 | pub fn uint_from_yrs(value: yrs::types::Value) -> Option { 12 | if let yrs::types::Value::Any(any) = value { 13 | uint_from_yrs_any(&any) 14 | } else { 15 | None 16 | } 17 | } 18 | 19 | pub fn uint_from_yrs_any(value: &Any) -> Option { 20 | if let Any::Number(num) = value { 21 | Some(*num as u32) 22 | } else { 23 | None 24 | } 25 | } 26 | 27 | pub fn int64_from_yrs(value: yrs::types::Value) -> Option { 28 | if let yrs::types::Value::Any(Any::BigInt(num)) = value { 29 | Some(num) 30 | } else { 31 | None 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /bolik_sdk/src/import.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fs::DirEntry, 3 | io::Read, 4 | path::{Path, PathBuf}, 5 | }; 6 | 7 | use anyhow::{bail, Result}; 8 | 9 | use crate::{ 10 | account::AccView, 11 | import::v2::ImportCardResult, 12 | registry::{WithAccountAtom, WithDeviceAtom, WithDocsAtom, WithTimelineAtom, WithTxn}, 13 | }; 14 | 15 | mod v1; 16 | mod v2; 17 | 18 | #[derive(Default, Clone)] 19 | pub struct ImportResult { 20 | pub imported: u32, 21 | /// List of file names that were already imported. 22 | pub duplicates: Vec, 23 | /// List of file names that failed. 24 | pub failed: Vec, 25 | } 26 | 27 | pub trait ImportCtx<'a>: 28 | WithTxn<'a> + WithAccountAtom + WithTimelineAtom + WithDocsAtom + WithDeviceAtom 29 | { 30 | } 31 | impl<'a, T> ImportCtx<'a> for T where 32 | T: WithTxn<'a> + WithAccountAtom + WithTimelineAtom + WithDocsAtom + WithDeviceAtom 33 | { 34 | } 35 | 36 | #[derive(Clone)] 37 | pub struct ImportAtom {} 38 | 39 | impl ImportAtom { 40 | pub fn new() -> Self { 41 | Self {} 42 | } 43 | 44 | pub fn run<'a>(&self, ctx: &impl ImportCtx<'a>, in_dir: PathBuf) -> Result { 45 | let mut acc = ctx.account().require_account(ctx)?; 46 | 47 | tracing::info!("Starting import from {}", in_dir.display()); 48 | // Read the dir 49 | let md_files = std::fs::read_dir(&in_dir)?; 50 | let mut res = ImportResult::default(); 51 | 52 | for md_file in md_files { 53 | let Ok(md_file) = md_file else { 54 | continue; 55 | }; 56 | 57 | let name = md_file.file_name().to_string_lossy().to_string(); 58 | match self.process_single(ctx, &in_dir, &mut acc, md_file) { 59 | Ok(ImportSingleResult::Imported) => res.imported += 1, 60 | Ok(ImportSingleResult::Duplicate) => { 61 | res.duplicates.push(name); 62 | } 63 | Ok(ImportSingleResult::Directory) => {} 64 | Err(err) => { 65 | tracing::warn!("Failed to process file {}: {}", name, err); 66 | res.failed.push(name); 67 | } 68 | } 69 | } 70 | 71 | Ok(res) 72 | } 73 | 74 | fn process_single<'a>( 75 | &self, 76 | ctx: &impl ImportCtx<'a>, 77 | in_dir: &Path, 78 | acc: &mut AccView, 79 | md_file: DirEntry, 80 | ) -> Result { 81 | if md_file.file_type()?.is_dir() { 82 | return Ok(ImportSingleResult::Directory); 83 | } 84 | 85 | // Try to read markdown file 86 | tracing::info!("Importing {}", md_file.path().display()); 87 | let mut file = std::fs::File::open(md_file.path())?; 88 | let mut content = String::new(); 89 | file.read_to_string(&mut content)?; 90 | 91 | let mut lines = content.split('\n'); 92 | let first_line = lines.next(); 93 | let res = match first_line { 94 | Some("# Bolik card") => { 95 | let _res = v1::import_card(ctx, &in_dir, acc, &content)?; 96 | ImportSingleResult::Imported 97 | } 98 | Some("# Bolik card v2") => match v2::import_card(ctx, &in_dir, acc, &content)? { 99 | ImportCardResult::Imported(_) => ImportSingleResult::Imported, 100 | ImportCardResult::Duplicate => ImportSingleResult::Duplicate, 101 | }, 102 | _ => { 103 | bail!("Unsupported card {}", md_file.path().display()); 104 | } 105 | }; 106 | Ok(res) 107 | } 108 | } 109 | 110 | enum ImportSingleResult { 111 | Imported, 112 | Duplicate, 113 | Directory, 114 | } 115 | -------------------------------------------------------------------------------- /bolik_sdk/src/input.rs: -------------------------------------------------------------------------------- 1 | // use crate::{ 2 | // account::{AccLabel, DeviceView}, 3 | // timeline_item::{MediaContentView, TimelineItem, TimelineItemPreview}, 4 | // }; 5 | 6 | // #[derive(Debug)] 7 | // pub enum InputEvent { 8 | // CreateTimelineItem { 9 | // respond: tokio::sync::oneshot::Sender, 10 | // }, 11 | // GetTimelineItem { 12 | // item_id: String, 13 | // respond: tokio::sync::oneshot::Sender>, 14 | // }, 15 | // CreateAccountLabel { 16 | // label: AccLabel, 17 | // }, 18 | // AddItemLabel { 19 | // item_id: String, 20 | // label_id: String, 21 | // }, 22 | // RemoveItemLabel { 23 | // item_id: String, 24 | // label_id: String, 25 | // }, 26 | // AddTextBlock { 27 | // item_id: String, 28 | // content_id: String, 29 | // text: Option, 30 | // }, 31 | // EditText { 32 | // item_id: String, 33 | // content_id: String, 34 | // new_value: String, 35 | // }, 36 | // AddMediaBlock { 37 | // item_id: String, 38 | // content_id: String, 39 | // media: MediaContentView, 40 | // }, 41 | // RemoveBlock { 42 | // item_id: String, 43 | // index: u32, 44 | // }, 45 | // CloseTimelineItem { 46 | // item_id: String, 47 | // }, 48 | // TimelineDays { 49 | // respond: tokio::sync::oneshot::Sender>, 50 | // }, 51 | // TimelineByPrevDay { 52 | // prev_day: Option, 53 | // respond: tokio::sync::oneshot::Sender>, 54 | // }, 55 | // DeviceShare { 56 | // respond: tokio::sync::oneshot::Sender, 57 | // }, 58 | // LinkToDevice { 59 | // ed25519_key: Ed25519PublicKey, 60 | // curve25519_key: Curve25519PublicKey, 61 | // one_time_key: Curve25519PublicKey, 62 | // }, 63 | // CreateAccount, 64 | // } 65 | -------------------------------------------------------------------------------- /bolik_sdk/src/mailbox.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use bolik_migrations::rusqlite::params; 3 | use bolik_proto::sync::request; 4 | use prost::Message; 5 | 6 | mod mailbox_atom; 7 | use chrono::{Timelike, Utc}; 8 | pub use mailbox_atom::{MailboxAtom, MailboxCtx}; 9 | use uuid::Uuid; 10 | 11 | use crate::registry::WithTxn; 12 | 13 | /// Add MLS message to push mailbox queue 14 | pub fn queue_mls_message<'a>( 15 | ctx: &impl WithTxn<'a>, 16 | message: request::SecretGroupMessage, 17 | ) -> Result<()> { 18 | queue_mailbox(ctx, request::push_mailbox::Value::Message(message)) 19 | } 20 | 21 | /// Add MLS commit to push mailbox queue 22 | pub fn queue_mls_commit<'a>( 23 | ctx: &impl WithTxn<'a>, 24 | message: request::SecretGroupCommit, 25 | ) -> Result<()> { 26 | queue_mailbox(ctx, request::push_mailbox::Value::Commit(message)) 27 | } 28 | 29 | /// Add mailbox message to push mailbox queue 30 | pub fn queue_mailbox<'a>( 31 | ctx: &impl WithTxn<'a>, 32 | value: request::push_mailbox::Value, 33 | ) -> Result<()> { 34 | let now = Utc::now(); 35 | let message = request::PushMailbox { 36 | id: Uuid::new_v4().to_string(), 37 | value: Some(value), 38 | created_at_sec: now.timestamp(), 39 | created_at_nano: now.nanosecond(), 40 | }; 41 | ctx.txn() 42 | .execute( 43 | "INSERT INTO push_mailbox_queue (id, message) VALUES (?1, ?2)", 44 | params![message.id, message.encode_to_vec()], 45 | ) 46 | .context("Insert push_mailbox_queue")?; 47 | Ok(()) 48 | } 49 | -------------------------------------------------------------------------------- /bolik_sdk/src/output.rs: -------------------------------------------------------------------------------- 1 | use crate::account::{AccNotification, AccView}; 2 | 3 | #[derive(Debug, Clone, PartialEq)] 4 | pub enum OutputEvent { 5 | Synced, 6 | SyncFailed, 7 | TimelineUpdated, 8 | DeviceAdded { 9 | device_name: String, 10 | }, 11 | ConnectedToAccount { 12 | view: AccView, 13 | }, 14 | AccUpdated { 15 | view: AccView, 16 | }, 17 | DocUpdated { 18 | doc_id: String, 19 | }, 20 | DownloadCompleted { 21 | blob_id: String, 22 | device_id: String, 23 | path: String, 24 | }, 25 | DownloadFailed { 26 | blob_id: String, 27 | }, 28 | Notification(AccNotification), 29 | NotificationsUpdated, 30 | LogOut, 31 | } 32 | -------------------------------------------------------------------------------- /bolik_server/.gitignore: -------------------------------------------------------------------------------- 1 | # Dev database 2 | timeline.db 3 | timeline.db-shm 4 | timeline.db-wal 5 | 6 | # Locally stored blobs 7 | blobs 8 | -------------------------------------------------------------------------------- /bolik_server/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "bolik_server" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | axum = { version = "0.6", features = ["tokio", "macros", "http1", "tower-log", "query"], default-features = false } 10 | bolik_chain = { path = "../bolik_chain" } 11 | bolik_migrations = { path = "../common/migrations" } 12 | bolik_proto = { path = "../bolik_proto" } 13 | bs58 = { workspace = true } 14 | chrono = "^0.4" 15 | futures = { version = "0.3" } 16 | hyper = "0.14" 17 | multihash = { version = "^0.16", default-features = false, features = ["std", "multihash-impl", "sha2", "blake3"] } 18 | openmls = { workspace = true } 19 | openmls_rust_crypto = { workspace = true} 20 | openmls_traits = { workspace = true } 21 | rust-s3 = { version = "0.33.0-beta4", default-features = false, features = ["tokio-native-tls"] } 22 | serde = { version = "1.0", features = ["derive"] } 23 | tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "fs", "signal"] } 24 | tokio-stream = "^0.1" 25 | tokio-util = { version = "^0.7", features = ["io"] } 26 | thiserror = "1" 27 | tls_codec = "^0.2" 28 | tower = { version = "0.4", features = ["util", "timeout"] } 29 | tower-http = { version = "0.3", features = ["add-extension", "trace"] } 30 | tracing = { workspace = true } 31 | tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "std", "ansi"], default-features = false } 32 | -------------------------------------------------------------------------------- /bolik_server/src/account.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | extract::{Path, State}, 3 | response::IntoResponse, 4 | Extension, 5 | }; 6 | use bolik_chain::SignatureChain; 7 | use bolik_migrations::rusqlite::{params, Connection, OptionalExtension}; 8 | use bolik_proto::sync::response; 9 | use hyper::StatusCode; 10 | use tracing::instrument; 11 | 12 | use crate::{ 13 | device::db_device_packages, 14 | error::{AppError, DbContext, ServerError, UserError}, 15 | mls::CryptoProvider, 16 | router::CurrentDevice, 17 | state::{AppState, Protobuf}, 18 | }; 19 | 20 | #[axum::debug_handler] 21 | #[instrument(skip(app, _current_device))] 22 | pub async fn list_devices( 23 | State(app): State, 24 | Extension(_current_device): Extension, 25 | Path(account_id): Path, 26 | ) -> Result { 27 | let mut conn = app.conn.lock().unwrap(); 28 | let txn = conn.transaction().db_txn()?; 29 | let chain = get_account_chain(&txn, &account_id)?; 30 | 31 | let mut all_packages = vec![]; 32 | let members = chain 33 | .members(&CryptoProvider::default()) 34 | .map_err(ServerError::SignatureChain)?; 35 | for device_id in members.device_ids() { 36 | let packages = db_device_packages(&txn, device_id)?; 37 | all_packages.extend(packages); 38 | } 39 | 40 | let response = response::AccountDevices { 41 | chain: Some(chain.encode().map_err(ServerError::SignatureChain)?), 42 | key_packages: all_packages, 43 | }; 44 | 45 | Ok((StatusCode::OK, Protobuf(response))) 46 | } 47 | 48 | pub fn find_account_id(conn: &Connection, device_id: &str) -> Result { 49 | let account_id = conn 50 | .query_row( 51 | r#" 52 | SELECT c.id 53 | FROM signature_chains c 54 | JOIN signature_chain_members m ON c.id = m.chain_id 55 | WHERE c.is_account = 1 AND m.device_id = ?"#, 56 | params![device_id], 57 | |row| row.get(0), 58 | ) 59 | .optional() 60 | .db_context("Find account id")?; 61 | 62 | if let Some(id) = account_id { 63 | Ok(id) 64 | } else { 65 | Err(UserError::NoAccount.into()) 66 | } 67 | } 68 | 69 | pub fn get_account_chain(conn: &Connection, account_id: &str) -> Result { 70 | let chain_bytes: Option> = conn 71 | .query_row( 72 | "SELECT chain FROM signature_chains WHERE id = ?", 73 | params![account_id], 74 | |row| row.get(0), 75 | ) 76 | .optional() 77 | .db_context("Find chain")?; 78 | 79 | if let Some(bytes) = chain_bytes { 80 | let chain = SignatureChain::decode_bytes(&bytes).map_err(ServerError::SignatureChain)?; 81 | Ok(chain) 82 | } else { 83 | Err(AppError::User(UserError::NotFound("Account".into()))) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /bolik_server/src/blobs.rs: -------------------------------------------------------------------------------- 1 | use axum::{extract::State, response::IntoResponse, Extension}; 2 | use bolik_migrations::rusqlite::params; 3 | use bolik_proto::sync::{request, response}; 4 | use chrono::Utc; 5 | use hyper::{header::CONTENT_LENGTH, HeaderMap, StatusCode}; 6 | use tracing::instrument; 7 | 8 | use crate::{ 9 | account::find_account_id, 10 | error::{AppError, DbContext, ServerError, UserError}, 11 | router::CurrentDevice, 12 | state::{AppState, Protobuf}, 13 | }; 14 | 15 | #[axum::debug_handler] 16 | #[instrument(skip(app, current_device))] 17 | pub async fn presign_upload( 18 | State(app): State, 19 | Extension(current_device): Extension, 20 | Protobuf(payload): Protobuf, 21 | ) -> Result { 22 | let blob_id = payload.blob_id; 23 | // Blob ID is a UUID 24 | if !blob_id 25 | .chars() 26 | .all(|c| c.is_ascii_alphanumeric() || c == '-') 27 | { 28 | return Err(UserError::InvalidBlobId.into()); 29 | } 30 | 31 | if payload.size_bytes > 20 * 1024 * 1024 { 32 | return Err(UserError::BlobTooBig.into()); 33 | } 34 | 35 | // Presign upload URL 36 | let now = Utc::now(); 37 | let day = now.format("%Y%m%d"); 38 | let path = format!("{}/blob_{}_dev_{}", day, blob_id, current_device.device_id); 39 | 40 | // Restrict Content-Length header 41 | let mut custom_headers = HeaderMap::with_capacity(1); 42 | custom_headers.insert(CONTENT_LENGTH, payload.size_bytes.into()); 43 | 44 | let upload_url = app 45 | .bucket 46 | .presign_put(&path, 60 * 5, Some(custom_headers)) 47 | .map_err(ServerError::from)?; 48 | 49 | // Store blob in the db 50 | { 51 | tracing::debug!( 52 | bucket = app.bucket.name, 53 | path, 54 | "Blob will be available after upload" 55 | ); 56 | let conn = app.conn.lock().unwrap(); 57 | conn.execute( 58 | r#" 59 | INSERT INTO blobs (id, device_id, bucket, path, size_bytes) 60 | VALUES (?1, ?2, ?3, ?4, ?5) 61 | ON CONFLICT (id, device_id) DO UPDATE 62 | SET bucket = excluded.bucket, 63 | path = excluded.path, 64 | size_bytes = excluded.size_bytes"#, 65 | params![ 66 | blob_id, 67 | current_device.device_id, 68 | app.bucket.name, 69 | path, 70 | payload.size_bytes 71 | ], 72 | ) 73 | .db_context("Insert blob")?; 74 | } 75 | 76 | let res = response::PresignedUrl { url: upload_url }; 77 | Ok((StatusCode::OK, Protobuf(res))) 78 | } 79 | 80 | #[axum::debug_handler] 81 | #[instrument(skip(app, current_device), fields(blob_id = payload.blob_id, blob_device_id = payload.device_id, account_id))] 82 | pub async fn presign_download( 83 | State(app): State, 84 | Extension(current_device): Extension, 85 | Protobuf(payload): Protobuf, 86 | ) -> Result { 87 | let path = { 88 | // ACL 89 | let conn = app.conn.lock().unwrap(); 90 | let account_id = find_account_id(&conn, ¤t_device.device_id)?; 91 | tracing::Span::current().record("account_id", &account_id); 92 | 93 | // Check this account is allowed to access the doc and that doc references the blob 94 | conn.query_row( 95 | r#" 96 | SELECT 1 97 | FROM doc_blobs 98 | WHERE account_id = ?1 AND doc_id = ?2 AND blob_id = ?3 AND device_id = ?4"#, 99 | params![ 100 | account_id, 101 | payload.doc_id, 102 | payload.blob_id, 103 | payload.device_id 104 | ], 105 | |_row| Ok(()), 106 | ) 107 | .db_context("Find doc_payload_blob")?; 108 | 109 | // Find a file 110 | let (path, is_uploaded): (String, Option) = conn 111 | .query_row( 112 | "SELECT path, uploaded FROM blobs WHERE id = ?1 AND device_id = ?2", 113 | params![payload.blob_id, payload.device_id], 114 | |row| Ok((row.get(0)?, row.get(1)?)), 115 | ) 116 | .db_context("Find blob")?; 117 | 118 | if !is_uploaded.unwrap_or(false) { 119 | return Err(UserError::MissingBlob { 120 | blob_id: payload.blob_id, 121 | device_id: payload.device_id, 122 | } 123 | .into()); 124 | } 125 | 126 | path 127 | }; 128 | 129 | let url = app 130 | .bucket 131 | .presign_get(path, 60 * 5, None) 132 | .map_err(ServerError::from)?; 133 | let res = response::PresignedUrl { url }; 134 | Ok((StatusCode::OK, Protobuf(res))) 135 | } 136 | -------------------------------------------------------------------------------- /bolik_server/src/device.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | extract::{Path, State}, 3 | response::IntoResponse, 4 | Extension, 5 | }; 6 | use bolik_migrations::rusqlite::{params, Connection}; 7 | use bolik_proto::sync::{response, KeyPackageMessage}; 8 | use hyper::StatusCode; 9 | use openmls::prelude::{KeyPackage, TlsDeserializeTrait, TlsSerializeTrait}; 10 | use tracing::instrument; 11 | 12 | use crate::{ 13 | error::{AppError, DbContext, UserError}, 14 | mls::{get_device_id, get_key_package_ref}, 15 | router::CurrentDevice, 16 | state::{AppState, Protobuf}, 17 | }; 18 | 19 | #[axum::debug_handler] 20 | pub async fn save_key_package( 21 | State(app): State, 22 | Extension(current_device): Extension, 23 | Protobuf(package): Protobuf, 24 | ) -> Result { 25 | let key_package = KeyPackage::tls_deserialize(&mut package.data.as_slice()) 26 | .map_err(|err| UserError::KeyPackageDecode(err))?; 27 | 28 | let device_id = get_device_id(key_package.credential())?; 29 | let key_package_ref = get_key_package_ref(&key_package)?; 30 | let credential_data = key_package 31 | .credential() 32 | .tls_serialize_detached() 33 | .map_err(|err| UserError::KeyPackageEncode(err))?; 34 | 35 | if device_id != current_device.device_id { 36 | return Err(UserError::KeyPackageCredMismatch.into()); 37 | } 38 | 39 | let mut conn = app.conn.lock().unwrap(); 40 | let txn = conn.transaction().db_txn()?; 41 | txn.execute( 42 | r#" 43 | INSERT INTO credentials (device_id, data) VALUES (?, ?) 44 | ON CONFLICT (device_id) DO NOTHING"#, 45 | params![device_id, credential_data], 46 | ) 47 | .db_context("Insert credential")?; 48 | 49 | txn.execute( 50 | r#" 51 | INSERT INTO unused_key_packages (ref, device_id, data) VALUES (?, ?, ?) 52 | ON CONFLICT (ref) DO NOTHING"#, 53 | params![key_package_ref, device_id, package.data], 54 | ) 55 | .db_context("Insert key package")?; 56 | txn.commit().db_commit()?; 57 | 58 | tracing::trace!(%key_package_ref, "Saved KeyPackage"); 59 | Ok(StatusCode::CREATED) 60 | } 61 | 62 | #[axum::debug_handler] 63 | #[instrument(skip(app, _current_device))] 64 | pub async fn list_packages( 65 | State(app): State, 66 | Extension(_current_device): Extension, 67 | Path(device_id): Path, 68 | ) -> Result { 69 | let conn = app.conn.lock().unwrap(); 70 | let packages = db_device_packages(&conn, &device_id)?; 71 | let response = response::DevicePackages { 72 | key_packages: packages, 73 | }; 74 | Ok((StatusCode::OK, Protobuf(response))) 75 | } 76 | 77 | pub fn db_device_packages( 78 | conn: &Connection, 79 | device_id: &str, 80 | ) -> Result, AppError> { 81 | let mut stmt = conn 82 | .prepare("SELECT data FROM unused_key_packages WHERE device_id = ?") 83 | .db_context("Find key packages (prepare)")?; 84 | let mut rows = stmt 85 | .query(params![device_id]) 86 | .db_context("Find key packages")?; 87 | 88 | let mut packages = vec![]; 89 | while let Some(row) = rows.next().db_context("Read row")? { 90 | let data: Vec = row.get(0).db_context("Read data")?; 91 | packages.push(KeyPackageMessage { data }); 92 | } 93 | Ok(packages) 94 | } 95 | -------------------------------------------------------------------------------- /bolik_server/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use error::SetupError; 4 | pub use state::AppConfig; 5 | use state::AppState; 6 | use tokio::signal; 7 | 8 | use crate::{router::router, state::build_app_state}; 9 | 10 | mod account; 11 | mod blobs; 12 | mod device; 13 | mod docs; 14 | pub mod error; 15 | mod mailbox; 16 | mod migration; 17 | mod mls; 18 | pub mod router; 19 | pub mod state; 20 | 21 | pub use mls::get_device_id; 22 | 23 | pub async fn run() -> Result<(), SetupError> { 24 | let conf = AppConfig::from_env()?; 25 | let addr = conf.addr; 26 | let state = build_app_state(conf).await?; 27 | let app = router(state.clone()); 28 | 29 | tokio::spawn(async move { 30 | batch_jobs_task(state).await; 31 | }); 32 | 33 | tracing::info!("Listening on {}", addr); 34 | axum::Server::bind(&addr) 35 | .serve(app.into_make_service()) 36 | .with_graceful_shutdown(shutdown_signal()) 37 | .await?; 38 | Ok(()) 39 | } 40 | 41 | async fn batch_jobs_task(state: AppState) { 42 | tokio::time::sleep(Duration::from_secs(30)).await; 43 | loop { 44 | tracing::info!("Running batch jobs"); 45 | 46 | if let Err(err) = state.mark_unused_blobs() { 47 | tracing::warn!("Cannot mark unused blobs: {}", err); 48 | } 49 | 50 | match state.cleanup_blobs(None).await { 51 | Ok(info) => { 52 | tracing::info!("Cleanup info: {:?}", info); 53 | } 54 | Err(err) => { 55 | tracing::warn!("Cannot cleanup blobs: {}", err); 56 | } 57 | } 58 | 59 | tokio::time::sleep(Duration::from_secs(15 * 60)).await; 60 | } 61 | } 62 | 63 | async fn shutdown_signal() { 64 | let ctrl_c = async { 65 | signal::ctrl_c() 66 | .await 67 | .expect("failed to install Ctrl+C handler"); 68 | }; 69 | 70 | #[cfg(unix)] 71 | let terminate = async { 72 | signal::unix::signal(signal::unix::SignalKind::terminate()) 73 | .expect("failed to install signal handler") 74 | .recv() 75 | .await; 76 | }; 77 | 78 | #[cfg(not(unix))] 79 | let terminate = std::future::pending::<()>(); 80 | 81 | tokio::select! { 82 | _ = ctrl_c => {}, 83 | _ = terminate => {}, 84 | } 85 | 86 | tracing::info!("Signal received, starting graceful shutdown"); 87 | } 88 | -------------------------------------------------------------------------------- /bolik_server/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | 3 | use bolik_server::run; 4 | use tracing_subscriber::EnvFilter; 5 | 6 | #[tokio::main] 7 | async fn main() { 8 | if env::var_os("RUST_LOG").is_none() { 9 | // Set `RUST_LOG=backend=debug` to see debug logs, 10 | // this only shows access logs. 11 | env::set_var("RUST_LOG", "info,bolik_server=debug,tower_http=info"); 12 | } 13 | tracing_subscriber::fmt() 14 | .with_max_level(tracing::Level::TRACE) 15 | .with_env_filter(EnvFilter::from_default_env()) 16 | .init(); 17 | 18 | if let Err(err) = run().await { 19 | tracing::error!("{:?}", err); 20 | std::process::exit(1); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /bolik_server/src/migration.rs: -------------------------------------------------------------------------------- 1 | use bolik_migrations::{rusqlite::Connection, MigrationError}; 2 | 3 | const CHANGELOG: [(&str, &str); 1] = [( 4 | "20220807", 5 | r#" 6 | CREATE TABLE credentials ( 7 | device_id TEXT PRIMARY KEY, 8 | data BLOB NOT NULL 9 | ) WITHOUT ROWID; 10 | 11 | CREATE TABLE unused_key_packages ( 12 | ref TEXT PRIMARY KEY, 13 | data BLOB NOT NULL, 14 | device_id TEXT NOT NULL REFERENCES credentials(device_id) ON DELETE CASCADE 15 | ) WITHOUT ROWID; 16 | 17 | CREATE TABLE signature_chains ( 18 | id TEXT PRIMARY KEY, 19 | chain BLOB NOT NULL, 20 | is_account INT 21 | ) WITHOUT ROWID; 22 | 23 | CREATE TABLE signature_chain_members ( 24 | chain_id TEXT NOT NULL REFERENCES signature_chains(id) ON DELETE CASCADE, 25 | device_id TEXT NOT NULL REFERENCES credentials(device_id), 26 | PRIMARY KEY (chain_id, device_id) 27 | ) WITHOUT ROWID; 28 | 29 | CREATE TABLE device_mailbox ( 30 | id TEXT NOT NULL, 31 | device_id TEXT NOT NULL, 32 | data BLOB NOT NULL, 33 | created_at TEXT NOT NULL, 34 | PRIMARY KEY (id, device_id) 35 | ); 36 | 37 | CREATE TABLE blobs ( 38 | id TEXT NOT NULL, 39 | device_id TEXT NOT NULL, 40 | bucket TEXT NOT NULL, 41 | path TEXT NOT NULL, 42 | uploaded INT, 43 | size_bytes INT, 44 | unused_since TEXT, 45 | PRIMARY KEY (id, device_id) 46 | ) WITHOUT ROWID; 47 | 48 | CREATE TABLE account_docs ( 49 | account_id TEXT NOT NULL, 50 | doc_id TEXT NOT NULL, 51 | author_device_id TEXT NOT NULL, 52 | counter INT NOT NULL, 53 | secret_id TEXT, 54 | payload BLOB, 55 | payload_signature TEXT NOT NULL, 56 | created_at TEXT NOT NULL, 57 | deleted_at TEXT, 58 | PRIMARY KEY (account_id, doc_id, author_device_id) 59 | ) WITHOUT ROWID; 60 | 61 | -- blob_ref (blob_id and device_id) point to blobs 62 | -- doc_ref (account_id, doc_id, author_device_id) point to docs 63 | CREATE TABLE doc_blobs ( 64 | blob_id TEXT NOT NULL, 65 | device_id TEXT NOT NULL, 66 | account_id TEXT NOT NULL, 67 | doc_id TEXT NOT NULL, 68 | author_device_id TEXT NOT NULL, 69 | PRIMARY KEY (blob_id, device_id, account_id, doc_id, author_device_id), 70 | FOREIGN KEY(blob_id, device_id) REFERENCES blobs(id, device_id), 71 | FOREIGN KEY(account_id, doc_id, author_device_id) 72 | REFERENCES account_docs(account_id, doc_id, author_device_id) 73 | ON DELETE CASCADE 74 | ) WITHOUT ROWID; 75 | 76 | CREATE TABLE mailbox_ack_stats ( 77 | day TEXT PRIMARY KEY, 78 | processed INT DEFAULT 0, 79 | failed INT DEFAULT 0 80 | ) WITHOUT ROWID; 81 | "#, 82 | )]; 83 | 84 | pub fn apply(conn: &Connection) -> Result<(), MigrationError> { 85 | bolik_migrations::apply(conn, &CHANGELOG)?; 86 | bolik_migrations::add_seahash(conn)?; 87 | bolik_migrations::add_group_seahash(conn)?; 88 | 89 | Ok(()) 90 | } 91 | -------------------------------------------------------------------------------- /bolik_server/src/mls.rs: -------------------------------------------------------------------------------- 1 | use std::{error::Error, fmt::Display}; 2 | 3 | use openmls::prelude::{ 4 | Credential, KeyPackage, OpenMlsKeyStore, Signature, TlsDeserializeTrait, TlsSerializeTrait, 5 | }; 6 | use openmls_rust_crypto::RustCrypto; 7 | use openmls_traits::{ 8 | key_store::{FromKeyStoreValue, ToKeyStoreValue}, 9 | OpenMlsCryptoProvider, 10 | }; 11 | 12 | use crate::error::{AppError, AuthError, UserError}; 13 | 14 | pub type CryptoProvider = RustCrypto; 15 | 16 | /// Crypto provider that doesn't remember anything. 17 | #[derive(Default)] 18 | pub struct VoidCryptoProvider { 19 | crypto: CryptoProvider, 20 | } 21 | 22 | impl OpenMlsCryptoProvider for VoidCryptoProvider { 23 | type CryptoProvider = CryptoProvider; 24 | type RandProvider = CryptoProvider; 25 | type KeyStoreProvider = Self; 26 | 27 | fn crypto(&self) -> &Self::CryptoProvider { 28 | &self.crypto 29 | } 30 | 31 | fn rand(&self) -> &Self::RandProvider { 32 | &self.crypto 33 | } 34 | 35 | fn key_store(&self) -> &Self::KeyStoreProvider { 36 | self 37 | } 38 | } 39 | 40 | impl OpenMlsKeyStore for VoidCryptoProvider { 41 | type Error = VoidError; 42 | 43 | fn store(&self, _k: &[u8], _v: &V) -> Result<(), Self::Error> 44 | where 45 | Self: Sized, 46 | { 47 | Ok(()) 48 | } 49 | 50 | fn read(&self, _k: &[u8]) -> Option 51 | where 52 | Self: Sized, 53 | { 54 | None 55 | } 56 | 57 | fn delete(&self, _k: &[u8]) -> Result<(), Self::Error> { 58 | Ok(()) 59 | } 60 | } 61 | 62 | #[derive(Debug)] 63 | pub struct VoidError {} 64 | 65 | impl Display for VoidError { 66 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 67 | f.write_str("VoidError") 68 | } 69 | } 70 | 71 | impl Error for VoidError { 72 | fn source(&self) -> Option<&(dyn Error + 'static)> { 73 | None 74 | } 75 | } 76 | 77 | pub fn get_device_id(credential: &Credential) -> Result { 78 | let device_id = id_from_key( 79 | credential 80 | .signature_key() 81 | .tls_serialize_detached() 82 | .map_err(UserError::KeyPackageDecode)? 83 | .as_slice(), 84 | ); 85 | Ok(device_id) 86 | } 87 | 88 | pub fn get_key_package_ref(key_package: &KeyPackage) -> Result { 89 | let key_package_ref = id_from_key( 90 | key_package 91 | .hash_ref(&CryptoProvider::default()) 92 | .map_err(UserError::KeyPackageHash)? 93 | .as_slice(), 94 | ); 95 | Ok(key_package_ref) 96 | } 97 | 98 | pub fn read_signature(id: &str) -> Result { 99 | let bytes = key_from_id(id)?; 100 | let signature = 101 | Signature::tls_deserialize(&mut bytes.as_slice()).map_err(|_| AuthError::BadSignature)?; 102 | Ok(signature) 103 | } 104 | 105 | /// Encode bytes into base58 string 106 | fn id_from_key(k: &[u8]) -> String { 107 | bs58::encode(k).into_string() 108 | } 109 | 110 | /// Decode base58 string into bytes 111 | fn key_from_id(id: &str) -> Result, AppError> { 112 | let key = bs58::decode(id) 113 | .into_vec() 114 | .map_err(UserError::Base58Decode)?; 115 | Ok(key) 116 | } 117 | -------------------------------------------------------------------------------- /bolik_tests/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "bolik_tests" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | anyhow = "1" 10 | axum = { version = "0.6", features = ["tokio", "http1", "tower-log"], default-features = false } 11 | bolik_sdk = { path = "../bolik_sdk" } 12 | bolik_server = { path = "../bolik_server" } 13 | bolik_migrations = { path = "../common/migrations" } 14 | bolik_proto = { path = "../bolik_proto" } 15 | chrono = "^0.4" 16 | hyper = "0.14" 17 | openmls = { workspace = true } 18 | rand = "0.8" 19 | rust-s3 = { version = "0.33.0-beta4", default-features = false, features = [] } 20 | tempfile = "3" 21 | tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "fs", "signal"] } 22 | tokio-stream = "^0.1" 23 | tower = { version = "0.4", features = [] } 24 | tracing = { workspace = true } 25 | tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "std", "ansi"], default-features = false } 26 | -------------------------------------------------------------------------------- /common/migrations/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "bolik_migrations" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | base16ct = { version = "0.1", features = ["alloc"] } 8 | rusqlite = { version = "0.28", features = ["bundled", "chrono", "functions"] } 9 | seahash = { workspace = true } 10 | sha2 = "0.10" 11 | thiserror = "1" 12 | tracing = { workspace = true } 13 | 14 | [dev-dependencies] 15 | rand = "0.8" 16 | -------------------------------------------------------------------------------- /fly-bolik-api.toml: -------------------------------------------------------------------------------- 1 | # fly.toml app configuration file generated for bolik-api on 2023-07-26T13:38:38+03:00 2 | # 3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file. 4 | # 5 | 6 | app = "bolik-api" 7 | primary_region = "waw" 8 | kill_signal = "SIGINT" 9 | kill_timeout = "10s" 10 | 11 | [experimental] 12 | auto_rollback = true 13 | 14 | [env] 15 | PORT = "8080" 16 | SQLITE_PATH = "/litefs/timeline.db" 17 | 18 | [[mounts]] 19 | source = "bolik_api_db_machines" 20 | destination = "/var/lib/bolik_api_db" 21 | processes = ["app"] 22 | 23 | [[services]] 24 | protocol = "tcp" 25 | internal_port = 8080 26 | processes = ["app"] 27 | 28 | [[services.ports]] 29 | port = 80 30 | handlers = ["http"] 31 | force_https = true 32 | 33 | [[services.ports]] 34 | port = 443 35 | handlers = ["tls", "http"] 36 | [services.concurrency] 37 | type = "connections" 38 | hard_limit = 25 39 | soft_limit = 20 40 | 41 | [[services.tcp_checks]] 42 | interval = "15s" 43 | timeout = "2s" 44 | grace_period = "1s" 45 | restart_limit = 0 46 | -------------------------------------------------------------------------------- /litefs.yml: -------------------------------------------------------------------------------- 1 | # Specify a directory where app should access the database from. 2 | fuse: 3 | dir: "/litefs" 4 | 5 | # Specify a directory where LiteFS stores its data. 6 | # We use a volume mount. 7 | data: 8 | dir: "/var/lib/bolik_api_db" 9 | 10 | # Set to false if you want LiteFS not to exit in case of an error. 11 | # Useful for debugging via SSH. 12 | exit-on-error: true 13 | 14 | # This node is always a primary for the time being. 15 | lease: 16 | type: "static" 17 | -------------------------------------------------------------------------------- /test_data/bolik_export_1/2022-10-07T07:35:23 (45941d).md: -------------------------------------------------------------------------------- 1 | # Bolik card 2 | 3 | * ID: 45941d0a-7836-443b-a430-a9518eca56b9 4 | * Created at: 2022-10-07T07:35:23+00:00 5 | * Labels: Testing, Writing 6 | 7 | ## Content 8 | 9 | ### Text 10 | 11 | This is a sample card. 12 | The text spawns multiple lines 13 | 14 | and has empty lines. 15 | 16 | ### Tasks 17 | 18 | * [x] Share this card with Sam 19 | * [ ] Something else 20 | 21 | ### File 22 | 23 | * Name: hello world.txt 24 | * ID: 52a19034-9e83-4404-93eb-f6bcbd6b379c 25 | 26 | [Link to hello world.txt](./Files/hello%20world%20%28version%202460e6%29%2Etxt) 27 | 28 | ### Text 29 | 30 | The End. 31 | -------------------------------------------------------------------------------- /test_data/bolik_export_1/Files/hello world (version 2460e6).txt: -------------------------------------------------------------------------------- 1 | Hi to you! 2 | -------------------------------------------------------------------------------- /test_data/bolik_export_2/2022-12-26T13:06:46 (9aa6b4).md: -------------------------------------------------------------------------------- 1 | # Bolik card v2 2 | 3 | * ID: 9aa6b40a-d8c8-4bf0-8b36-a93436b14487 4 | * Created at: 2022-12-26T13:06:46.620320382+00:00 5 | * Labels: Example, Bolik 6 | 7 | ------------------------------- 8 | 9 | Hello world! 10 | Multiline paragraph. 11 | 12 | * Item 1 13 | * 14 | 15 | 1. Number 1 16 | 1. **Number** 2 17 | 18 | Something in between. 19 | 20 | * [ ] Task 1 21 | * [x] Task 2 22 | 23 | New paragraph with **bold** _italic_ underline ~~strikethrough~~ 24 | **_~~all~~_** 25 | 26 | # Heading 27 | 28 | ## Sub-Heading 29 | 30 | * [File:hello-world.txt](./Files/hello%2Dworld%20%28version%209aa6b4%29%2Etxt) 31 | 32 | -------------------------------------------------------------------------------- /test_data/bolik_export_2/Files/hello-world (version 9aa6b4).txt: -------------------------------------------------------------------------------- 1 | Hello world! 2 | -------------------------------------------------------------------------------- /test_data/device-A.bundles: -------------------------------------------------------------------------------- 1 | {"credential":{"credential_type":"Basic","credential":{"Basic":{"identity":{"vec":[65]},"signature_scheme":"ED25519","public_key":{"signature_scheme":"ED25519","value":[175,180,166,235,20,200,4,217,157,129,102,206,75,158,233,43,98,229,1,17,214,88,162,86,91,195,224,211,78,145,105,80]}}}},"signature_private_key":{"signature_scheme":"ED25519","value":[219,131,64,138,152,218,73,28,68,142,23,252,181,99,251,79,74,28,36,145,6,240,29,129,106,113,122,80,89,50,118,141,175,180,166,235,20,200,4,217,157,129,102,206,75,158,233,43,98,229,1,17,214,88,162,86,91,195,224,211,78,145,105,80]}} 2 | {"key_package":{"payload":{"protocol_version":"Mls10","ciphersuite":"MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519","hpke_init_key":{"value":[195,120,99,98,178,91,11,112,69,219,5,244,50,241,69,93,97,158,220,0,235,129,93,104,54,154,66,15,220,24,35,52]},"credential":{"credential_type":"Basic","credential":{"Basic":{"identity":{"vec":[65]},"signature_scheme":"ED25519","public_key":{"signature_scheme":"ED25519","value":[175,180,166,235,20,200,4,217,157,129,102,206,75,158,233,43,98,229,1,17,214,88,162,86,91,195,224,211,78,145,105,80]}}}},"extensions":{"vec":[{"Capabilities":{"versions":{"vec":["Mls10"]},"ciphersuites":{"vec":["MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519","MLS_128_DHKEMX25519_CHACHA20POLY1305_SHA256_Ed25519","MLS_256_DHKEMX448_AES256GCM_SHA512_Ed448"]},"extensions":{"vec":["Capabilities","Lifetime","ExternalKeyId"]},"proposals":{"vec":["Add","Update","Remove","Presharedkey","Reinit","GroupContextExtensions"]}}},{"LifeTime":{"not_before":1671623081,"not_after":1678884281}}]}},"signature":{"value":{"vec":[226,1,9,53,198,187,231,229,151,215,83,236,55,254,7,167,85,245,158,242,241,23,60,120,255,147,106,245,115,251,169,95,16,105,45,60,0,48,11,110,134,49,7,182,24,88,197,176,150,122,38,149,21,212,209,200,125,133,167,38,102,104,15,14]}}},"private_key":{"value":[136,93,163,227,204,24,188,79,143,81,253,188,162,104,171,163,43,16,221,234,159,254,59,27,120,42,252,79,50,93,119,51]},"leaf_secret":{"ciphersuite":"MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519","value":[227,154,170,202,55,7,169,99,127,191,152,243,112,171,234,2,45,233,130,15,129,171,159,69,206,36,93,18,136,169,150,75],"mls_version":"Mls10"}} 3 | {"key_package":{"payload":{"protocol_version":"Mls10","ciphersuite":"MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519","hpke_init_key":{"value":[164,88,120,150,8,217,190,77,4,230,209,134,29,179,39,118,94,98,28,73,102,74,23,38,122,250,246,248,28,68,38,50]},"credential":{"credential_type":"Basic","credential":{"Basic":{"identity":{"vec":[65]},"signature_scheme":"ED25519","public_key":{"signature_scheme":"ED25519","value":[198,127,55,219,191,61,241,148,29,95,114,203,127,144,37,193,7,87,132,7,225,72,6,115,96,10,26,12,123,71,127,0]}}}},"extensions":{"vec":[{"Capabilities":{"versions":{"vec":["Mls10"]},"ciphersuites":{"vec":["MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519","MLS_128_DHKEMX25519_CHACHA20POLY1305_SHA256_Ed25519","MLS_256_DHKEMX448_AES256GCM_SHA512_Ed448"]},"extensions":{"vec":["Capabilities","Lifetime","ExternalKeyId"]},"proposals":{"vec":["Add","Update","Remove","Presharedkey","Reinit","GroupContextExtensions"]}}},{"LifeTime":{"not_before":1671624538,"not_after":1678885738}}]}},"signature":{"value":{"vec":[122,182,227,57,70,143,72,159,255,13,17,209,59,215,119,198,24,46,43,17,244,238,192,218,44,164,40,135,118,119,43,19,52,130,71,236,194,44,81,107,226,253,242,238,128,65,199,124,56,198,187,162,1,186,48,98,103,100,138,28,128,209,67,3]}}},"private_key":{"value":[224,65,236,246,49,102,4,218,20,174,197,5,162,21,73,144,58,80,180,74,157,113,92,124,71,246,19,108,43,163,179,206]},"leaf_secret":{"ciphersuite":"MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519","value":[56,76,250,240,198,164,109,230,193,14,249,83,47,223,138,128,171,89,235,161,103,234,149,130,4,164,140,5,164,27,204,145],"mls_version":"Mls10"}} 4 | -------------------------------------------------------------------------------- /test_data/device-B.bundles: -------------------------------------------------------------------------------- 1 | {"credential":{"credential_type":"Basic","credential":{"Basic":{"identity":{"vec":[66]},"signature_scheme":"ED25519","public_key":{"signature_scheme":"ED25519","value":[126,194,108,145,165,49,55,117,176,201,92,126,95,74,146,209,12,112,137,202,211,111,254,31,119,27,33,87,218,122,205,253]}}}},"signature_private_key":{"signature_scheme":"ED25519","value":[102,240,1,198,245,253,236,94,55,185,214,80,251,81,122,145,104,219,204,91,55,223,186,50,64,61,195,40,84,80,139,8,126,194,108,145,165,49,55,117,176,201,92,126,95,74,146,209,12,112,137,202,211,111,254,31,119,27,33,87,218,122,205,253]}} 2 | {"key_package":{"payload":{"protocol_version":"Mls10","ciphersuite":"MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519","hpke_init_key":{"value":[46,195,226,214,94,145,40,100,126,224,153,246,204,198,116,166,242,123,139,194,237,94,71,252,97,231,150,96,238,201,204,39]},"credential":{"credential_type":"Basic","credential":{"Basic":{"identity":{"vec":[66]},"signature_scheme":"ED25519","public_key":{"signature_scheme":"ED25519","value":[126,194,108,145,165,49,55,117,176,201,92,126,95,74,146,209,12,112,137,202,211,111,254,31,119,27,33,87,218,122,205,253]}}}},"extensions":{"vec":[{"Capabilities":{"versions":{"vec":["Mls10"]},"ciphersuites":{"vec":["MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519","MLS_128_DHKEMX25519_CHACHA20POLY1305_SHA256_Ed25519","MLS_256_DHKEMX448_AES256GCM_SHA512_Ed448"]},"extensions":{"vec":["Capabilities","Lifetime","ExternalKeyId"]},"proposals":{"vec":["Add","Update","Remove","Presharedkey","Reinit","GroupContextExtensions"]}}},{"LifeTime":{"not_before":1671623081,"not_after":1678884281}}]}},"signature":{"value":{"vec":[53,127,34,26,125,250,93,153,116,34,209,20,158,206,117,210,168,104,40,143,113,152,88,33,85,202,233,23,246,20,218,173,91,182,34,10,37,250,193,118,36,10,181,145,168,142,217,90,173,234,40,243,222,90,85,34,171,38,169,241,89,242,224,2]}}},"private_key":{"value":[147,183,71,135,131,134,183,3,35,134,70,179,155,96,49,135,109,63,235,40,50,237,206,83,65,62,232,27,116,58,143,199]},"leaf_secret":{"ciphersuite":"MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519","value":[193,222,40,67,188,120,129,99,30,123,143,215,121,244,222,74,192,170,214,248,106,124,161,95,199,133,232,47,108,240,132,255],"mls_version":"Mls10"}} 3 | {"key_package":{"payload":{"protocol_version":"Mls10","ciphersuite":"MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519","hpke_init_key":{"value":[59,160,57,72,226,168,58,178,82,127,80,48,140,137,131,172,111,168,217,108,194,184,166,26,98,244,77,248,121,107,139,8]},"credential":{"credential_type":"Basic","credential":{"Basic":{"identity":{"vec":[66]},"signature_scheme":"ED25519","public_key":{"signature_scheme":"ED25519","value":[250,168,177,142,189,116,220,102,220,204,232,249,207,175,73,206,172,129,24,140,203,74,30,218,2,111,125,96,249,255,86,21]}}}},"extensions":{"vec":[{"Capabilities":{"versions":{"vec":["Mls10"]},"ciphersuites":{"vec":["MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519","MLS_128_DHKEMX25519_CHACHA20POLY1305_SHA256_Ed25519","MLS_256_DHKEMX448_AES256GCM_SHA512_Ed448"]},"extensions":{"vec":["Capabilities","Lifetime","ExternalKeyId"]},"proposals":{"vec":["Add","Update","Remove","Presharedkey","Reinit","GroupContextExtensions"]}}},{"LifeTime":{"not_before":1671624620,"not_after":1678885820}}]}},"signature":{"value":{"vec":[110,235,241,185,59,46,210,197,114,182,206,52,61,246,117,108,146,153,231,90,255,86,203,181,182,162,255,77,1,15,180,234,19,35,229,121,96,9,117,180,145,119,219,197,198,1,102,122,197,202,1,29,241,134,70,19,31,43,222,137,203,85,54,6]}}},"private_key":{"value":[4,150,126,90,127,205,251,107,194,154,59,70,130,238,72,149,83,254,214,135,253,115,174,85,178,79,68,254,223,138,50,13]},"leaf_secret":{"ciphersuite":"MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519","value":[140,224,80,204,112,217,242,247,101,9,151,15,77,221,191,26,76,52,2,62,193,42,56,100,104,183,219,70,53,128,54,250],"mls_version":"Mls10"}} 4 | -------------------------------------------------------------------------------- /test_data/device-C.bundles: -------------------------------------------------------------------------------- 1 | {"credential":{"credential_type":"Basic","credential":{"Basic":{"identity":{"vec":[67]},"signature_scheme":"ED25519","public_key":{"signature_scheme":"ED25519","value":[192,5,128,202,113,176,153,98,161,104,89,127,209,217,221,24,210,1,50,205,161,41,196,149,31,194,35,122,206,87,22,216]}}}},"signature_private_key":{"signature_scheme":"ED25519","value":[62,185,46,171,74,74,113,152,6,185,253,132,103,128,102,247,114,50,241,27,68,208,124,224,59,206,190,143,31,42,254,58,192,5,128,202,113,176,153,98,161,104,89,127,209,217,221,24,210,1,50,205,161,41,196,149,31,194,35,122,206,87,22,216]}} 2 | {"key_package":{"payload":{"protocol_version":"Mls10","ciphersuite":"MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519","hpke_init_key":{"value":[88,228,3,93,163,115,187,227,246,185,152,144,148,28,43,119,156,50,18,76,157,214,35,79,103,186,41,120,179,88,247,106]},"credential":{"credential_type":"Basic","credential":{"Basic":{"identity":{"vec":[67]},"signature_scheme":"ED25519","public_key":{"signature_scheme":"ED25519","value":[192,5,128,202,113,176,153,98,161,104,89,127,209,217,221,24,210,1,50,205,161,41,196,149,31,194,35,122,206,87,22,216]}}}},"extensions":{"vec":[{"Capabilities":{"versions":{"vec":["Mls10"]},"ciphersuites":{"vec":["MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519","MLS_128_DHKEMX25519_CHACHA20POLY1305_SHA256_Ed25519","MLS_256_DHKEMX448_AES256GCM_SHA512_Ed448"]},"extensions":{"vec":["Capabilities","Lifetime","ExternalKeyId"]},"proposals":{"vec":["Add","Update","Remove","Presharedkey","Reinit","GroupContextExtensions"]}}},{"LifeTime":{"not_before":1671623081,"not_after":1678884281}}]}},"signature":{"value":{"vec":[156,101,75,138,231,51,10,241,219,22,48,171,37,102,196,13,124,7,149,96,51,115,236,173,154,110,169,135,60,38,165,233,37,227,190,247,80,7,69,183,237,128,113,68,250,52,82,223,131,184,152,90,56,162,180,243,49,140,102,235,180,70,197,13]}}},"private_key":{"value":[173,207,235,22,28,216,11,233,29,152,125,77,195,170,160,140,248,202,146,122,103,149,23,22,124,231,193,110,29,64,169,139]},"leaf_secret":{"ciphersuite":"MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519","value":[184,210,160,4,117,11,41,95,42,74,76,192,84,248,204,188,237,130,97,121,31,109,127,203,223,222,208,180,28,128,200,134],"mls_version":"Mls10"}} -------------------------------------------------------------------------------- /test_data/device-D.bundles: -------------------------------------------------------------------------------- 1 | {"credential":{"credential_type":"Basic","credential":{"Basic":{"identity":{"vec":[68]},"signature_scheme":"ED25519","public_key":{"signature_scheme":"ED25519","value":[246,97,233,3,42,116,16,165,178,224,99,216,147,152,137,31,192,248,52,1,135,64,142,16,47,83,47,40,187,89,189,86]}}}},"signature_private_key":{"signature_scheme":"ED25519","value":[190,119,94,235,107,222,23,14,49,197,180,238,132,139,1,243,27,224,207,194,30,186,42,175,3,233,74,13,16,210,216,238,246,97,233,3,42,116,16,165,178,224,99,216,147,152,137,31,192,248,52,1,135,64,142,16,47,83,47,40,187,89,189,86]}} 2 | {"key_package":{"payload":{"protocol_version":"Mls10","ciphersuite":"MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519","hpke_init_key":{"value":[1,223,42,242,237,166,129,33,217,61,244,12,209,0,75,207,174,96,238,176,123,171,48,152,77,219,28,204,139,208,202,47]},"credential":{"credential_type":"Basic","credential":{"Basic":{"identity":{"vec":[68]},"signature_scheme":"ED25519","public_key":{"signature_scheme":"ED25519","value":[246,97,233,3,42,116,16,165,178,224,99,216,147,152,137,31,192,248,52,1,135,64,142,16,47,83,47,40,187,89,189,86]}}}},"extensions":{"vec":[{"Capabilities":{"versions":{"vec":["Mls10"]},"ciphersuites":{"vec":["MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519","MLS_128_DHKEMX25519_CHACHA20POLY1305_SHA256_Ed25519","MLS_256_DHKEMX448_AES256GCM_SHA512_Ed448"]},"extensions":{"vec":["Capabilities","Lifetime","ExternalKeyId"]},"proposals":{"vec":["Add","Update","Remove","Presharedkey","Reinit","GroupContextExtensions"]}}},{"LifeTime":{"not_before":1671623559,"not_after":1678884759}}]}},"signature":{"value":{"vec":[105,215,107,141,162,57,240,17,47,235,154,13,234,36,6,41,234,171,54,75,209,148,1,228,185,22,184,203,242,23,56,123,108,182,28,130,14,133,238,7,201,71,150,148,154,239,18,77,86,4,242,23,55,183,2,34,6,217,233,237,170,193,220,11]}}},"private_key":{"value":[15,44,226,160,148,251,217,138,38,6,255,182,254,10,92,14,206,187,193,33,3,81,43,92,80,170,154,7,175,184,55,25]},"leaf_secret":{"ciphersuite":"MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519","value":[248,124,101,46,127,242,160,70,162,210,165,111,199,216,212,247,237,64,2,91,72,245,99,206,165,6,55,146,209,207,182,200],"mls_version":"Mls10"}} -------------------------------------------------------------------------------- /test_data/device-E.bundles: -------------------------------------------------------------------------------- 1 | {"credential":{"credential_type":"Basic","credential":{"Basic":{"identity":{"vec":[69]},"signature_scheme":"ED25519","public_key":{"signature_scheme":"ED25519","value":[101,131,52,148,106,164,161,107,50,5,36,219,243,146,237,224,238,62,84,160,157,177,40,135,247,197,119,45,223,188,88,239]}}}},"signature_private_key":{"signature_scheme":"ED25519","value":[57,65,43,123,113,43,214,248,85,241,195,25,205,35,85,250,71,153,89,191,225,182,64,133,198,166,106,149,59,12,235,142,101,131,52,148,106,164,161,107,50,5,36,219,243,146,237,224,238,62,84,160,157,177,40,135,247,197,119,45,223,188,88,239]}} 2 | {"key_package":{"payload":{"protocol_version":"Mls10","ciphersuite":"MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519","hpke_init_key":{"value":[28,224,210,135,15,115,55,146,88,125,13,107,200,126,0,80,2,95,246,65,48,226,95,224,149,33,194,85,170,113,7,69]},"credential":{"credential_type":"Basic","credential":{"Basic":{"identity":{"vec":[69]},"signature_scheme":"ED25519","public_key":{"signature_scheme":"ED25519","value":[101,131,52,148,106,164,161,107,50,5,36,219,243,146,237,224,238,62,84,160,157,177,40,135,247,197,119,45,223,188,88,239]}}}},"extensions":{"vec":[{"Capabilities":{"versions":{"vec":["Mls10"]},"ciphersuites":{"vec":["MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519","MLS_128_DHKEMX25519_CHACHA20POLY1305_SHA256_Ed25519","MLS_256_DHKEMX448_AES256GCM_SHA512_Ed448"]},"extensions":{"vec":["Capabilities","Lifetime","ExternalKeyId"]},"proposals":{"vec":["Add","Update","Remove","Presharedkey","Reinit","GroupContextExtensions"]}}},{"LifeTime":{"not_before":1671624216,"not_after":1678885416}}]}},"signature":{"value":{"vec":[78,176,87,204,39,152,183,17,215,157,121,177,122,154,184,161,148,67,123,76,96,46,95,121,227,105,67,76,131,211,85,112,109,229,95,38,97,209,184,34,10,49,192,189,252,73,208,63,38,7,12,134,93,174,172,167,185,197,122,239,129,67,212,12]}}},"private_key":{"value":[153,231,196,86,69,218,130,112,221,106,80,109,52,227,208,236,54,228,229,186,119,172,136,118,38,54,44,241,14,209,136,230]},"leaf_secret":{"ciphersuite":"MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519","value":[157,137,231,79,71,229,7,143,163,227,149,130,2,7,203,90,161,104,23,72,212,137,59,144,75,233,209,205,104,164,164,142],"mls_version":"Mls10"}} 3 | --------------------------------------------------------------------------------