diff --git a/.github/workflows/bridge-bindings.yml b/.github/workflows/bridge-bindings.yml new file mode 100644 index 0000000000..5359e72eb0 --- /dev/null +++ b/.github/workflows/bridge-bindings.yml @@ -0,0 +1,58 @@ +name: Rust bridge validation + +on: + workflow_dispatch: + push: + paths: + - ".github/workflows/bridge-bindings.yml" + - "rust/src/api/**" + - "flutter_rust_bridge.yaml" + - "lib/src/rust/**" + - "rust/src/frb_generated*" + pull_request: + paths: + - ".github/workflows/bridge-bindings.yml" + - "rust/src/api/**" + - "flutter_rust_bridge.yaml" + - "lib/src/rust/**" + - "rust/src/frb_generated*" + +jobs: + bindings: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install native build tools + run: | + sudo apt-get update + sudo apt-get install -y protobuf-compiler + + - name: Set up test FairPlay certificates + shell: bash + run: | + destination="rustpush/certs/fairplay" + mkdir -p "$destination" + for name in \ + 4056631661436364584235346952193 \ + 4056631661436364584235346952194 \ + 4056631661436364584235346952195 \ + 4056631661436364584235346952196 \ + 4056631661436364584235346952197 \ + 4056631661436364584235346952198 \ + 4056631661436364584235346952199 \ + 4056631661436364584235346952200 \ + 4056631661436364584235346952201 \ + 4056631661436364584235346952208 + do + cp rustpush/certs/legacy-fairplay/fairplay.pem "$destination/$name.pem" + cp rustpush/certs/legacy-fairplay/fairplay.crt "$destination/$name.crt" + done + + - name: Compile committed Rust bridge + run: cargo check --manifest-path rust/Cargo.toml --lib --message-format short diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f118701ddf..fad9677085 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -64,12 +64,28 @@ jobs: cp rustpush/certs/legacy-fairplay/fairplay.crt rustpush/certs/fairplay/$name.crt done + - name: Run focused message helper tests + run: flutter test test/helpers/message_helper_test.dart + + - name: Run bounded memory cache tests + run: >- + flutter test + test/helpers/memory/bounded_byte_cache_test.dart + test/helpers/memory/bounded_lru_map_test.dart + # First run is expected to fail until ffmpeg_kit_flutter_new is fixed. - - name: Run Build Script - run: | - flutter build apk --flavor alpha --debug --target-platform android-arm64 - + - name: Build Alpha Profile APK + run: flutter build apk --flavor alpha --profile --target-platform android-arm64 + + - uses: actions/upload-artifact@v4 + with: + name: Alpha Profile APK + path: build/app/outputs/flutter-apk/app-alpha-profile.apk + + - name: Build Alpha Debug APK + run: flutter build apk --flavor alpha --debug --target-platform android-arm64 + - uses: actions/upload-artifact@v4 with: name: Alpha Debug APK - path: build/app/outputs/flutter-apk/app-alpha-debug.apk \ No newline at end of file + path: build/app/outputs/flutter-apk/app-alpha-debug.apk diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml new file mode 100644 index 0000000000..7396509840 --- /dev/null +++ b/.github/workflows/windows-build.yml @@ -0,0 +1,223 @@ +name: Windows builds + +on: + workflow_dispatch: + push: + pull_request: + +permissions: + contents: read + +concurrency: + group: windows-build-${{ github.ref }} + cancel-in-progress: true + +jobs: + windows: + name: Windows ${{ matrix.arch }} + runs-on: ${{ matrix.runner }} + continue-on-error: ${{ matrix.experimental }} + strategy: + fail-fast: false + matrix: + include: + - arch: x64 + # Flutter 3.24 does not recognize the Visual Studio 2026 image + # currently behind windows-latest. Pin VS 2022 until the app's + # Flutter toolchain is upgraded. + runner: windows-2022 + flutter-version: 3.24.0 + rust-target: x86_64-pc-windows-msvc + build-directory: x64 + experimental: false + - arch: arm64 + runner: windows-11-arm + # Native Windows ARM64 Dart and engine artifacts are available + # starting with Flutter 3.44. + flutter-version: 3.44.8 + rust-target: aarch64-pc-windows-msvc + build-directory: arm64 + experimental: true + + steps: + - name: Check out source + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Report runner architecture + shell: pwsh + run: | + $osArchitecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture + Write-Host "Runner architecture: $osArchitecture" + Write-Host "PROCESSOR_ARCHITECTURE=$env:PROCESSOR_ARCHITECTURE" + if ("$osArchitecture".ToLowerInvariant() -ne "${{ matrix.arch }}") { + throw "Expected a native ${{ matrix.arch }} runner, but received $osArchitecture." + } + + # Do not remove this guard merely to make CI green. A native ARM64 + # executable cannot load the x64 DLLs currently selected by these + # packages. Re-audit all three dependencies before enabling the build. + - name: Audit native ARM64 dependency compatibility + if: matrix.arch == 'arm64' + shell: pwsh + run: | + Write-Error @" + Native Windows ARM64 packaging is blocked by the locked native dependencies: + + 1. objectbox_flutter_libs 4.0.3 selects ObjectBox C 4.0.2 using + CMAKE_SYSTEM_PROCESSOR, but that release provides Windows x86 and + x64 archives only. There is no objectbox-windows-ARM64.zip. + 2. printing 5.13.4 hard-codes PDFIUM_ARCH=x64 and downloads an x64 + PDFium archive. + 3. media_kit_libs_windows_video 1.0.10 hard-codes an x86_64 libmpv + archive and an x64 ANGLE bundle. + + This job is experimental and allowed to fail. Keep it blocked until + native ARM64 replacements exist and are runtime-tested. + "@ + exit 1 + + - name: Set up Flutter + id: flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + flutter-version: ${{ matrix.flutter-version }} + # Flutter's full Windows SDK archive is x64. On the ARM64 runner, + # the next step replaces its Dart SDK and downloads ARM64 engines. + architecture: x64 + cache: true + + - name: Bootstrap native ARM64 Dart and Flutter engine + if: matrix.arch == 'arm64' + shell: pwsh + run: | + $flutterRoot = "${{ steps.flutter.outputs['cache-path'] }}" + Remove-Item -Force "$flutterRoot\bin\cache\engine-dart-sdk.stamp" -ErrorAction SilentlyContinue + & "$flutterRoot\bin\internal\update_dart_sdk.ps1" + + $dartVersion = & "$flutterRoot\bin\dart.bat" --version 2>&1 | Out-String + Write-Host $dartVersion + if ($dartVersion -notmatch "windows_arm64") { + throw "Expected an ARM64 Dart SDK, but got: $dartVersion" + } + + & "$flutterRoot\bin\flutter.bat" precache --windows + $engineDirectory = "$flutterRoot\bin\cache\artifacts\engine\windows-arm64-release" + if (-not (Test-Path $engineDirectory)) { + throw "Flutter did not install the native Windows ARM64 release engine." + } + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.rust-target }} + + - name: Set up Protocol Buffers compiler + shell: pwsh + run: | + # protobuf does not publish a native Windows ARM64 protoc archive. + # The official win64 compiler is build-time tooling and runs under + # Windows 11 ARM's x64 emulation without affecting app architecture. + $version = "29.5" + $expectedHash = "633d3e555fc97f0a1f55b4adb03256cd94b8059e51e7abbae98ff39e58a9dfa5" + $archive = Join-Path $env:RUNNER_TEMP "protoc-$version-win64.zip" + $destination = Join-Path $env:RUNNER_TOOL_CACHE "protoc\$version\x64" + Invoke-WebRequest ` + "https://github.com/protocolbuffers/protobuf/releases/download/v$version/protoc-$version-win64.zip" ` + -OutFile $archive + + $actualHash = (Get-FileHash $archive -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualHash -ne $expectedHash) { + throw "protoc archive checksum mismatch: expected $expectedHash, got $actualHash" + } + + Expand-Archive -Path $archive -DestinationPath $destination -Force + "$destination\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + & "$destination\bin\protoc.exe" --version + + - name: Set up test FairPlay certificates + shell: pwsh + run: | + $destination = "rustpush\certs\fairplay" + New-Item -ItemType Directory -Force -Path $destination | Out-Null + $certificateNames = @( + "4056631661436364584235346952193", + "4056631661436364584235346952194", + "4056631661436364584235346952195", + "4056631661436364584235346952196", + "4056631661436364584235346952197", + "4056631661436364584235346952198", + "4056631661436364584235346952199", + "4056631661436364584235346952200", + "4056631661436364584235346952201", + "4056631661436364584235346952208" + ) + foreach ($name in $certificateNames) { + Copy-Item "rustpush\certs\legacy-fairplay\fairplay.pem" "$destination\$name.pem" + Copy-Item "rustpush\certs\legacy-fairplay\fairplay.crt" "$destination\$name.crt" + } + + - name: Resolve Flutter dependencies + shell: pwsh + run: | + flutter config --enable-windows-desktop + flutter doctor -v + flutter pub get + + - name: Build Windows release + shell: pwsh + env: + # Keep the large Rust release link inside the standard hosted + # runner's memory budget. + CARGO_BUILD_JOBS: "2" + CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "4" + CARGO_TERM_COLOR: never + CARGOKIT_VERBOSE: "1" + run: | + # OpenSSL's longest generated object path exceeds the legacy + # 260-character compiler limit from GitHub's nested checkout path. + # Build from a real short path and regenerate Flutter's absolute + # plugin links there. A substituted drive breaks Cargokit's link + # integrity checks because the links retain the original drive. + $shortWorkspace = "D:\s" + New-Item -ItemType Directory -Force -Path $shortWorkspace | Out-Null + robocopy "$env:GITHUB_WORKSPACE" $shortWorkspace /E ` + /XD ` + "$env:GITHUB_WORKSPACE\.git" ` + "$env:GITHUB_WORKSPACE\.dart_tool" ` + "$env:GITHUB_WORKSPACE\build" ` + "$env:GITHUB_WORKSPACE\windows\flutter\ephemeral" ` + /NFL /NDL /NJH /NJS /NP + if ($LASTEXITCODE -ge 8) { + throw "Failed to stage the short Windows build workspace." + } + + Set-Location $shortWorkspace + flutter pub get + flutter build windows --release + + - name: Package Windows release + shell: pwsh + run: | + $bundle = "D:\s\build\windows\${{ matrix.build-directory }}\runner\Release" + if (-not (Test-Path "$bundle\bluebubbles_app.exe")) { + throw "Expected release executable was not produced at $bundle\bluebubbles_app.exe" + } + + New-Item -ItemType Directory -Force -Path dist | Out-Null + $archive = "dist\OpenBubbles-windows-${{ matrix.arch }}.zip" + Compress-Archive -Path "$bundle\*" -DestinationPath $archive -Force + $hash = (Get-FileHash $archive -Algorithm SHA256).Hash.ToLowerInvariant() + "$hash OpenBubbles-windows-${{ matrix.arch }}.zip" | + Set-Content "$archive.sha256" + + - name: Upload Windows artifact + uses: actions/upload-artifact@v4 + with: + name: OpenBubbles-windows-${{ matrix.arch }} + path: | + dist/OpenBubbles-windows-${{ matrix.arch }}.zip + dist/OpenBubbles-windows-${{ matrix.arch }}.zip.sha256 + if-no-files-found: error diff --git a/.gitmodules b/.gitmodules index 9b859a75b8..468942beac 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,6 @@ [submodule "rustpush"] path = rustpush - url = git@github.com:OpenBubbles/rustpush.git + url = https://github.com/OpenBubbles/rustpush.git [submodule "telephony_plus"] path = telephony_plus - url = git@github.com:OpenBubbles/telephony_plus.git + url = https://github.com/OpenBubbles/telephony_plus.git diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index db0adb5686..92b78de3c9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,7 +11,7 @@ We encourage all contributions to this project! All we ask are you follow these Please make sure you have completed the following pre-requisites: * Install Git: [download](https://git-scm.com/downloads) -* Install Java: [download](https://www.oracle.com/java/technologies/javase/javase-jdk8-downloads.html) +* Install Java 21: [download](https://adoptium.net/temurin/releases/?version=21) * Install Flutter: [guide/download](https://flutter.dev/docs/get-started/install) * Install Android Studio [download](https://developer.android.com/studio) - Also install the Flutter & Dart Plugins via the Plugin Manager @@ -26,6 +26,11 @@ Once you have a code editor installed, remember to install all of the required p * Flutter * Intellisense/Intellicode +Before opening a pull request, read the repository-specific +[development and diagnostics notes](docs/DEVELOPMENT.md). Do not commit relay +registration codes, Apple credentials, phone numbers, message text, generated +signing files, or `.env` values. + ## Forking the Repository In order to start contributing, follow these steps: diff --git a/README.md b/README.md index 7daf5ca364..8f64968c5b 100644 --- a/README.md +++ b/README.md @@ -34,3 +34,11 @@ If you need help setting up the app, have any issues or feature requests, or jus ## Getting Started [Quickstart](https://openbubbles.app/quickstart.html) + +## Contributor documentation + +The repository-specific development and diagnostic notes are in +[`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) and +[`docs/DIAGNOSTICS.md`](docs/DIAGNOSTICS.md). They document the Android build +matrix, the iMessage/relay versus SMS/MMS/RCS boundary, safe log collection, +and the current known limitations. diff --git a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/foreground/SocketIOForegroundService.kt b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/foreground/SocketIOForegroundService.kt index c5a56dfe77..e7d0192715 100644 --- a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/foreground/SocketIOForegroundService.kt +++ b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/foreground/SocketIOForegroundService.kt @@ -9,7 +9,9 @@ import android.content.Context import android.content.Intent import android.util.Log import android.os.Build +import android.os.Handler import android.os.IBinder +import android.os.Looper import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat import com.bluebubbles.messaging.Constants @@ -47,6 +49,9 @@ class SocketIOForegroundService : Service() { private var isBeingDestroyed: Boolean = false private var hasStarted: Boolean = false + private val reconnectHandler = Handler(Looper.getMainLooper()) + private var reconnectRunnable: Runnable? = null + private var reconnectAttempt: Int = 0 private val eventBlacklist: Array = arrayOf( "typing-indicator", @@ -102,6 +107,9 @@ class SocketIOForegroundService : Service() { Log.d(Constants.logTag, "Foreground Service is connecting to: $serverUrl") val opts = IO.Options() + // Reconnects are scheduled by this service so the Socket.IO manager + // cannot race a second retry loop with our URL/service lifecycle. + opts.reconnection = false try { // Read the custom headers JSON string from preferences and parse it into a map @@ -124,10 +132,12 @@ class SocketIOForegroundService : Service() { val encodedPw = URLEncoder.encode(storedPassword, "UTF-8") opts.query = "password=$encodedPw" mSocket = IO.socket(serverUrl, opts) - mSocket!!.connect() mSocket!!.on(Socket.EVENT_CONNECT) { Log.d(Constants.logTag, "Socket.io connected to your server!") + reconnectAttempt = 0 + reconnectRunnable?.let { reconnectHandler.removeCallbacks(it) } + reconnectRunnable = null updateNotification(CONNECTED) } @@ -135,6 +145,7 @@ class SocketIOForegroundService : Service() { val error = args[0] as Exception Log.d(Constants.logTag, "Socket.io failed to connect to $serverUrl! Error: ${error.message}") updateNotification(CONNECT_FAILED + error.message) + tryReconnect() } // with reason, details args @@ -148,6 +159,7 @@ class SocketIOForegroundService : Service() { val details = args.getOrNull(1) Log.d(Constants.logTag, "Socket.io disconnected from server! Reason: $reason, Details: $details") updateNotification(DISCONNECTED + reason) + tryReconnect() } mSocket!!.on("reconnecting") { @@ -165,15 +177,17 @@ class SocketIOForegroundService : Service() { val event = args[0] as String val message = args[1] as JSONObject - Log.d(Constants.logTag, "Received event of type $event from Socket.io...") if (!eventBlacklist.contains(event)) { - Log.d(Constants.logTag, "Received event of type $event from Socket.io...") DartWorkManager.createWorker(applicationContext, "socket-event", hashMapOf("event" to event, "data" to message.toString())) {} } else { Log.d(Constants.logTag, "Ignored event of type $event from Socket.io...") } } } + + // Register every callback before opening the transport so an + // immediate connect or event cannot race listener setup. + mSocket!!.connect() } catch (e: Exception) { if (isBeingDestroyed) { return @@ -190,11 +204,18 @@ class SocketIOForegroundService : Service() { private fun tryReconnect() { if (mSocket != null && !mSocket!!.connected()) { - Log.e(Constants.logTag, "Waiting 30 seconds before reconnecting...") - - // Sleep for 30 seconds before attempting to reconnect - Thread.sleep(30000) - mSocket!!.connect() + if (reconnectRunnable != null) return + val delaySeconds = 30L * (1L shl reconnectAttempt.coerceAtMost(3)) + reconnectAttempt = (reconnectAttempt + 1).coerceAtMost(3) + Log.e(Constants.logTag, "Scheduling reconnect in ${delaySeconds}s...") + val runnable = Runnable { + reconnectRunnable = null + if (!isBeingDestroyed && mSocket != null && !mSocket!!.connected()) { + mSocket!!.connect() + } + } + reconnectRunnable = runnable + reconnectHandler.postDelayed(runnable, delaySeconds * 1000L) } } @@ -261,6 +282,8 @@ class SocketIOForegroundService : Service() { override fun onDestroy() { isBeingDestroyed = true hasStarted = false + reconnectRunnable?.let { reconnectHandler.removeCallbacks(it) } + reconnectRunnable = null Log.d(Constants.logTag, "BlueBubbles Service is being destroyed!") super.onDestroy() diff --git a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/rustpush/APNService.kt b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/rustpush/APNService.kt index cc72a4e65e..8f531715df 100644 --- a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/rustpush/APNService.kt +++ b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/rustpush/APNService.kt @@ -96,12 +96,10 @@ class APNService : Service(), MsgReceiver { override fun receievedMsg(ptr: ULong, retry: ULong) { Handler(Looper.getMainLooper()).post { if (MainActivity.engine != null) { - Log.i("ugh running", "here $ptr $retry") // app is alive, deliver directly there MethodCallHandler.invokeMethod("APNMsg", mapOf("pointer" to ptr.toString(), "retry" to retry.toString())) return@post } - Log.i("ugh running", "backend $ptr $retry") CoroutineScope(Dispatchers.Main).launch { DartWorker.callMethod(this@APNService, "APNMsg", mapOf("pointer" to ptr.toString(), "retry" to retry.toString())) } diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000000..cb860ae2e7 --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,136 @@ +# OpenBubbles Android development + +This document describes the supported local workflow for the Flutter Android +client. It is intentionally separate from the end-user setup guide: a local +build is useful for testing, but it does not replace a trusted relay, Apple +device, or production signing configuration. + +## Scope and message routing + +OpenBubbles is the iMessage client. The relay or Mac/iPhone side handles the +Apple service connection; the Android app renders conversations, sends user +actions, persists local state, and receives relay events. + +For a predictable test environment, keep the routing boundary explicit: + +- iMessage traffic stays in OpenBubbles and its configured relay. +- SMS, MMS, and RCS stay in the device's default Google Messages app unless a + test specifically targets forwarding. +- Do not enable Google Messages and OpenBubbles SMS forwarding at the same time + during a delivery test. Two active paths can create duplicates, reorder + messages, or make a successful delivery look lost. + +This boundary is a diagnostic control, not a claim that every carrier or relay +configuration behaves identically. + +## Toolchain + +The current CI workflow is the source of truth for the tested build matrix: + +- Flutter 3.24.0, stable channel +- Dart SDK supplied by that Flutter release +- Rust stable for the Rust bridge and RustPush components +- Java 21 (Temurin in CI) +- Android SDK and command-line tools +- Protobuf compiler (`protoc`) + +The older Java 8 link in historical contribution notes is not the CI target. +Use the same major Java version as CI when diagnosing Gradle failures. + +## Local checkout and build + +Clone the repository with submodules, then install dependencies: + +```bash +git clone --recurse-submodules +cd openbubbles-app +flutter pub get +``` + +Run a focused test before a full build: + +```bash +flutter test test/helpers/message_helper_test.dart +flutter test test/helpers/memory/bounded_byte_cache_test.dart \ + test/helpers/memory/bounded_lru_map_test.dart +``` + +The CI workflow builds unsigned arm64 Alpha artifacts. The equivalent local +commands are: + +```bash +flutter build apk --flavor alpha --profile --target-platform android-arm64 +flutter build apk --flavor alpha --debug --target-platform android-arm64 +``` + +On Windows, use the verified wrapper so a stale APK cannot survive a failed +Rust native build: + +```powershell +.\tooling\android\build_verified_alpha.ps1 -Mode profile +``` + +The wrapper deletes only the previous target APK before building and verifies +that the resulting archive contains Flutter, Dart AOT, and +`librust_lib_bluebubbles.so` ARM64 libraries. Do not install an APK when the +wrapper fails this gate. It discovers Flutter and `protoc` from `PATH`, the +Android SDK from `ANDROID_SDK_ROOT` or `ANDROID_HOME`, and Cargo/Rustup from +their standard environment variables. Non-standard toolchains can be passed +with the script's explicit path parameters. `-PerlExecutable`, +`-PerlModuleRoot`, and `-MakeExecutable` are available for Windows OpenSSL +environments that do not provide those prerequisites on `PATH`. + +Use a release build for performance measurements. Do not compare a debug build +with a store release and attribute every frame difference to application code. +Generated files, signing keys, `.env` values, relay registration codes, and +Apple credentials must not be committed. + +## Change and review workflow + +1. Start from a clean branch based on the intended upstream branch. +2. Make one narrow change per branch where practical. +3. Add or update a focused test for message parsing, routing, or state changes. +4. Run the focused test and the relevant Flutter analyzer/build locally. +5. Describe the user-visible behavior, failure mode, and test evidence in the PR. +6. Keep performance claims tied to a reproducible device, build mode, and test + scenario. + +Changes that affect both the Android client and ValidationRelay should be +reviewed as a coordinated pair. The Android client must tolerate relay +disconnects and malformed responses; the relay must not log or persist secrets. + +## Safe diagnostics + +Enable the app's Developer Mode only for a controlled reproduction. Capture a +short window around one send or receive operation, then redact or remove the +capture before sharing it publicly. See [`DIAGNOSTICS.md`](DIAGNOSTICS.md) for +the collection checklist and known failure classes. + +Never include registration codes, registration secrets, Apple IDs, phone +numbers, message text, attachment URLs, auth tokens, or full device identifiers +in an issue or pull request. A short-lived hash or local incident ID is enough +to correlate events. + +## Memory and media lifecycle + +See [`MEMORY_MANAGEMENT.md`](MEMORY_MANAGEMENT.md) for cache budgets, media +ownership, physical-device measurement, acceptance thresholds, known +limitations, and rollback guidance. + +## Known limitations + +- Android background execution, Doze, OEM battery policies, and network path + changes can delay relay delivery even when the app code is healthy. +- CloudKit/Apple plist payloads can contain types that are not present in every + historical message. Decoding must fail closed and preserve the rest of the + sync rather than crashing the UI. +- Notifications can arrive with incomplete contact/group metadata. UI and + notification code must use a generic avatar fallback instead of throwing. +- Reaction events may race message persistence. A missing target should be + retried or ignored with bounded logging, not trigger an unbounded lookup loop. +- A green WebSocket connection only proves transport connectivity. It does not + prove that registration, validation, or message persistence succeeded. + +These are test boundaries, not promises of feature support. Record the exact +device, Android version, build variant, relay, and network when reporting a +failure. diff --git a/docs/DIAGNOSTICS.md b/docs/DIAGNOSTICS.md new file mode 100644 index 0000000000..179146afc5 --- /dev/null +++ b/docs/DIAGNOSTICS.md @@ -0,0 +1,99 @@ +# Diagnostics and delivery troubleshooting + +Use this guide to collect evidence for slow delivery, duplicate messages, +rendering failures, battery drain, or incorrect routing. The goal is a small, +redacted reproduction, not a permanent verbose log. + +## Controlled reproduction + +1. Keep Google Messages as the default SMS app. +2. Turn off OpenBubbles SMS forwarding while testing iMessage delivery. +3. Turn on OpenBubbles Developer Mode. +4. Force-close and reopen the test build. +5. Send one uniquely identifiable test message in each direction. +6. Record the sender, receiver, network (Wi-Fi or cellular), and approximate + timestamps locally. Do not put message text or phone numbers in a public + issue. +7. Export only the relevant log window and remove credentials and personal + content before sharing. + +For Android-side timing, a developer can also collect a bounded window with +the package filter (replace the package if testing a different flavor): + +```bash +adb logcat -c +adb shell am force-stop com.bluebubbles.messaging.alpha +adb shell monkey -p com.bluebubbles.messaging.alpha 1 +# Reproduce one operation, then stop capture promptly. +adb logcat -d -v threadtime -t 2000 > openbubbles-log.txt +``` + +Do not attach an unfiltered `logcat` dump. It can contain notification text, +contact data, URLs, and platform identifiers. + +## What to look for + +### Slow or missing receives + +Trace the sequence, in order: + +1. relay/WebSocket receive +2. event parsing and validation +3. message-service queueing +4. database save/upsert +5. acknowledgement back to the relay +6. UI refresh and notification + +A transport connection without a database save is not a delivered message. +An acknowledgement before persistence can create a loss window after a process +restart. Any instrumentation should use a short redacted event ID and elapsed +milliseconds, never message text or a secret. + +### Duplicates or wrong app routing + +Check that only one SMS/MMS/RCS path is enabled. If both Google Messages and +OpenBubbles forwarding are enabled, disable forwarding and repeat the test. +For iMessage, verify that the event is not being inserted once from the live +stream and again from a refresh/reconnect path. Compare stable server/message +IDs rather than message text. + +### Battery drain + +Look for reconnect loops, unbounded timers, repeated database refreshes, or +notifications that fail and retry continuously. A healthy relay should use +bounded reconnect backoff and one active connection manager. Compare Android +battery statistics over the same time window with the test build stopped. + +### UI/rendering failures + +Repeated `LateError`, null avatar, or “unexpected error occurred when +rendering” entries indicate a UI data-shape problem. Capture the incident ID, +screen, and safe event timing. Do not work around a rendering failure by +silently dropping the entire conversation. + +### Registration and validation + +Registration input should not be decoded until it matches the complete expected +format. Validation errors should be surfaced as a bounded failure and retry, +not a tight loop. Relay registration secrets belong in Keychain on iOS and must +never appear in logs or shared preferences. + +## Current audit themes + +The recent field logs identified four recurring classes to keep covered by +tests and review: + +- notification avatar data can be incomplete; +- CloudKit plist decoding can receive an unexpected byte-array shape; +- reaction events can race message persistence; +- anisette/validation WebSockets can reset during provisioning. + +These are not all necessarily present in every build. When a fix is proposed, +include the before/after log counts and a focused test or reproduction. + +## Privacy and retention + +Keep raw captures in a local, access-controlled folder. Delete them when the +issue is closed. Redact before uploading to GitHub, Discord, or a bug tracker. +Never request or paste an Apple password, two-factor code, registration secret, +private key, or full device identifier into an issue. diff --git a/docs/MEMORY_MANAGEMENT.md b/docs/MEMORY_MANAGEMENT.md new file mode 100644 index 0000000000..bba1138aa4 --- /dev/null +++ b/docs/MEMORY_MANAGEMENT.md @@ -0,0 +1,144 @@ +--- +type: technical_design +title: OpenBubbles Conversation Memory Management +description: Ownership, limits, validation, and rollback guidance for attachment caches and media controllers. +resource: openbubbles-app +tags: [android, flutter, memory, media, lifecycle] +timestamp: 2026-07-28 +--- + +# Conversation memory management + +## Scope + +This design bounds memory retained by an open conversation without changing +message routing, attachment persistence, package contents, or network behavior. +It addresses encoded attachment previews, sticker data, parsed text, metadata +previews, and the lifetime of audio and video players. + +The limits below are architecture-neutral. They apply to every Android build, +not only a Pixel-specific package. + +## Ownership model + +| Resource | Owner | Lifetime | Release point | +| --- | --- | --- | --- | +| Encoded image preview bytes | `ConversationViewController.imageData` | Current conversation, subject to LRU eviction | Eviction or controller close | +| Sticker bytes | `ConversationViewController.stickerData` | Current conversation, subject to weighted LRU eviction | Eviction or controller close | +| Parsed ML Kit text | `ConversationViewController.mlKitParsedText` | Current conversation, subject to entry LRU eviction | Eviction or controller close | +| Link metadata previews | `ConversationViewController.legacyUrlPreviews` | Current conversation, subject to entry LRU eviction | Eviction or controller close | +| Message audio player | Audio message widget | Mounted widget | Widget dispose | +| Message video player | Video message widget | Mounted widget after the user presses play | Widget dispose | +| Full-screen video player | Full-screen view when it creates the player | Full-screen route | Route dispose | +| Poster images | `ConversationViewController` | Current poster generation | Replacement or controller close | + +The controller's audio and video maps are non-owning indexes used for controls +such as pause-on-background. Widgets remain responsible for disposing the +players they create. This prevents both leaks and double-disposal. + +## Cache limits + +| Cache | Limit | +| --- | --- | +| Encoded image previews | 32 MiB and 48 entries | +| Sticker bytes | 16 MiB and 64 message entries | +| Parsed ML Kit text | 256 entries | +| Metadata previews | 64 entries | + +Reads promote entries in least-recently-used order. Replacing or removing an +entry updates byte accounting. An image larger than the entire image-cache +budget is served to its active caller but is not retained. Other bounded maps +reject an oversized value. Replacing an existing key with an oversized object +removes the stale value for that key. + +The 32 MiB image budget covers encoded preview bytes only. Flutter's decoded +image cache, native media codecs, player buffers, in-flight file reads, and +full-screen media are separate allocations and can temporarily raise process +memory above these limits. + +## Runtime flow + +### Image preview + +1. A mounted image widget requests bytes from the conversation controller. +2. A cached value is returned immediately when available. +3. Concurrent requests for the same attachment share one in-flight load. +4. The queue retains no `BuildContext`, so it cannot retain a widget subtree. +5. A successful result is inserted into the bounded cache and returned. +6. Failures complete the request and allow the next queued load to proceed. +7. Closing the conversation completes pending requests with empty bytes and + prevents late work from repopulating the cache. + +### Audio and video + +Audio and video widgets own their players and cancel every listener or stream +subscription during disposal. Video initialization is lazy and begins only +after the user presses play. When the app backgrounds, the lifecycle service +pauses players belonging to an already registered conversation controller. It +does not create a new controller merely to pause media. + +### Stickers and extracted text + +Sticker maps use copy-on-write updates so adding one sticker preserves earlier +stickers while keeping weight accounting accurate. ML Kit extractors close in +a `finally` block. Widget-facing asynchronous results check `mounted` before +updating widget state. + +## Validation procedure + +Use the same release or profile build, device, relay, conversations, and media +set for baseline and changed runs. Debug builds are not suitable for comparative +frame or memory claims. + +1. Run unit tests for both bounded-cache implementations. +2. Run Flutter analysis on every changed Dart file. +3. Follow the ADB capture procedure in + [`VERIFICATION.md`](VERIFICATION.md). On a physical Pixel, perform three + baseline and three changed runs: + open a large conversation, scroll through image and sticker history, play + audio and video, enter and leave full-screen video, background and resume, + then leave the app idle for five minutes. +4. Record peak PSS, five-minute post-workload PSS and SwapPss, frame timing, + attachment reload behavior after eviction, and any dropped playback events. +5. Confirm that audio, video, full-screen playback, image previews, stickers, + metadata, and text extraction still work after eviction and navigation. + +Suggested acceptance thresholds: + +- Cache unit tests and targeted analysis pass. +- The conversation image cache never retains more than 32 MiB or 48 entries. +- Sticker data never retains more than 16 MiB or 64 message entries. +- No callback updates a disposed widget and no controller is double-disposed. +- Evicted media reloads correctly. +- Median peak PSS improves by at least 15 percent across matched runs. +- Five-minute SwapPss falls by at least 40 percent, with a target below 40 MiB. +- The 95th-percentile frame time does not regress by more than 5 percent. + +The percentage thresholds are evaluation targets, not claims about the current +unmeasured implementation. + +## Known limitations + +- Android controls paging and swap. The application can reduce retained memory + but cannot guarantee a specific SwapPss value. +- In-flight file reads and full-screen media can temporarily exceed cache + budgets. +- Mounted image widgets retain their current byte arrays, and queued image + requests retain distinct `PlatformFile` values until processed. Duplicate + attachment GUIDs are coalesced, but the distinct-request queue is not yet + bounded. +- Flutter's decoded image cache and native player buffers require separate + measurement. +- Attachment files on disk are unchanged. +- Desktop and web use different media implementations and need their own + behavioral checks. +- Background delivery, relay latency, and download throughput are outside this + change's scope. + +## Rollback + +Revert the bounded-cache helpers and the conversation/media lifecycle changes +as one unit. Do not revert only widget ownership or only controller cleanup: +the non-owning controller indexes depend on widgets remaining the sole owners +of their media players. After rollback, rerun the same cache, playback, and +navigation checks to confirm the previous behavior is restored. diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md new file mode 100644 index 0000000000..55a3a96e81 --- /dev/null +++ b/docs/VERIFICATION.md @@ -0,0 +1,124 @@ +# Delivery, routing, and performance verification + +Use this plan when comparing an app change against a known-good Alpha build. +It separates transport, persistence, rendering, and Android background work so +that a faster WebSocket does not hide a database or notification regression. + +## Reproducible build gate + +The repository CI workflow (`.github/workflows/build.yml`) currently uses: + +```text +Flutter 3.24.0 (stable) +Rust stable +Java 21 (Temurin) +Android SDK and protoc +``` + +Run the same commands locally from the repository root: + +```bash +flutter pub get +flutter test test/helpers/message_helper_test.dart +flutter analyze +flutter build apk --flavor alpha --profile --target-platform android-arm64 +flutter build apk --flavor alpha --debug --target-platform android-arm64 +``` + +The focused helper test is the required CI test today. `flutter analyze` is an +additional local gate for source changes. Do not call a build verified when the +machine has a different Flutter or Java major version. + +### Environment evidence from the Windows review host + +On 2026-07-24 this host had `adb 37.0.0` and Java 8, but `flutter` and `dart` +were not on `PATH`. Therefore no Flutter test, analyzer, or APK build was +claimed locally. The expected next action is to install/use Flutter 3.24.0 and +Java 21, then run the commands above or rely on the GitHub workflow. + +## Functional delivery matrix + +Run each scenario once on the baseline and at least three times on the changed +build. Use a unique local test ID, not message text, in the timing sheet. + +| Scenario | Expected result | Evidence | +| --- | --- | --- | +| iMessage receive, app foreground | One database row and one UI message | relay receive, save, UI timestamps | +| iMessage receive, app background | One notification and one database row | notification ID plus save timestamp | +| Duplicate relay event | One logical message, no duplicate notification | stable server/message ID count | +| Reconnect during receive | Event is retried or recovered, no loss | disconnect, reconnect, save, acknowledgement order | +| Updated message/reaction before base message | Event is eventually applied or boundedly deferred | target ID, retry count, final row | +| Malformed/partial payload | Event rejected safely; process remains alive | redacted error and subsequent message success | +| SMS/MMS/RCS routing | Delivered only by Google Messages in the control test | default-app state and notification source | + +For the SMS/RCS control, keep Google Messages as the default SMS app and turn +off OpenBubbles forwarding. Test the forwarding path separately; do not use two +active paths to judge loss or duplication. + +## Timing and correctness metrics + +Instrument or correlate a redacted event ID at these boundaries: + +1. relay/WebSocket receive +2. payload parse/validation +3. incoming queue start +4. database save/upsert complete +5. acknowledgement sent +6. UI listener refresh +7. notification scheduled + +Report median and p95 milliseconds for each segment, plus counts of duplicate +IDs, missing IDs, parse failures, notification failures, and reconnects. The +minimum acceptance gate for a delivery fix is zero lost IDs and zero duplicate +IDs in the controlled run. Latency improvements are secondary to correctness. + +## On-device performance capture + +Use a profile APK for frame and CPU comparisons, and keep the same device, +Android version, screen refresh rate, chat history, network, and test script. +Replace the package name if using another flavor. + +```bash +adb shell am force-stop com.bluebubbles.messaging.alpha +adb shell monkey -p com.bluebubbles.messaging.alpha 1 +adb shell dumpsys gfxinfo com.bluebubbles.messaging.alpha reset +# Perform one 60-second scroll/open/send/receive script. +adb shell dumpsys gfxinfo com.bluebubbles.messaging.alpha framestats > gfxinfo.txt +adb shell dumpsys meminfo com.bluebubbles.messaging.alpha > meminfo.txt +adb shell dumpsys cpuinfo > cpuinfo.txt +``` + +Record total frames, missed frames, 90th/95th/99th percentile frame time, peak +RSS, and CPU share. A smoother UI should reduce long frames without trading +away receive or database work. + +For battery and background behavior, use a fresh controlled window: + +```bash +adb shell dumpsys batterystats --reset +# Leave the same build idle for 30 minutes, then exercise five receives. +adb shell dumpsys batterystats --charged > batterystats.txt +adb shell dumpsys netstats detail > netstats.txt +``` + +Compare baseline and changed builds under the same network. Look specifically +for reconnect loops, repeated refresh timers, foreground services that never +stop, and notification retry storms. + +## Failure triage + +- A WebSocket connect without a database save is a receive failure, not a pass. +- An acknowledgement before persistence is a possible loss window after a + process restart; capture the ordering explicitly. +- Repeated null-avatar or render exceptions can make the app feel slow even if + transport latency is normal. +- Reaction lookups that miss their target should be bounded and observable, not + an unbounded retry loop. +- CloudKit plist decoding failures should isolate the malformed item and allow + later messages to continue. +- Registration/anisette failures should back off; a tight retry loop is both a + battery and delivery risk. + +Attach only redacted logs and a small timing table to a review. Never include +message text, phone numbers, Apple credentials, relay secrets, auth tokens, or +full device identifiers. diff --git a/flutter_rust_bridge.yaml b/flutter_rust_bridge.yaml index a4af61728d..66a525a080 100644 --- a/flutter_rust_bridge.yaml +++ b/flutter_rust_bridge.yaml @@ -1,2 +1,3 @@ rust_input: crate::api -dart_output: lib/src/rust \ No newline at end of file +dart_output: lib/src/rust +build_runner: false diff --git a/lib/app/animations/balloon_classes.dart b/lib/app/animations/balloon_classes.dart index f7e220dc3e..38bf95a2ee 100644 --- a/lib/app/animations/balloon_classes.dart +++ b/lib/app/animations/balloon_classes.dart @@ -14,7 +14,7 @@ class BalloonController implements Listenable { final Random random = Random(); Size windowSize; - late Ticker ticker; + Ticker? ticker; bool isPlaying = false; bool requestedToStop = false; @@ -28,6 +28,7 @@ class BalloonController implements Listenable { isPlaying = true; autoLaunchDuration = const Duration(milliseconds: 100); lastAutoLaunch = Duration.zero; + ticker?.dispose(); ticker = vsync.createTicker(update)..start(); } @@ -53,7 +54,8 @@ class BalloonController implements Listenable { void dispose() { listeners.clear(); - ticker.dispose(); + ticker?.dispose(); + ticker = null; } void update(Duration elapsedDuration) { @@ -82,8 +84,9 @@ class BalloonController implements Listenable { return element.position.y < -100 || element.position.x < -100; }); if (balloons.isEmpty && requestedToStop) { - ticker.stop(); - ticker.dispose(); + ticker?.stop(); + ticker?.dispose(); + ticker = null; isPlaying = false; requestedToStop = false; stopFunc?.call(); @@ -132,4 +135,4 @@ const List primaries = [ Colors.lightGreen, Colors.orange, Colors.yellow, -]; \ No newline at end of file +]; diff --git a/lib/app/animations/celebration_class.dart b/lib/app/animations/celebration_class.dart index 82875f76e6..dd3dddc7cb 100644 --- a/lib/app/animations/celebration_class.dart +++ b/lib/app/animations/celebration_class.dart @@ -14,6 +14,7 @@ class CelebrationController extends FireworkController { isPlaying = true; autoLaunchDuration = const Duration(milliseconds: 100); lastAutoLaunch = Duration.zero; + ticker?.dispose(); ticker = vsync.createTicker(update)..start(); } @@ -41,7 +42,8 @@ class CelebrationController extends FireworkController { @override void dispose() { listeners.clear(); - ticker.dispose(); + ticker?.dispose(); + ticker = null; } @override @@ -63,8 +65,9 @@ class CelebrationController extends FireworkController { particles.removeWhere((element) => element.alpha <= 0); if (particles.isEmpty && requestedToStop) { - ticker.stop(); - ticker.dispose(); + ticker?.stop(); + ticker?.dispose(); + ticker = null; isPlaying = false; requestedToStop = false; hasCreatedParticles = false; @@ -94,4 +97,4 @@ class CelebrationController extends FireworkController { )); } } -} \ No newline at end of file +} diff --git a/lib/app/animations/fireworks_classes.dart b/lib/app/animations/fireworks_classes.dart index 268d805d6c..baa7bae345 100644 --- a/lib/app/animations/fireworks_classes.dart +++ b/lib/app/animations/fireworks_classes.dart @@ -23,7 +23,7 @@ class FireworkController implements Listenable { Size windowSize; double globalHue = 42; - late Ticker ticker; + Ticker? ticker; bool hasCreatedParticles = false; bool isPlaying = false; @@ -41,6 +41,7 @@ class FireworkController implements Listenable { isPlaying = true; autoLaunchDuration = const Duration(milliseconds: 100); lastAutoLaunch = Duration.zero; + ticker?.dispose(); ticker = vsync.createTicker(update)..start(); } @@ -66,7 +67,8 @@ class FireworkController implements Listenable { void dispose() { listeners.clear(); - ticker.dispose(); + ticker?.dispose(); + ticker = null; } void update(Duration elapsedDuration) { @@ -113,8 +115,9 @@ class FireworkController implements Listenable { }); particles.removeWhere((element) => element.alpha <= 0); if (particles.isEmpty && requestedToStop && hasCreatedParticles) { - ticker.stop(); - ticker.dispose(); + ticker?.stop(); + ticker?.dispose(); + ticker = null; isPlaying = false; requestedToStop = false; hasCreatedParticles = false; @@ -283,4 +286,4 @@ class FireworkRocket extends FireworkObjectWithTrail { position += vp; } } -} \ No newline at end of file +} diff --git a/lib/app/animations/laser_classes.dart b/lib/app/animations/laser_classes.dart index 3644b718ac..a0d12b04c5 100644 --- a/lib/app/animations/laser_classes.dart +++ b/lib/app/animations/laser_classes.dart @@ -16,7 +16,7 @@ class LaserController implements Listenable { final Random random = Random(); Size windowSize; - late Ticker ticker; + Ticker? ticker; late Point position; late double size; double globalHue = 42; @@ -34,6 +34,7 @@ class LaserController implements Listenable { autoLaunchDuration = const Duration(milliseconds: 500); lastAutoLaunch = Duration.zero; position = Point((bubbleDimensions.left + bubbleDimensions.right) / 2, (bubbleDimensions.top + bubbleDimensions.bottom) / 2); + ticker?.dispose(); ticker = vsync.createTicker(update)..start(); } @@ -58,7 +59,8 @@ class LaserController implements Listenable { void dispose() { listeners.clear(); - ticker.dispose(); + ticker?.dispose(); + ticker = null; } void update(Duration elapsedDuration) { @@ -103,8 +105,9 @@ class LaserController implements Listenable { } if (elapsedDuration.inSeconds > 5 && requestedToStop) { - ticker.stop(); - ticker.dispose(); + ticker?.stop(); + ticker?.dispose(); + ticker = null; isPlaying = false; requestedToStop = false; laser = null; @@ -199,4 +202,4 @@ class LaserBeam { } } -enum Direction {up, down} \ No newline at end of file +enum Direction {up, down} diff --git a/lib/app/animations/love_classes.dart b/lib/app/animations/love_classes.dart index 9619ee3c69..792c8cd8a6 100644 --- a/lib/app/animations/love_classes.dart +++ b/lib/app/animations/love_classes.dart @@ -14,7 +14,7 @@ class LoveController implements Listenable { final Random random = Random(); Size windowSize; - late Ticker ticker; + Ticker? ticker; late Point position; bool isPlaying = false; @@ -30,6 +30,7 @@ class LoveController implements Listenable { autoLaunchDuration = const Duration(milliseconds: 100); lastAutoLaunch = Duration.zero; position = startPos; + ticker?.dispose(); ticker = vsync.createTicker(update)..start(); } @@ -55,7 +56,8 @@ class LoveController implements Listenable { void dispose() { listeners.clear(); - ticker.dispose(); + ticker?.dispose(); + ticker = null; } void update(Duration elapsedDuration) { @@ -75,8 +77,9 @@ class LoveController implements Listenable { heart!.update(); if (heart!.position.y < -200 && requestedToStop) { - ticker.stop(); - ticker.dispose(); + ticker?.stop(); + ticker?.dispose(); + ticker = null; isPlaying = false; requestedToStop = false; heart = null; @@ -128,4 +131,4 @@ class LoveObject { velocity *= acceleration; velocity.clamp(0.5, 2); } -} \ No newline at end of file +} diff --git a/lib/app/animations/spotlight_classes.dart b/lib/app/animations/spotlight_classes.dart index 557a04049f..dd7c7fa53d 100644 --- a/lib/app/animations/spotlight_classes.dart +++ b/lib/app/animations/spotlight_classes.dart @@ -14,7 +14,7 @@ class SpotlightController implements Listenable { final Random random = Random(); Size windowSize; - late Ticker ticker; + Ticker? ticker; late Point position; late double size; @@ -32,6 +32,7 @@ class SpotlightController implements Listenable { lastAutoLaunch = Duration.zero; position = Point((bubbleDimensions.left + bubbleDimensions.right) / 2, (bubbleDimensions.top + bubbleDimensions.bottom) / 2); size = max(bubbleDimensions.width, bubbleDimensions.height) + 50; + ticker?.dispose(); ticker = vsync.createTicker(update)..start(); } @@ -57,7 +58,8 @@ class SpotlightController implements Listenable { void dispose() { listeners.clear(); - ticker.dispose(); + ticker?.dispose(); + ticker = null; } void update(Duration elapsedDuration) { @@ -76,8 +78,9 @@ class SpotlightController implements Listenable { spotlight!.update(elapsedDuration); if (spotlight!.stop < 0 && requestedToStop) { - ticker.stop(); - ticker.dispose(); + ticker?.stop(); + ticker?.dispose(); + ticker = null; isPlaying = false; requestedToStop = false; spotlight = null; @@ -116,4 +119,4 @@ class SpotlightObject { stop = stop - 0.05; } } -} \ No newline at end of file +} diff --git a/lib/app/components/avatars/contact_avatar_group_widget.dart b/lib/app/components/avatars/contact_avatar_group_widget.dart index 1bae06b89f..957f9c713a 100644 --- a/lib/app/components/avatars/contact_avatar_group_widget.dart +++ b/lib/app/components/avatars/contact_avatar_group_widget.dart @@ -31,7 +31,7 @@ class ContactAvatarGroupWidget extends StatefulWidget { } class _ContactAvatarGroupWidgetState extends OptimizedState { - late final List participants = widget.chat?.participants ?? widget.participants ?? []; + late List participants; final Map materialGeneration = { 2: [24.5/40, 10.5/40, [Alignment.topRight, Alignment.bottomLeft]], 3: [21.5/40, 9/40, [Alignment.bottomRight, Alignment.bottomLeft, Alignment.topCenter]], @@ -41,6 +41,20 @@ class _ContactAvatarGroupWidgetState extends OptimizedState.from(widget.chat?.participants ?? widget.participants ?? []); participants.sort((a, b) { bool avatarA = a.contact?.avatar?.isNotEmpty ?? false; bool avatarB = b.contact?.avatar?.isNotEmpty ?? false; diff --git a/lib/app/components/avatars/contact_avatar_widget.dart b/lib/app/components/avatars/contact_avatar_widget.dart index 25e7910556..4a5bb12a7a 100644 --- a/lib/app/components/avatars/contact_avatar_widget.dart +++ b/lib/app/components/avatars/contact_avatar_widget.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:bluebubbles/helpers/helpers.dart'; import 'package:bluebubbles/app/wrappers/stateful_boilerplate.dart'; import 'package:bluebubbles/database/models.dart'; @@ -36,12 +38,16 @@ class ContactAvatarWidget extends StatefulWidget { class _ContactAvatarWidgetState extends OptimizedState { Contact? get contact => widget.contact ?? widget.handle?.contact; - String get keyPrefix => widget.handle?.address ?? randomString(8); + late final String _keyPrefix; + String get keyPrefix => _keyPrefix; + StreamSubscription? _avatarRefreshSubscription; @override void initState() { super.initState(); - eventDispatcher.stream.listen((event) { + _keyPrefix = widget.handle?.address ?? randomString(8); + _avatarRefreshSubscription = eventDispatcher.stream.listen((event) { + if (!mounted) return; if (event.item1 != 'refresh-avatar') return; if (event.item2[0] != widget.handle?.address) return; widget.handle?.color = event.item2[1]; @@ -49,6 +55,12 @@ class _ContactAvatarWidgetState extends OptimizedState { }); } + @override + void dispose() { + _avatarRefreshSubscription?.cancel(); + super.dispose(); + } + void onAvatarTap() async { if (!ss.settings.colorfulAvatars.value && !ss.settings.colorfulBubbles.value) return; diff --git a/lib/app/layouts/conversation_list/widgets/conversation_list_fab.dart b/lib/app/layouts/conversation_list/widgets/conversation_list_fab.dart index 54a78d9114..9762f788fd 100644 --- a/lib/app/layouts/conversation_list/widgets/conversation_list_fab.dart +++ b/lib/app/layouts/conversation_list/widgets/conversation_list_fab.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:bluebubbles/app/layouts/conversation_list/pages/conversation_list.dart'; import 'package:bluebubbles/app/wrappers/stateful_boilerplate.dart'; import 'package:bluebubbles/app/wrappers/theme_switcher.dart'; @@ -18,6 +20,8 @@ class ConversationListFAB extends CustomStateful { } class _ConversationListFABState extends CustomState { + StreamSubscription? _avatarOnlySubscription; + void _focusBackToList() { if (!FocusScope.of(context).focusInDirection(TraversalDirection.left)) { FocusScope.of(context).previousFocus(); @@ -31,27 +35,29 @@ class _ConversationListFABState extends CustomState controller.openNewChatCreator(context), }; + void _handleMaterialScroll() { + if (!mounted || !material) return; + if (controller.materialScrollStartPosition - controller.materialScrollController.offset < -75 + && controller.materialScrollController.position.userScrollDirection == ScrollDirection.reverse + && controller.showMaterialFABText) { + setState(() { + controller.showMaterialFABText = false; + }); + } else if (controller.materialScrollStartPosition - controller.materialScrollController.offset > 75 + && controller.materialScrollController.position.userScrollDirection == ScrollDirection.forward + && !controller.showMaterialFABText) { + setState(() { + controller.showMaterialFABText = true; + }); + } + } + @override void initState() { super.initState(); - controller.materialScrollController.addListener(() { - if (!material) return; - if (controller.materialScrollStartPosition - controller.materialScrollController.offset < -75 - && controller.materialScrollController.position.userScrollDirection == ScrollDirection.reverse - && controller.showMaterialFABText) { - setState(() { - controller.showMaterialFABText = false; - }); - } else if (controller.materialScrollStartPosition - controller.materialScrollController.offset > 75 - && controller.materialScrollController.position.userScrollDirection == ScrollDirection.forward - && !controller.showMaterialFABText) { - setState(() { - controller.showMaterialFABText = true; - }); - } - }); - ns.listener.stream.listen((event) { + controller.materialScrollController.addListener(_handleMaterialScroll); + _avatarOnlySubscription = ns.listener.stream.listen((event) { if (!mounted) return; if (ns.isAvatarOnly(context) && controller.showMaterialFABText) { setState(() { @@ -61,6 +67,13 @@ class _ConversationListFABState extends CustomState Column( diff --git a/lib/app/layouts/conversation_list/widgets/tile/conversation_tile.dart b/lib/app/layouts/conversation_list/widgets/tile/conversation_tile.dart index de10e12257..f3421425fe 100644 --- a/lib/app/layouts/conversation_list/widgets/tile/conversation_tile.dart +++ b/lib/app/layouts/conversation_list/widgets/tile/conversation_tile.dart @@ -220,6 +220,7 @@ class ConversationTile extends CustomStateful { class _ConversationTileState extends CustomState with AutomaticKeepAliveClientMixin { ConversationListController get listController => controller.listController; + StreamSubscription? _highlightSubscription; @override bool get wantKeepAlive => true; @@ -237,7 +238,8 @@ class _ConversationTileState extends CustomState { class _ChatTitleState extends CustomState { String title = "Unknown"; StreamSubscription? sub; + StreamSubscription? eventSub; String? cachedDisplayName = ""; List cachedParticipants = []; @@ -356,7 +365,8 @@ class _ChatTitleState extends CustomState { class _CupertinoTrailingState extends CustomState { DateTime? dateCreated; - late final StreamSubscription sub; + StreamSubscription? sub; String? cachedLatestMessageGuid = ""; Message? cachedLatestMessage; @@ -240,7 +240,7 @@ class _CupertinoTrailingState extends CustomState { class _MaterialTrailingState extends CustomState { DateTime? dateCreated; - late final StreamSubscription sub; + StreamSubscription? sub; String? cachedLatestMessageGuid = ""; Message? cachedLatestMessage; @@ -235,7 +235,7 @@ class _MaterialTrailingState extends CustomState class _PinnedConversationTileState extends CustomState { ConversationListController get listController => controller.listController; Offset? longPressPosition; + StreamSubscription? _highlightSubscription; @override void initState() { @@ -53,7 +54,8 @@ class _PinnedConversationTileState extends CustomState { class _ChatTitleState extends CustomState { String title = "Unknown"; - late final StreamSubscription sub; + StreamSubscription? sub; String? cachedDisplayName = ""; List cachedParticipants = []; @@ -323,7 +331,7 @@ class _ChatTitleState extends CustomState { class _SamsungTrailingState extends CustomState { DateTime? dateCreated; - late final StreamSubscription sub; + StreamSubscription? sub; String? cachedLatestMessageGuid = ""; Message? cachedLatestMessage; @@ -205,7 +205,7 @@ class _SamsungTrailingState extends CustomState { cm.activeChat!.controller = controller; Logger.debug("Conversation View initialized for ${chat.guid}"); + controller.updatePoster(); + if (widget.onInit != null) { Future.delayed(Duration.zero, widget.onInit!); } @@ -93,6 +95,12 @@ class ConversationViewState extends OptimizedState { canPop: false, onPopInvoked: (didPop) async { if (didPop) return; + if (controller.keyboardOpen || + controller.focusNode.hasFocus || + controller.subjectFocusNode.hasFocus) { + controller.dismissKeyboard(); + return; + } if (controller.inSelectMode.value) { controller.inSelectMode.value = false; controller.selected.clear(); @@ -155,10 +163,20 @@ class ConversationViewState extends OptimizedState { Expanded( child: Stack( children: [ - MessagesView( - key: Key(chat.guid), - customService: widget.customService, - controller: controller, + Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (_) { + if (controller.keyboardOpen || + controller.focusNode.hasFocus || + controller.subjectFocusNode.hasFocus) { + controller.dismissKeyboard(); + } + }, + child: MessagesView( + key: Key(chat.guid), + customService: widget.customService, + controller: controller, + ), ), Align( alignment: iOS ? Alignment.bottomRight : Alignment.bottomCenter, @@ -218,8 +236,7 @@ class ConversationViewState extends OptimizedState { if (ss.settings.swipeToCloseKeyboard.value && details.delta.dy > 0 && controller.keyboardOpen) { - controller.focusNode.unfocus(); - controller.subjectFocusNode.unfocus(); + controller.dismissKeyboard(); } else if (ss.settings.swipeToOpenKeyboard.value && details.delta.dy < 0 && !controller.keyboardOpen) { diff --git a/lib/app/layouts/conversation_view/pages/messages_view.dart b/lib/app/layouts/conversation_view/pages/messages_view.dart index 59c1f2b484..9c8de0f055 100644 --- a/lib/app/layouts/conversation_view/pages/messages_view.dart +++ b/lib/app/layouts/conversation_view/pages/messages_view.dart @@ -12,7 +12,6 @@ import 'package:bluebubbles/utils/logger/logger.dart'; import 'package:bluebubbles/helpers/helpers.dart'; import 'package:bluebubbles/services/network/backend_service.dart'; import 'package:bluebubbles/app/wrappers/scrollbar_wrapper.dart'; -import 'package:bluebubbles/app/components/avatars/contact_avatar_widget.dart'; import 'package:bluebubbles/app/wrappers/theme_switcher.dart'; import 'package:bluebubbles/app/wrappers/stateful_boilerplate.dart'; import 'package:bluebubbles/database/models.dart'; @@ -46,14 +45,14 @@ class MessagesView extends StatefulWidget { class MessagesViewState extends OptimizedState { bool initialized = false; bool fetching = false; + bool _refreshing = false; late bool noMoreMessages = widget.customService != null; List _messages = []; RxList smartReplies = [].obs; RxMap internalSmartReplies = {}.obs; - late final messageService = widget.customService ?? ms(chat.guid) - ..init(chat, handleNewMessage, handleUpdatedMessage, handleDeletedMessage, jumpToMessage); + late MessagesService messageService; final smartReply = GoogleMlKit.nlp.smartReply(); final listKey = GlobalKey(); final RxBool dragging = false.obs; @@ -61,6 +60,8 @@ class MessagesViewState extends OptimizedState { final RxBool latestMessageDeliveredState = false.obs; final RxBool jumpingToOldestUnread = false.obs; final Map messageFocusNodes = {}; + StreamSubscription? _eventSubscription; + int _lifecycleGeneration = 0; ConversationViewController get controller => widget.controller; @@ -134,16 +135,13 @@ class MessagesViewState extends OptimizedState { @override void initState() { super.initState(); + messageService = widget.customService ?? ms(chat.guid); + messageService.init(chat, handleNewMessage, handleUpdatedMessage, handleDeletedMessage, jumpToMessage); - eventDispatcher.stream.listen((e) async { + _eventSubscription = eventDispatcher.stream.listen((e) async { + if (!mounted) return; if (e.item1 == "refresh-messagebloc" && e.item2 == chat.guid) { - // Clear state items - noMoreMessages = false; - _messages = []; - // Reload the state after refreshing - messageService.reload(); - messageService.init(chat, handleNewMessage, handleUpdatedMessage, handleDeletedMessage, jumpToMessage); - setState(() {}); + await _refreshMessageBloc(); } else if (e.item1 == "add-custom-smartreply") { if (e.item2 != null && internalSmartReplies['attach-recent'] == null) { internalSmartReplies['attach-recent'] = _buildReply("Attach recent photo", onTap: () async { @@ -155,29 +153,34 @@ class MessagesViewState extends OptimizedState { }); updateObx(() async { + if (!mounted) return; + final generation = _lifecycleGeneration; if (chat.isIMessage && !chat.isGroup) { getFocusState(); } - final searchMessage = (messageService.method == null) ? null : messageService.struct.messages.firstOrNull; - if (messageService.method != null) { - await messageService.loadSearchChunk( - messageService.struct.messages.first, messageService.method == "local" ? SearchMethod.local : SearchMethod.network); - } else if (messageService.struct.isEmpty) { - await messageService.loadChunk(0, controller); + final initialService = messageService; + final searchMessage = (initialService.method == null) ? null : initialService.struct.messages.firstOrNull; + if (initialService.method != null) { + await initialService.loadSearchChunk( + initialService.struct.messages.first, initialService.method == "local" ? SearchMethod.local : SearchMethod.network); + } else if (initialService.struct.isEmpty) { + await initialService.loadChunk(0, controller); } - _messages = messageService.struct.messages; + if (!mounted || generation != _lifecycleGeneration || !identical(messageService, initialService)) return; + _messages = initialService.struct.messages; _messages.sort(Message.sort); setState(() {}); _messages.forEachIndexed((i, m) { final c = mwc(m); c.cvController = controller; - listKey.currentState!.insertItem(i, duration: const Duration(milliseconds: 0)); + listKey.currentState?.insertItem(i, duration: const Duration(milliseconds: 0)); }); _syncBottomMessageFocusNode(); // scroll to message if needed if (searchMessage != null) { final index = _messages.indexWhere((element) => element.guid == searchMessage.guid); await scrollController.scrollToIndex(index, preferPosition: AutoScrollPosition.middle); + if (!mounted || generation != _lifecycleGeneration) return; scrollController.highlight(index, highlightDuration: const Duration(milliseconds: 500)); } else if (!(_messages.firstOrNull?.isFromMe ?? true)) { updateReplies(); @@ -198,11 +201,81 @@ class MessagesViewState extends OptimizedState { }); } + void _closeMessageControllers(Iterable messages) { + for (final message in messages) { + final guid = message.guid; + if (guid != null) getActiveMwc(guid)?.close(); + } + } + + void _bindMessageControllers(Iterable messages) { + if (!mounted) return; + for (final message in messages) { + if (message.guid == null) continue; + final messageController = mwc(message); + messageController.cvController = controller; + } + } + + Future _refreshMessageBloc() async { + if (_refreshing) return; + if (widget.customService != null) { + Logger.info("message_refresh skipped_custom_service"); + return; + } + _refreshing = true; + final generation = ++_lifecycleGeneration; + try { + final staleMessages = List.from(_messages); + _closeMessageControllers(staleMessages); + for (var index = _messages.length - 1; index >= 0; index--) { + listKey.currentState?.removeItem( + index, + (context, animation) => const SizedBox.shrink(), + duration: Duration.zero, + ); + } + for (final node in messageFocusNodes.values) { + node.dispose(); + } + messageFocusNodes.clear(); + + noMoreMessages = false; + fetching = false; + _messages = []; + + // Get.reload rebuilds the original Get.put instance. Close it instead + // so its subscriptions and in-memory message structure are flushed + // before registering a genuinely new service for this transcript. + messageService.close(force: true); + final refreshedService = ms(chat.guid); + messageService = refreshedService; + refreshedService.init(chat, handleNewMessage, handleUpdatedMessage, handleDeletedMessage, jumpToMessage); + await refreshedService.loadChunk(0, controller); + if (!mounted || generation != _lifecycleGeneration || !identical(messageService, refreshedService)) return; + + _messages = List.from(refreshedService.struct.messages); + _messages.sort(Message.sort); + _bindMessageControllers(_messages); + _syncBottomMessageFocusNode(); + setState(() {}); + for (var index = 0; index < _messages.length; index++) { + listKey.currentState?.insertItem(index, duration: Duration.zero); + } + } finally { + _refreshing = false; + } + } + @override void dispose() { + _lifecycleGeneration++; + _eventSubscription?.cancel(); if (!kIsWeb && !kIsDesktop) smartReply.close(); - chat.lastReadMessageGuid = _messages.first.guid; - chat.save(updateLastReadMessageGuid: true); + if (_messages.isNotEmpty) { + chat.lastReadMessageGuid = _messages.first.guid; + chat.save(updateLastReadMessageGuid: true); + } messageService.close(force: widget.customService != null); if (controller.bottomMessageFocusNode != null && messageFocusNodes.containsValue(controller.bottomMessageFocusNode)) { controller.bottomMessageFocusNode = null; @@ -288,28 +361,35 @@ class MessagesViewState extends OptimizedState { if (noMoreMessages || fetching) return; fetching = true; - // Start loading the next chunk of messages - noMoreMessages = !(await messageService.loadChunk(_messages.length, controller, limit: limit).catchError((e, stack) { - Logger.error("Failed to fetch message chunk!", error: e, trace: stack); - return true; - })); - - if (noMoreMessages) return setState(() {}); + try { + // Start loading the next chunk of messages + noMoreMessages = !(await messageService.loadChunk(_messages.length, controller, limit: limit).catchError((e, stack) { + Logger.error("Failed to fetch message chunk!", error: e, trace: stack); + return true; + })); - final oldLength = _messages.length; - _messages = messageService.struct.messages; - _messages.sort(Message.sort); - fetching = false; - _messages.sublist(max(oldLength - 1, 0)).forEachIndexed((i, m) { if (!mounted) return; - final c = mwc(m); - c.cvController = controller; - listKey.currentState!.insertItem(i, duration: const Duration(milliseconds: 0)); - }); - _syncBottomMessageFocusNode(); - // should only happen when a reaction is the most recent message - if (oldLength == 0) { - setState(() {}); + + if (noMoreMessages) { + setState(() {}); + return; + } + + final oldLength = _messages.length; + _messages = messageService.struct.messages; + _messages.sort(Message.sort); + _messages.sublist(max(oldLength - 1, 0)).forEachIndexed((i, m) { + final c = mwc(m); + c.cvController = controller; + listKey.currentState!.insertItem(i, duration: const Duration(milliseconds: 0)); + }); + _syncBottomMessageFocusNode(); + // should only happen when a reaction is the most recent message + if (oldLength == 0) { + setState(() {}); + } + } finally { + fetching = false; } } @@ -678,7 +758,7 @@ class MessagesViewState extends OptimizedState { .copyWith(color: Colors.deepPurple)), style: TextButton.styleFrom( padding: EdgeInsets.zero, - minimumSize: Size(50, 30), + minimumSize: const Size(50, 30), tapTargetSize: MaterialTapTargetSize.shrinkWrap, alignment: Alignment.centerLeft), onPressed: () async { diff --git a/lib/app/layouts/conversation_view/widgets/effects/screen_effects_widget.dart b/lib/app/layouts/conversation_view/widgets/effects/screen_effects_widget.dart index 7924e089d2..901bcb5cae 100644 --- a/lib/app/layouts/conversation_view/widgets/effects/screen_effects_widget.dart +++ b/lib/app/layouts/conversation_view/widgets/effects/screen_effects_widget.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math'; import 'package:bluebubbles/app/animations/balloon_classes.dart'; @@ -36,23 +37,26 @@ class _ScreenEffectsWidgetState extends OptimizedState with late final SpotlightController spotlightController; late final LaserController laserController; String screenSelected = ""; + StreamSubscription? _effectSubscription; + bool _controllersInitialized = false; + int _effectGeneration = 0; + + bool _isEffectActive(int generation) => mounted && generation == _effectGeneration; + + void _clearEffect(int generation) { + if (!_isEffectActive(generation)) return; + setState(() { + screenSelected = ""; + }); + } @override void initState() { super.initState(); - updateObx(() { - fireworkController = FireworkController(vsync: this, windowSize: Size(ns.width(context), context.height)); - celebrationController = CelebrationController(vsync: this, windowSize: Size(ns.width(context), context.height)); - confettiController = ConfettiController(duration: const Duration(seconds: 1)); - balloonController = BalloonController(vsync: this, windowSize: Size(ns.width(context), context.height)); - loveController = LoveController(vsync: this, windowSize: Size(ns.width(context), context.height)); - spotlightController = SpotlightController(vsync: this, windowSize: Size(ns.width(context), context.height)); - laserController = LaserController(vsync: this, windowSize: Size(ns.width(context), context.height)); - }); - - eventDispatcher.stream.listen((event) async { - if (event.item1 == 'play-effect' && mounted && screenSelected.isEmpty) { + _effectSubscription = eventDispatcher.stream.listen((event) async { + if (event.item1 == 'play-effect' && mounted && _controllersInitialized && screenSelected.isEmpty) { + final generation = ++_effectGeneration; setState(() { screenSelected = event.item2['type']; }); @@ -61,71 +65,104 @@ class _ScreenEffectsWidgetState extends OptimizedState with fireworkController.windowSize = Size(ns.width(context), context.height); fireworkController.start(); await Future.delayed(const Duration(seconds: 1)); + if (!_isEffectActive(generation)) return; fireworkController.stop(onStop: () { - setState(() { - screenSelected = ""; - }); + _clearEffect(generation); }); } else if (screenSelected == "celebration" && !celebrationController.isPlaying) { celebrationController.windowSize = Size(ns.width(context), context.height); celebrationController.start(); await Future.delayed(const Duration(seconds: 1)); + if (!_isEffectActive(generation)) return; celebrationController.stop(onStop: () { - setState(() { - screenSelected = ""; - }); + _clearEffect(generation); }); } else if (screenSelected == "balloons" && !balloonController.isPlaying) { balloonController.windowSize = Size(ns.width(context), context.height); balloonController.start(); await Future.delayed(const Duration(seconds: 1)); + if (!_isEffectActive(generation)) return; balloonController.stop(onStop: () { - setState(() { - screenSelected = ""; - }); + _clearEffect(generation); }); } else if (screenSelected == "love" && !loveController.isPlaying) { if (rect != null) { loveController.windowSize = Size(ns.width(context), context.height); loveController.start(Point((rect!.left + rect!.right) / 2, (rect!.top + rect!.bottom) / 2)); await Future.delayed(const Duration(seconds: 1)); + if (!_isEffectActive(generation)) return; loveController.stop(onStop: () { - setState(() { - screenSelected = ""; - }); + _clearEffect(generation); }); + } else { + _clearEffect(generation); } } else if (screenSelected == "spotlight" && !spotlightController.isPlaying) { if (rect != null) { spotlightController.windowSize = Size(ns.width(context), context.height); spotlightController.start(rect!); await Future.delayed(const Duration(seconds: 1)); + if (!_isEffectActive(generation)) return; spotlightController.stop(onStop: () { - setState(() { - screenSelected = ""; - }); + _clearEffect(generation); }); + } else { + _clearEffect(generation); } } else if (screenSelected == "lasers" && !laserController.isPlaying) { if (rect != null) { laserController.windowSize = Size(ns.width(context), context.height); laserController.start(rect!); await Future.delayed(const Duration(seconds: 1)); + if (!_isEffectActive(generation)) return; laserController.stop(onStop: () { - setState(() { - screenSelected = ""; - }); + _clearEffect(generation); }); + } else { + _clearEffect(generation); } } else if (screenSelected == "confetti") { confettiController.play(); await Future.delayed(const Duration(seconds: 1)); - screenSelected = ""; + _clearEffect(generation); + } else { + _clearEffect(generation); } } }); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_controllersInitialized) return; + final windowSize = Size(ns.width(context), context.height); + fireworkController = FireworkController(vsync: this, windowSize: windowSize); + celebrationController = CelebrationController(vsync: this, windowSize: windowSize); + confettiController = ConfettiController(duration: const Duration(seconds: 1)); + balloonController = BalloonController(vsync: this, windowSize: windowSize); + loveController = LoveController(vsync: this, windowSize: windowSize); + spotlightController = SpotlightController(vsync: this, windowSize: windowSize); + laserController = LaserController(vsync: this, windowSize: windowSize); + _controllersInitialized = true; + } + + @override + void dispose() { + _effectGeneration++; + _effectSubscription?.cancel(); + if (_controllersInitialized) { + fireworkController.dispose(); + celebrationController.dispose(); + confettiController.dispose(); + balloonController.dispose(); + loveController.dispose(); + spotlightController.dispose(); + laserController.dispose(); + } + super.dispose(); + } + @override Widget build(BuildContext context) { @@ -157,4 +194,4 @@ class _ScreenEffectsWidgetState extends OptimizedState with ), ); } -} \ No newline at end of file +} diff --git a/lib/app/layouts/conversation_view/widgets/header/cupertino_header.dart b/lib/app/layouts/conversation_view/widgets/header/cupertino_header.dart index 43e4c44e23..5b60474f8c 100644 --- a/lib/app/layouts/conversation_view/widgets/header/cupertino_header.dart +++ b/lib/app/layouts/conversation_view/widgets/header/cupertino_header.dart @@ -448,7 +448,7 @@ class _ChatIconAndTitle extends CustomStateful { class _ChatIconAndTitleState extends CustomState<_ChatIconAndTitle, void, ConversationViewController> { String title = "Unknown"; - late final StreamSubscription sub; + StreamSubscription? sub; String? cachedDisplayName = ""; List cachedParticipants = []; late String cachedGuid; @@ -523,7 +523,7 @@ class _ChatIconAndTitleState extends CustomState<_ChatIconAndTitle, void, Conver @override void dispose() { - sub.cancel(); + sub?.cancel(); sub2.cancel(); super.dispose(); } diff --git a/lib/app/layouts/conversation_view/widgets/header/header_widgets.dart b/lib/app/layouts/conversation_view/widgets/header/header_widgets.dart index 7791780cd9..e77a4bca9f 100644 --- a/lib/app/layouts/conversation_view/widgets/header/header_widgets.dart +++ b/lib/app/layouts/conversation_view/widgets/header/header_widgets.dart @@ -150,9 +150,21 @@ class FaceTimeBtnState extends OptimizedState { void initState() { super.initState(); (() async { - var data = await chat.getConversationData(); - ftSupportedParticipants = await api.validateTargetsFacetime(state: pushService.state!.client, targets: data.participants, sender: await chat.ensureHandle()); - setState(() { }); + final state = pushService.state; + if (state == null) return; + try { + var data = await chat.getConversationData(); + final supported = await api.validateTargetsFacetime( + state: state.client, + targets: data.participants, + sender: await chat.ensureHandle()); + if (!mounted) return; + ftSupportedParticipants = supported; + setState(() { }); + } catch (error, trace) { + Logger.warn("Failed to load FaceTime availability", + error: error, trace: trace); + } })(); } @@ -271,4 +283,4 @@ class ConnectionIndicator extends StatelessWidget { )), ); } -} \ No newline at end of file +} diff --git a/lib/app/layouts/conversation_view/widgets/header/material_header.dart b/lib/app/layouts/conversation_view/widgets/header/material_header.dart index 3511cfafa7..6c315cc8fd 100644 --- a/lib/app/layouts/conversation_view/widgets/header/material_header.dart +++ b/lib/app/layouts/conversation_view/widgets/header/material_header.dart @@ -421,7 +421,7 @@ class _ChatIconAndTitle extends CustomStateful { class _ChatIconAndTitleState extends CustomState<_ChatIconAndTitle, void, ConversationViewController> { String title = "Unknown"; - late final StreamSubscription sub; + StreamSubscription? sub; String? cachedDisplayName = ""; List cachedParticipants = []; @@ -491,7 +491,7 @@ class _ChatIconAndTitleState extends CustomState<_ChatIconAndTitle, void, Conver @override void dispose() { - sub.cancel(); + sub?.cancel(); sub2.cancel(); super.dispose(); } diff --git a/lib/app/layouts/conversation_view/widgets/message/attachment/attachment_holder.dart b/lib/app/layouts/conversation_view/widgets/message/attachment/attachment_holder.dart index 3c89499897..43e8b1b5f9 100644 --- a/lib/app/layouts/conversation_view/widgets/message/attachment/attachment_holder.dart +++ b/lib/app/layouts/conversation_view/widgets/message/attachment/attachment_holder.dart @@ -44,12 +44,14 @@ class _AttachmentHolderState extends CustomState getAudioTranscriptsFromAttributedBody(message.attributedBody)[part.part]; late dynamic content; late bool selected = controller.cvController?.isSelected(message.guid!) ?? false; + Worker? _selectionWorker; @override void initState() { forceDelete = false; if (controller.cvController != null && !iOS) { - ever>(controller.cvController!.selected, (event) { + _selectionWorker = ever>(controller.cvController!.selected, (event) { + if (!mounted) return; if (controller.cvController!.isSelected(message.guid!) && !selected) { setState(() { selected = true; @@ -65,6 +67,11 @@ class _AttachmentHolderState extends CustomState(tag: _content.attachment.guid); + final downloader = attachmentDownloader.startDownload( + _content.attachment, onComplete: onComplete, prioritized: true); setState(() { - content = attachmentDownloader.startDownload(_content.attachment, onComplete: onComplete); + content = downloader; }); } }, diff --git a/lib/app/layouts/conversation_view/widgets/message/attachment/audio_player.dart b/lib/app/layouts/conversation_view/widgets/message/attachment/audio_player.dart index ce218a4066..b954277906 100644 --- a/lib/app/layouts/conversation_view/widgets/message/attachment/audio_player.dart +++ b/lib/app/layouts/conversation_view/widgets/message/attachment/audio_player.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:audio_waveforms/audio_waveforms.dart'; import 'package:bluebubbles/app/wrappers/stateful_boilerplate.dart'; import 'package:bluebubbles/helpers/helpers.dart'; @@ -14,7 +16,6 @@ class AudioPlayer extends StatefulWidget { final Attachment? attachment; final String? transcript; - AudioPlayer({ super.key, required this.file, @@ -35,7 +36,7 @@ class AudioPlayer extends StatefulWidget { } class _AudioPlayerState extends OptimizedState - with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin { + with SingleTickerProviderStateMixin { Attachment? get attachment => widget.attachment; PlatformFile get file => widget.file; @@ -43,6 +44,7 @@ class _AudioPlayerState extends OptimizedState ConversationViewController? get cvController => widget.controller; PlayerController? controller; + StreamSubscription? _playerStateSubscription; late final animController = AnimationController( vsync: this, duration: const Duration(milliseconds: 400), @@ -51,8 +53,6 @@ class _AudioPlayerState extends OptimizedState @override void initState() { super.initState(); - if (attachment != null) - controller = cvController?.audioPlayers[attachment!.guid]; updateObx(() { initBytes(); }); @@ -60,22 +60,28 @@ class _AudioPlayerState extends OptimizedState @override void dispose() { - if (attachment == null) { - controller?.dispose(); + final guid = attachment?.guid; + if (guid != null && + identical(cvController?.audioPlayers[guid], controller)) { + cvController?.audioPlayers.remove(guid); } + _playerStateSubscription?.cancel(); + controller?.dispose(); + controller = null; animController.dispose(); super.dispose(); } - void initBytes() async { - if (attachment != null) - controller = cvController?.audioPlayers[attachment!.guid]; + Future initBytes() async { if (controller == null) { controller = PlayerController() ..addListener(() { + if (!mounted) return; setState(() {}); }); - controller!.onPlayerStateChanged.listen((event) { + _playerStateSubscription = + controller!.onPlayerStateChanged.listen((event) { + if (!mounted) return; if ((controller!.playerState == PlayerState.paused || controller!.playerState == PlayerState.stopped) && animController.value > 0) { @@ -84,136 +90,157 @@ class _AudioPlayerState extends OptimizedState setState(() {}); }); await controller!.preparePlayer(path: file.path!); - if (attachment != null) + if (!mounted) { + await _playerStateSubscription?.cancel(); + controller?.dispose(); + controller = null; + return; + } + if (attachment != null) { cvController?.audioPlayers[attachment!.guid!] = controller!; + } } + if (!mounted) return; setState(() {}); } @override Widget build(BuildContext context) { - super.build(context); return Padding( padding: const EdgeInsets.all(5), child: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, - children: [ - Row( children: [ - CallbackShortcuts( - bindings: { - const SingleActivator(LogicalKeyboardKey.arrowRight): () => widget.nextFocusNode?.requestFocus(), - const SingleActivator(LogicalKeyboardKey.arrowDown): () => widget.nextFocusNode?.requestFocus(), - const SingleActivator(LogicalKeyboardKey.enter): () async { - if (controller == null) return; - if (controller!.playerState == PlayerState.playing) { - animController.reverse(); - await controller!.pausePlayer(); - } else { - animController.forward(); - controller!.setFinishMode(finishMode: FinishMode.pause); - await controller!.startPlayer(); - } - setState(() {}); - }, - const SingleActivator(LogicalKeyboardKey.select): () async { - if (controller == null) return; - if (controller!.playerState == PlayerState.playing) { - animController.reverse(); - await controller!.pausePlayer(); - } else { - animController.forward(); - controller!.setFinishMode(finishMode: FinishMode.pause); - await controller!.startPlayer(); - } - setState(() {}); - }, - const SingleActivator(LogicalKeyboardKey.space): () async { - if (controller == null) return; - if (controller!.playerState == PlayerState.playing) { - animController.reverse(); - await controller!.pausePlayer(); - } else { - animController.forward(); - controller!.setFinishMode(finishMode: FinishMode.pause); - await controller!.startPlayer(); - } - setState(() {}); - }, - }, - child: IconButton( - focusNode: widget.playButtonFocusNode, - style: ButtonStyle( - backgroundColor: WidgetStateProperty.resolveWith((states) => - states.contains(WidgetState.focused) ? context.theme.colorScheme.outline.withOpacity(0.2) : null), + Row( + children: [ + CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.arrowRight): + () => widget.nextFocusNode?.requestFocus(), + const SingleActivator(LogicalKeyboardKey.arrowDown): () => + widget.nextFocusNode?.requestFocus(), + const SingleActivator(LogicalKeyboardKey.enter): + () async { + if (controller == null) return; + if (controller!.playerState == PlayerState.playing) { + animController.reverse(); + await controller!.pausePlayer(); + } else { + animController.forward(); + controller! + .setFinishMode(finishMode: FinishMode.pause); + await controller!.startPlayer(); + } + if (!mounted) return; + setState(() {}); + }, + const SingleActivator(LogicalKeyboardKey.select): + () async { + if (controller == null) return; + if (controller!.playerState == PlayerState.playing) { + animController.reverse(); + await controller!.pausePlayer(); + } else { + animController.forward(); + controller! + .setFinishMode(finishMode: FinishMode.pause); + await controller!.startPlayer(); + } + if (!mounted) return; + setState(() {}); + }, + const SingleActivator(LogicalKeyboardKey.space): + () async { + if (controller == null) return; + if (controller!.playerState == PlayerState.playing) { + animController.reverse(); + await controller!.pausePlayer(); + } else { + animController.forward(); + controller! + .setFinishMode(finishMode: FinishMode.pause); + await controller!.startPlayer(); + } + if (!mounted) return; + setState(() {}); + }, + }, + child: IconButton( + focusNode: widget.playButtonFocusNode, + style: ButtonStyle( + backgroundColor: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.focused) + ? context.theme.colorScheme.outline + .withOpacity(0.2) + : null), + ), + onPressed: () async { + if (controller == null) return; + if (controller!.playerState == PlayerState.playing) { + animController.reverse(); + await controller!.pausePlayer(); + } else { + animController.forward(); + controller! + .setFinishMode(finishMode: FinishMode.pause); + await controller!.startPlayer(); + } + if (!mounted) return; + setState(() {}); + }, + icon: AnimatedIcon( + icon: AnimatedIcons.play_pause, + progress: animController, + ), + color: context.theme.colorScheme.properOnSurface, + visualDensity: VisualDensity.compact, + ), ), - onPressed: () async { - if (controller == null) return; - if (controller!.playerState == PlayerState.playing) { - animController.reverse(); - await controller!.pausePlayer(); - } else { - animController.forward(); - controller!.setFinishMode(finishMode: FinishMode.pause); - await controller!.startPlayer(); - } - setState(() {}); - }, - icon: AnimatedIcon( - icon: AnimatedIcons.play_pause, - progress: animController, + (controller?.maxDuration ?? 0) == 0 + ? SizedBox(width: ns.width(context) * 0.25) + : AudioFileWaveforms( + size: Size(ns.width(context) * 0.20, 40), + playerController: controller!, + padding: EdgeInsets.zero, + playerWaveStyle: PlayerWaveStyle( + fixedWaveColor: context + .theme.colorScheme.properSurface + .oppositeLightenOrDarken(20), + liveWaveColor: + context.theme.colorScheme.properOnSurface, + waveCap: StrokeCap.square, + waveThickness: 2, + seekLineThickness: 2, + showSeekLine: false), + ), + const SizedBox(width: 5), + Expanded( + child: Center( + heightFactor: 1, + child: Text( + prettyDuration(Duration( + milliseconds: controller?.maxDuration ?? 0)), + style: context.theme.textTheme.labelLarge!), + ), ), - color: context.theme.colorScheme.properOnSurface, - visualDensity: VisualDensity.compact, - ), + ], ), - (controller?.maxDuration ?? 0) == 0 - ? SizedBox(width: ns.width(context) * 0.25) - : AudioFileWaveforms( - size: Size(ns.width(context) * 0.20, 40), - playerController: controller!, - padding: EdgeInsets.zero, - playerWaveStyle: PlayerWaveStyle( - fixedWaveColor: context - .theme.colorScheme.properSurface - .oppositeLightenOrDarken(20), - liveWaveColor: - context.theme.colorScheme.properOnSurface, - waveCap: StrokeCap.square, - waveThickness: 2, - seekLineThickness: 2, - showSeekLine: false), - ), - const SizedBox(width: 5), - Expanded( - child: Center( - heightFactor: 1, + if (widget.transcript != null) + Padding( + padding: const EdgeInsets.only( + top: 5, left: 10, right: 10, bottom: 5), child: Text( - prettyDuration( - Duration(milliseconds: controller?.maxDuration ?? 0)), - style: context.theme.textTheme.labelLarge!), + "${widget.transcript}", + style: context.theme.textTheme.bodySmall, + ), ), - ), - ], - ), - if (widget.transcript != null) - Padding( - padding: const EdgeInsets.only(top: 5, left: 10, right: 10, bottom: 5), - child: Text( - "${widget.transcript}", - style: context.theme.textTheme.bodySmall, - ), - ), - ])); + ])); } - - @override - bool get wantKeepAlive => true; } class _DesktopAudioPlayerState extends OptimizedState - with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin { + with SingleTickerProviderStateMixin { Attachment? get attachment => widget.attachment; PlatformFile get file => widget.file; @@ -221,6 +248,10 @@ class _DesktopAudioPlayerState extends OptimizedState ConversationViewController? get cvController => widget.controller; Player? controller; + StreamSubscription? _positionSubscription; + StreamSubscription? _completedSubscription; + Future? _initialization; + bool _isDisposed = false; late final animController = AnimationController( vsync: this, duration: const Duration(milliseconds: 400), @@ -229,47 +260,74 @@ class _DesktopAudioPlayerState extends OptimizedState @override void initState() { super.initState(); - if (attachment != null) - controller = cvController?.audioPlayersDesktop[attachment!.guid]; updateObx(() { - initBytes(); + _initialization = initBytes(); }); } @override void dispose() { - if (attachment == null) { - controller?.dispose(); + _isDisposed = true; + final ownedController = controller; + final guid = attachment?.guid; + if (guid != null && + identical(cvController?.audioPlayersDesktop[guid], controller)) { + cvController?.audioPlayersDesktop.remove(guid); } + _positionSubscription?.cancel(); + _completedSubscription?.cancel(); + if (ownedController != null) { + unawaited(_disposePlayer(ownedController)); + } + controller = null; animController.dispose(); super.dispose(); } - void initBytes() async { - if (attachment != null) - controller = cvController?.audioPlayersDesktop[attachment!.guid]; + Future initBytes() async { if (controller == null) { - controller = Player() - ..stream.position.listen((position) => setState(() {})) - ..stream.completed.listen((bool completed) async { - if (completed) { - await controller!.pause(); - await controller!.seek(Duration.zero); - animController.reverse(); - } - setState(() {}); - }); + controller = Player(); + _positionSubscription = controller!.stream.position.listen((position) { + if (!mounted) return; + setState(() {}); + }); + _completedSubscription = + controller!.stream.completed.listen((bool completed) async { + if (completed) { + await controller!.pause(); + await controller!.seek(Duration.zero); + if (!mounted) return; + animController.reverse(); + } + if (!mounted) return; + setState(() {}); + }); await controller!.setPlaylistMode(PlaylistMode.none); + if (_isDisposed) return; await controller!.open(Media(file.path!), play: false); - if (attachment != null) + if (!mounted || _isDisposed) return; + if (attachment != null) { cvController?.audioPlayersDesktop[attachment!.guid!] = controller!; + } } + if (!mounted) return; setState(() {}); } + Future _disposePlayer(Player player) async { + final initialization = _initialization; + if (initialization != null) { + try { + await initialization; + } catch (_) { + // Disposal must still run when native initialization fails. + } + } + await player.dispose(); + } + @override Widget build(BuildContext context) { - super.build(context); return Padding( padding: const EdgeInsets.all(5), child: Row( @@ -277,8 +335,10 @@ class _DesktopAudioPlayerState extends OptimizedState children: [ CallbackShortcuts( bindings: { - const SingleActivator(LogicalKeyboardKey.arrowRight): () => widget.nextFocusNode?.requestFocus(), - const SingleActivator(LogicalKeyboardKey.arrowDown): () => widget.nextFocusNode?.requestFocus(), + const SingleActivator(LogicalKeyboardKey.arrowRight): () => + widget.nextFocusNode?.requestFocus(), + const SingleActivator(LogicalKeyboardKey.arrowDown): () => + widget.nextFocusNode?.requestFocus(), const SingleActivator(LogicalKeyboardKey.enter): () async { if (controller == null) return; if (controller!.state.playing) { @@ -288,6 +348,7 @@ class _DesktopAudioPlayerState extends OptimizedState animController.forward(); await controller!.play(); } + if (!mounted) return; setState(() {}); }, const SingleActivator(LogicalKeyboardKey.select): () async { @@ -299,6 +360,7 @@ class _DesktopAudioPlayerState extends OptimizedState animController.forward(); await controller!.play(); } + if (!mounted) return; setState(() {}); }, const SingleActivator(LogicalKeyboardKey.space): () async { @@ -310,6 +372,7 @@ class _DesktopAudioPlayerState extends OptimizedState animController.forward(); await controller!.play(); } + if (!mounted) return; setState(() {}); }, }, @@ -317,7 +380,9 @@ class _DesktopAudioPlayerState extends OptimizedState focusNode: widget.playButtonFocusNode, style: ButtonStyle( backgroundColor: WidgetStateProperty.resolveWith((states) => - states.contains(WidgetState.focused) ? context.theme.colorScheme.outline.withOpacity(0.2) : null), + states.contains(WidgetState.focused) + ? context.theme.colorScheme.outline.withOpacity(0.2) + : null), ), onPressed: () async { if (controller == null) return; @@ -328,6 +393,7 @@ class _DesktopAudioPlayerState extends OptimizedState animController.forward(); await controller!.play(); } + if (!mounted) return; setState(() {}); }, icon: AnimatedIcon( @@ -358,7 +424,4 @@ class _DesktopAudioPlayerState extends OptimizedState ], )); } - - @override - bool get wantKeepAlive => true; } diff --git a/lib/app/layouts/conversation_view/widgets/message/attachment/image_viewer.dart b/lib/app/layouts/conversation_view/widgets/message/attachment/image_viewer.dart index bb40c5b64b..4d138e276b 100644 --- a/lib/app/layouts/conversation_view/widgets/message/attachment/image_viewer.dart +++ b/lib/app/layouts/conversation_view/widgets/message/attachment/image_viewer.dart @@ -1,15 +1,12 @@ -import 'dart:async'; import 'dart:math'; import 'package:bluebubbles/app/wrappers/stateful_boilerplate.dart'; -import 'package:bluebubbles/helpers/helpers.dart'; import 'package:bluebubbles/database/models.dart'; import 'package:bluebubbles/services/services.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:tuple/tuple.dart'; class ImageViewer extends StatefulWidget { final PlatformFile file; @@ -30,7 +27,7 @@ class ImageViewer extends StatefulWidget { OptimizedState createState() => _ImageViewerState(); } -class _ImageViewerState extends OptimizedState with AutomaticKeepAliveClientMixin { +class _ImageViewerState extends OptimizedState { Attachment get attachment => widget.attachment; PlatformFile get file => widget.file; ConversationViewController? get controller => widget.controller; @@ -52,14 +49,13 @@ class _ImageViewerState extends OptimizedState with AutomaticKeepAl // Try to get the image data from the "cache" Uint8List? tmpData = controller!.imageData[attachment.guid]; if (tmpData == null) { - final completer = Completer(); - controller!.queueImage(Tuple4(attachment, file, context, completer)); - final newData = await completer.future; - if (newData.isEmpty) return; + final newData = await controller!.queueImage(attachment, file); + if (!mounted || newData.isEmpty) return; setState(() { data = newData; }); } else { + if (!mounted) return; setState(() { data = tmpData; }); @@ -68,59 +64,89 @@ class _ImageViewerState extends OptimizedState with AutomaticKeepAl @override Widget build(BuildContext context) { - super.build(context); if (attachment.guid!.contains("demo")) { return Image.asset(attachment.transferName!, fit: BoxFit.cover); } if (data == null) { return SizedBox( - width: min((attachment.width?.toDouble() ?? ns.width(context) * 0.5), ns.width(context) * 0.5), - height: min((attachment.height?.toDouble() ?? ns.width(context) * 0.5 / attachment.aspectRatio), ns.width(context) * 0.5 / attachment.aspectRatio), + width: min((attachment.width?.toDouble() ?? ns.width(context) * 0.5), + ns.width(context) * 0.5), + height: min( + (attachment.height?.toDouble() ?? + ns.width(context) * 0.5 / attachment.aspectRatio), + ns.width(context) * 0.5 / attachment.aspectRatio), ); } + final maximumDisplayWidth = ns.width(context) * 0.5; + final sourceWidth = attachment.width?.toDouble(); + final sourceHeight = attachment.height?.toDouble(); + final displayWidth = min( + sourceWidth != null && sourceWidth > 0 + ? sourceWidth + : maximumDisplayWidth, + maximumDisplayWidth, + ); + final fallbackHeight = maximumDisplayWidth / + (attachment.aspectRatio.isFinite && attachment.aspectRatio > 0 + ? attachment.aspectRatio + : 1); + final displayHeight = min( + sourceHeight != null && sourceHeight > 0 ? sourceHeight : fallbackHeight, + fallbackHeight, + ); + final cacheWidth = + (displayWidth * Get.pixelRatio / 2).round().clamp(1, 1024).toInt(); + final cacheHeight = + (displayHeight * Get.pixelRatio / 2).round().clamp(1, 1024).toInt(); return Image.memory( data!, // prevents the image widget from "refreshing" when the provider changes gaplessPlayback: true, filterQuality: FilterQuality.none, - cacheWidth: (min((attachment.width ?? 0), ns.width(context) * 0.5) * Get.pixelRatio / 2).round().abs().nonZero, - cacheHeight: (min((attachment.height ?? 0), ns.width(context) * 0.5 / attachment.aspectRatio) * Get.pixelRatio / 2).round().abs().nonZero, + cacheWidth: cacheWidth, + cacheHeight: cacheHeight, fit: BoxFit.cover, frameBuilder: (context, w, frame, wasSyncLoaded) { return AnimatedCrossFade( - crossFadeState: frame == null ? CrossFadeState.showFirst : CrossFadeState.showSecond, - alignment: Alignment.center, - duration: const Duration(milliseconds: 150), - secondChild: ConstrainedBox( - constraints: const BoxConstraints( - minHeight: 40, - minWidth: 100, - ), - child: Stack( - alignment: !widget.isFromMe ? Alignment.topRight : Alignment.topLeft, - children: [ - w, - if (attachment.hasLivePhoto) - const Padding( - padding: EdgeInsets.all(10.0), - child: Icon(CupertinoIcons.smallcircle_circle, color: Colors.white, size: 20), - ), - ], + crossFadeState: frame == null + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + alignment: Alignment.center, + duration: const Duration(milliseconds: 150), + secondChild: ConstrainedBox( + constraints: const BoxConstraints( + minHeight: 40, + minWidth: 100, + ), + child: Stack( + alignment: + !widget.isFromMe ? Alignment.topRight : Alignment.topLeft, + children: [ + w, + if (attachment.hasLivePhoto) + const Padding( + padding: EdgeInsets.all(10.0), + child: Icon(CupertinoIcons.smallcircle_circle, + color: Colors.white, size: 20), + ), + ], + ), ), - ), - firstChild: SizedBox( - width: min((attachment.width?.toDouble() ?? ns.width(context) * 0.5), ns.width(context) * 0.5), - height: min((attachment.height?.toDouble() ?? ns.width(context) * 0.5 / attachment.aspectRatio), ns.width(context) * 0.5 / attachment.aspectRatio), - ) - ); + firstChild: SizedBox( + width: min( + (attachment.width?.toDouble() ?? ns.width(context) * 0.5), + ns.width(context) * 0.5), + height: min( + (attachment.height?.toDouble() ?? + ns.width(context) * 0.5 / attachment.aspectRatio), + ns.width(context) * 0.5 / attachment.aspectRatio), + )); }, errorBuilder: (context, object, stacktrace) => Center( heightFactor: 1, - child: Text("Failed to display image", style: context.theme.textTheme.bodyLarge), + child: Text("Failed to display image", + style: context.theme.textTheme.bodyLarge), ), ); } - - @override - bool get wantKeepAlive => true; } diff --git a/lib/app/layouts/conversation_view/widgets/message/attachment/sticker_holder.dart b/lib/app/layouts/conversation_view/widgets/message/attachment/sticker_holder.dart index a79ffc7402..04fff0bb46 100644 --- a/lib/app/layouts/conversation_view/widgets/message/attachment/sticker_holder.dart +++ b/lib/app/layouts/conversation_view/widgets/message/attachment/sticker_holder.dart @@ -1,15 +1,16 @@ import 'dart:async'; +import 'dart:typed_data'; import 'package:bluebubbles/app/wrappers/stateful_boilerplate.dart'; import 'package:bluebubbles/database/models.dart'; import 'package:bluebubbles/services/services.dart'; import 'package:bluebubbles/utils/logger/logger.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:universal_io/io.dart'; class StickerHolder extends StatefulWidget { - StickerHolder({super.key, required this.stickerMessages, required this.controller}); + StickerHolder( + {super.key, required this.stickerMessages, required this.controller}); final Iterable stickerMessages; final ConversationViewController controller; @@ -17,10 +18,10 @@ class StickerHolder extends StatefulWidget { State createState() => _StickerHolderState(); } -class _StickerHolderState extends OptimizedState with AutomaticKeepAliveClientMixin { +class _StickerHolderState extends OptimizedState { Iterable get messages => widget.stickerMessages; ConversationViewController get controller => widget.controller; - + bool _visible = true; int renderedStickers = 0; @@ -37,16 +38,25 @@ class _StickerHolderState extends OptimizedState with AutomaticKe renderedStickers = messages.length; for (Message msg in messages) { for (Attachment? attachment in msg.attachments) { + if (attachment == null || attachment.guid == null) continue; + final currentAttachment = attachment; // If we've already loaded it, don't try again - if (controller.stickerData.keys.contains(attachment!.guid)) continue; + if (controller.stickerData[msg.guid!] + ?.containsKey(currentAttachment.guid) ?? + false) { + continue; + } - final pathName = attachment.path; - if (await FileSystemEntity.type(pathName) == FileSystemEntityType.notFound) { - attachmentDownloader.startDownload(attachment, onComplete: (_) async { - await checkImage(msg, attachment); + final pathName = currentAttachment.path; + if (await FileSystemEntity.type(pathName) == + FileSystemEntityType.notFound) { + if (!mounted) return; + attachmentDownloader.startDownload(currentAttachment, + onComplete: (_) async { + await checkImage(msg, currentAttachment); }); } else { - await checkImage(msg, attachment); + await checkImage(msg, currentAttachment); } } } @@ -63,17 +73,23 @@ class _StickerHolderState extends OptimizedState with AutomaticKe // ), // ); final bytes = await File(pathName).readAsBytes(); + if (!mounted) return; var stickerData = message.attributedBody.firstOrNull?.runs - .firstWhere((element) => element.attributes?.attachmentGuid == attachment.guid).attributes?.stickerData; - controller.stickerData[message.guid!] = { - attachment.guid!: (bytes, stickerData) - }; + .firstWhere( + (element) => element.attributes?.attachmentGuid == attachment.guid) + .attributes + ?.stickerData; + final messageStickers = Map.from( + controller.stickerData[message.guid!] ?? const {}, + ); + messageStickers[attachment.guid!] = (bytes, stickerData); + controller.stickerData[message.guid!] = messageStickers; Logger.debug("sticker count ${controller.stickerData.length}"); setState(() {}); } @override - void didUpdateWidget(StickerHolder oldWidget) { + void didUpdateWidget(StickerHolder oldWidget) { super.didUpdateWidget(oldWidget); Logger.debug("ugh why ${messages.length}"); updateObx(() { @@ -83,44 +99,50 @@ class _StickerHolderState extends OptimizedState with AutomaticKe @override Widget build(BuildContext context) { - super.build(context); final guids = messages.map((e) => e.guid!); - final stickers = controller.stickerData.entries.where((element) => guids.contains(element.key)).map((e) => e.value); + final stickers = guids + .map((guid) => controller.stickerData[guid]) + .whereType>(); if (stickers.isEmpty) return const SizedBox.shrink(); final data = stickers.map((e) => e.values).expand((element) => element); - return Positioned(top: -20, left: -20, right: -20, bottom: -20, child: GestureDetector( - onTap: () { - setState(() { - _visible = !_visible; - }); - }, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 150), - opacity: _visible ? 1.0 : 0.25, - child: Stack( - children: data.map((e) => Container( - child: Transform.rotate( - angle: e.$2?.rotation ?? 0, - alignment: FractionalOffset(e.$2?.normalizedX ?? .5, e.$2?.normalizedY ?? .5), - child: Transform.scale( - child: Image.memory( - e.$1, - scale: .01, - gaplessPlayback: true, - cacheHeight: 200, - filterQuality: FilterQuality.none, - ), - scale: e.$2?.scale ?? 1, - ), - ), - alignment: FractionalOffset(e.$2?.normalizedX ?? .5, e.$2?.normalizedY ?? .5), - )).toList(), - ) - ), - )); + return Positioned( + top: -20, + left: -20, + right: -20, + bottom: -20, + child: GestureDetector( + onTap: () { + setState(() { + _visible = !_visible; + }); + }, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 150), + opacity: _visible ? 1.0 : 0.25, + child: Stack( + children: data + .map((e) => Container( + child: Transform.rotate( + angle: e.$2?.rotation ?? 0, + alignment: FractionalOffset(e.$2?.normalizedX ?? .5, + e.$2?.normalizedY ?? .5), + child: Transform.scale( + child: Image.memory( + e.$1, + scale: .01, + gaplessPlayback: true, + cacheHeight: 200, + filterQuality: FilterQuality.none, + ), + scale: e.$2?.scale ?? 1, + ), + ), + alignment: FractionalOffset( + e.$2?.normalizedX ?? .5, e.$2?.normalizedY ?? .5), + )) + .toList(), + )), + )); } - - @override - bool get wantKeepAlive => true; } diff --git a/lib/app/layouts/conversation_view/widgets/message/attachment/video_player.dart b/lib/app/layouts/conversation_view/widgets/message/attachment/video_player.dart index 6665c703a6..ebfe386332 100644 --- a/lib/app/layouts/conversation_view/widgets/message/attachment/video_player.dart +++ b/lib/app/layouts/conversation_view/widgets/message/attachment/video_player.dart @@ -22,7 +22,11 @@ class VideoPlayer extends StatefulWidget { final bool isFromMe; VideoPlayer( - {super.key, required this.file, required this.attachment, required this.controller, required this.isFromMe}); + {super.key, + required this.file, + required this.attachment, + required this.controller, + required this.isFromMe}); final ConversationViewController? controller; @@ -56,7 +60,8 @@ class PlayPauseButton extends StatelessWidget { child: AnimatedOpacity( opacity: _hover.value ? 1 - : showPlayPauseOverlay.value && ReplyScope.maybeOf(context) == null + : showPlayPauseOverlay.value && + ReplyScope.maybeOf(context) == null ? 0.5 : 0, duration: const Duration(milliseconds: 100), @@ -86,14 +91,17 @@ class PlayPauseButton extends StatelessWidget { height: 75, width: 75, decoration: BoxDecoration( - color: context.theme.colorScheme.background.withOpacity(0.5), + color: + context.theme.colorScheme.background.withOpacity(0.5), borderRadius: BorderRadius.circular(40), ), clipBehavior: Clip.antiAlias, child: Padding( padding: EdgeInsets.only( - left: - ss.settings.skin.value == Skins.iOS && !(controller?.player.state.playing ?? false) ? 17 : 10, + left: ss.settings.skin.value == Skins.iOS && + !(controller?.player.state.playing ?? false) + ? 17 + : 10, top: ss.settings.skin.value == Skins.iOS ? 13 : 10, right: 10, bottom: 10, @@ -101,12 +109,16 @@ class PlayPauseButton extends StatelessWidget { child: Obx( () => controller?.player.state.playing ?? false ? Icon( - ss.settings.skin.value == Skins.iOS ? CupertinoIcons.pause : Icons.pause, + ss.settings.skin.value == Skins.iOS + ? CupertinoIcons.pause + : Icons.pause, color: context.iconColor, size: 45, ) : Icon( - ss.settings.skin.value == Skins.iOS ? CupertinoIcons.play : Icons.play_arrow, + ss.settings.skin.value == Skins.iOS + ? CupertinoIcons.play + : Icons.play_arrow, color: context.iconColor, size: 45, ), @@ -142,7 +154,10 @@ class MuteButton extends StatelessWidget { right: (isFromMe) ? 15 : 8, child: Obx(() { return AnimatedOpacity( - opacity: showPlayPauseOverlay.value && ReplyScope.maybeOf(context) == null ? 1 : 0, + opacity: showPlayPauseOverlay.value && + ReplyScope.maybeOf(context) == null + ? 1 + : 0, duration: const Duration(milliseconds: 250), child: AbsorbPointer( absorbing: !showPlayPauseOverlay.value, @@ -152,13 +167,15 @@ class MuteButton extends StatelessWidget { borderRadius: BorderRadius.circular(40), onTap: () async { muted.value = !muted.value; - await controller?.player.setVolume(muted.value ? 0.0 : 100.0); + await controller?.player + .setVolume(muted.value ? 0.0 : 100.0); }, child: Container( height: 30, width: 30, decoration: BoxDecoration( - color: context.theme.colorScheme.background.withOpacity(0.5), + color: context.theme.colorScheme.background + .withOpacity(0.5), borderRadius: BorderRadius.circular(40), ), padding: const EdgeInsets.all(5), @@ -181,7 +198,7 @@ class MuteButton extends StatelessWidget { } } -class _VideoPlayerState extends OptimizedState with AutomaticKeepAliveClientMixin { +class _VideoPlayerState extends OptimizedState { Attachment get attachment => widget.attachment; PlatformFile get file => widget.file; @@ -192,6 +209,10 @@ class _VideoPlayerState extends OptimizedState with AutomaticKeepAl bool hasListener = false; VideoController? videoController; + VoidCallback? _rectListener; + StreamSubscription? _completedSubscription; + Future? _initialization; + bool _isDisposed = false; final RxBool showPlayPauseOverlay = true.obs; final RxBool muted = ss.settings.startVideosMuted.value.obs; @@ -200,29 +221,31 @@ class _VideoPlayerState extends OptimizedState with AutomaticKeepAl @override void initState() { - VideoController? cachedController = cvController?.videoPlayers[attachment.guid]; + super.initState(); thumbnail = cvController?.imageData[attachment.guid]; - if (cachedController != null) { - videoController = cachedController; - aspectRatio.value = videoController!.aspectRatio; - - updateObx(() { - createListener(videoController!); - }); - } - if (thumbnail == null && !kIsDesktop && !kIsWeb) { updateObx(() { getThumbnail(); }); } - - initializeController(); - super.initState(); } Future initializeController() async { + final inFlight = _initialization; + if (inFlight != null) return inFlight; + final initialization = _initializeController(); + _initialization = initialization; + try { + await initialization; + } finally { + if (identical(_initialization, initialization)) { + _initialization = null; + } + } + } + + Future _initializeController() async { late final Media media; if (widget.file.path == null) { final blob = html.Blob([widget.file.bytes]); @@ -232,28 +255,34 @@ class _VideoPlayerState extends OptimizedState with AutomaticKeepAl media = Media(widget.file.path!); } - videoController ??= VideoController(Player()); - await videoController!.player.setPlaylistMode(PlaylistMode.none); - await videoController!.player.open(media, play: false); - await videoController!.player.setVolume(muted.value ? 0 : 100); - createListener(videoController!); - cvController?.videoPlayers[attachment.guid!] = videoController!; + final controller = videoController ??= VideoController(Player()); + await controller.player.setPlaylistMode(PlaylistMode.none); + if (_isDisposed) return; + await controller.player.open(media, play: false); + if (_isDisposed) return; + await controller.player.setVolume(muted.value ? 0 : 100); + if (!mounted || _isDisposed) return; + createListener(controller); + cvController?.videoPlayers[attachment.guid!] = controller; setState(() {}); } void createListener(VideoController controller) { if (hasListener) return; - controller.rect.addListener(() { + _rectListener = () { aspectRatio.value = controller.aspectRatio; - }); + }; + controller.rect.addListener(_rectListener!); - controller.player.stream.completed.listen((completed) async { + _completedSubscription = + controller.player.stream.completed.listen((completed) async { // If the status is ended, restart if (completed) { await controller.player.pause(); await controller.player.seek(Duration.zero); await controller.player.pause(); + if (!mounted) return; showPlayPauseOverlay.value = true; showPlayPauseOverlay.refresh(); } @@ -283,20 +312,51 @@ class _VideoPlayerState extends OptimizedState with AutomaticKeepAl } } - if (thumbnail == null) return; + if (!mounted || thumbnail == null) return; cvController?.imageData[attachment.guid!] = thumbnail!; - await precacheImage(MemoryImage(thumbnail!), context); setState(() {}); } } + @override + void dispose() { + _isDisposed = true; + final controller = videoController; + final guid = attachment.guid; + if (guid != null && + identical(cvController?.videoPlayers[guid], controller)) { + cvController?.videoPlayers.remove(guid); + } + final rectListener = _rectListener; + if (controller != null && rectListener != null) { + controller.rect.removeListener(rectListener); + } + _completedSubscription?.cancel(); + if (controller != null) unawaited(_disposePlayer(controller)); + videoController = null; + super.dispose(); + } + + Future _disposePlayer(VideoController controller) async { + final initialization = _initialization; + if (initialization != null) { + try { + await initialization; + } catch (_) { + // Disposal must still run when native initialization fails. + } + } + await controller.player.pause(); + await controller.player.dispose(); + } + @override Widget build(BuildContext context) { - super.build(context); if (videoController != null) { return MouseRegion( onEnter: (event) => showPlayPauseOverlay.value = true, - onExit: (event) => showPlayPauseOverlay.value = !videoController!.player.state.playing, + onExit: (event) => + showPlayPauseOverlay.value = !videoController!.player.state.playing, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: !kIsDesktop && !kIsWeb @@ -348,7 +408,9 @@ class _VideoPlayerState extends OptimizedState with AutomaticKeepAl controls: null, ), )), - PlayPauseButton(showPlayPauseOverlay: showPlayPauseOverlay, controller: videoController), + PlayPauseButton( + showPlayPauseOverlay: showPlayPauseOverlay, + controller: videoController), MuteButton( showPlayPauseOverlay: showPlayPauseOverlay, muted: muted, @@ -356,10 +418,9 @@ class _VideoPlayerState extends OptimizedState with AutomaticKeepAl isFromMe: widget.isFromMe), if (kIsDesktop) FullscreenButton( - attachment: attachment, - isFromMe: widget.isFromMe, - muted: muted - ), + attachment: attachment, + isFromMe: widget.isFromMe, + muted: muted), ], ), ), @@ -397,7 +458,9 @@ class _VideoPlayerState extends OptimizedState with AutomaticKeepAl hover: hover, customOnTap: () async { await initializeController(); - await videoController?.player.setVolume(muted.value ? 0.0 : 100.0); + if (!mounted || _isDisposed || !ls.isAlive) return; + await videoController?.player + .setVolume(muted.value ? 0.0 : 100.0); await videoController?.player.play(); showPlayPauseOverlay.value = false; }, @@ -412,13 +475,16 @@ class _VideoPlayerState extends OptimizedState with AutomaticKeepAl file.name, maxLines: 2, overflow: TextOverflow.ellipsis, - style: context.theme.textTheme.bodyMedium!.apply(fontWeightDelta: 2), + style: context.theme.textTheme.bodyMedium! + .apply(fontWeightDelta: 2), ), const SizedBox(height: 2.5), Text( "${(mime(file.name)?.split("/").lastOrNull ?? mime(file.name) ?? "file").toUpperCase()} • ${file.size.toDouble().getFriendlySize()}", style: context.theme.textTheme.labelMedium! - .copyWith(fontWeight: FontWeight.normal, color: context.theme.colorScheme.outline), + .copyWith( + fontWeight: FontWeight.normal, + color: context.theme.colorScheme.outline), overflow: TextOverflow.clip, maxLines: 1, ), @@ -433,11 +499,18 @@ class _VideoPlayerState extends OptimizedState with AutomaticKeepAl // prevents the image widget from "refreshing" when the provider changes gaplessPlayback: true, filterQuality: FilterQuality.none, - cacheWidth: (min((attachment.width ?? 0), ns.width(context) * 0.5) * Get.pixelRatio / 2) - .round() - .abs() - .nonZero, - cacheHeight: (min((attachment.height ?? 0), ns.width(context) * 0.5 / attachment.aspectRatio) * + cacheWidth: + (min((attachment.width ?? 0), ns.width(context) * 0.5) * + Get.pixelRatio / + 2) + .round() + .abs() + .nonZero, + cacheHeight: (min( + (attachment.height ?? 0), + ns.width(context) * + 0.5 / + attachment.aspectRatio) * Get.pixelRatio / 2) .round() @@ -446,7 +519,9 @@ class _VideoPlayerState extends OptimizedState with AutomaticKeepAl fit: BoxFit.cover, frameBuilder: (context, widget, frame, wasSyncLoaded) { return AnimatedCrossFade( - crossFadeState: frame == null ? CrossFadeState.showFirst : CrossFadeState.showSecond, + crossFadeState: frame == null + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, alignment: Alignment.center, duration: const Duration(milliseconds: 150), secondChild: Stack( @@ -458,7 +533,11 @@ class _VideoPlayerState extends OptimizedState with AutomaticKeepAl controller: videoController, customOnTap: () async { await initializeController(); - await videoController?.player.setVolume(muted.value ? 0.0 : 100.0); + if (!mounted || _isDisposed || !ls.isAlive) { + return; + } + await videoController?.player + .setVolume(muted.value ? 0.0 : 100.0); await videoController?.player.play(); showPlayPauseOverlay.value = false; }, @@ -471,23 +550,30 @@ class _VideoPlayerState extends OptimizedState with AutomaticKeepAl ], ), firstChild: SizedBox( - width: - min((attachment.width?.toDouble() ?? ns.width(context) * 0.5), ns.width(context) * 0.5), + width: min( + (attachment.width?.toDouble() ?? + ns.width(context) * 0.5), + ns.width(context) * 0.5), height: min( - (attachment.height?.toDouble() ?? ns.width(context) * 0.5 / attachment.aspectRatio), + (attachment.height?.toDouble() ?? + ns.width(context) * + 0.5 / + attachment.aspectRatio), ns.width(context) * 0.5 / attachment.aspectRatio), )); }, )), ); } - - @override - bool get wantKeepAlive => true; } class FullscreenButton extends StatelessWidget { - const FullscreenButton({super.key, required this.attachment, required this.isFromMe, this.videoController, this.muted}); + const FullscreenButton( + {super.key, + required this.attachment, + required this.isFromMe, + this.videoController, + this.muted}); final Attachment attachment; final bool isFromMe; @@ -509,12 +595,11 @@ class FullscreenButton extends StatelessWidget { await Navigator.of(Get.context!).push( ThemeSwitcher.buildPageRoute( builder: (context) => FullscreenMediaHolder( - currentChat: cm.activeChat, - attachment: attachment, - showInteractions: true, - videoController: videoController, - mute: muted - ), + currentChat: cm.activeChat, + attachment: attachment, + showInteractions: true, + videoController: videoController, + mute: muted), ), ); }, @@ -527,7 +612,9 @@ class FullscreenButton extends StatelessWidget { ), padding: const EdgeInsets.all(5), child: Icon( - ss.settings.skin.value == Skins.iOS ? CupertinoIcons.fullscreen : Icons.fullscreen, + ss.settings.skin.value == Skins.iOS + ? CupertinoIcons.fullscreen + : Icons.fullscreen, color: Colors.white, size: 15, ), diff --git a/lib/app/layouts/conversation_view/widgets/message/interactive/interactive_holder.dart b/lib/app/layouts/conversation_view/widgets/message/interactive/interactive_holder.dart index 920e0806a5..de9bfd1879 100644 --- a/lib/app/layouts/conversation_view/widgets/message/interactive/interactive_holder.dart +++ b/lib/app/layouts/conversation_view/widgets/message/interactive/interactive_holder.dart @@ -43,12 +43,14 @@ class _InteractiveHolderState extends CustomState>(controller.cvController!.selected, (event) { + _selectionWorker = ever>(controller.cvController!.selected, (event) { + if (!mounted) return; if (controller.cvController!.isSelected(message.guid!) && !selected) { setState(() { selected = true; @@ -86,6 +88,12 @@ class _InteractiveHolderState extends CustomState true; diff --git a/lib/app/layouts/conversation_view/widgets/message/message_holder.dart b/lib/app/layouts/conversation_view/widgets/message/message_holder.dart index 1710febcea..18a2299d10 100644 --- a/lib/app/layouts/conversation_view/widgets/message/message_holder.dart +++ b/lib/app/layouts/conversation_view/widgets/message/message_holder.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:bluebubbles/app/components/custom/custom_bouncing_scroll_physics.dart'; @@ -15,6 +16,7 @@ import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/popup/ import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/reaction/reaction_holder.dart'; import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/reply/reply_bubble.dart'; import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/reply/reply_line_painter.dart'; +import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/reply/reply_thread_popup.dart'; import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/text/text_bubble.dart'; import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/timestamp/delivered_indicator.dart'; import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/timestamp/message_timestamp.dart'; @@ -84,6 +86,7 @@ class _MessageHolderState extends CustomState keys = []; bool gaveHapticFeedback = false; final RxBool tapped = false.obs; + StreamSubscription? _avatarRefreshSubscription; @override void initState() { @@ -105,7 +108,8 @@ class _MessageHolderState extends CustomState GlobalKey()); } - eventDispatcher.stream.listen((event) { + _avatarRefreshSubscription = eventDispatcher.stream.listen((event) { + if (!mounted) return; if (event.item1 != 'refresh-avatar') return; if (event.item2[0] != message.handle?.address) return; message.handle?.color = event.item2[1]; @@ -113,6 +117,12 @@ class _MessageHolderState extends CustomState reactionsForPart(int part) { return reactions.where((s) => (s.associatedMessagePart ?? 0) == part); } + final replyTarget = replyTo; + MessageWidgetController? replyController; + if (replyTarget?.guid != null) { + replyController = getActiveMwc(replyTarget!.guid!) ?? mwc(replyTarget); + replyController.cvController ??= widget.cvController; + } /// Layout tree /// - Timestamp /// - Stack (see code comment) @@ -272,12 +288,12 @@ class _MessageHolderState extends CustomState tapped.value = !tapped.value : null, child: IgnorePointer( ignoring: widget.cvController.inSelectMode.value, @@ -751,7 +769,7 @@ class _MessageHolderState extends CustomState { late MovieTween tween; Control controller = Control.stop; Size size = Size.zero; + StreamSubscription? _effectSubscription; @override void initState() { getTween(); - eventDispatcher.stream.listen((event) async { + _effectSubscription = eventDispatcher.stream.listen((event) { + if (!mounted) return; if (event.item1 == 'play-bubble-effect' && event.item2 == '${widget.part}/${widget.message.guid}') { size = widget.globalKey?.currentContext?.size ?? Size.zero; setState(() { @@ -58,6 +61,12 @@ class _BubbleEffectsState extends OptimizedState { super.initState(); } + @override + void dispose() { + _effectSubscription?.cancel(); + super.dispose(); + } + void getTween() { if (effect == MessageEffect.gentle) { tween = MovieTween() diff --git a/lib/app/layouts/conversation_view/widgets/message/reaction/reaction.dart b/lib/app/layouts/conversation_view/widgets/message/reaction/reaction.dart index 860caa1bb9..31b322d7e7 100644 --- a/lib/app/layouts/conversation_view/widgets/message/reaction/reaction.dart +++ b/lib/app/layouts/conversation_view/widgets/message/reaction/reaction.dart @@ -12,7 +12,6 @@ import 'package:defer_pointer/defer_pointer.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_svg/svg.dart'; import 'package:get/get.dart'; import 'package:universal_io/io.dart'; @@ -34,8 +33,7 @@ class ReactionWidget extends StatefulWidget { class ReactionWidgetState extends OptimizedState { late Message reaction = widget.reaction; - late final StreamSubscription sub; - bool hasStream = false; + StreamSubscription? sub; List? get reactions => widget.reactions; bool get reactionIsFromMe => reaction.isFromMe!; @@ -52,13 +50,15 @@ class ReactionWidgetState extends OptimizedState { updateReaction(); updateObx(() { if (!kIsWeb && widget.message != null) { - final messageQuery = Database.messages.query(Message_.id.equals(reaction.id!)).watch(); + final messageQuery = + Database.messages.query(Message_.id.equals(reaction.id!)).watch(); sub = messageQuery.listen((Query query) async { final _message = await runAsync(() { return Database.messages.get(reaction.id!); }); if (_message != null) { - if (_message.guid != reaction.guid || _message.dateDelivered != reaction.dateDelivered) { + if (_message.guid != reaction.guid || + _message.dateDelivered != reaction.dateDelivered) { setState(() { reaction = _message; updateReaction(); @@ -67,11 +67,10 @@ class ReactionWidgetState extends OptimizedState { reaction = _message; updateReaction(); } - getActiveMwc(widget.message!.guid!)?.updateAssociatedMessage(reaction, updateHolder: false); + getActiveMwc(widget.message!.guid!) + ?.updateAssociatedMessage(reaction, updateHolder: false); } }); - - hasStream = true; } else if (kIsWeb && widget.message != null) { sub = WebListeners.messageUpdate.listen((tuple) { final _message = tuple.item1; @@ -81,7 +80,8 @@ class ReactionWidgetState extends OptimizedState { reaction = _message; updateReaction(); }); - getActiveMwc(widget.message!.guid!)?.updateAssociatedMessage(reaction, updateHolder: false); + getActiveMwc(widget.message!.guid!) + ?.updateAssociatedMessage(reaction, updateHolder: false); } }); } @@ -99,9 +99,14 @@ class ReactionWidgetState extends OptimizedState { // ), // ); final bytes = await File(pathName).readAsBytes(); - controller!.stickerData[reaction.guid!] = { - attachment.guid!: (bytes, null) - }; + if (!mounted) return; + final activeController = controller; + if (activeController == null) return; + final reactionStickers = Map.from( + activeController.stickerData[reaction.guid!] ?? const {}, + ); + reactionStickers[attachment.guid!] = (bytes, null); + activeController.stickerData[reaction.guid!] = reactionStickers; setState(() {}); } @@ -109,127 +114,158 @@ class ReactionWidgetState extends OptimizedState { if (reactionType != ReactionTypes.STICKERBACK) return; reaction.fetchAttachments(); for (Attachment? attachment in reaction.attachments) { + if (attachment == null || attachment.guid == null) continue; + final currentAttachment = attachment; // If we've already loaded it, don't try again - if (controller!.stickerData.keys.contains(attachment!.guid)) continue; + final activeController = controller; + if (activeController == null) return; + if (activeController.stickerData[reaction.guid!] + ?.containsKey(currentAttachment.guid) ?? + false) { + continue; + } - final pathName = attachment.path; - if (await FileSystemEntity.type(pathName) == FileSystemEntityType.notFound) { - attachmentDownloader.startDownload(attachment, onComplete: (_) async { - await checkImage(attachment); + final pathName = currentAttachment.path; + if (await FileSystemEntity.type(pathName) == + FileSystemEntityType.notFound) { + if (!mounted) return; + attachmentDownloader.startDownload(currentAttachment, + onComplete: (_) async { + await checkImage(currentAttachment); }); } else { - await checkImage(attachment); + await checkImage(currentAttachment); } } } @override void dispose() { - if (!kIsWeb && hasStream) sub.cancel(); + sub?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { - var emoji = ReactionTypes.reactionToEmoji[reactionType] ?? reaction.associatedMessageEmoji ?? "X"; + var emoji = ReactionTypes.reactionToEmoji[reactionType] ?? + reaction.associatedMessageEmoji ?? + "X"; if (ss.settings.skin.value != Skins.iOS) { return Container( - width: 30, - height: 30, - decoration: BoxDecoration( - color: reactionIsFromMe ? context.theme.colorScheme.primary : context.theme.colorScheme.properSurface, - border: Border.all(color: context.theme.colorScheme.background), - shape: BoxShape.circle, - ), - child: GestureDetector( - onTap: () { - if (reactions == null) return; - for (Message m in reactions!) { - if (!m.isFromMe!) { - m.handle ??= m.getHandle(); + width: 30, + height: 30, + decoration: BoxDecoration( + color: reactionIsFromMe + ? context.theme.colorScheme.primary + : context.theme.colorScheme.properSurface, + border: Border.all(color: context.theme.colorScheme.background), + shape: BoxShape.circle, + ), + child: GestureDetector( + onTap: () { + if (reactions == null) return; + for (Message m in reactions!) { + if (!m.isFromMe!) { + m.handle ??= m.getHandle(); + } } - } - Navigator.push( - context, - PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 500), - pageBuilder: (context, animation, secondaryAnimation) { - return SlideTransition( - position: Tween( - begin: const Offset(0.0, 1.0), - end: Offset.zero, - ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)), - child: Theme( - data: context.theme.copyWith( - // in case some components still use legacy theming - primaryColor: context.theme.colorScheme.bubble(context, true), - colorScheme: context.theme.colorScheme.copyWith( - primary: context.theme.colorScheme.bubble(context, true), - onPrimary: context.theme.colorScheme.onBubble(context, true), - surface: ss.settings.monetTheming.value == Monet.full ? null : (context.theme.extensions[BubbleColors] as BubbleColors?)?.receivedBubbleColor, - onSurface: ss.settings.monetTheming.value == Monet.full ? null : (context.theme.extensions[BubbleColors] as BubbleColors?)?.onReceivedBubbleColor, + Navigator.push( + context, + PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 500), + pageBuilder: (context, animation, secondaryAnimation) { + return SlideTransition( + position: Tween( + begin: const Offset(0.0, 1.0), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: animation, curve: Curves.easeOut)), + child: Theme( + data: context.theme.copyWith( + // in case some components still use legacy theming + primaryColor: + context.theme.colorScheme.bubble(context, true), + colorScheme: context.theme.colorScheme.copyWith( + primary: + context.theme.colorScheme.bubble(context, true), + onPrimary: context.theme.colorScheme + .onBubble(context, true), + surface: + ss.settings.monetTheming.value == Monet.full + ? null + : (context.theme.extensions[BubbleColors] + as BubbleColors?) + ?.receivedBubbleColor, + onSurface: + ss.settings.monetTheming.value == Monet.full + ? null + : (context.theme.extensions[BubbleColors] + as BubbleColors?) + ?.onReceivedBubbleColor, + ), + ), + child: Stack( + alignment: Alignment.bottomCenter, + children: [ + GestureDetector( + onTap: () { + Navigator.of(context).pop(); + }, + ), + Positioned( + bottom: 10, + left: 15, + right: 15, + child: ReactionDetails(reactions: reactions!)), + ], ), ), - child: Stack( - alignment: Alignment.bottomCenter, - children: [ - GestureDetector( - onTap: () { - Navigator.of(context).pop(); - }, - ), - Positioned( - bottom: 10, - left: 15, - right: 15, - child: ReactionDetails(reactions: reactions!) + ); + }, + fullscreenDialog: true, + opaque: false, + barrierDismissible: true, + ), + ); + }, + child: Center( + child: Builder(builder: (context) { + if (reactionType == ReactionTypes.STICKERBACK) { + var image = controller! + .stickerData[reaction.guid] + ?[reaction.attachments[0]?.guid] + ?.$1; + return image != null + ? Padding( + padding: const EdgeInsets.all(5), + child: Image.memory( + image, + gaplessPlayback: true, + cacheHeight: 200, + filterQuality: FilterQuality.none, ), - ], - ), - ), - ); - }, - fullscreenDialog: true, - opaque: false, - barrierDismissible: true, - ), - ); - }, - child: Center( - child: Builder( - builder: (context) { - if (reactionType == ReactionTypes.STICKERBACK) { - var image = controller!.stickerData[reaction.guid]?[reaction.attachments[0]?.guid]?.$1; - return image != null ? Padding( - padding: const EdgeInsets.all(5), - child: Image.memory( - image, - gaplessPlayback: true, - cacheHeight: 200, - filterQuality: FilterQuality.none, - ), - ) : const SizedBox.shrink(); - } - final text = Text( - emoji, - style: const TextStyle(fontSize: 15, fontFamily: 'Apple Color Emoji'), - textAlign: TextAlign.center, + ) + : const SizedBox.shrink(); + } + final text = Text( + emoji, + style: const TextStyle( + fontSize: 15, fontFamily: 'Apple Color Emoji'), + textAlign: TextAlign.center, + ); + // rotate thumbs down to match iOS + if (reactionType == "dislike") { + return Transform( + transform: Matrix4.identity()..rotateY(pi), + alignment: FractionalOffset.center, + child: text, ); - // rotate thumbs down to match iOS - if (reactionType == "dislike") { - return Transform( - transform: Matrix4.identity()..rotateY(pi), - alignment: FractionalOffset.center, - child: text, - ); - } - return text; } + return text; + }), ), - ), - ) - ); + )); } return Stack( alignment: messageIsFromMe ? Alignment.centerRight : Alignment.centerLeft, @@ -250,52 +286,55 @@ class ReactionWidgetState extends OptimizedState { ), ), ClipPath( - clipper: ReactionClipper(isFromMe: messageIsFromMe), - child: Container( - width: iosSize, - height: iosSize, - color: reactionIsFromMe - ? context.theme.colorScheme.primary - : context.theme.colorScheme.properSurface, - alignment: messageIsFromMe ? Alignment.topRight : Alignment.topLeft, - child: SizedBox( - width: iosSize*0.8, - height: iosSize*0.8, - child: Center( - child: Builder( - builder: (context) { - if (reactionType == ReactionTypes.STICKERBACK) { - var image = controller!.stickerData[reaction.guid]?[reaction.attachments[0]?.guid]?.$1; - return image != null ? Padding( - padding: const EdgeInsets.all(5), - child: Image.memory( - image, - gaplessPlayback: true, - cacheHeight: 200, - filterQuality: FilterQuality.none, - ), - ) : const SizedBox.shrink(); - } - final text = Text( - emoji, - style: const TextStyle(fontSize: 16, fontFamily: 'Apple Color Emoji'), - textAlign: TextAlign.center, - ); - // rotate thumbs down to match iOS - if (reactionType == "dislike") { - return Transform( - transform: Matrix4.identity()..rotateY(pi), - alignment: FractionalOffset.center, - child: text, + clipper: ReactionClipper(isFromMe: messageIsFromMe), + child: Container( + width: iosSize, + height: iosSize, + color: reactionIsFromMe + ? context.theme.colorScheme.primary + : context.theme.colorScheme.properSurface, + alignment: + messageIsFromMe ? Alignment.topRight : Alignment.topLeft, + child: SizedBox( + width: iosSize * 0.8, + height: iosSize * 0.8, + child: Center( + child: Builder(builder: (context) { + if (reactionType == ReactionTypes.STICKERBACK) { + var image = controller! + .stickerData[reaction.guid] + ?[reaction.attachments[0]?.guid] + ?.$1; + return image != null + ? Padding( + padding: const EdgeInsets.all(5), + child: Image.memory( + image, + gaplessPlayback: true, + cacheHeight: 200, + filterQuality: FilterQuality.none, + ), + ) + : const SizedBox.shrink(); + } + final text = Text( + emoji, + style: const TextStyle( + fontSize: 16, fontFamily: 'Apple Color Emoji'), + textAlign: TextAlign.center, ); - } - return text; - } - ), - ), - ) - ) - ), + // rotate thumbs down to match iOS + if (reactionType == "dislike") { + return Transform( + transform: Matrix4.identity()..rotateY(pi), + alignment: FractionalOffset.center, + child: text, + ); + } + return text; + }), + ), + ))), Positioned( left: !messageIsFromMe ? 0 : -75, right: messageIsFromMe ? 0 : -75, @@ -306,13 +345,15 @@ class ReactionWidgetState extends OptimizedState { if (errorCode == 22) { errorText = "The recipient is not registered with iMessage!"; } else if (reaction.guid!.startsWith("error-")) { - errorText = reaction.guid!.substring(reaction.guid!.indexOf('-') + 1); + errorText = errorFromGuid(reaction.guid!); } return DeferPointer( child: GestureDetector( child: Icon( - ss.settings.skin.value == Skins.iOS ? CupertinoIcons.exclamationmark_circle : Icons.error_outline, + ss.settings.skin.value == Skins.iOS + ? CupertinoIcons.exclamationmark_circle + : Icons.error_outline, color: context.theme.colorScheme.error, ), onTap: () { @@ -320,30 +361,40 @@ class ReactionWidgetState extends OptimizedState { context: context, builder: (BuildContext context) { return AlertDialog( - backgroundColor: context.theme.colorScheme.properSurface, - title: Text("Message failed to send", style: context.theme.textTheme.titleLarge), - content: Text("Error ($errorCode): $errorText", style: context.theme.textTheme.bodyLarge), + backgroundColor: + context.theme.colorScheme.properSurface, + title: Text("Message failed to send", + style: context.theme.textTheme.titleLarge), + content: Text("Error ($errorCode): $errorText", + style: context.theme.textTheme.bodyLarge), actions: [ TextButton( - child: Text( - "Retry", - style: context.theme.textTheme.bodyLarge!.copyWith(color: Get.context!.theme.colorScheme.primary) - ), + child: Text("Retry", + style: context.theme.textTheme.bodyLarge! + .copyWith( + color: Get.context!.theme.colorScheme + .primary)), onPressed: () async { // Remove the original message and notification Navigator.of(context).pop(); Message.delete(reaction.guid!); - await notif.clearFailedToSend(cm.activeChat!.chat.id!); - getActiveMwc(reaction.associatedMessageGuid!)?.removeAssociatedMessage(reaction); + await notif + .clearFailedToSend(cm.activeChat!.chat.id!); + getActiveMwc(reaction.associatedMessageGuid!) + ?.removeAssociatedMessage(reaction); // Re-send - final selected = getActiveMwc(reaction.associatedMessageGuid!)!.message; + final selected = getActiveMwc( + reaction.associatedMessageGuid!)! + .message; outq.queue(OutgoingItem( type: QueueType.sendMessage, chat: cm.activeChat!.chat, message: Message( associatedMessageGuid: selected.guid, - associatedMessageType: reaction.associatedMessageType, - associatedMessagePart: reaction.associatedMessagePart, + associatedMessageType: + reaction.associatedMessageType, + associatedMessagePart: + reaction.associatedMessagePart, dateCreated: DateTime.now(), hasAttachments: false, isFromMe: true, @@ -355,32 +406,37 @@ class ReactionWidgetState extends OptimizedState { }, ), TextButton( - child: Text( - "Remove", - style: context.theme.textTheme.bodyLarge!.copyWith(color: Get.context!.theme.colorScheme.primary) - ), + child: Text("Remove", + style: context.theme.textTheme.bodyLarge! + .copyWith( + color: Get.context!.theme.colorScheme + .primary)), onPressed: () async { Navigator.of(context).pop(); // Delete the message from the DB Message.delete(reaction.guid!); // Remove the message from the Bloc - getActiveMwc(reaction.associatedMessageGuid!)?.removeAssociatedMessage(reaction); + getActiveMwc(reaction.associatedMessageGuid!) + ?.removeAssociatedMessage(reaction); final chat = cm.activeChat!.chat; await notif.clearFailedToSend(chat.id!); // Get the "new" latest info - List latest = Chat.getMessages(chat, limit: 1); + List latest = + Chat.getMessages(chat, limit: 1); chat.latestMessage = latest.first; chat.save(); }, ), TextButton( - child: Text( - "Cancel", - style: context.theme.textTheme.bodyLarge!.copyWith(color: Get.context!.theme.colorScheme.primary) - ), + child: Text("Cancel", + style: context.theme.textTheme.bodyLarge! + .copyWith( + color: Get.context!.theme.colorScheme + .primary)), onPressed: () async { Navigator.of(context).pop(); - await notif.clearFailedToSend(cm.activeChat!.chat.id!); + await notif + .clearFailedToSend(cm.activeChat!.chat.id!); }, ) ], @@ -398,4 +454,3 @@ class ReactionWidgetState extends OptimizedState { ); } } - diff --git a/lib/app/layouts/conversation_view/widgets/message/reply/reply_thread_popup.dart b/lib/app/layouts/conversation_view/widgets/message/reply/reply_thread_popup.dart index f8f5ca31fc..08e872df58 100644 --- a/lib/app/layouts/conversation_view/widgets/message/reply/reply_thread_popup.dart +++ b/lib/app/layouts/conversation_view/widgets/message/reply/reply_thread_popup.dart @@ -5,7 +5,6 @@ import 'package:bluebubbles/helpers/helpers.dart'; import 'package:bluebubbles/database/database.dart'; import 'package:bluebubbles/database/models.dart'; import 'package:bluebubbles/services/services.dart'; -import 'package:collection/collection.dart'; import 'package:defer_pointer/defer_pointer.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -13,9 +12,36 @@ import 'package:flutter/services.dart'; import 'package:get/get.dart'; import 'package:flutter_acrylic/flutter_acrylic.dart'; -void showReplyThread(BuildContext context, Message message, MessagePart part, MessagesService service, ConversationViewController cvController) { +Future showReplyThread(BuildContext context, Message message, MessagePart part, MessagesService service, ConversationViewController cvController) async { + cvController.dismissKeyboard(); final originatorPart = message.threadOriginatorGuid != null ? message.normalizedThreadPart : part.part; - final _messages = service.struct.threads(message.threadOriginatorGuid ?? message.guid!, originatorPart); + final originatorGuid = message.threadOriginatorGuid ?? message.guid!; + final messagesByGuid = {}; + + if (!kIsWeb) { + final originator = Message.findOne(guid: originatorGuid); + final query = Database.messages.query(Message_.threadOriginatorGuid.equals(originatorGuid)).build(); + final storedReplies = query.find(); + query.close(); + + for (final stored in [ + if (originator != null) originator, + ...storedReplies, + ]) { + if (stored.guid == null || stored.associatedMessageGuid != null) continue; + if (stored.guid != originatorGuid && stored.normalizedThreadPart != originatorPart) continue; + stored.fetchAttachments(); + stored.fetchAssociatedMessages(service: service); + stored.handle = stored.getHandle(); + messagesByGuid[stored.guid!] = stored; + } + } + + // Prefer live in-memory messages when both sources contain the same item. + for (final live in service.struct.threads(originatorGuid, originatorPart)) { + if (live.guid != null) messagesByGuid[live.guid!] = live; + } + final _messages = messagesByGuid.values.toList(); _messages.sort((a, b) => Message.sort(a, b, descending: false)); _buildThreadView(_messages, originatorPart, cvController, context); } @@ -92,39 +118,40 @@ void _buildThreadView(List _messages, int? originatorPart, Conversation ), Container( child: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Center( - child: SingleChildScrollView( - controller: controller, - child: Column( - children: _messages.mapIndexed((index, e) => GestureDetector( - onTap: () { - Navigator.of(context).pop(); - if (originatorPart == null && ss.settings.skin.value == Skins.iOS) { - // pop twice to remove convo details page - Navigator.of(context).pop(); - } - ms(cvController.chat.guid).jumpToMessage.call(e.guid!); - }, - child: AbsorbPointer( - absorbing: true, - child: Padding( - padding: const EdgeInsets.only(left: 5.0, right: 5.0), - child: MessageHolder( - cvController: cvController, - message: _messages[index], - oldMessageGuid: index > 0 ? _messages[index - 1].guid : null, - newMessageGuid: index < _messages.length - 1 ? _messages[index + 1].guid : null, - isReplyThread: true, - replyPart: index == 0 ? originatorPart : null, - ), - ), + child: ListView.builder( + controller: controller, + padding: const EdgeInsets.symmetric(vertical: 16), + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + itemCount: _messages.length, + itemBuilder: (context, index) { + final threadMessage = _messages[index]; + final messageController = getActiveMwc(threadMessage.guid!) ?? mwc(threadMessage); + messageController.cvController = cvController; + return GestureDetector( + onTap: () { + Navigator.of(context).pop(); + if (originatorPart == null && ss.settings.skin.value == Skins.iOS) { + // pop twice to remove convo details page + Navigator.of(context).pop(); + } + ms(cvController.chat.guid).jumpToMessage.call(threadMessage.guid!); + }, + child: AbsorbPointer( + absorbing: true, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 5), + child: MessageHolder( + cvController: cvController, + message: threadMessage, + oldMessageGuid: index > 0 ? _messages[index - 1].guid : null, + newMessageGuid: index < _messages.length - 1 ? _messages[index + 1].guid : null, + isReplyThread: true, + replyPart: index == 0 ? originatorPart : null, ), - )).toList(), + ), ), - ), - ), + ); + }, ), ), ), diff --git a/lib/app/layouts/conversation_view/widgets/message/text/text_bubble.dart b/lib/app/layouts/conversation_view/widgets/message/text/text_bubble.dart index 08d497377b..0e305e8aee 100644 --- a/lib/app/layouts/conversation_view/widgets/message/text/text_bubble.dart +++ b/lib/app/layouts/conversation_view/widgets/message/text/text_bubble.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:ui'; import 'package:bluebubbles/app/wrappers/stateful_boilerplate.dart'; @@ -36,6 +37,8 @@ class _TextBubbleState extends CustomState>(controller.cvController!.selected, (event) { + _selectionWorker = ever>(controller.cvController!.selected, (event) { + if (!mounted) return; if (controller.cvController!.isSelected(message.guid!) && !selected) { setState(() { selected = true; @@ -76,6 +81,13 @@ class _TextBubbleState extends CustomState getBubbleColors() { if (selected && !iOS) return [context.theme.colorScheme.tertiaryContainer, context.theme.colorScheme.tertiaryContainer]; List bubbleColors = [context.theme.colorScheme.properSurface, context.theme.colorScheme.properSurface]; diff --git a/lib/app/layouts/conversation_view/widgets/message/timestamp/delivered_indicator.dart b/lib/app/layouts/conversation_view/widgets/message/timestamp/delivered_indicator.dart index 1d85e62366..63a596d267 100644 --- a/lib/app/layouts/conversation_view/widgets/message/timestamp/delivered_indicator.dart +++ b/lib/app/layouts/conversation_view/widgets/message/timestamp/delivered_indicator.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:bluebubbles/app/wrappers/stateful_boilerplate.dart'; import 'package:bluebubbles/helpers/helpers.dart'; import 'package:bluebubbles/database/models.dart'; @@ -22,19 +24,27 @@ class DeliveredIndicator extends CustomStateful { class _DeliveredIndicatorState extends CustomState { Message get message => controller.message; bool get showAvatar => (controller.cvController?.chat ?? cm.activeChat!.chat).isGroup; + StreamSubscription? _messageUpdateSubscription; @override void initState() { forceDelete = false; super.initState(); - eventDispatcher.stream.listen((event) { + _messageUpdateSubscription = eventDispatcher.stream.listen((event) { + if (!mounted) return; if (event.item1 == "message-updated-${message.guid}") { setState(() {}); } }); } + @override + void dispose() { + _messageUpdateSubscription?.cancel(); + super.dispose(); + } + bool get shouldShow { if (controller.audioWasKept.value != null) return true; if (widget.forceShow || message.guid!.contains("temp")) return true; diff --git a/lib/app/layouts/conversation_view/widgets/text_field/send_button.dart b/lib/app/layouts/conversation_view/widgets/text_field/send_button.dart index ae8e817406..56357268d6 100644 --- a/lib/app/layouts/conversation_view/widgets/text_field/send_button.dart +++ b/lib/app/layouts/conversation_view/widgets/text_field/send_button.dart @@ -44,6 +44,7 @@ class SendButtonState extends OptimizedState with SingleTickerProvid @override Widget build(BuildContext context) { + final tapTargetSize = iOS || kIsDesktop ? 28.0 : 48.0; return GestureDetector( onSecondaryTap: () { if (controller.isAnimating) { @@ -76,69 +77,78 @@ class SendButtonState extends OptimizedState with SingleTickerProvid widget.sendMessage.call(); } }, - child: TextButton( - style: TextButton.styleFrom( - backgroundColor: iOS ? context.theme.colorScheme.primary : null, - shape: const CircleBorder(), - padding: const EdgeInsets.all(0), - maximumSize: const Size(28, 28), - minimumSize: const Size(28, 28), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - child: AnimatedBuilder( - animation: controller, - builder: (context, widget) { - return Container( - constraints: const BoxConstraints(minHeight: 28, minWidth: 28), - decoration: BoxDecoration( - shape: iOS ? BoxShape.circle : BoxShape.rectangle, - borderRadius: iOS ? null : BorderRadius.circular(10), - gradient: iOS || controller.value != 0 - ? LinearGradient( - begin: Alignment.bottomCenter, - end: Alignment.topCenter, - colors: [ - baseColor, - baseColor, - context.theme.colorScheme.error, - context.theme.colorScheme.error - ], - stops: [0.0, 1 - controller.value, 1 - controller.value, 1.0], - ) - : null), - alignment: Alignment.center, - child: Icon( - controller.value == 0 - ? (iOS ? CupertinoIcons.arrow_up : Icons.send_outlined) - : (iOS ? CupertinoIcons.xmark : Icons.close), - color: controller.value == 0 - ? (iOS ? context.theme.colorScheme.onPrimary : context.theme.colorScheme.secondary) - : context.theme.colorScheme.onError, - size: iOS || controller.value != 0 ? 20 : 28, + child: Tooltip( + message: "Send message", + child: Semantics( + button: true, + label: "Send message", + tooltip: "Send message", + child: TextButton( + style: TextButton.styleFrom( + backgroundColor: iOS ? context.theme.colorScheme.primary : null, + shape: const CircleBorder(), + padding: const EdgeInsets.all(0), + maximumSize: Size(tapTargetSize, tapTargetSize), + minimumSize: Size(tapTargetSize, tapTargetSize), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: AnimatedBuilder( + animation: controller, + builder: (context, widget) { + return Container( + constraints: const BoxConstraints(minHeight: 28, minWidth: 28), + decoration: BoxDecoration( + shape: iOS ? BoxShape.circle : BoxShape.rectangle, + borderRadius: iOS ? null : BorderRadius.circular(10), + gradient: iOS || controller.value != 0 + ? LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + baseColor, + baseColor, + context.theme.colorScheme.error, + context.theme.colorScheme.error, + ], + stops: [0.0, 1 - controller.value, 1 - controller.value, 1.0], + ) + : null, + ), + alignment: Alignment.center, + child: Icon( + controller.value == 0 + ? (iOS ? CupertinoIcons.arrow_up : Icons.send_outlined) + : (iOS ? CupertinoIcons.xmark : Icons.close), + color: controller.value == 0 + ? (iOS ? context.theme.colorScheme.onPrimary : context.theme.colorScheme.secondary) + : context.theme.colorScheme.onError, + size: iOS || controller.value != 0 ? 20 : 28, + ), + ); + }, + ), + onPressed: () { + if (controller.isAnimating) { + controller.reset(); + } else if (ss.settings.sendDelay.value != 0) { + controller.forward(); + } else { + HapticFeedback.lightImpact(); + widget.sendMessage.call(); + } + }, + onLongPress: () { + if (controller.isAnimating) { + controller.reset(); + } else { + widget.onLongPress.call(); + } + }, ), - ); - }, - ), - onPressed: () { - if (controller.isAnimating) { - controller.reset(); - } else if (ss.settings.sendDelay.value != 0) { - controller.forward(); - } else { - HapticFeedback.lightImpact(); - widget.sendMessage.call(); - } - }, - onLongPress: () { - if (controller.isAnimating) { - controller.reset(); - } else { - widget.onLongPress.call(); - } - }, + ), + ), ), - ) - ) + ), ); } } diff --git a/lib/app/layouts/conversation_view/widgets/text_field/text_field_suffix.dart b/lib/app/layouts/conversation_view/widgets/text_field/text_field_suffix.dart index 29a881cd3c..139c5182a3 100644 --- a/lib/app/layouts/conversation_view/widgets/text_field/text_field_suffix.dart +++ b/lib/app/layouts/conversation_view/widgets/text_field/text_field_suffix.dart @@ -233,6 +233,7 @@ class _TextFieldSuffixState extends OptimizedState { (widget.controller?.pickedAttachments.isNotEmpty ?? false.obs.value); bool showRecording = (widget.controller?.showRecording.value ?? false.obs.value) && widget.recorderController != null; bool isLinuxArm64 = kIsDesktop && Platform.isLinux && SysInfo.kernelArchitecture == ProcessorArchitecture.arm64; + final recordTapTargetSize = kIsDesktop || iOS ? (kIsDesktop ? 40.0 : 32.0) : 48.0; return Padding( padding: const EdgeInsets.all(3.0), child: AnimatedCrossFade( @@ -255,31 +256,45 @@ class _TextFieldSuffixState extends OptimizedState { toggleRecording(context); }, }, - child: TextButton( - style: TextButton.styleFrom( - backgroundColor: !iOS || (iOS && !isChatCreator && !showRecording) - ? null - : !isChatCreator && !showRecording - ? context.theme.colorScheme.outline - : context.theme.colorScheme.primary.withOpacity(0.4), - shape: const CircleBorder(), - padding: const EdgeInsets.all(0), - maximumSize: kIsDesktop ? const Size(40, 40) : const Size(32, 32), - minimumSize: kIsDesktop ? const Size(40, 40) : const Size(32, 32), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, + child: Tooltip( + message: showRecording ? "Stop recording audio" : "Record audio", + child: Semantics( + button: true, + label: showRecording ? "Stop recording audio" : "Record audio", + tooltip: showRecording ? "Stop recording audio" : "Record audio", + child: TextButton( + style: TextButton.styleFrom( + backgroundColor: !iOS || (iOS && !isChatCreator && !showRecording) + ? null + : !isChatCreator && !showRecording + ? context.theme.colorScheme.outline + : context.theme.colorScheme.primary.withOpacity(0.4), + shape: const CircleBorder(), + padding: const EdgeInsets.all(0), + maximumSize: Size(recordTapTargetSize, recordTapTargetSize), + minimumSize: Size(recordTapTargetSize, recordTapTargetSize), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: isLinuxArm64 + ? const SizedBox(height: 40) + : !isChatCreator && !showRecording + ? CupertinoIconWrapper( + icon: Icon( + iOS ? CupertinoIcons.waveform : Icons.mic_none, + color: iOS ? context.theme.colorScheme.outline : context.theme.colorScheme.properOnSurface, + size: iOS ? 24 : 20, // Waveform icon appears smaller, using size 24 + ), + ) + : CupertinoIconWrapper( + icon: Icon( + iOS ? CupertinoIcons.stop_fill : Icons.stop_circle, + color: iOS ? context.theme.colorScheme.primary : context.theme.colorScheme.properOnSurface, + size: 15, + ), + ), + onPressed: () async => toggleRecording(context), + ), ), - child: isLinuxArm64 ? const SizedBox(height: 40) : - !isChatCreator && !showRecording - ? CupertinoIconWrapper(icon: Icon( - iOS ? CupertinoIcons.waveform : Icons.mic_none, - color: iOS ? context.theme.colorScheme.outline : context.theme.colorScheme.properOnSurface, - size: iOS ? 24 : 20, // Waveform icon appears smaller, using size 24 - )) : CupertinoIconWrapper(icon: Icon( - iOS ? CupertinoIcons.stop_fill : Icons.stop_circle, - color: iOS ? context.theme.colorScheme.primary : context.theme.colorScheme.properOnSurface, - size: 15, - )), - onPressed: () async => toggleRecording(context), ), ), secondChild: SendButton( diff --git a/lib/app/layouts/fullscreen_media/fullscreen_holder.dart b/lib/app/layouts/fullscreen_media/fullscreen_holder.dart index dec44c1892..f02e7975ad 100644 --- a/lib/app/layouts/fullscreen_media/fullscreen_holder.dart +++ b/lib/app/layouts/fullscreen_media/fullscreen_holder.dart @@ -14,6 +14,7 @@ import "package:flutter/material.dart"; import 'package:flutter/services.dart'; import 'package:gesture_x_detector/gesture_x_detector.dart'; import 'package:get/get.dart'; +import 'package:photo_view/photo_view.dart' show PhotoViewGestureDetectorScope; class FullscreenMediaHolder extends StatefulWidget { FullscreenMediaHolder({ @@ -151,7 +152,12 @@ class FullscreenMediaHolderState extends OptimizedState { } return KeyEventResult.ignored; }, - child: PageView.builder( + child: PhotoViewGestureDetectorScope( + // Lets a contained image yield horizontal drags to the + // PageView. A zoomed image keeps its own pan gesture, and + // FullscreenImage disables this PageView while zoomed. + axis: Axis.horizontal, + child: PageView.builder( physics: physics ?? (attachments.length == 1 ? const NeverScrollableScrollPhysics() @@ -302,6 +308,7 @@ class FullscreenMediaHolderState extends OptimizedState { ); } }, + ), ), ), ), diff --git a/lib/app/layouts/fullscreen_media/fullscreen_video.dart b/lib/app/layouts/fullscreen_media/fullscreen_video.dart index 41228ac730..03e0a22eda 100644 --- a/lib/app/layouts/fullscreen_media/fullscreen_video.dart +++ b/lib/app/layouts/fullscreen_media/fullscreen_video.dart @@ -12,7 +12,8 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:media_kit_video/media_kit_video_controls/media_kit_video_controls.dart' as media_kit_video_controls; +import 'package:media_kit_video/media_kit_video_controls/media_kit_video_controls.dart' + as media_kit_video_controls; import 'package:universal_html/html.dart' as html; class FullscreenVideo extends StatefulWidget { @@ -36,13 +37,18 @@ class FullscreenVideo extends StatefulWidget { OptimizedState createState() => _FullscreenVideoState(); } -class _FullscreenVideoState extends OptimizedState with AutomaticKeepAliveClientMixin { +class _FullscreenVideoState extends OptimizedState + with AutomaticKeepAliveClientMixin { Timer? hideOverlayTimer; late VideoController videoController; bool hasListener = false; bool hasDisposed = false; + VoidCallback? _rectListener; + StreamSubscription? _completedSubscription; + Future? _initialization; + Future? _refreshOperation; final RxBool muted = ss.settings.startVideosMutedFullscreen.value.obs; final RxBool showPlayPauseOverlay = true.obs; final RxDouble aspectRatio = 1.0.obs; @@ -55,10 +61,10 @@ class _FullscreenVideoState extends OptimizedState with Automat muted.value = widget.mute!.value; } - initControllers(); + _initialization = initControllers(); } - void initControllers() async { + Future initControllers() async { if (widget.videoController != null) { videoController = widget.videoController!; } else { @@ -72,12 +78,15 @@ class _FullscreenVideoState extends OptimizedState with Automat } else { media = Media(widget.file.path!); } - + await videoController.player.setPlaylistMode(PlaylistMode.none); + if (hasDisposed) return; await videoController.player.open(media, play: false); + if (hasDisposed) return; await videoController.player.setVolume(muted.value ? 0 : 100); } - + + if (!mounted || hasDisposed) return; createListener(videoController); showPlayPauseOverlay.value = true; setState(() {}); @@ -86,11 +95,13 @@ class _FullscreenVideoState extends OptimizedState with Automat void createListener(VideoController controller) { if (hasListener) return; - controller.rect.addListener(() { + _rectListener = () { aspectRatio.value = controller.aspectRatio; - }); + }; + controller.rect.addListener(_rectListener!); - controller.player.stream.completed.listen((completed) async { + _completedSubscription = + controller.player.stream.completed.listen((completed) async { // If the status is ended, restart if (completed && !hasDisposed) { await controller.player.pause(); @@ -108,35 +119,71 @@ class _FullscreenVideoState extends OptimizedState with Automat void dispose() { hasDisposed = true; hideOverlayTimer?.cancel(); - + if (hasListener && _rectListener != null) { + videoController.rect.removeListener(_rectListener!); + } + _completedSubscription?.cancel(); + // Only dispose the player if one was not passed in (via a controller) if (widget.videoController == null) { - videoController.player.dispose(); + unawaited(_disposeOwnedPlayer()); } super.dispose(); } + Future _disposeOwnedPlayer() async { + final initialization = _initialization; + if (initialization != null) { + try { + await initialization; + } catch (_) { + // Disposal must still run when native initialization fails. + } + } + final refreshOperation = _refreshOperation; + if (refreshOperation != null) { + try { + await refreshOperation; + } catch (_) { + // Disposal must still run when a refresh fails. + } + } + await videoController.player.dispose(); + } + void refreshAttachment() { showSnackbar('In Progress', 'Redownloading attachment. Please wait...'); as.redownloadAttachment(widget.attachment, onComplete: (file) async { if (hasDisposed) return; - hasListener = false; - late final Media media; - if (widget.file.path == null) { - final blob = html.Blob([widget.file.bytes]); - final url = html.Url.createObjectUrlFromBlob(blob); - media = Media(url); - } else { - media = Media(widget.file.path!); + final operation = _refreshPlayer(file); + _refreshOperation = operation; + try { + await operation; + } finally { + if (identical(_refreshOperation, operation)) { + _refreshOperation = null; + } } - await videoController.player.open(media, play: false); - await videoController.player.setVolume(muted.value ? 0 : 100); - createListener(videoController); - showPlayPauseOverlay.value = !videoController.player.state.playing; }); } + Future _refreshPlayer(PlatformFile file) async { + late final Media media; + if (file.path == null) { + final blob = html.Blob([file.bytes]); + final url = html.Url.createObjectUrlFromBlob(blob); + media = Media(url); + } else { + media = Media(file.path!); + } + await videoController.player.open(media, play: false); + if (hasDisposed) return; + await videoController.player.setVolume(muted.value ? 0 : 100); + if (hasDisposed) return; + showPlayPauseOverlay.value = !videoController.player.state.playing; + } + @override bool get wantKeepAlive => true; @@ -153,20 +200,28 @@ class _FullscreenVideoState extends OptimizedState with Automat : Theme( data: context.theme.copyWith( navigationBarTheme: context.theme.navigationBarTheme.copyWith( - indicatorColor: samsung ? Colors.black : context.theme.colorScheme.properSurface, + indicatorColor: samsung + ? Colors.black + : context.theme.colorScheme.properSurface, ), ), child: NavigationBar( selectedIndex: 0, - backgroundColor: samsung ? Colors.black : context.theme.colorScheme.properSurface, + backgroundColor: samsung + ? Colors.black + : context.theme.colorScheme.properSurface, labelBehavior: NavigationDestinationLabelBehavior.alwaysHide, elevation: 0, height: 60, destinations: [ NavigationDestination( icon: Icon( - iOS ? CupertinoIcons.cloud_download : Icons.file_download, - color: samsung ? Colors.white : context.theme.colorScheme.primary, + iOS + ? CupertinoIcons.cloud_download + : Icons.file_download, + color: samsung + ? Colors.white + : context.theme.colorScheme.primary, ), label: 'Download'), NavigationDestination( @@ -203,7 +258,8 @@ class _FullscreenVideoState extends OptimizedState with Automat refreshAttachment(); } else if (value == 3) { muted.toggle(); - await videoController.player.setVolume(muted.value ? 0.0 : 100.0); + await videoController.player + .setVolume(muted.value ? 0.0 : 100.0); setState(() {}); } }, @@ -211,85 +267,116 @@ class _FullscreenVideoState extends OptimizedState with Automat ), body: MouseRegion( onEnter: (event) => showPlayPauseOverlay.value = true, - onExit: (event) => showPlayPauseOverlay.value = !videoController.player.state.playing, + onExit: (event) => showPlayPauseOverlay.value = + !videoController.player.state.playing, child: Obx(() { return SafeArea( child: Center( child: Theme( data: context.theme.copyWith( - platform: iOS ? TargetPlatform.iOS : TargetPlatform.android, - dialogBackgroundColor: context.theme.colorScheme.properSurface, - iconTheme: context.theme.iconTheme.copyWith(color: context.theme.textTheme.bodyMedium?.color)), + platform: + iOS ? TargetPlatform.iOS : TargetPlatform.android, + dialogBackgroundColor: + context.theme.colorScheme.properSurface, + iconTheme: context.theme.iconTheme.copyWith( + color: context.theme.textTheme.bodyMedium?.color)), child: Stack( alignment: Alignment.center, children: [ - Video(controller: videoController, controls: (state) => Padding( - padding: EdgeInsets.all(!kIsWeb && !kIsDesktop ? 0 : 20).copyWith(bottom: !kIsWeb && !kIsDesktop ? 10 : 0), - child: media_kit_video_controls.AdaptiveVideoControls(state), - ), filterQuality: FilterQuality.medium), + Video( + controller: videoController, + controls: (state) => Padding( + padding: EdgeInsets.all( + !kIsWeb && !kIsDesktop ? 0 : 20) + .copyWith( + bottom: + !kIsWeb && !kIsDesktop ? 10 : 0), + child: media_kit_video_controls + .AdaptiveVideoControls(state), + ), + filterQuality: FilterQuality.medium), if (kIsWeb || kIsDesktop) Obx(() { - return MouseRegion( - onEnter: (event) => _hover.value = true, - onExit: (event) => _hover.value = false, - child: AbsorbPointer( - absorbing: !showPlayPauseOverlay.value && !_hover.value, - child: AnimatedOpacity( - opacity: _hover.value - ? 1 - : showPlayPauseOverlay.value - ? 0.5 - : 0, - duration: const Duration(milliseconds: 100), - child: Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(40), - onTap: () async { - if (videoController.player.state.playing) { - await videoController.player.pause(); - showPlayPauseOverlay.value = true; - } else { - await videoController.player.play(); - showPlayPauseOverlay.value = false; - } - }, - child: Container( - height: 75, - width: 75, - decoration: BoxDecoration( - color: context.theme.colorScheme.background.withOpacity(0.5), - borderRadius: BorderRadius.circular(40), - ), - clipBehavior: Clip.antiAlias, - child: Padding( - padding: EdgeInsets.only( - left: ss.settings.skin.value == Skins.iOS && !videoController.player.state.playing ? 17 : 10, - top: ss.settings.skin.value == Skins.iOS ? 13 : 10, - right: 10, - bottom: 10, + return MouseRegion( + onEnter: (event) => _hover.value = true, + onExit: (event) => _hover.value = false, + child: AbsorbPointer( + absorbing: + !showPlayPauseOverlay.value && !_hover.value, + child: AnimatedOpacity( + opacity: _hover.value + ? 1 + : showPlayPauseOverlay.value + ? 0.5 + : 0, + duration: const Duration(milliseconds: 100), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(40), + onTap: () async { + if (videoController + .player.state.playing) { + await videoController.player.pause(); + showPlayPauseOverlay.value = true; + } else { + await videoController.player.play(); + showPlayPauseOverlay.value = false; + } + }, + child: Container( + height: 75, + width: 75, + decoration: BoxDecoration( + color: context + .theme.colorScheme.background + .withOpacity(0.5), + borderRadius: BorderRadius.circular(40), ), - child: Obx( - () => videoController.player.state.playing - ? Icon( - ss.settings.skin.value == Skins.iOS ? CupertinoIcons.pause : Icons.pause, - color: context.iconColor, - size: 45, - ) - : Icon( - ss.settings.skin.value == Skins.iOS ? CupertinoIcons.play : Icons.play_arrow, - color: context.iconColor, - size: 45, - ), + clipBehavior: Clip.antiAlias, + child: Padding( + padding: EdgeInsets.only( + left: ss.settings.skin.value == + Skins.iOS && + !videoController + .player.state.playing + ? 17 + : 10, + top: ss.settings.skin.value == + Skins.iOS + ? 13 + : 10, + right: 10, + bottom: 10, + ), + child: Obx( + () => videoController + .player.state.playing + ? Icon( + ss.settings.skin.value == + Skins.iOS + ? CupertinoIcons.pause + : Icons.pause, + color: context.iconColor, + size: 45, + ) + : Icon( + ss.settings.skin.value == + Skins.iOS + ? CupertinoIcons.play + : Icons.play_arrow, + color: context.iconColor, + size: 45, + ), + ), ), ), ), ), ), ), - ), - ); - }), + ); + }), if (!iOS && (kIsWeb || kIsDesktop)) Positioned( top: 10, @@ -299,13 +386,14 @@ class _FullscreenVideoState extends OptimizedState with Automat onEnter: (event) => _hover.value = true, onExit: (event) => _hover.value = false, child: AbsorbPointer( - absorbing: !showPlayPauseOverlay.value && !_hover.value, + absorbing: !showPlayPauseOverlay.value && + !_hover.value, child: AnimatedOpacity( opacity: _hover.value ? 1 : showPlayPauseOverlay.value - ? 1 - : 0, + ? 1 + : 0, duration: const Duration(milliseconds: 100), child: Material( color: Colors.transparent, diff --git a/lib/app/layouts/settings/pages/profile/profile_panel.dart b/lib/app/layouts/settings/pages/profile/profile_panel.dart index 1b54ce0e9c..295f3e3331 100644 --- a/lib/app/layouts/settings/pages/profile/profile_panel.dart +++ b/lib/app/layouts/settings/pages/profile/profile_panel.dart @@ -63,6 +63,29 @@ class _ProfilePanelState extends OptimizedState with WidgetsBindin Rxn quotaInfo = Rxn(null); Rxn googleCreds = Rxn(null); + String relayHealthSubtitle() { + if (pushService.relayHealthChecking.value) { + return "Testing the iPhone relay..."; + } + + final checked = pushService.relayLastChecked.value; + final lastSuccess = pushService.relayLastSuccess.value; + final checkedText = + checked == null ? null : buildChatListDateMaterial(checked); + final successText = + lastSuccess == null ? null : buildChatListDateMaterial(lastSuccess); + + if (pushService.relayReachable.value == true) { + return "Reachable${checkedText == null ? "" : " as of $checkedText"}. Tap to test again."; + } + if (pushService.relayReachable.value == false) { + final lastSuccessSuffix = + successText == null ? "" : " Last successful check: $successText."; + return "Unavailable${checkedText == null ? "" : " as of $checkedText"}.$lastSuccessSuffix Turn on the relay and tap to retry."; + } + return "Not checked yet. Tap to verify the iPhone is online before registration renewal."; + } + Future handleSubscriptionToken(String subscription) async { var activated = await http.dio.post("https://hw.openbubbles.app/ticket/${ticket!}/activate", data: {"purchase_token": subscription}); var useTicket = activated.data["ticket"]; @@ -124,7 +147,7 @@ class _ProfilePanelState extends OptimizedState with WidgetsBindin ss.settings.hostedToken.value = detail.purchaseToken; ss.saveSettings(); await wrapPromise(handleSubscriptionToken(detail.purchaseToken), "Validating subscription..."); - Logger.info("Purchased token ${detail.purchaseToken}"); + Logger.info("Hosted subscription purchase received"); return true; } return false; @@ -187,11 +210,13 @@ class _ProfilePanelState extends OptimizedState with WidgetsBindin api.SimplifiedIncomingCallPoster? poster; if (ss.settings.userPosterPath.value != null && !kIsDesktop) { var data = await File("${ss.settings.userPosterPath.value!}.jpg").readAsBytes(); - print("Parsing file"); poster = await api.fromPosterSave(poster: data); } - await restorePoster(poster?.poster, ss.settings.userPosterPath.value!); + final posterPath = ss.settings.userPosterPath.value; + if (poster != null && posterPath != null) { + await restorePoster(poster.poster, posterPath); + } api.ShareProfileMessage message; try { @@ -585,7 +610,7 @@ class _ProfilePanelState extends OptimizedState with WidgetsBindin onTap: () async { final credentials = await pushService.googleSignIn.signIn(); if (credentials != null) { - print('Signed in successfully: ${credentials.accessToken}'); + Logger.info("Google account sign-in succeeded"); googleCreds.value = credentials; cs.refreshContacts(); } else { @@ -664,6 +689,66 @@ class _ProfilePanelState extends OptimizedState with WidgetsBindin ), )); }), + if ((accountInfo["can_pnr"] ?? false) && + !ss.settings.deviceIsHosted.value) + Obx(() { + if (!pushService.relayHealthAvailable.value) { + return const SizedBox.shrink(); + } + final reachable = + pushService.relayReachable.value; + final checking = + pushService.relayHealthChecking.value; + final color = checking + ? context.theme.colorScheme.outline + : reachable == true + ? getIndicatorColor( + SocketState.connected) + : reachable == false + ? getIndicatorColor( + SocketState.disconnected) + : context.theme.colorScheme.outline; + + return SettingsTile( + title: "iPhone Relay", + subtitle: relayHealthSubtitle(), + isThreeLine: true, + leading: + Icon(Icons.phone_iphone, color: color), + trailing: checking + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 3, + valueColor: + AlwaysStoppedAnimation( + color), + ), + ) + : Icon( + reachable == true + ? Icons.check_circle + : reachable == false + ? Icons.error + : Icons.help_outline, + color: color, + ), + onTap: checking + ? null + : () async { + final result = + await pushService.checkRelayHealth(); + if (result == true) { + showSnackbar("iPhone Relay", + "The relay is online and responding."); + } else if (result == false) { + showSnackbar("iPhone Relay", + "The relay could not be reached. Check its power, Wi-Fi, and ValidationRelay status."); + } + }, + ); + }), if (accountInfo['login_status_message']?.startsWith("Deregistered") ?? false) Container( color: tileColor, diff --git a/lib/app/layouts/settings/pages/theming/theming_panel.dart b/lib/app/layouts/settings/pages/theming/theming_panel.dart index 3ed3d92d0a..0926739cf6 100644 --- a/lib/app/layouts/settings/pages/theming/theming_panel.dart +++ b/lib/app/layouts/settings/pages/theming/theming_panel.dart @@ -463,7 +463,7 @@ class _ThemingPanelState extends CustomState 2) { + if (controller.refreshRates.length > 1) { return SettingsHeader( iosSubtitle: iosSubtitle, materialSubtitle: materialSubtitle, @@ -474,7 +474,7 @@ class _ThemingPanelState extends CustomState 2) { + if (controller.refreshRates.length > 1) { return SettingsSection( backgroundColor: tileColor, children: [ diff --git a/lib/app/layouts/setup/pages/page_template.dart b/lib/app/layouts/setup/pages/page_template.dart index d2c7d867a2..d996d522bf 100644 --- a/lib/app/layouts/setup/pages/page_template.dart +++ b/lib/app/layouts/setup/pages/page_template.dart @@ -181,6 +181,11 @@ class PageButtons extends StatelessWidget { @override Widget build(BuildContext context) { + final isAndroid = Theme.of(context).platform == TargetPlatform.android; + final navigationButtonHeight = isAndroid ? 48.0 : 40.0; + final navigationButtonPadding = isAndroid ? EdgeInsets.zero : const EdgeInsets.all(2); + final navigationButtonContentHeight = isAndroid ? 48.0 : 36.0; + final navigationButtonMinimumWidth = isAndroid ? 48.0 : 30.0; return customButton ?? Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -192,8 +197,8 @@ class PageButtons extends StatelessWidget { colors: [HexColor('2772C3'), HexColor('5CA7F8').darkenPercent(5)], ), ), - height: 40, - padding: const EdgeInsets.all(2), + height: navigationButtonHeight, + padding: navigationButtonPadding, child: ElevatedButton( style: ButtonStyle( shape: WidgetStateProperty.all( @@ -203,8 +208,8 @@ class PageButtons extends StatelessWidget { ), backgroundColor: WidgetStateProperty.all(context.theme.colorScheme.background), shadowColor: WidgetStateProperty.all(context.theme.colorScheme.background), - maximumSize: WidgetStateProperty.all(const Size(200, 36)), - minimumSize: WidgetStateProperty.all(const Size(30, 30)), + maximumSize: WidgetStateProperty.all(Size(200, navigationButtonContentHeight)), + minimumSize: WidgetStateProperty.all(Size(navigationButtonMinimumWidth, navigationButtonContentHeight)), ), onPressed: () async { previousPage(); @@ -227,7 +232,7 @@ class PageButtons extends StatelessWidget { colors: [HexColor('2772C3'), HexColor('5CA7F8').darkenPercent(5)], ), ), - height: 40, + height: navigationButtonHeight, child: ElevatedButton( style: ButtonStyle( shape: WidgetStateProperty.all( @@ -237,8 +242,8 @@ class PageButtons extends StatelessWidget { ), backgroundColor: WidgetStateProperty.all(Colors.transparent), shadowColor: WidgetStateProperty.all(Colors.transparent), - maximumSize: WidgetStateProperty.all(const Size(200, 36)), - minimumSize: WidgetStateProperty.all(const Size(30, 30)), + maximumSize: WidgetStateProperty.all(Size(200, navigationButtonContentHeight)), + minimumSize: WidgetStateProperty.all(Size(navigationButtonMinimumWidth, navigationButtonContentHeight)), ), onPressed: () async { final proceed = (await onNextPressed?.call()) ?? true; diff --git a/lib/app/layouts/setup/pages/rustpush/finalize.dart b/lib/app/layouts/setup/pages/rustpush/finalize.dart index 1b90750142..bc14b3cc62 100644 --- a/lib/app/layouts/setup/pages/rustpush/finalize.dart +++ b/lib/app/layouts/setup/pages/rustpush/finalize.dart @@ -15,6 +15,7 @@ import 'package:bluebubbles/services/backend/settings/settings_service.dart'; import 'package:bluebubbles/services/network/backend_service.dart'; import 'package:bluebubbles/services/ui/contact_service.dart'; import 'package:bluebubbles/src/rust/api/api.dart' as api; +import 'package:bluebubbles/utils/logger/logger.dart'; import 'package:bluebubbles/helpers/helpers.dart'; import 'package:bluebubbles/services/rustpush/rustpush_service.dart'; import 'package:flutter/cupertino.dart'; @@ -156,7 +157,7 @@ class _FinalizePageState extends OptimizedState { onTap: () async { final credentials = await pushService.googleSignIn.signIn(); if (credentials != null) { - print('Signed in successfully: ${credentials.accessToken}'); + Logger.info("Google account sign-in succeeded"); googleCreds.value = credentials; cs.refreshContacts(); } else { diff --git a/lib/app/layouts/setup/pages/rustpush/hw_inp.dart b/lib/app/layouts/setup/pages/rustpush/hw_inp.dart index cbae9a5604..19fc31bbca 100644 --- a/lib/app/layouts/setup/pages/rustpush/hw_inp.dart +++ b/lib/app/layouts/setup/pages/rustpush/hw_inp.dart @@ -43,6 +43,8 @@ class HwInpState extends OptimizedState { final TextEditingController hostedCodeController = TextEditingController(); final controller = Get.find(); final FocusNode focusNode = FocusNode(); + late final VoidCallback _codeListener; + late final VoidCallback _hostedCodeListener; bool loading = false; bool hosted = true; @@ -118,12 +120,28 @@ class HwInpState extends OptimizedState { } String lastCheckedCode = ""; - String relayHost = "https://registration-relay.beeper.com"; + String relayHost = registrationRelayHost; + + String normalizeRelayHost(String value) { + final uri = Uri.tryParse(value.trim()); + if (uri == null || + uri.scheme != "https" || + uri.host.isEmpty || + uri.userInfo.isNotEmpty || + (uri.path.isNotEmpty && uri.path != "/") || + uri.query.isNotEmpty || + uri.fragment.isNotEmpty) { + throw const FormatException( + "Relay server must be a secure HTTPS origin without credentials, a path, a query, or a fragment."); + } + return uri.toString().replaceFirst(RegExp(r"/+$"), ""); + } Future handleBeeper(String code) async { if (code == lastCheckedCode) return; lastCheckedCode = code; try { + relayHost = normalizeRelayHost(relayHost); if (staging == null) { FocusManager.instance.primaryFocus?.unfocus(); } @@ -134,7 +152,8 @@ class HwInpState extends OptimizedState { options: Options( headers: { // not a secret; burner account - "X-Beeper-Access-Token": "5c175851953ecaf5209185d897591badb6c3e712", + "X-Beeper-Access-Token": + registrationRelayAccessToken, "Authorization": "Bearer $code", }, ) @@ -143,7 +162,10 @@ class HwInpState extends OptimizedState { api.JoinedOsConfig parsed; if (response2.data["versions"]["software_name"] == "iPhone OS") { Logger.debug("Using as iOS"); - parsed = await api.configFromRelay(code: code, host: relayHost, token: "5c175851953ecaf5209185d897591badb6c3e712"); + parsed = await api.configFromRelay( + code: code, + host: relayHost, + token: registrationRelayAccessToken); usingBeeper = false; } else { final response = await http.dio.post( @@ -152,7 +174,8 @@ class HwInpState extends OptimizedState { options: Options( headers: { // not a secret; burner account - "X-Beeper-Access-Token": "5c175851953ecaf5209185d897591badb6c3e712", + "X-Beeper-Access-Token": + registrationRelayAccessToken, "Authorization": "Bearer $code", }, ) @@ -160,6 +183,7 @@ class HwInpState extends OptimizedState { if (response.statusCode == 404) { showSnackbar("Fetching validation data", "Mac Offline"); + lastCheckedCode = ""; return; } parsed = await api.configFromValidationData(data: base64Decode(response.data["data"]), extra: api.HwExtra( @@ -172,10 +196,13 @@ class HwInpState extends OptimizedState { usingBeeper = true; } showSnackbar("Fetching validation data", "Done"); + await ss.prefs.setString( + "registration-relay-host", relayHost); stagingNonInp = true; select(parsed, true); } catch (e) { showSnackbar("Fetching validation data", "Failed"); + lastCheckedCode = ""; rethrow; } } @@ -331,13 +358,21 @@ class HwInpState extends OptimizedState { } } + Future _checkCodeSafely(String text) async { + try { + await checkCode(text); + } catch (e, stack) { + Logger.error("Failed to check registration code", error: e, trace: stack); + } + } + void updateInitial() async { Logger.debug("updating app link"); final _appLinks = AppLinks(); var link = await _appLinks.getLatestLink(); if (link != null && link.toString().startsWith(rpApiRoot)) { - checkCode(link.toString()); + unawaited(_checkCodeSafely(link.toString())); } else { if (controller.config != null) { // restore @@ -432,8 +467,13 @@ class HwInpState extends OptimizedState { @override void dispose() { - super.dispose(); + codeController.removeListener(_codeListener); + hostedCodeController.removeListener(_hostedCodeListener); subscription?.cancel(); + codeController.dispose(); + hostedCodeController.dispose(); + focusNode.dispose(); + super.dispose(); } Future handlePurchases(PurchasesResultWrapper details) async { @@ -442,7 +482,7 @@ class HwInpState extends OptimizedState { ss.settings.hostedToken.value = detail.purchaseToken; ss.saveSettings(); await wrapSubscriptionPromise(handleSubscriptionToken(detail.purchaseToken)); - Logger.info("Purchased token ${detail.purchaseToken}"); + Logger.info("Hosted subscription purchase received"); return true; } return false; @@ -470,16 +510,18 @@ class HwInpState extends OptimizedState { } // Start listening to changes. - codeController.addListener(() async { - checkCode(codeController.text); - }); + _codeListener = () { + unawaited(_checkCodeSafely(codeController.text)); + }; + codeController.addListener(_codeListener); - hostedCodeController.addListener(() async { + _hostedCodeListener = () { if (hostedCodeController.text.length == 36 || hostedCodeController.text.length == 9) { controller.currentWaitlist = hostedCodeController.text; controller.updateIAPState(); } - }); + }; + hostedCodeController.addListener(_hostedCodeListener); } Widget materialButton(Widget inner, bool selected, void Function() onTap) { @@ -737,7 +779,7 @@ class HwInpState extends OptimizedState { textInputAction: TextInputAction.done, onSubmitted: (value) { lastCheckedCode = ""; - checkCode(codeController.text); + unawaited(_checkCodeSafely(codeController.text)); }, decoration: InputDecoration( enabledBorder: OutlineInputBorder( @@ -785,10 +827,19 @@ class HwInpState extends OptimizedState { TextButton( child: Text("OK", style: Get.context!.theme.textTheme.bodyLarge!.copyWith(color: Get.context!.theme.colorScheme.primary)), onPressed: () async { - relayHost = server.text; - lastCheckedCode = ""; - Get.back(); - checkCode(codeController.text); + try { + relayHost = + normalizeRelayHost( + server.text); + lastCheckedCode = ""; + Get.back(); + unawaited(_checkCodeSafely( + codeController.text)); + } on FormatException catch (e) { + showSnackbar( + "Invalid relay URL", + e.message); + } }, ), ], @@ -1087,4 +1138,4 @@ class HwInpState extends OptimizedState { // Get.delete(force: true); } -} \ No newline at end of file +} diff --git a/lib/app/layouts/setup/setup_view.dart b/lib/app/layouts/setup/setup_view.dart index cda5c2331c..4befda527e 100644 --- a/lib/app/layouts/setup/setup_view.dart +++ b/lib/app/layouts/setup/setup_view.dart @@ -49,6 +49,9 @@ import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:in_app_purchase_android/billing_client_wrappers.dart'; class SetupViewController extends StatefulController { + static const int _maxIdsAliasRetries = 1; + static const Duration _idsAliasRetryDelay = Duration(seconds: 5); + final pageController = PageController(initialPage: 0); int currentPage = 1; int numberToDownload = 25; @@ -137,7 +140,7 @@ class SetupViewController extends StatefulController { Future updateIAPState() async { hasDanglingSubscription = false; - if (currentWaitlist == null && !fetchedReferrer) { + if (Platform.isAndroid && currentWaitlist == null && !fetchedReferrer) { try { ReferrerDetails referrerDetails = await AndroidPlayInstallReferrer.installReferrer; var referrer = referrerDetails.installReferrer; @@ -180,6 +183,11 @@ class SetupViewController extends StatefulController { return; } } + if (!Platform.isAndroid) { + // Google Play Billing has no desktop implementation. + availableIAP.value = null; + return; + } var details = await pushService.client.runWithClient((client) => client.queryProductDetails(productList: [const ProductWrapper(productId: 'monthly_hosted', productType: ProductType.subs)])); if (details.productDetailsList.isEmpty) { Logger.warn("Product not found!"); @@ -365,7 +373,7 @@ class SetupViewController extends StatefulController { if (selectedRadio == -1) { return ret; } - ret = await api.send2FaSms(locked: circleSession, account: currentAppleAccount!, phoneId: options.$1[0].id); + ret = await api.send2FaSms(locked: circleSession, account: currentAppleAccount!, phoneId: selectedRadio); circleSession = null; } isSms.value = true; @@ -429,14 +437,7 @@ class SetupViewController extends StatefulController { if (users.isEmpty) { throw Exception("No users to register!"); } - var (newUsers, response) = await api.registerIds( - path: pushService.statePath, - aps: connection!, - identity: identity!, - config: config!, - // stupid FRB will take ownership for us, so we have to do this - users: users.map((i) => api.duplicateUser(user: i)).toList(), - ); + var (newUsers, response) = await _registerIdsWithRetry(users); if (response != null) { var devInfo = await api.getDeviceInfo(config: config!); await showDialog( @@ -559,6 +560,31 @@ class SetupViewController extends StatefulController { setup.finishSetup(); } + Future<(List?, api.SupportAlert?)> _registerIdsWithRetry( + List users) async { + for (var attempt = 0;; attempt++) { + try { + return await api.registerIds( + path: pushService.statePath, + aps: connection!, + identity: identity!, + config: config!, + // FRB takes ownership, so duplicate users for every attempt. + users: users.map((i) => api.duplicateUser(user: i)).toList(), + ); + } catch (error) { + final isTransientAliasRemoval = error is AnyhowException && + RegExp(r'(^|\D)5052(\D|$)').hasMatch(error.message); + if (!isTransientAliasRemoval || attempt >= _maxIdsAliasRetries) { + rethrow; + } + Logger.warn( + "IDS registration returned transient alias-removal status 5052; retrying once"); + await Future.delayed(_idsAliasRetryDelay); + } + } + } + Future cacheCode(String code) async { if (ss.settings.cachedCodes.containsKey(code)) { return; diff --git a/lib/app/wrappers/stateful_boilerplate.dart b/lib/app/wrappers/stateful_boilerplate.dart index f7285a80f3..ec1868f2f5 100644 --- a/lib/app/wrappers/stateful_boilerplate.dart +++ b/lib/app/wrappers/stateful_boilerplate.dart @@ -8,7 +8,7 @@ import 'package:get/get.dart'; /// [GetxController] with support for optimized state management class StatefulController extends GetxController { final Map> updateWidgetFunctions = {}; - late final void Function(VoidCallback) updateObx; + late void Function(VoidCallback) updateObx; void updateWidgets(Object? arg) { updateWidgetFunctions[T]?.forEach((e) => e.call(arg)); @@ -28,6 +28,7 @@ abstract class CustomStateful extends StatefulWidg abstract class CustomState extends State with ThemeHelpers { // completer to check if the page animation is complete final animCompleted = Completer(); + late final void Function(R) _updateWidgetCallback; @protected /// Convenience getter for the [GetxController] @@ -49,13 +50,14 @@ abstract class CustomState(tag: _tag); super.dispose(); } diff --git a/lib/app/wrappers/tablet_mode_wrapper.dart b/lib/app/wrappers/tablet_mode_wrapper.dart index ba6caed091..fe189698e0 100644 --- a/lib/app/wrappers/tablet_mode_wrapper.dart +++ b/lib/app/wrappers/tablet_mode_wrapper.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math'; import 'package:bluebubbles/helpers/ui/theme_helpers.dart'; @@ -43,6 +44,8 @@ class _TabletModeWrapperState extends OptimizedState { late final RxDouble _ratio; double? _maxWidth; bool? altLayoutCache; + StreamSubscription? _eventSubscription; + Worker? _ratioWorker; get _width1 => max(min(_ratio * _maxWidth!, widget.maxWidthLeft ?? double.infinity), widget.minWidthLeft ?? double.negativeInfinity); @@ -52,7 +55,8 @@ class _TabletModeWrapperState extends OptimizedState { void initState() { super.initState(); _ratio = RxDouble((ss.prefs.getDouble('splitRatio') ?? widget.initialRatio).clamp(widget.minRatio, widget.maxRatio)); - eventDispatcher.stream.listen((event) { + _eventSubscription = eventDispatcher.stream.listen((event) { + if (!mounted) return; if (event.item1 == 'split-refresh') { _ratio.value = ss.prefs.getDouble('splitRatio') ?? _ratio.value; setState(() {}); @@ -61,12 +65,19 @@ class _TabletModeWrapperState extends OptimizedState { setState(() {}); } }); - debounce(_ratio, (val) async { + _ratioWorker = debounce(_ratio, (val) async { await ss.prefs.setDouble('splitRatio', val); eventDispatcher.emit('split-refresh', null); }); } + @override + void dispose() { + _eventSubscription?.cancel(); + _ratioWorker?.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { if (!showAltLayout) { @@ -137,4 +148,4 @@ class _TabletModeWrapperState extends OptimizedState { }, ); } -} \ No newline at end of file +} diff --git a/lib/database/global/chat_messages.dart b/lib/database/global/chat_messages.dart index 131468a6a0..399dad255b 100644 --- a/lib/database/global/chat_messages.dart +++ b/lib/database/global/chat_messages.dart @@ -1,8 +1,11 @@ import 'package:bluebubbles/database/models.dart'; class ChatMessages { + static const int _maxPendingReactionsPerMessage = 32; + static const int _maxPendingReactionParents = 128; final Map _messages = {}; final Map _reactions = {}; + final Map> _pendingReactions = {}; final Map _attachments = {}; final Map> _threads = {}; final Map> _edits = {}; @@ -12,23 +15,65 @@ class ChatMessages { List get messages => _messages.values.toList(); List get reactions => _reactions.values.toList(); List get attachments => _attachments.values.toList(); - List threads(String originatorGuid, int originatorPart, {bool returnOriginator = true}) => - _threads[originatorGuid]?.values.where((e) => - (e.normalizedThreadPart == originatorPart && e.guid != originatorGuid) || (returnOriginator ? e.guid == originatorGuid : false)).toList() ?? []; + List threads(String originatorGuid, int originatorPart, {bool returnOriginator = true}) { + final thread = _threads[originatorGuid]; + if (returnOriginator && thread?[originatorGuid] == null) { + final originator = _messages[originatorGuid]; + if (originator != null) { + addThreadOriginator(originator); + } + } + return _threads[originatorGuid]?.values.where((e) => + (e.normalizedThreadPart == originatorPart && e.guid != originatorGuid) || + (returnOriginator && e.guid == originatorGuid)).toList() ?? []; + } void addMessages(List __messages) { for (Message m in __messages) { if (m.associatedMessageGuid != null) { // add reactions _reactions[m.guid!] = m; + final parent = getMessage(m.associatedMessageGuid!); + if (parent != null) { + _attachReaction(parent, m); + } else { + final parentGuid = m.associatedMessageGuid!; + var pending = _pendingReactions[parentGuid]; + if (pending == null) { + if (_pendingReactions.length >= _maxPendingReactionParents) { + _pendingReactions.remove(_pendingReactions.keys.first); + } + pending = {}; + _pendingReactions[parentGuid] = pending; + } + // A malformed or delayed stream must not grow this cache forever. + // The database sync remains the source of truth if an item is + // evicted before its parent arrives. + if (pending.length >= _maxPendingReactionsPerMessage && + !pending.containsKey(m.guid)) { + pending.remove(pending.keys.first); + } + pending[m.guid!] = m; + } } else { // add regular texts _messages[m.guid!] = m; + final pending = _pendingReactions.remove(m.guid); + if (pending != null) { + for (final reaction in pending.values) { + _attachReaction(m, reaction); + } + } } if (m.threadOriginatorGuid != null && !m.guid!.startsWith("temp") && m.associatedMessageGuid == null) { // add threaded messages - _threads[m.threadOriginatorGuid!] ??= {}; - _threads[m.threadOriginatorGuid]![m.guid!] = m; + final originatorGuid = m.threadOriginatorGuid!; + _threads[originatorGuid] ??= {}; + _threads[originatorGuid]![m.guid!] = m; + final loadedOriginator = _messages[originatorGuid]; + if (loadedOriginator != null) { + _threads[originatorGuid]![originatorGuid] = loadedOriginator; + } } if (_threads.keys.contains(m.guid)) { // add thread 'originator' @@ -38,9 +83,17 @@ class ChatMessages { } } + void _attachReaction(Message parent, Message reaction) { + if (!parent.associatedMessages.any((item) => item.guid == reaction.guid)) { + parent.associatedMessages.add(reaction); + } + parent.hasReactions = true; + } + void removeMessage(String guid) { _messages.remove(guid); _reactions.remove(guid); + _pendingReactions.remove(guid); final result = _threads.remove(guid); if (result == null) { for (Map element in _threads.values) { @@ -100,8 +153,9 @@ class ChatMessages { flush() { _messages.clear(); _reactions.clear(); + _pendingReactions.clear(); _attachments.clear(); _threads.clear(); _edits.clear(); } -} \ No newline at end of file +} diff --git a/lib/database/html/message.dart b/lib/database/html/message.dart index d0e2319c42..ee61d3f3bd 100644 --- a/lib/database/html/message.dart +++ b/lib/database/html/message.dart @@ -432,7 +432,8 @@ class Message { return isFromMe != newerMessage.isFromMe; } - int get normalizedThreadPart => threadOriginatorPart == null ? 0 : int.parse(threadOriginatorPart![0]); + int get normalizedThreadPart => + int.tryParse(threadOriginatorPart?.split(':').first ?? '') ?? 0; bool connectToUpper() => threadOriginatorGuid != null; diff --git a/lib/database/io/chat.dart b/lib/database/io/chat.dart index db4c8a7df5..82ee7ed437 100644 --- a/lib/database/io/chat.dart +++ b/lib/database/io/chat.dart @@ -390,7 +390,11 @@ class Chat { RxDouble sendProgress = 0.0.obs; void handlesChanged() { - var cachedChat = cvc(this).chat; + // Group updates can arrive while the app is backgrounded. Do not create a + // full conversation controller just to mirror a relation that has no + // visible UI; that leaks controller resources and can wake rendering work. + if (!Get.isRegistered(tag: guid)) return; + var cachedChat = Get.find(tag: guid).chat; cachedChat.handles = handles; // someone can't keep their objects in sync... cachedChat._participants = []; } diff --git a/lib/database/io/message.dart b/lib/database/io/message.dart index 4440c2ef6a..14bb87bfdc 100644 --- a/lib/database/io/message.dart +++ b/lib/database/io/message.dart @@ -26,7 +26,6 @@ import 'package:telephony_plus/src/models/attachment.dart' as TelephonyAttachmen import 'package:bluebubbles/src/rust/api/api.dart' as api; import 'package:tuple/tuple.dart'; import 'dart:typed_data'; -import 'package:convert/convert.dart'; const IS_FINISHED = 1 << 0; // this one probably, although there are some unset in db, all are set on local db const IS_EMOTE = 1 << 1; @@ -400,19 +399,27 @@ class Message { var attachments = fetchAttachments()!; bool useMMS = chat.participants.length > 1 || attachments.isNotEmpty; int status; - if (useMMS) { - status = await TelephonyPlus().sendMMS( - addresses: chat.participants.map((e) => e.address).filter((e) => e.isPhoneNumber).toList(), - message: text?.trim() == "" ? null : text, - threadId: chat.telephonyId, - attachments: await Future.wait(attachments.map((e) => e!.toTelephony()).toList()) - ); - } else { - status = await TelephonyPlus().sendSMS( - address: chat.participants.first.address, - threadId: chat.telephonyId, - message: text!, - ); + try { + if (useMMS) { + status = await TelephonyPlus().sendMMS( + addresses: chat.participants.map((e) => e.address).filter((e) => e.isPhoneNumber).toList(), + message: text?.trim() == "" ? null : text, + threadId: chat.telephonyId, + attachments: await Future.wait(attachments.map((e) => e!.toTelephony()).toList()) + ); + } else { + status = await TelephonyPlus().sendSMS( + address: chat.participants.first.address, + threadId: chat.telephonyId, + message: text!, + ); + } + } catch (_) { + // No native status means the forwarding attempt did not complete. Let + // the transport retry instead of permanently suppressing forwarding. + hasBeenForwarded = false; + save(chat: chat); + rethrow; } if (status != -1) { await (backend as RustPushBackend).confirmSmsSent(this, chat, false); @@ -1098,7 +1105,6 @@ class Message { } void applyFromCloud(api.CloudMessage c, String cloudkitId) { - Logger.info("item ${c.chatId}"); Chat? chat; if (c.chatId.contains(";")) { final query = Database.chats.query(Chat_.chatIdentifier.equals(c.chatId.split(";")[2])).build(); @@ -1114,8 +1120,6 @@ class Message { if (chat?.isRpSms ?? true) return; - Logger.info("Syncing new message"); - ckRecordId = cloudkitId; error = c.error; @@ -1137,7 +1141,11 @@ class Message { try { payloadData = proto1.payloadData != null && !eraseBalloonBundle ? pushService.appToData(api.decodeExtensionApp(bp: gzip.encode(proto1.payloadData!), bid: proto1.balloonBundleId!)) : null; } catch (e, s) { - Logger.info("Failed item ${hex.encode(proto1.payloadData!)} ${proto1.balloonBundleId}", error: e, trace: s); + Logger.warn( + "Failed to decode extension payload for bundle ${proto1.balloonBundleId ?? "unknown"} (${proto1.payloadData?.length ?? 0} bytes)", + error: e, + trace: s, + ); } hasApplePayloadData = proto1.payloadData != null && !eraseBalloonBundle; @@ -1453,7 +1461,8 @@ class Message { return "$part:${run.range[0]}:${run.range[1]}"; } - int get normalizedThreadPart => threadOriginatorPart == null ? 0 : int.parse(threadOriginatorPart![0]); + int get normalizedThreadPart => + int.tryParse(threadOriginatorPart?.split(':').first ?? '') ?? 0; bool connectToUpper() => threadOriginatorGuid != null; diff --git a/lib/helpers/group_participant_helpers.dart b/lib/helpers/group_participant_helpers.dart new file mode 100644 index 0000000000..9f76275200 --- /dev/null +++ b/lib/helpers/group_participant_helpers.dart @@ -0,0 +1,28 @@ +import 'package:bluebubbles/database/models.dart'; + +/// Reconciles a locally cached participant list with the server's latest +/// ordered list. +/// +/// The old fetch path only handled length changes and could leave a group +/// stale when one participant was replaced by another. It also added only one +/// handle when several participants were added. Preserve existing Handle +/// objects (and their contact metadata) when the identity is unchanged, while +/// applying the server list exactly once and dropping duplicate identities. +List reconcileGroupParticipants( + List current, + List incoming, +) { + final existingByIdentity = {}; + for (final handle in current) { + existingByIdentity.putIfAbsent(handle.uniqueAddressAndService, () => handle); + } + + final seen = {}; + final reconciled = []; + for (final handle in incoming) { + final identity = handle.uniqueAddressAndService; + if (!seen.add(identity)) continue; + reconciled.add(existingByIdentity[identity] ?? handle); + } + return reconciled; +} diff --git a/lib/helpers/memory/bounded_byte_cache.dart b/lib/helpers/memory/bounded_byte_cache.dart new file mode 100644 index 0000000000..342d8f96bf --- /dev/null +++ b/lib/helpers/memory/bounded_byte_cache.dart @@ -0,0 +1,75 @@ +import 'dart:collection'; +import 'dart:typed_data'; + +/// A byte-aware least-recently-used cache. +/// +/// Flutter's global [ImageCache] limits decoded images, but OpenBubbles also +/// retains encoded attachment bytes in conversation state. This cache bounds +/// that separate allocation and evicts the least recently accessed entries. +class BoundedByteCache { + BoundedByteCache({ + required this.maximumSizeBytes, + required this.maximumEntries, + }) : assert(maximumSizeBytes > 0), + assert(maximumEntries > 0); + + final int maximumSizeBytes; + final int maximumEntries; + final LinkedHashMap _entries = LinkedHashMap(); + int _currentSizeBytes = 0; + + int get currentSizeBytes => _currentSizeBytes; + int get length => _entries.length; + bool get isEmpty => _entries.isEmpty; + + bool containsKey(String? key) => key != null && _entries.containsKey(key); + + Uint8List? operator [](String? key) { + if (key == null) return null; + final value = _entries.remove(key); + if (value == null) return null; + _entries[key] = value; + return value; + } + + void operator []=(String key, Uint8List value) { + final previous = _entries.remove(key); + if (previous != null) { + _currentSizeBytes -= previous.lengthInBytes; + } + + // An entry larger than the full budget is useful to the active widget but + // must not evict the entire cache and then remain resident indefinitely. + if (value.lengthInBytes > maximumSizeBytes) { + return; + } + + _entries[key] = value; + _currentSizeBytes += value.lengthInBytes; + _evictToBudget(); + } + + Uint8List? remove(String? key) { + if (key == null) return null; + final removed = _entries.remove(key); + if (removed != null) { + _currentSizeBytes -= removed.lengthInBytes; + } + return removed; + } + + void clear() { + _entries.clear(); + _currentSizeBytes = 0; + } + + void _evictToBudget() { + while (_entries.isNotEmpty && + (_entries.length > maximumEntries || + _currentSizeBytes > maximumSizeBytes)) { + final oldestKey = _entries.keys.first; + final oldest = _entries.remove(oldestKey)!; + _currentSizeBytes -= oldest.lengthInBytes; + } + } +} diff --git a/lib/helpers/memory/bounded_lru_map.dart b/lib/helpers/memory/bounded_lru_map.dart new file mode 100644 index 0000000000..6d7ca06ed8 --- /dev/null +++ b/lib/helpers/memory/bounded_lru_map.dart @@ -0,0 +1,71 @@ +import 'dart:collection'; + +/// A least-recently-used map bounded by entry count and optional weight. +class BoundedLruMap { + BoundedLruMap({ + required this.maximumEntries, + this.maximumWeight, + int Function(V value)? weightOf, + }) : assert(maximumEntries > 0), + assert(maximumWeight == null || maximumWeight > 0), + _weightOf = weightOf ?? ((_) => 1); + + final int maximumEntries; + final int? maximumWeight; + final int Function(V value) _weightOf; + final LinkedHashMap _entries = LinkedHashMap(); + int _currentWeight = 0; + + int get length => _entries.length; + int get currentWeight => _currentWeight; + Iterable get keys => _entries.keys; + Iterable> get entries => _entries.entries; + + bool containsKey(Object? key) => _entries.containsKey(key); + + V? operator [](Object? key) { + final value = _entries.remove(key); + if (value == null) return null; + _entries[key as K] = value; + return value; + } + + void operator []=(K key, V value) { + final previous = _entries.remove(key); + if (previous != null) { + _currentWeight -= _weightOf(previous); + } + + final weight = _weightOf(value); + if (maximumWeight != null && weight > maximumWeight!) { + return; + } + + _entries[key] = value; + _currentWeight += weight; + _evictToBudget(); + } + + V? remove(Object? key) { + final removed = _entries.remove(key); + if (removed != null) { + _currentWeight -= _weightOf(removed); + } + return removed; + } + + void clear() { + _entries.clear(); + _currentWeight = 0; + } + + void _evictToBudget() { + while (_entries.isNotEmpty && + (_entries.length > maximumEntries || + (maximumWeight != null && _currentWeight > maximumWeight!))) { + final oldestKey = _entries.keys.first; + final oldest = _entries.remove(oldestKey) as V; + _currentWeight -= _weightOf(oldest); + } + } +} diff --git a/lib/helpers/types/helpers/message_helper.dart b/lib/helpers/types/helpers/message_helper.dart index ed5fb86d2b..28a06a48dd 100644 --- a/lib/helpers/types/helpers/message_helper.dart +++ b/lib/helpers/types/helpers/message_helper.dart @@ -225,14 +225,24 @@ class MessageHelper { // if we can't fetch the associated message for some reason // (or none of the above conditions about it are true) // then we should fallback to unparsed reaction messages - Logger.info("Couldn't fetch associated message for message: ${message.guid}"); - return "$sender ${message.text}"; + // The bounded ChatMessages pending cache attaches this reaction if the + // parent arrives later. Keep the fallback without flooding normal logs. + Logger.debug("Reaction parent is not available yet"); + return getReactionFallbackText(sender, message.text); } else { // It's all other message types return sender + message.fullText; } } + static String getReactionFallbackText(String sender, String? messageText) { + final text = messageText?.trim(); + if (text == null || text.isEmpty) { + return "$sender reacted to a message"; + } + return "$sender $text"; + } + // returns the attachments as a string static String _getAttachmentText(List attachments) { Map counts = {}; diff --git a/lib/helpers/types/helpers/string_helpers.dart b/lib/helpers/types/helpers/string_helpers.dart index 4d076bfeb8..d3418b166f 100644 --- a/lib/helpers/types/helpers/string_helpers.dart +++ b/lib/helpers/types/helpers/string_helpers.dart @@ -7,6 +7,14 @@ const _chars = 'AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz1234567890'; String randomString(int length) => String.fromCharCodes(Iterable.generate(length, (_) => _chars.codeUnitAt(Random().nextInt(_chars.length)))); +/// Failed messages stash their error inside the GUID as `error--` +/// (the suffix is left over from the original `temp-` GUID), so strip the +/// leftovers back off before showing the error to the user. +final RegExp _errorGuidRegex = RegExp(r'^error-(.*?)(?:-[A-Za-z0-9]{8})?$', dotAll: true); + +String errorFromGuid(String guid) => + _errorGuidRegex.firstMatch(guid)?.group(1) ?? guid.substring(guid.indexOf('-') + 1); + String sanitizeString(String? input) { return input?.replaceAll(String.fromCharCode(65532), '') ?? ""; } diff --git a/lib/helpers/ui/message_widget_helpers.dart b/lib/helpers/ui/message_widget_helpers.dart index 8507ce9aa7..253ffadb30 100644 --- a/lib/helpers/ui/message_widget_helpers.dart +++ b/lib/helpers/ui/message_widget_helpers.dart @@ -13,12 +13,27 @@ import 'package:maps_launcher/maps_launcher.dart'; import 'package:tuple/tuple.dart'; import 'package:url_launcher/url_launcher.dart'; -List buildMessageSpans(BuildContext context, MessagePart part, Message message, {Color? colorOverride, bool hideBodyText = false}) { +@visibleForTesting +String? safeMessageSubstring(String? text, List range) { + if (text == null || range.length < 2) return null; + final start = range.first.clamp(0, text.length).toInt(); + final end = range.last.clamp(start, text.length).toInt(); + if (start >= end) return null; + return text.substring(start, end); +} + +List buildMessageSpans( + BuildContext context, MessagePart part, Message message, + {Color? colorOverride, bool hideBodyText = false}) { final textSpans = []; - final textStyle = (context.theme.extensions[BubbleText] as BubbleText).bubbleText.apply( - color: colorOverride ?? (message.isFromMe! ? context.theme.colorScheme.onPrimary : context.theme.colorScheme.properOnSurface), - fontSizeFactor: message.isBigEmoji ? 3 : 1, - ); + final textStyle = + (context.theme.extensions[BubbleText] as BubbleText).bubbleText.apply( + color: colorOverride ?? + ((message.isFromMe ?? false) + ? context.theme.colorScheme.onPrimary + : context.theme.colorScheme.properOnSurface), + fontSizeFactor: message.isBigEmoji ? 3 : 1, + ); if (!isNullOrEmpty(part.subject)) { textSpans.addAll(MessageHelper.buildEmojiText( @@ -29,36 +44,46 @@ List buildMessageSpans(BuildContext context, MessagePart part, Messa if (part.annotations.isNotEmpty) { part.annotations.forEachIndexed((i, e) { final range = part.annotations[i].range; + final text = safeMessageSubstring(part.displayText, range); + if (text == null) return; var style = textStyle; if (e.bold ?? false) style = style.apply(fontWeightDelta: 2); if (e.italic ?? false) style = style.apply(fontStyle: FontStyle.italic); - style = style.apply(decoration: TextDecoration.combine([ + style = style.apply( + decoration: TextDecoration.combine([ if (e.strikethrough ?? false) TextDecoration.lineThrough, if (e.underline ?? false) TextDecoration.underline, ])); if (e.textEffect == Attributes.BIG) style = style.apply(fontSizeDelta: 4); - if (e.textEffect == Attributes.SMALL) style = style.apply(fontSizeDelta: -2); + if (e.textEffect == Attributes.SMALL) { + style = style.apply(fontSizeDelta: -2); + } if (e.mentionedAddress != null) { textSpans.addAll(MessageHelper.buildEmojiText( - part.displayText!.substring(range.first, range.last), - style.apply(fontWeightDelta: 2), - recognizer: TapGestureRecognizer()..onTap = () async { - if (kIsDesktop || kIsWeb) return; - final handle = cm.activeChat!.chat.participants.firstWhereOrNull((e) => e.address == part.annotations[i].mentionedAddress); - if (handle?.contact == null && handle != null) { - await mcs.invokeMethod("open-contact-form", {'address': handle.address, 'address_type': handle.address.isEmail ? 'email' : 'phone'}); - } else if (handle?.contact != null) { - try { - await mcs.invokeMethod("view-contact-form", {'id': handle!.contact!.id}); - } catch (_) { - showSnackbar("Error", "Failed to find contact on device!"); - } - } - } - )); - } else { + text, style.apply(fontWeightDelta: 2), + recognizer: TapGestureRecognizer() + ..onTap = () async { + if (kIsDesktop || kIsWeb) return; + final handle = cm.activeChat!.chat.participants + .firstWhereOrNull((e) => + e.address == part.annotations[i].mentionedAddress); + if (handle?.contact == null && handle != null) { + await mcs.invokeMethod("open-contact-form", { + 'address': handle.address, + 'address_type': handle.address.isEmail ? 'email' : 'phone' + }); + } else if (handle?.contact != null) { + try { + await mcs.invokeMethod( + "view-contact-form", {'id': handle!.contact!.id}); + } catch (_) { + showSnackbar("Error", "Failed to find contact on device!"); + } + } + })); + } else { textSpans.addAll(MessageHelper.buildEmojiText( - part.displayText!.substring(range.first, range.last), + text, style, )); } @@ -73,16 +98,27 @@ List buildMessageSpans(BuildContext context, MessagePart part, Messa return textSpans; } -Future> buildEnrichedMessageSpans(BuildContext context, MessagePart part, Message message, {Color? colorOverride, bool hideBodyText = false}) async { +Future> buildEnrichedMessageSpans( + BuildContext context, MessagePart part, Message message, + {Color? colorOverride, bool hideBodyText = false}) async { final textSpans = []; - final textStyle = (context.theme.extensions[BubbleText] as BubbleText).bubbleText.apply( - color: colorOverride ?? (message.isFromMe! ? context.theme.colorScheme.onPrimary : context.theme.colorScheme.properOnSurface), - fontSizeFactor: message.isBigEmoji ? 3 : 1, - ); + final textStyle = + (context.theme.extensions[BubbleText] as BubbleText).bubbleText.apply( + color: colorOverride ?? + ((message.isFromMe ?? false) + ? context.theme.colorScheme.onPrimary + : context.theme.colorScheme.properOnSurface), + fontSizeFactor: message.isBigEmoji ? 3 : 1, + ); // extract rich content - final urlRegex = RegExp(r'((https?://)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}([-a-zA-Z0-9/()@:%_.~#?&=*\[\]]*)\b'); + final urlRegex = RegExp( + r'((https?://)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}([-a-zA-Z0-9/()@:%_.~#?&=*\[\]]*)\b'); - List annotations = part.annotations.map((a) => a.copy()).toList(); + List annotations = part.annotations + .where((annotation) => + safeMessageSubstring(part.displayText, annotation.range) != null) + .map((annotation) => annotation.copy()) + .toList(); void markRange(Tuple3, List?> annotation) { var range = annotation.item2; List extras = []; @@ -119,14 +155,20 @@ Future> buildEnrichedMessageSpans(BuildContext context, Message if (!isNullOrEmpty(part.text)) { if (!kIsWeb && !kIsDesktop && ss.settings.smartReply.value) { if (controller.mlKitParsedText["${message.guid!}-${part.part}"] == null) { + final extractor = + GoogleMlKit.nlp.entityExtractor(EntityExtractorLanguage.english); try { - controller.mlKitParsedText["${message.guid!}-${part.part}"] = await GoogleMlKit.nlp.entityExtractor(EntityExtractorLanguage.english) - .annotateText(part.text!); + controller.mlKitParsedText["${message.guid!}-${part.part}"] = + await extractor.annotateText(part.text!); } catch (ex, stack) { - Logger.warn('Failed to extract entities using mlkit!', error: ex, trace: stack); + Logger.warn('Failed to extract entities using mlkit!', + error: ex, trace: stack); + } finally { + await extractor.close(); } } - final entities = controller.mlKitParsedText["${message.guid!}-${part.part}"] ?? []; + final entities = + controller.mlKitParsedText["${message.guid!}-${part.part}"] ?? []; for (EntityAnnotation element in entities) { if (element.entities.first is AddressEntity) { markRange(Tuple3("map", [element.start, element.end], null)); @@ -138,16 +180,20 @@ Future> buildEnrichedMessageSpans(BuildContext context, Message markRange(Tuple3("link", [element.start, element.end], null)); } else if (element.entities.first is DateTimeEntity) { final ent = (element.entities.first as DateTimeEntity); - if (part.text?.substring(element.start, element.end).toLowerCase() == "now") { + if (part.text?.substring(element.start, element.end).toLowerCase() == + "now") { continue; } - markRange(Tuple3("date", [element.start, element.end], [ent.timestamp])); + markRange( + Tuple3("date", [element.start, element.end], [ent.timestamp])); } else if (element.entities.first is TrackingNumberEntity) { final ent = (element.entities.first as TrackingNumberEntity); - markRange(Tuple3("tracking", [element.start, element.end], [ent.carrier, ent.number])); + markRange(Tuple3("tracking", [element.start, element.end], + [ent.carrier, ent.number])); } else if (element.entities.first is FlightNumberEntity) { final ent = (element.entities.first as FlightNumberEntity); - markRange(Tuple3("flight", [element.start, element.end], [ent.airlineCode, ent.flightNumber])); + markRange(Tuple3("flight", [element.start, element.end], + [ent.airlineCode, ent.flightNumber])); } } } else { @@ -159,7 +205,7 @@ Future> buildEnrichedMessageSpans(BuildContext context, Message } annotations.sort((a, b) => a.range[0].compareTo(b.range[0])); - + // render subject if (!isNullOrEmpty(part.subject)) { textSpans.addAll(MessageHelper.buildEmojiText( @@ -170,43 +216,56 @@ Future> buildEnrichedMessageSpans(BuildContext context, Message // render rich content if needed if (annotations.isNotEmpty) { annotations.forEachIndexed((i, e) { - var item = e.renderExtras.firstOrNull; final type = item?.item1; final range = e.range; final data = item?.item3; - final text = part.displayText!.substring(range.first, range.last); + final text = safeMessageSubstring(part.displayText, range); + if (text == null) return; var style = textStyle; if (e.bold ?? false) style = style.apply(fontWeightDelta: 2); if (e.italic ?? false) style = style.apply(fontStyle: FontStyle.italic); - style = style.apply(decoration: TextDecoration.combine([ + style = style.apply( + decoration: TextDecoration.combine([ if (e.strikethrough ?? false) TextDecoration.lineThrough, if (e.underline ?? false) TextDecoration.underline, ])); if (e.textEffect == Attributes.BIG) style = style.apply(fontSizeDelta: 4); - if (e.textEffect == Attributes.SMALL) style = style.apply(fontSizeDelta: -2); + if (e.textEffect == Attributes.SMALL) { + style = style.apply(fontSizeDelta: -2); + } if (e.mentionedAddress != null) { textSpans.addAll(MessageHelper.buildEmojiText( - text, - style.apply(fontWeightDelta: 2), - recognizer: TapGestureRecognizer()..onTap = () async { - if (kIsDesktop || kIsWeb) return; - final handle = cm.activeChat!.chat.participants.firstWhereOrNull((e) => e.address == data!.first); - if (handle?.contact == null && handle != null) { - await mcs.invokeMethod("open-contact-form", {'address': handle.address, 'address_type': handle.address.isEmail ? 'email' : 'phone'}); - } else if (handle?.contact != null) { - try { - await mcs.invokeMethod("view-contact-form", {'id': handle!.contact!.id}); - } catch (_) { - showSnackbar("Error", "Failed to find contact on device!"); - } - } - } - )); - } else if (urlRegex.hasMatch(text) || type == "map" || text.isPhoneNumber || text.isEmail || type == "date" || type == "tracking" || type == "flight") { + text, style.apply(fontWeightDelta: 2), + recognizer: TapGestureRecognizer() + ..onTap = () async { + if (kIsDesktop || kIsWeb) return; + final handle = cm.activeChat!.chat.participants + .firstWhereOrNull((e) => e.address == data!.first); + if (handle?.contact == null && handle != null) { + await mcs.invokeMethod("open-contact-form", { + 'address': handle.address, + 'address_type': handle.address.isEmail ? 'email' : 'phone' + }); + } else if (handle?.contact != null) { + try { + await mcs.invokeMethod( + "view-contact-form", {'id': handle!.contact!.id}); + } catch (_) { + showSnackbar("Error", "Failed to find contact on device!"); + } + } + })); + } else if (urlRegex.hasMatch(text) || + type == "map" || + text.isPhoneNumber || + text.isEmail || + type == "date" || + type == "tracking" || + type == "flight") { textSpans.add( TextSpan( text: text, @@ -214,10 +273,12 @@ Future> buildEnrichedMessageSpans(BuildContext context, Message ..onTap = () async { if (type == "link") { String url = text; - if (!url.startsWith("http://") && !url.startsWith("https://")) { + if (!url.startsWith("http://") && + !url.startsWith("https://")) { url = "http://$url"; } - await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); + await launchUrl(Uri.parse(url), + mode: LaunchMode.externalApplication); } else if (type == "map") { await MapsLauncher.launchQuery(text); } else if (type == "phone") { @@ -225,16 +286,23 @@ Future> buildEnrichedMessageSpans(BuildContext context, Message } else if (type == "email") { await launchUrl(Uri(scheme: "mailto", path: text)); } else if (type == "date") { - await mcs.invokeMethod("open-calendar", {"date": data!.first}); + await mcs + .invokeMethod("open-calendar", {"date": data!.first}); } else if (type == "tracking") { final TrackingCarrier c = data!.first; final String number = data.last; Clipboard.setData(ClipboardData(text: number)); - await launchUrl(Uri.parse("https://www.google.com/search?q=${c.name} $number"), mode: LaunchMode.externalApplication); + await launchUrl( + Uri.parse( + "https://www.google.com/search?q=${c.name} $number"), + mode: LaunchMode.externalApplication); } else if (type == "flight") { final String c = data!.first; final String number = data.last; - await launchUrl(Uri.parse("https://www.google.com/search?q=flight $c$number"), mode: LaunchMode.externalApplication); + await launchUrl( + Uri.parse( + "https://www.google.com/search?q=flight $c$number"), + mode: LaunchMode.externalApplication); } }, style: style.apply(decoration: TextDecoration.underline), @@ -255,4 +323,4 @@ Future> buildEnrichedMessageSpans(BuildContext context, Message } return textSpans; -} \ No newline at end of file +} diff --git a/lib/helpers/ui/ui_helpers.dart b/lib/helpers/ui/ui_helpers.dart index 083ea931fa..94b57af924 100644 --- a/lib/helpers/ui/ui_helpers.dart +++ b/lib/helpers/ui/ui_helpers.dart @@ -395,12 +395,14 @@ Future avatarAsBytes({ await paintGroupAvatar( chat: chat, participants: participants, canvas: canvas, size: quality, usingParticipantsOverride: participantsOverride != null); - ui.Picture picture = pictureRecorder.endRecording(); - ui.Image image = await picture.toImage(quality.toInt(), quality.toInt()); - - Uint8List bytes = (await image.toByteData(format: ui.ImageByteFormat.png))!.buffer.asUint8List(); - - return bytes; + final picture = pictureRecorder.endRecording(); + final image = await picture.toImage(quality.toInt(), quality.toInt()); + try { + return (await image.toByteData(format: ui.ImageByteFormat.png))!.buffer.asUint8List(); + } finally { + image.dispose(); + picture.dispose(); + } } Future paintGroupAvatar({ @@ -412,14 +414,15 @@ Future paintGroupAvatar({ }) async { late final ThemeData theme; final bool systemDark = PlatformDispatcher.instance.platformBrightness == Brightness.dark; - if (!ls.isAlive) { + final context = Get.context; + if (!ls.isAlive || context == null) { if (systemDark) { theme = ThemeStruct.getDarkTheme().data; } else { theme = ThemeStruct.getLightTheme().data; } } else { - theme = Get.context!.theme; + theme = context.theme; } if (chat.customAvatarPath != null && !usingParticipantsOverride) { @@ -430,7 +433,12 @@ Future paintGroupAvatar({ Logger.warn("Failed to load/clip custom avatar!", error: e, trace: stack); } if (customAvatar != null) { - canvas.drawImage(await loadImage(customAvatar), const Offset(0, 0), Paint()); + final avatarImage = await loadImage(customAvatar); + try { + canvas.drawImage(avatarImage, const Offset(0, 0), Paint()); + } finally { + avatarImage.dispose(); + } return; } } @@ -519,7 +527,12 @@ Future paintAvatar( if (contact?.avatar != null) { Uint8List? contactAvatar = await clip(contact!.avatar ?? contact.avatar!, size: size.toInt(), circle: kIsDesktop || inGroup); if (contactAvatar != null) { - canvas.drawImage(await loadImage(contactAvatar), offset, Paint()); + final avatarImage = await loadImage(contactAvatar); + try { + canvas.drawImage(avatarImage, offset, Paint()); + } finally { + avatarImage.dispose(); + } return; } } @@ -586,7 +599,6 @@ Future paintAvatar( } Future clip(Uint8List data, {required int size, required bool circle}) async { - ui.Image image; Uint8List _data = data; // Resize the image if it's the wrong size @@ -597,26 +609,29 @@ Future clip(Uint8List data, {required int size, required bool circle _data = img.encodePng(_image); } - image = await loadImage(_data); + final sourceImage = await loadImage(_data); ui.PictureRecorder pictureRecorder = ui.PictureRecorder(); Canvas canvas = Canvas(pictureRecorder); Paint paint = Paint(); paint.isAntiAlias = true; - Rect bounds = Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()); + Rect bounds = Rect.fromLTWH(0, 0, sourceImage.width.toDouble(), sourceImage.height.toDouble()); Path path = circle ? (Path()..addOval(bounds)) : (Path()..addRect(bounds)); canvas.clipPath(path); - canvas.drawImage(image, const Offset(0, 0), paint); - - ui.Picture picture = pictureRecorder.endRecording(); - image = await picture.toImage(image.width, image.height); + canvas.drawImage(sourceImage, const Offset(0, 0), paint); - Uint8List? bytes = (await image.toByteData(format: ui.ImageByteFormat.png))?.buffer.asUint8List(); - - return bytes; + final picture = pictureRecorder.endRecording(); + final clippedImage = await picture.toImage(sourceImage.width, sourceImage.height); + try { + return (await clippedImage.toByteData(format: ui.ImageByteFormat.png))?.buffer.asUint8List(); + } finally { + clippedImage.dispose(); + picture.dispose(); + sourceImage.dispose(); + } } Future loadImage(Uint8List data) async { diff --git a/lib/main.dart b/lib/main.dart index 1db315d28d..478f88a897 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,7 +10,6 @@ import 'package:bluebubbles/helpers/backend/startup_tasks.dart'; import 'package:bluebubbles/helpers/helpers.dart'; import 'package:bluebubbles/services/network/http_overrides.dart'; import 'package:bluebubbles/utils/logger/logger.dart'; -import 'package:bluebubbles/services/network/backend_service.dart'; import 'package:bluebubbles/utils/window_effects.dart'; import 'package:bluebubbles/app/layouts/conversation_list/pages/conversation_list.dart'; import 'package:bluebubbles/app/layouts/startup/failure_to_start.dart'; @@ -29,7 +28,6 @@ import 'package:flutter/scheduler.dart' hide Priority; import 'package:flutter/services.dart'; import 'package:flutter_acrylic/flutter_acrylic.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:flutter_timezone/flutter_timezone.dart'; import 'package:get/get.dart'; import 'package:google_ml_kit/google_ml_kit.dart' hide Message; @@ -56,6 +54,22 @@ var usingRustPush = true; bool isAuthing = false; final systemTray = st.SystemTray(); +String _renderIncidentId() => Random.secure().nextInt(0x7fffffff).toRadixString(16).padLeft(8, '0'); + +String _redactedRenderContext(FlutterErrorDetails details) { + final contextType = details.context?.runtimeType.toString() ?? "none"; + return "contextType=$contextType"; +} + +void _logRenderError(FlutterErrorDetails details) { + final incidentId = _renderIncidentId(); + Logger.error( + "Render error incident=$incidentId exceptionType=${details.exception.runtimeType} context=${_redactedRenderContext(details)}", + error: details.exception, + trace: details.stack, + ); +} + @pragma('vm:entry-point') //ignore: prefer_void_to_null Future main(List arguments) async { @@ -82,7 +96,7 @@ Future initApp(bool bubble, List arguments) async { StackTrace? stacktrace; FlutterError.onError = (details) { - Logger.error("Rendering Error: ${details.exceptionAsString()}", error: details.exception, trace: details.stack); + _logRenderError(details); }; try { @@ -465,7 +479,8 @@ class _HomeState extends OptimizedState with WidgetsBindingObserver, TrayL } ErrorWidget.builder = (FlutterErrorDetails error) { - Logger.error("An unexpected error occurred when rendering.", error: error.exception, trace: error.stack); + // FlutterError.onError above records the incident. Logging here would + // produce a second incident ID for the same rendering failure. return CustomErrorWidget( "An unexpected error occurred when rendering.", ); diff --git a/lib/services/backend/action_handler.dart b/lib/services/backend/action_handler.dart index d0e8f9faee..15d3498a7f 100644 --- a/lib/services/backend/action_handler.dart +++ b/lib/services/backend/action_handler.dart @@ -32,8 +32,19 @@ class ActionHandler extends GetxService { final RxList> attachmentProgress = >[].obs; final List outOfOrderTempGuids = []; final List handledNewMessages = []; + final Map> _inFlightNewMessages = {}; CancelToken? latestCancelToken; + Future _notifyNewMessageBestEffort(Message message, Chat chat) async { + try { + await MessageHelper.handleNotification(message, chat, findExisting: false); + } catch (_, stack) { + // Notification rendering must never prevent a message from being kept. + // Do not include the message or sender in diagnostics. + Logger.warn("Incoming message notification failed after persistence", tag: "Notification", trace: stack); + } + } + /// Checks if a GUID has been handled. /// After each check, before returning, trim the list of GUIDs to the last 100. bool shouldNotifyForNewMessageGuid(String guid) { @@ -487,6 +498,35 @@ class ActionHandler extends GetxService { } Future handleNewMessage(Chat c, Message m, String? tempGuid, {bool checkExisting = true}) async { + final key = m.guid; + final existingFlight = key == null ? null : _inFlightNewMessages[key]; + if (existingFlight != null) { + try { + await existingFlight; + } catch (_) { + // A failed first attempt must not prevent a concurrent fallback from retrying. + } + if (checkExisting && Message.findOne(guid: tempGuid ?? m.guid) != null) { + return await handleUpdatedMessage(c, m, tempGuid, checkExisting: false); + } + } + + if (key == null) { + return await _handleNewMessage(c, m, tempGuid, checkExisting: checkExisting); + } + + final flight = _handleNewMessage(c, m, tempGuid, checkExisting: checkExisting); + _inFlightNewMessages[key] = flight; + try { + await flight; + } finally { + if (identical(_inFlightNewMessages[key], flight)) { + _inFlightNewMessages.remove(key); + } + } + } + + Future _handleNewMessage(Chat c, Message m, String? tempGuid, {bool checkExisting = true}) async { Logger.info("handling new ${m.id}"); // sanity check if (checkExisting) { @@ -498,7 +538,7 @@ class ActionHandler extends GetxService { } // should have been handled by the sanity check if (tempGuid != null) return; - Logger.info("New message: [${m.text}] - for chat [${c.guid}]", tag: "ActionHandler"); + Logger.debug("Received new message (attachments=${m.hasAttachments})", tag: "ActionHandler"); // Gets the chat from the db or server (if new) c = m.isParticipantEvent ? await handleNewOrUpdatedChat(c) : kIsWeb ? c : (Chat.findOne(guid: c.guid) ?? await handleNewOrUpdatedChat(c)); // Get the message handle @@ -510,11 +550,13 @@ class ActionHandler extends GetxService { Logger.info("Not notifying for already handled new message with GUID ${m.guid}...", tag: "ActionHandler"); } + await c.addMessage(m); + await m.forwardIfNessesary(c, markFailed: true); + // Persistence is complete before notification work begins. Notification + // failures are isolated so they cannot make the transport drop the message. if ((!ls.isAlive || ss.settings.endpointUnifiedPush.value != "") && shouldNotify) { - await MessageHelper.handleNotification(m, c); + unawaited(_notifyNewMessageBestEffort(m, c)); } - await m.forwardIfNessesary(c, markFailed: true); - await c.addMessage(m); } Future handleUpdatedMessage(Chat c, Message m, String? tempGuid, {bool checkExisting = true}) async { diff --git a/lib/services/backend/java_dart_interop/method_channel_service.dart b/lib/services/backend/java_dart_interop/method_channel_service.dart index 6e48850a4d..bdc327e6f8 100644 --- a/lib/services/backend/java_dart_interop/method_channel_service.dart +++ b/lib/services/backend/java_dart_interop/method_channel_service.dart @@ -141,13 +141,12 @@ class MethodChannelService extends GetxService { await Database.waitForInit(); Logger.info("Received new message from MethodChannel"); - // The socket will handle this event if the app is alive and unifiedpush is not enabled + // When the app is backgrounded, FCM is the safe fallback if the optional + // foreground socket is disconnected or still reconnecting. The message + // handler deduplicates by GUID after the first delivery is persisted. if (ls.isAlive && socket.socket.connected && ss.settings.endpointUnifiedPush.value == "") { Logger.debug("App is alive, ignoring new message..."); return Future.value(true); - } else if (!ls.isAlive && ss.settings.keepAppAlive.value) { - Logger.debug("Ignoring FCM message while app is not alive, but keepAppAlive is enabled"); - return Future.value(true); } try { @@ -174,9 +173,6 @@ class MethodChannelService extends GetxService { if (ls.isAlive && socket.socket.connected) { Logger.debug("App is alive, ignoring updated message..."); return Future.value(true); - } else if (!ls.isAlive && ss.settings.keepAppAlive.value) { - Logger.debug("Ignoring FCM message while app is not alive, but keepAppAlive is enabled"); - return Future.value(true); } try { @@ -221,9 +217,6 @@ class MethodChannelService extends GetxService { if (ls.isAlive && socket.socket.connected) { Logger.debug("App is alive, ignoring updated message..."); return Future.value(true); - } else if (!ls.isAlive && ss.settings.keepAppAlive.value) { - Logger.debug("Ignoring FCM message while app is not alive, but keepAppAlive is enabled"); - return Future.value(true); } try { @@ -246,9 +239,6 @@ class MethodChannelService extends GetxService { if (ls.isAlive && socket.socket.connected) { Logger.debug("App is alive, ignoring updated message..."); return Future.value(true); - } else if (!ls.isAlive && ss.settings.keepAppAlive.value) { - Logger.debug("Ignoring FCM message while app is not alive, but keepAppAlive is enabled"); - return Future.value(true); } try { @@ -409,7 +399,7 @@ class MethodChannelService extends GetxService { try { if (!isNullOrEmpty(data)) { final payload = ServerPayload.fromJson(data!); - Logger.info("Alias(es) removed ${payload.data["aliases"]}"); + Logger.info("Alias(es) removed"); await notif.createAliasesRemovedNotification((payload.data["aliases"] as List).cast()); } else { Logger.warn("Aliases removed data empty or null"); diff --git a/lib/services/backend/lifecycle/lifecycle_service.dart b/lib/services/backend/lifecycle/lifecycle_service.dart index b32b87d3c4..bb6880c648 100644 --- a/lib/services/backend/lifecycle/lifecycle_service.dart +++ b/lib/services/backend/lifecycle/lifecycle_service.dart @@ -15,17 +15,23 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:universal_html/html.dart' hide Platform; import 'dart:io' show Platform; -LifecycleService ls = Get.isRegistered() ? Get.find() : Get.put(LifecycleService()); +LifecycleService ls = Get.isRegistered() + ? Get.find() + : Get.put(LifecycleService()); class LifecycleService extends GetxService with WidgetsBindingObserver { bool isBubble = false; bool isUiThread = true; bool windowFocused = true; bool? wasActiveAliveBefore; - bool get isAlive => kIsWeb ? !(window.document.hidden ?? false) - : kIsDesktop ? windowFocused : (WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed - || IsolateNameServer.lookupPortByName('bg_isolate') != null); - + bool get isAlive => kIsWeb + ? !(window.document.hidden ?? false) + : kIsDesktop + ? windowFocused + : (WidgetsBinding.instance.lifecycleState == + AppLifecycleState.resumed || + IsolateNameServer.lookupPortByName('bg_isolate') != null); + bool isDead = false; Timer? closeTimer; @@ -33,9 +39,13 @@ class LifecycleService extends GetxService with WidgetsBindingObserver { List statesSinceLastResume = []; - bool get wasPaused => statesSinceLastResume.contains(AppLifecycleState.paused); - bool get wasHidden => statesSinceLastResume.contains(AppLifecycleState.inactive) || statesSinceLastResume.contains(AppLifecycleState.detached); - bool get hasResumed => statesSinceLastResume.contains(AppLifecycleState.resumed); + bool get wasPaused => + statesSinceLastResume.contains(AppLifecycleState.paused); + bool get wasHidden => + statesSinceLastResume.contains(AppLifecycleState.inactive) || + statesSinceLastResume.contains(AppLifecycleState.detached); + bool get hasResumed => + statesSinceLastResume.contains(AppLifecycleState.resumed); @override void onInit() { @@ -44,7 +54,8 @@ class LifecycleService extends GetxService with WidgetsBindingObserver { } Future init({bool headless = false, bool isBubble = false}) async { - Logger.debug("Initializing LifecycleService${headless ? " in headless mode" : ""}"); + Logger.debug( + "Initializing LifecycleService${headless ? " in headless mode" : ""}"); isUiThread = !headless; this.isBubble = isBubble; @@ -65,9 +76,11 @@ class LifecycleService extends GetxService with WidgetsBindingObserver { Logger.debug("App State changed to $state"); // If the current state is resume, and we've already had a resume, remove all states up to the last resume. - if (state == AppLifecycleState.resumed && statesSinceLastResume.contains(AppLifecycleState.resumed)) { + if (state == AppLifecycleState.resumed && + statesSinceLastResume.contains(AppLifecycleState.resumed)) { // Remove states up to the last resume - while (statesSinceLastResume.isNotEmpty && statesSinceLastResume.first != AppLifecycleState.resumed) { + while (statesSinceLastResume.isNotEmpty && + statesSinceLastResume.first != AppLifecycleState.resumed) { statesSinceLastResume.removeAt(0); } } else { @@ -78,8 +91,11 @@ class LifecycleService extends GetxService with WidgetsBindingObserver { await Database.waitForInit(); open(); } else if (state != AppLifecycleState.inactive) { - SystemChannels.textInput.invokeMethod('TextInput.hide').catchError((e, stack) { - Logger.error("Error caught while hiding keyboard!", error: e, trace: stack); + SystemChannels.textInput + .invokeMethod('TextInput.hide') + .catchError((e, stack) { + Logger.error("Error caught while hiding keyboard!", + error: e, trace: stack); }); if (isBubble) { closeBubble(); @@ -97,7 +113,10 @@ class LifecycleService extends GetxService with WidgetsBindingObserver { // is not started when in headless mode. if (!isUiThread) return; - if ([AppLifecycleState.inactive, AppLifecycleState.hidden].contains(state)) return; + if ([AppLifecycleState.inactive, AppLifecycleState.hidden] + .contains(state)) { + return; + } // This may get called before the settings service is initialized SharedPreferences prefs = await SharedPreferences.getInstance(); @@ -108,7 +127,8 @@ class LifecycleService extends GetxService with WidgetsBindingObserver { if (state == AppLifecycleState.resumed) { Logger.info(tag: "LifecycleService", "Stopping foreground service"); mcs.invokeMethod("stop-foreground-service"); - } else if ([AppLifecycleState.paused, AppLifecycleState.detached].contains(state)) { + } else if ([AppLifecycleState.paused, AppLifecycleState.detached] + .contains(state)) { Logger.info(tag: "LifecycleService", "Starting foreground service"); mcs.invokeMethod("start-foreground-service"); } @@ -144,7 +164,7 @@ class LifecycleService extends GetxService with WidgetsBindingObserver { if (!isBubble) { createFakePort(); } - + socket.reconnect(); } @@ -174,8 +194,12 @@ class LifecycleService extends GetxService with WidgetsBindingObserver { socket.disconnect(); } if (cm.activeChat != null) { - ConversationViewController _cvc = cvc(cm.activeChat!.chat); - _cvc.lastFocusedNode.unfocus(); + final chat = cm.activeChat!.chat; + if (Get.isRegistered(tag: chat.guid)) { + final controller = Get.find(tag: chat.guid); + controller.lastFocusedNode.unfocus(); + controller.pauseMediaPlayers(); + } } if (kIsDesktop) { windowFocused = false; @@ -186,4 +210,4 @@ class LifecycleService extends GetxService with WidgetsBindingObserver { cm.setActiveToDead(); socket.disconnect(); } -} \ No newline at end of file +} diff --git a/lib/services/backend/notifications/notifications_service.dart b/lib/services/backend/notifications/notifications_service.dart index b042d45c9e..405ae21d8c 100644 --- a/lib/services/backend/notifications/notifications_service.dart +++ b/lib/services/backend/notifications/notifications_service.dart @@ -51,10 +51,13 @@ class NotificationsService extends GetxService { final FlutterLocalNotificationsPlugin flnp = FlutterLocalNotificationsPlugin(); StreamSubscription? countSub; int currentCount = 0; + Timer? relayReminderTimer; + Future? _initializationFuture; /// For desktop use only static LocalNotification? allToast; static LocalNotification? failedToast; + static LocalNotification? relayToast; static LocalNotification? socketToast; static LocalNotification? aliasesToast; static Map> notifications = {}; @@ -68,7 +71,11 @@ class NotificationsService extends GetxService { bool get hideContent => ss.settings.hideTextPreviews.value; - Future init() async { + Future init() { + return _initializationFuture ??= _init(); + } + + Future _init() async { if (!kIsWeb && !kIsDesktop) { const AndroidInitializationSettings initializationSettingsAndroid = AndroidInitializationSettings('ic_stat_icon'); const InitializationSettings initializationSettings = InitializationSettings(android: initializationSettingsAndroid); @@ -169,6 +176,8 @@ class NotificationsService extends GetxService { @override void onClose() { countSub?.cancel(); + relayReminderTimer?.cancel(); + relayReminderTimer = null; super.onClose(); } @@ -1070,6 +1079,77 @@ class NotificationsService extends GetxService { ); } + Future cancelRelayCheckReminder() async { + relayReminderTimer?.cancel(); + relayReminderTimer = null; + + if (kIsDesktop) { + await relayToast?.close(); + relayToast = null; + return; + } + if (!kIsWeb) { + await flnp.cancel(-7 - 50); + } + } + + Future scheduleRelayCheckReminder(DateTime time) async { + // Relay registration can finish before startup notification tasks. + await init(); + await cancelRelayCheckReminder(); + + const title = "Check your iPhone relay"; + const subtitle = + "Phone number registration renews soon. Tap to verify that the relay is online."; + if (kIsDesktop) { + final delay = time.difference(DateTime.now()); + relayReminderTimer = + Timer(delay.isNegative ? Duration.zero : delay, () async { + relayToast = LocalNotification( + title: title, + body: subtitle, + actions: [], + ); + + relayToast!.onClick = () async { + relayToast = null; + await windowManager.show(); + if (ss.settings.finishedSetup.value) { + ns.pushLeft(Get.context!, ProfilePanel()); + } + }; + + await relayToast!.show(); + }); + return; + } + if (kIsWeb) { + return; + } + + await flnp.zonedSchedule( + -7 - 50, + title, + subtitle, + TZDateTime.from(time, local), + NotificationDetails( + android: AndroidNotificationDetails( + ERROR_CHANNEL, + "Errors", + channelDescription: + "Displays message send failures, connection failures, and more", + priority: Priority.max, + importance: Importance.max, + color: HexColor("4990de"), + ), + ), + payload: "-51", + androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle, + uiLocalNotificationDateInterpretation: + UILocalNotificationDateInterpretation.absoluteTime, + ); + } + Future createSubscriptionFailed() async { const title = "Your subscription is no longer active!"; const subtitle = diff --git a/lib/services/backend/queue/queue_impl.dart b/lib/services/backend/queue/queue_impl.dart index 38e7868de1..d76f731467 100644 --- a/lib/services/backend/queue/queue_impl.dart +++ b/lib/services/backend/queue/queue_impl.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:isolate'; import 'package:bluebubbles/helpers/helpers.dart'; import 'package:bluebubbles/database/models.dart'; @@ -10,72 +9,88 @@ import 'package:get/get.dart'; abstract class Queue extends GetxService { RxBool isProcessing = false.obs; List items = []; + bool _runnerActive = false; Future queue(QueueItem item, {bool prep = true}) async { - if (prep) { - final returned = await prepItem(item); - // we may get a link split into 2 messages - if (item is OutgoingItem && returned is List) { - items.addAll(returned.map((e) => OutgoingItem( - type: item.type, - chat: item.chat, - message: e, - completer: item.completer, - selected: item.selected, - reaction: item.reaction, - ))); + try { + if (prep) { + final returned = await prepItem(item); + // we may get a link split into 2 messages + if (item is OutgoingItem && returned is List) { + items.addAll(returned.map((e) => OutgoingItem( + type: item.type, + chat: item.chat, + message: e, + completer: item.completer, + selected: item.selected, + reaction: item.reaction, + ))); + } else { + items.add(item); + } } else { items.add(item); } - } else { - items.add(item); + } catch (ex, stacktrace) { + if (item.completer != null && !item.completer!.isCompleted) { + item.completer!.completeError(ex, stacktrace); + } + rethrow; } - if (!isProcessing.value || (items.isEmpty && item is IncomingItem)) processNextItem(); + _startRunner(); } Future prepItem(QueueItem _); + void _startRunner() { + if (_runnerActive) return; + _runnerActive = true; + unawaited(processNextItem()); + } + Future processNextItem() async { - if (items.isEmpty) { + isProcessing.value = true; + try { + while (items.isNotEmpty) { + ls.closeTimer?.cancel(); + ls.closeTimer = null; + + final queued = items.removeAt(0); + try { + await handleQueueItem(queued); + if (queued.completer != null && !queued.completer!.isCompleted) { + queued.completer!.complete(); + } + } catch (ex, stacktrace) { + Logger.error("Failed to handle queued item!", error: ex, trace: stacktrace); + if (queued is OutgoingItem && ss.settings.cancelQueuedMessages.value) { + final toCancel = List.from(items.whereType().where((e) => e.chat.guid == queued.chat.guid)); + for (final i in toCancel) { + items.remove(i); + final m = i.message; + final tempGuid = m.guid; + m.guid = m.guid!.replaceAll("temp", "error-Canceled due to previous failure"); + m.error = MessageError.BAD_REQUEST.code; + Message.replaceMessage(tempGuid, m); + } + } + if (queued.completer != null && !queued.completer!.isCompleted) { + queued.completer!.completeError(ex, stacktrace); + } + } + } + } finally { isProcessing.value = false; + _runnerActive = false; + if (items.isNotEmpty) _startRunner(); if (ls.isDead && !inq.isProcessing.value && !outq.isProcessing.value) { Logger.info("Done! waiting a bit for any stragglers"); ls.closeTimer = Timer(const Duration(seconds: 5), () { mcs.invokeMethod("engine-done"); }); } - return; - } - - ls.closeTimer?.cancel(); - ls.closeTimer = null; - - isProcessing.value = true; - QueueItem queued = items.removeAt(0); - - try { - await handleQueueItem(queued).catchError((err, trace) async { - Logger.error("Failed to handle queued item!", error: err, trace: trace); - if (queued is OutgoingItem && ss.settings.cancelQueuedMessages.value) { - final toCancel = List.from(items.whereType().where((e) => e.chat.guid == queued.chat.guid)); - for (OutgoingItem i in toCancel) { - items.remove(i); - final m = i.message; - final tempGuid = m.guid; - m.guid = m.guid!.replaceAll("temp", "error-Canceled due to previous failure"); - m.error = MessageError.BAD_REQUEST.code; - Message.replaceMessage(tempGuid, m); - } - } - }); - queued.completer?.complete(); - } catch (ex, stacktrace) { - Logger.error("Failed to handle queued item!", error: ex, trace: stacktrace); - queued.completer?.completeError(ex); } - - await processNextItem(); } Future handleQueueItem(QueueItem _); -} \ No newline at end of file +} diff --git a/lib/services/network/downloads_service.dart b/lib/services/network/downloads_service.dart index 8726a747ba..a44049d3df 100644 --- a/lib/services/network/downloads_service.dart +++ b/lib/services/network/downloads_service.dart @@ -1,11 +1,9 @@ import 'package:bluebubbles/services/network/backend_service.dart'; -import 'package:bluebubbles/utils/file_utils.dart'; import 'package:bluebubbles/utils/logger/logger.dart'; import 'package:bluebubbles/helpers/helpers.dart'; import 'package:bluebubbles/database/models.dart'; import 'package:bluebubbles/services/services.dart'; import 'package:collection/collection.dart'; -import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:get/get.dart' hide Response; import 'package:path/path.dart'; @@ -24,11 +22,13 @@ class AttachmentDownloadService extends GetxService { return _downloaders.values.flattened.firstWhereOrNull((element) => element.attachment.guid == guid); } - AttachmentDownloadController startDownload(Attachment a, {Function(PlatformFile)? onComplete, Function? onError}) { + AttachmentDownloadController startDownload(Attachment a, + {Function(PlatformFile)? onComplete, Function? onError, bool prioritized = false}) { return Get.put(AttachmentDownloadController( attachment: a, onComplete: onComplete, onError: onError, + prioritized: prioritized, ), tag: a.guid!); } @@ -36,7 +36,11 @@ class AttachmentDownloadService extends GetxService { downloaders.add(downloader.attachment.guid!); final chatGuid = downloader.attachment.message.target?.chat.target?.guid ?? "unknown"; if (_downloaders.containsKey(chatGuid)) { - _downloaders[chatGuid]!.add(downloader); + if (downloader.prioritized) { + _downloaders[chatGuid]!.insert(0, downloader); + } else { + _downloaders[chatGuid]!.add(downloader); + } } else { _downloaders[chatGuid] = [downloader]; } @@ -52,6 +56,15 @@ class AttachmentDownloadService extends GetxService { _fetchNext(); } + void prioritize(AttachmentDownloadController downloader) { + if (downloader.isFetching) return; + final chatGuid = downloader.attachment.message.target?.chat.target?.guid ?? "unknown"; + final queue = _downloaders[chatGuid]; + if (queue == null || !queue.remove(downloader)) return; + queue.insert(0, downloader); + _fetchNext(); + } + void _fetchNext() { if (_downloaders.values.flattened.where((e) => e.isFetching).length < maxDownloads) { AttachmentDownloadController? activeChatDownloader; @@ -75,6 +88,7 @@ class AttachmentDownloadController extends GetxController { final RxnNum progress = RxnNum(); final Rxn file = Rxn(); final RxBool error = RxBool(false); + final bool prioritized; Stopwatch stopwatch = Stopwatch(); bool isFetching = false; @@ -82,6 +96,7 @@ class AttachmentDownloadController extends GetxController { required this.attachment, Function(PlatformFile)? onComplete, Function? onError, + this.prioritized = false, }) { if (onComplete != null) completeFuncs.add(onComplete); if (onError != null) errorFuncs.add(onError); diff --git a/lib/services/network/firebase/cloud_messaging_service.dart b/lib/services/network/firebase/cloud_messaging_service.dart index fcddbeea46..9af4c8f2b5 100644 --- a/lib/services/network/firebase/cloud_messaging_service.dart +++ b/lib/services/network/firebase/cloud_messaging_service.dart @@ -47,7 +47,7 @@ class CloudMessagingService extends GetxService { // If we've already got a token, re-register with this token if (!isNullOrEmpty(token)) { - Logger.debug("Already authorized FCM device! Token: $token", tag: 'FCM-Auth'); + Logger.debug("Already authorized FCM device", tag: 'FCM-Auth'); Logger.info('Registering device with server...', tag: 'FCM-Auth'); String deviceName = await getDeviceName(); await http.addFcmDevice(deviceName.trim(), token!.trim()).then((_) { @@ -55,7 +55,7 @@ class CloudMessagingService extends GetxService { completer?.complete(); }).catchError((ex) { completer?.completeError(ex); - throw Exception("Failed to add FCM device to the server! Token: $token, ${ex.toString()}"); + throw Exception("Failed to add FCM device to the server: ${ex.toString()}"); }); closeCompleter = true; } @@ -135,7 +135,7 @@ class CloudMessagingService extends GetxService { completer?.complete(); }).catchError((ex) { completer?.completeError(ex); - throw Exception("Failed to add FCM device to the server! Token: $token, ${ex.toString()}"); + throw Exception("Failed to add FCM device to the server: ${ex.toString()}"); }); } } diff --git a/lib/services/network/firebase/firebase_database_service.dart b/lib/services/network/firebase/firebase_database_service.dart index 35c7798f59..b8c9d3ccf2 100644 --- a/lib/services/network/firebase/firebase_database_service.dart +++ b/lib/services/network/firebase/firebase_database_service.dart @@ -55,7 +55,7 @@ class FirebaseDatabaseService extends GetxService { } /// Fetch the new server URL from the Firebase Database - Future fetchNewUrl() async { + Future fetchNewUrl({bool restartSocket = true, bool tryRestartForegroundService = true}) async { // Make sure setup is complete and we have valid data if (!ss.settings.finishedSetup.value) return null; if (ss.fcmData.isNull) { @@ -104,7 +104,12 @@ class FirebaseDatabaseService extends GetxService { url = sanitizeServerAddress(address: await mcs.invokeMethod("get-server-url")); } - await saveNewServerUrl(url ?? ss.settings.serverAddress.value, force: true); + await saveNewServerUrl( + url ?? ss.settings.serverAddress.value, + force: true, + restartSocket: restartSocket, + tryRestartForegroundService: tryRestartForegroundService, + ); return url; } catch (e, s) { Logger.error("Failed to fetch URL!", error: e, trace: s); diff --git a/lib/services/network/socket_service.dart b/lib/services/network/socket_service.dart index 62f56a1903..f0676ca0dd 100644 --- a/lib/services/network/socket_service.dart +++ b/lib/services/network/socket_service.dart @@ -26,6 +26,9 @@ class SocketService extends GetxService { SocketState _lastState = SocketState.disconnected; RxString lastError = "".obs; Timer? _reconnectTimer; + int _connectionGeneration = 0; + int _reconnectEpoch = 0; + int _reconnectAttempt = 0; late Socket socket; String get serverAddress => http.origin; @@ -55,29 +58,33 @@ class SocketService extends GetxService { } void startSocket() { + _cancelReconnect(); + final generation = ++_connectionGeneration; OptionBuilder options = OptionBuilder() .setQuery({"guid": password}) .setTransports(['websocket', 'polling']) .setExtraHeaders(http.headers) // Disable so that we can create the listeners first .disableAutoConnect() - .enableReconnection(); + // Reconnection is owned here so that URL refresh and socket creation + // cannot race the Socket.IO manager's own retry loop. + .disableReconnection(); socket = io(serverAddress, options.build()); // placed here so that [socket] is still initialized if (isNullOrEmpty(serverAddress)) return; - socket.onConnect((data) => handleStatusUpdate(SocketState.connected, data)); - socket.onReconnect((data) => handleStatusUpdate(SocketState.connected, data)); + socket.onConnect((data) => handleStatusUpdate(SocketState.connected, data, generation: generation)); + socket.onReconnect((data) => handleStatusUpdate(SocketState.connected, data, generation: generation)); - socket.onReconnectAttempt((data) => handleStatusUpdate(SocketState.connecting, data)); - socket.onReconnecting((data) => handleStatusUpdate(SocketState.connecting, data)); - socket.onConnecting((data) => handleStatusUpdate(SocketState.connecting, data)); + socket.onReconnectAttempt((data) => handleStatusUpdate(SocketState.connecting, data, generation: generation)); + socket.onReconnecting((data) => handleStatusUpdate(SocketState.connecting, data, generation: generation)); + socket.onConnecting((data) => handleStatusUpdate(SocketState.connecting, data, generation: generation)); - socket.onDisconnect((data) => handleStatusUpdate(SocketState.disconnected, data)); + socket.onDisconnect((data) => handleStatusUpdate(SocketState.disconnected, data, generation: generation)); - socket.onConnectError((data) => handleStatusUpdate(SocketState.error, data)); - socket.onConnectTimeout((data) => handleStatusUpdate(SocketState.error, data)); - socket.onError((data) => handleStatusUpdate(SocketState.error, data)); + socket.onConnectError((data) => handleStatusUpdate(SocketState.error, data, generation: generation)); + socket.onConnectTimeout((data) => handleStatusUpdate(SocketState.error, data, generation: generation)); + socket.onError((data) => handleStatusUpdate(SocketState.error, data, generation: generation)); // custom events // only listen to these events from socket on web/desktop (FCM handles on Android) @@ -100,6 +107,8 @@ class SocketService extends GetxService { } void disconnect() { + _cancelReconnect(); + _connectionGeneration++; if (isNullOrEmpty(serverAddress)) return; socket.disconnect(); state.value = SocketState.disconnected; @@ -107,11 +116,14 @@ class SocketService extends GetxService { void reconnect() { if (state.value == SocketState.connected || isNullOrEmpty(serverAddress)) return; + _cancelReconnect(); state.value = SocketState.connecting; socket.connect(); } void closeSocket() { + _cancelReconnect(); + _connectionGeneration++; if (isNullOrEmpty(serverAddress)) return; socket.dispose(); state.value = SocketState.disconnected; @@ -144,7 +156,8 @@ class SocketService extends GetxService { return completer.future; } - void handleStatusUpdate(SocketState status, dynamic data) { + void handleStatusUpdate(SocketState status, dynamic data, {int? generation}) { + if (generation != null && generation != _connectionGeneration) return; if (_lastState == status) return; _lastState = status; @@ -153,12 +166,15 @@ class SocketService extends GetxService { state.value = SocketState.connected; _reconnectTimer?.cancel(); _reconnectTimer = null; + _reconnectEpoch++; + _reconnectAttempt = 0; NetworkTasks.onConnect(); notif.clearSocketError(); return; case SocketState.disconnected: Logger.info("Disconnected from socket..."); state.value = SocketState.disconnected; + _scheduleReconnect(); return; case SocketState.connecting: Logger.info("Connecting to socket..."); @@ -172,25 +188,48 @@ class SocketService extends GetxService { } state.value = SocketState.error; - // After 5 seconds of an error, we should retry the connection - _reconnectTimer = Timer(const Duration(seconds: 5), () async { - if (state.value == SocketState.connected) return; - - await fdb.fetchNewUrl(); - restartSocket(); - - if (state.value == SocketState.connected) return; - - if (!ss.settings.keepAppAlive.value) { - notif.createSocketError(); - } - }); + _scheduleReconnect(); return; default: return; } } + void _cancelReconnect() { + _reconnectTimer?.cancel(); + _reconnectTimer = null; + _reconnectEpoch++; + } + + void _scheduleReconnect() { + if (_reconnectTimer != null || state.value == SocketState.connected || isNullOrEmpty(serverAddress)) return; + + final epoch = _reconnectEpoch; + final generation = _connectionGeneration; + final attempt = _reconnectAttempt > 3 ? 3 : _reconnectAttempt; + final seconds = 5 * (1 << attempt); + if (_reconnectAttempt < 3) _reconnectAttempt++; + _reconnectTimer = Timer(Duration(seconds: seconds), () async { + _reconnectTimer = null; + if (epoch != _reconnectEpoch || generation != _connectionGeneration || state.value == SocketState.connected) return; + + try { + await fdb.fetchNewUrl(restartSocket: false, tryRestartForegroundService: false); + } catch (e, s) { + Logger.warn("Failed to refresh socket URL before reconnect", error: e, trace: s); + _scheduleReconnect(); + return; + } + + if (epoch != _reconnectEpoch || generation != _connectionGeneration) return; + restartSocket(); + + if (state.value != SocketState.connected && !ss.settings.keepAppAlive.value) { + notif.createSocketError(); + } + }); + } + void handleSocketException(SocketException e) { String msg = e.message; if (msg.contains("Failed host lookup")) { diff --git a/lib/services/rustpush/rustpush_service.dart b/lib/services/rustpush/rustpush_service.dart index 6deb3c1e42..5c6de9bdff 100644 --- a/lib/services/rustpush/rustpush_service.dart +++ b/lib/services/rustpush/rustpush_service.dart @@ -51,6 +51,7 @@ import 'package:mixpanel_flutter/mixpanel_flutter.dart'; import 'package:bluebubbles/helpers/backend/startup_tasks.dart'; import 'package:flutter_isolate/flutter_isolate.dart'; import 'package:google_sign_in_all_platforms/google_sign_in_all_platforms.dart'; +import 'package:synchronized/synchronized.dart'; var uuid = const Uuid(); RustPushService pushService = @@ -58,10 +59,17 @@ RustPushService pushService = const rpApiRoot = "https://hw.openbubbles.app/code"; +const registrationRelayHost = "https://registration-relay.beeper.com"; +const registrationRelayAccessToken = + "5c175851953ecaf5209185d897591badb6c3e712"; const clientId = '1041242226917-ik21n86fp43e82iu1e5soh6bu6gvuste.apps.googleusercontent.com'; const clientSecret = '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'; +String _diagnosticHash(String value) => sha256.convert(utf8.encode(value)).toString().substring(0, 12); + +String _durationMs(Stopwatch stopwatch) => stopwatch.elapsedMilliseconds.toString(); + class SyncIsolate { static void initialize() { @@ -398,22 +406,69 @@ class RustPushBackend implements BackendService { return const api.MessageType.iMessage(); } - Future sendMsg(api.MessageInst msg) async { + static final RegExp resourceRetryRegex = RegExp(r"retrying in (\d+)s"); + static const maxResourceWait = Duration(seconds: 35); + + /// rustpush's ResourceManager hands the *cached* failure to every caller for as long as + /// it is backing off, so anything we do inside that window fails instantly without ever + /// touching the network (e.g. the APNs socket got reset and won't be redialed for another + /// 30s). Those aren't real failures, they just mean "not yet" -- so return how long the + /// resource wants before it tries again, or null if the error isn't worth waiting on. + Duration? resourceRetryWait(Object e) { + if (e is! AnyhowException) return null; + // "not retrying" means the resource gave up entirely, nothing to wait for. Note that a + // "Do not retry" prefix is *not* the same thing; that only means the caller (e.g. an IDS + // lookup) already burned its own immediate retries, the resource itself is still coming back. + if (!e.message.contains("Failed to generate resource")) return null; + if (e.message.contains("not retrying")) return null; + var seconds = int.tryParse(resourceRetryRegex.firstMatch(e.message)?.group(1) ?? ""); + if (seconds == null) return null; + var wait = Duration(seconds: seconds); + return wait > maxResourceWait ? maxResourceWait : wait; + } + + /// How long we are willing to wait on a reconnecting resource before giving up on a send. + /// Kept under the 5 minute timeout ActionHandler puts on sends. + static const sendRetryBudget = Duration(minutes: 3); + static const sendTimeoutRetryWait = Duration(seconds: 2); + static const maxSendTimeoutRetries = 1; + + Future sendMsg(api.MessageInst msg, {bool waitForResource = true}) async { var message = Message.findOne(guid: msg.id); if (message != null) { message.sendingServiceId = pushService.serviceId; message.save(updateSendingServiceId: true); } var stillRunning = false; + var waited = Duration.zero; + var sendTimeoutRetries = 0; try { - stillRunning = await api.send(state: pushService.state!.client, local: pushService.state!.localBroadcast, msg: msg); - } catch (e) { - if (e is AnyhowException) { - if (e.message.contains("Failed to generate resource") && e.message.contains("not retrying")) { - pushService.markFailedToLogin(); + while (true) { + try { + stillRunning = await api.send(state: pushService.state!.client, local: pushService.state!.localBroadcast, msg: msg); + break; + } catch (e) { + if (e is AnyhowException) { + if (e.message.contains("Failed to generate resource") && e.message.contains("not retrying")) { + pushService.markFailedToLogin(); + rethrow; + } + if (e.message.contains("Send timeout; try again") && sendTimeoutRetries < maxSendTimeoutRetries) { + sendTimeoutRetries++; + Logger.warn("Send confirmation timed out; retrying once after the push connection reload"); + await Future.delayed(sendTimeoutRetryWait); + continue; + } + } + var wait = waitForResource ? resourceRetryWait(e) : null; + // add a second so we retry *after* rustpush has had a chance to regenerate + if (wait != null) wait += const Duration(seconds: 1); + if (wait == null || waited + wait > sendRetryBudget) rethrow; + Logger.warn("Connection isn't ready; retrying ${msg.id} in ${wait.inSeconds}s ($e)"); + await Future.delayed(wait); + waited += wait; } } - rethrow; } finally { if (!stillRunning) { message = Message.findOne(guid: msg.id); @@ -1279,7 +1334,7 @@ class RustPushBackend implements BackendService { icon: base64Decode(appdata.appIcon!), ) : null) ); - await sendMsg(msg); + await sendMsg(msg, waitForResource: false); // a typing indicator is worthless by the time we reconnect } @override @@ -1290,7 +1345,7 @@ class RustPushBackend implements BackendService { sender: await c.ensureHandle(), message: const api.Message.typing(false) ); - await sendMsg(msg); + await sendMsg(msg, waitForResource: false); } @override @@ -1313,6 +1368,234 @@ class RustPushService extends GetxService { var disableOutgoingSms = false; + final RxBool relayHealthChecking = false.obs; + final RxBool relayHealthAvailable = false.obs; + final RxnBool relayReachable = RxnBool(); + final Rxn relayLastChecked = Rxn(); + final Rxn relayLastSuccess = Rxn(); + Future? _relayHealthInFlight; + String? _relayHealthFingerprint; + final Lock _relayReminderLock = Lock(); + + Future getUserManagedIPhoneRelayDevice( + {api.SharedPushState? fromState}) async { + final currentState = fromState ?? state; + if (currentState == null || ss.settings.deviceIsHosted.value) { + return null; + } + + final device = + await api.getDeviceInfo(config: currentState.osConfig); + if (device.name.contains("iPhone") || + device.name.contains("iPod") || + device.name.contains("iPad")) { + return device; + } + return null; + } + + String relayHealthFingerprint(api.DeviceInfo device) { + final relayHost = ss.prefs.getString("registration-relay-host") ?? + registrationRelayHost; + final fingerprintSource = + "${device.serial}|${ss.settings.iCloudAccount.value}|$relayHost"; + return sha256.convert(utf8.encode(fingerprintSource)).toString(); + } + + Future clearRelayHealthState({bool clearPreferences = true}) async { + relayHealthAvailable.value = false; + relayReachable.value = null; + relayLastChecked.value = null; + relayLastSuccess.value = null; + _relayHealthFingerprint = null; + if (!clearPreferences) { + return; + } + + await Future.wait([ + ss.prefs.remove("relay-health-fingerprint"), + ss.prefs.remove("relay-health-last-checked"), + ss.prefs.remove("relay-health-last-success"), + ss.prefs.remove("relay-health-reachable"), + ]); + } + + Future restoreRelayHealthState() async { + final device = await getUserManagedIPhoneRelayDevice(); + if (device == null) { + await clearRelayHealthState(); + return; + } + + final fingerprint = relayHealthFingerprint(device); + relayHealthAvailable.value = true; + final savedFingerprint = + ss.prefs.getString("relay-health-fingerprint"); + if (savedFingerprint != fingerprint) { + await clearRelayHealthState(); + relayHealthAvailable.value = true; + _relayHealthFingerprint = fingerprint; + await ss.prefs.setString( + "relay-health-fingerprint", fingerprint); + return; + } + + _relayHealthFingerprint = fingerprint; + final lastChecked = ss.prefs.getInt("relay-health-last-checked"); + final lastSuccess = ss.prefs.getInt("relay-health-last-success"); + relayReachable.value = ss.prefs.getBool("relay-health-reachable"); + relayLastChecked.value = lastChecked == null + ? null + : DateTime.fromMillisecondsSinceEpoch(lastChecked); + relayLastSuccess.value = lastSuccess == null + ? null + : DateTime.fromMillisecondsSinceEpoch(lastSuccess); + } + + Future usesUserManagedIPhoneRelay() async { + return await getUserManagedIPhoneRelayDevice() != null; + } + + Future checkRelayHealth() async { + final existingCheck = _relayHealthInFlight; + if (existingCheck != null) { + return await existingCheck; + } + + final check = _performRelayHealthCheck(); + _relayHealthInFlight = check; + try { + return await check; + } finally { + if (identical(_relayHealthInFlight, check)) { + _relayHealthInFlight = null; + } + } + } + + Future _performRelayHealthCheck() async { + final currentState = state; + if (currentState == null) { + return null; + } + + api.DeviceInfo? relayDevice; + try { + relayDevice = await getUserManagedIPhoneRelayDevice( + fromState: currentState); + } catch (e, s) { + Logger.warn("Failed to identify iPhone relay", + error: e, trace: s); + return null; + } + if (relayDevice == null) { + return null; + } + relayHealthAvailable.value = true; + + final fingerprint = relayHealthFingerprint(relayDevice); + if (_relayHealthFingerprint != fingerprint) { + await clearRelayHealthState(); + relayHealthAvailable.value = true; + _relayHealthFingerprint = fingerprint; + await ss.prefs.setString( + "relay-health-fingerprint", fingerprint); + } + + relayHealthChecking.value = true; + final checkedAt = DateTime.now(); + relayLastChecked.value = checkedAt; + await ss.prefs.setInt( + "relay-health-last-checked", checkedAt.millisecondsSinceEpoch); + + try { + final relayCode = + await api.validateRelay(configRef: currentState.osConfig); + var reachable = false; + if (relayCode != null) { + final relayHost = + ss.prefs.getString("registration-relay-host") ?? + registrationRelayHost; + final response = await http.dio.post( + "$relayHost/api/v1/bridge/get-version-info", + data: {}, + options: Options( + headers: { + "X-Beeper-Access-Token": + registrationRelayAccessToken, + "Authorization": "Bearer $relayCode", + }, + ), + ); + final responseData = response.data; + final versions = + responseData is Map ? responseData["versions"] : null; + reachable = response.statusCode == 200 && + versions is Map && + versions["software_name"] == "iPhone OS"; + } + + relayReachable.value = reachable; + await ss.prefs.setBool("relay-health-reachable", reachable); + + if (reachable) { + relayLastSuccess.value = checkedAt; + await ss.prefs.setInt( + "relay-health-last-success", checkedAt.millisecondsSinceEpoch); + } + + return reachable; + } catch (e, s) { + relayReachable.value = false; + await ss.prefs.setBool("relay-health-reachable", false); + Logger.warn("iPhone relay health check failed", error: e, trace: s); + return false; + } finally { + relayHealthChecking.value = false; + } + } + + Future scheduleRelayHealthReminder( + int secondsUntilRenewal) async { + await _relayReminderLock.synchronized(() async { + await notif.cancelRelayCheckReminder(); + final currentState = state; + if (currentState == null) { + return; + } + try { + if (await getUserManagedIPhoneRelayDevice( + fromState: currentState) == + null) { + return; + } + if ((await api.getMyPhoneHandles( + state: currentState.client)) + .isEmpty) { + return; + } + } catch (e, s) { + Logger.warn("Failed to schedule iPhone relay reminder", + error: e, trace: s); + return; + } + if (!identical(state, currentState)) { + return; + } + + const warningLeadTime = Duration(minutes: 15); + final delaySeconds = + max(10, secondsUntilRenewal - warningLeadTime.inSeconds); + await notif.scheduleRelayCheckReminder( + DateTime.now().add(Duration(seconds: delaySeconds))); + }); + } + + Future cancelRelayHealthReminder() async { + await _relayReminderLock.synchronized( + () => notif.cancelRelayCheckReminder()); + } + Map attachments = {}; Future> doValidateTargets(List targets, String handle) async { @@ -1349,6 +1632,11 @@ class RustPushService extends GetxService { } Future updateChatParticipants(Chat c, api.MessageInst myMsg, List oldParticipants, List newParticipants) async { + final sender = myMsg.sender; + if (sender == null || sender.isEmpty) { + Logger.warn("Ignoring participant update without a sender"); + return; + } var myHandles = await api.getHandles(state: pushService.state!.client); var newP = newParticipants.filter((p) => !oldParticipants.contains(p) && !myHandles.contains(p)); var delP = oldParticipants.filter((p) => !newParticipants.contains(p)); @@ -1367,8 +1655,8 @@ class RustPushService extends GetxService { var bb = RustPushBBUtils.rustHandleToBB(item); var msg = Message( guid: useId ? myMsg.id : uuid.v4(), - isFromMe: myHandles.contains(myMsg.sender), - handleId: RustPushBBUtils.rustHandleToBB(myMsg.sender!).originalROWID!, + isFromMe: myHandles.contains(sender), + handleId: RustPushBBUtils.rustHandleToBB(sender).originalROWID!, dateCreated: DateTime.fromMillisecondsSinceEpoch(myMsg.sentTimestamp), itemType: 1, groupActionType: 0, @@ -1384,11 +1672,11 @@ class RustPushService extends GetxService { for (var item in delP) { var bb = RustPushBBUtils.rustHandleToBB(item); - var personDidLeave = item == myMsg.sender; + var personDidLeave = item == sender; var msg = Message( guid: useId ? myMsg.id : uuid.v4(), - isFromMe: myHandles.contains(myMsg.sender), - handleId: RustPushBBUtils.rustHandleToBB(myMsg.sender!).originalROWID!, + isFromMe: myHandles.contains(sender), + handleId: RustPushBBUtils.rustHandleToBB(sender).originalROWID!, dateCreated: DateTime.fromMillisecondsSinceEpoch(myMsg.sentTimestamp), itemType: personDidLeave ? 3 : 1, groupActionType: personDidLeave ? 0 : 1, @@ -1681,15 +1969,16 @@ class RustPushService extends GetxService { return msg; } else if (myMsg.message is api.Message_RenameMessage) { var msg = myMsg.message as api.Message_RenameMessage; - if (myMsg.verificationFailed) return null; + final sender = myMsg.sender; + if (myMsg.verificationFailed || chat == null || sender == null || sender.isEmpty) return null; - chat!.ckSyncState = false; + chat.ckSyncState = false; chat.save(updateCkSyncState: true); return Message( guid: myMsg.id, - isFromMe: myHandles.contains(myMsg.sender), - handleId: RustPushBBUtils.rustHandleToBB(myMsg.sender!).originalROWID!, + isFromMe: myHandles.contains(sender), + handleId: RustPushBBUtils.rustHandleToBB(sender).originalROWID!, dateCreated: DateTime.fromMillisecondsSinceEpoch(myMsg.sentTimestamp), itemType: 2, groupActionType: 2, @@ -1697,15 +1986,18 @@ class RustPushService extends GetxService { ); } else if (myMsg.message is api.Message_ChangeParticipants) { var msg = myMsg.message as api.Message_ChangeParticipants; - if (myMsg.verificationFailed) return null; - await updateChatParticipants(chat!, myMsg, myMsg.conversation!.participants, msg.field0.newParticipants); + final conversation = myMsg.conversation; + if (myMsg.verificationFailed || chat == null || conversation == null || myMsg.sender == null) return null; + await updateChatParticipants(chat, myMsg, conversation.participants, msg.field0.newParticipants); chat.groupVersion = msg.field0.groupVersion; chat.ckSyncState = false; chat.save(updateGroupVersion: true, updateCkSyncState: true); return null; } else if (myMsg.message is api.Message_IconChange) { var innerMsg = myMsg.message as api.Message_IconChange; - if (!chat!.lockChatIcon && (chat.groupVersion ?? 0) < innerMsg.field0.groupVersion) { + final sender = myMsg.sender; + if (chat == null || sender == null || sender.isEmpty) return null; + if (!chat.lockChatIcon && (chat.groupVersion ?? 0) < innerMsg.field0.groupVersion) { var file = innerMsg.field0.file; chat.groupVersion = innerMsg.field0.groupVersion; chat.ckSyncState = false; @@ -1724,16 +2016,21 @@ class RustPushService extends GetxService { } return Message( guid: myMsg.id, - isFromMe: myHandles.contains(myMsg.sender), - handleId: RustPushBBUtils.rustHandleToBB(myMsg.sender!).originalROWID!, + isFromMe: myHandles.contains(sender), + handleId: RustPushBBUtils.rustHandleToBB(sender).originalROWID!, dateCreated: DateTime.fromMillisecondsSinceEpoch(myMsg.sentTimestamp), itemType: 3, groupActionType: 1, ); } else if (myMsg.message is api.Message_React) { var msg = myMsg.message as api.Message_React; + final sender = myMsg.sender; + if (sender == null || sender.isEmpty) { + Logger.warn("Ignoring reaction without a sender"); + return null; + } if (msg.field0.embeddedProfile != null) { - handleSharedProfile(msg.field0.embeddedProfile!, myMsg.sender!, chat?.participants ?? []); + handleSharedProfile(msg.field0.embeddedProfile!, sender, chat?.participants ?? []); } String? reaction; @@ -1785,7 +2082,11 @@ class RustPushService extends GetxService { final messages = query.find(); query.close(); - final original = messages.firstWhere((msg) => (msg.stagingGuid ?? msg.guid) != myMsg.id); + final original = messages.firstWhereOrNull((msg) => (msg.stagingGuid ?? msg.guid) != myMsg.id); + if (original == null) { + Logger.warn("Ignoring extension update without a base message"); + return null; + } original.fetchAssociatedMessages(); @@ -1797,7 +2098,12 @@ class RustPushService extends GetxService { } // allow updating image - attributedBodyData = (attributedBodyData.$3.isEmpty ? original.attributedBody[0] : attributedBodyData.$1, original.text!, attributedBodyData.$3.isEmpty ? original.dbAttachments : attributedBodyData.$3); + final originalBody = original.attributedBody.firstOrNull; + if (attributedBodyData.$3.isEmpty && originalBody == null) { + Logger.warn("Ignoring extension update without message content"); + return null; + } + attributedBodyData = (attributedBodyData.$3.isEmpty ? originalBody! : attributedBodyData.$1, original.text ?? "", attributedBodyData.$3.isEmpty ? original.dbAttachments : attributedBodyData.$3); var tag = es.getLatest(msg.field0.toUuid); // updates cached value; we are latest if (tag.firstOrNull != myMsg.id) { @@ -1819,8 +2125,8 @@ class RustPushService extends GetxService { } var message = Message( guid: myMsg.id, - isFromMe: myHandles.contains(myMsg.sender), - handleId: RustPushBBUtils.rustHandleToBB(myMsg.sender!).originalROWID!, + isFromMe: myHandles.contains(sender), + handleId: RustPushBBUtils.rustHandleToBB(sender).originalROWID!, dateCreated: DateTime.fromMillisecondsSinceEpoch(myMsg.sentTimestamp), associatedMessagePart: msg.field0.toPart, associatedMessageGuid: reaction == null ? null : msg.field0.toUuid, @@ -1844,7 +2150,11 @@ class RustPushService extends GetxService { return message; } else if (myMsg.message is api.Message_Unsend) { var msg = myMsg.message as api.Message_Unsend; - var msgObj = Message.findOne(guid: msg.field0.tuuid)!; + var msgObj = Message.findOne(guid: msg.field0.tuuid); + if (msgObj == null) { + Logger.warn("Ignoring unsend for a missing message"); + return null; + } msgObj.verificationFailed = myMsg.verificationFailed; msgObj.dateEdited = DateTime.now(); var summaryInfo = msgObj.messageSummaryInfo.firstOrNull; @@ -2509,6 +2819,7 @@ class RustPushService extends GetxService { Logger.info("Syncing group of ${items2.length} messages, total $totalMessages"); totalMessages += items2.length; + var processedInBatch = 0; for (var item in items2.entries) { try { if (item.value == null) { @@ -2546,7 +2857,15 @@ class RustPushService extends GetxService { message.applyFromCloud(item.value!, item.key); remoteNew++; } catch (e, s) { - Logger.error("Failed to sync attachment ${item.key}", error: e, trace: s); + Logger.error("Failed to sync cloud message", error: e, trace: s); + } finally { + processedInBatch++; + // Cloud decoding and ObjectBox writes run on Flutter's main isolate. + // Yield periodically so window painting and input remain responsive + // during a multi-thousand-message initial sync. + if (processedInBatch % 25 == 0) { + await Future.delayed(const Duration(milliseconds: 1)); + } } } @@ -2661,6 +2980,7 @@ class RustPushService extends GetxService { } Future getPurchaseDetails() async { + if (!Platform.isAndroid) return null; try { var purchases = await pushService.client.runWithClient((client) => client.queryPurchases(ProductType.subs)); var token = purchases.purchasesList.firstOrNull?.purchaseToken; @@ -2686,7 +3006,7 @@ class RustPushService extends GetxService { Future handleRegistered() async { notif.clearRegisterFailed(); - if (ss.settings.hostedToken.value != null) { + if (Platform.isAndroid && ss.settings.hostedToken.value != null) { var detail = await getPurchaseDetails(); if (detail == null) return; @@ -2827,8 +3147,99 @@ class RustPushService extends GetxService { return null; } - List profilesDownloading = []; - Future handleSharedProfile(api.ShareProfileMessage shared, String sender, List targets) async { + final Set profilesDownloading = {}; + final Map _profileRetryTimers = {}; + final Map _profileRetryAttempts = {}; + static const List _profileRetryDelays = [ + Duration(seconds: 5), + Duration(seconds: 30), + Duration(minutes: 2), + ]; + + bool _isTransientProfileFailure(String category) => + category == "service_unavailable" || + category == "timeout" || + category == "network"; + + String _profileFailureCategory(Object error) { + final description = error.toString().toLowerCase(); + if (description.contains("profile service unavailable")) return "service_unavailable"; + if (description.contains("timeout")) return "timeout"; + if (description.contains("connection") || + description.contains("network") || + description.contains("socket") || + description.contains("dns")) { + return "network"; + } + if (description.contains("record") && description.contains("not found")) return "record_not_found"; + if (description.contains("plist") || description.contains("serde")) return "plist"; + if (description.contains("decrypt") || + description.contains("hmac") || + description.contains("crypto")) { + return "crypto"; + } + if (description.contains("asset")) return "asset"; + if (description.contains("panic")) return "panic"; + return error.runtimeType.toString(); + } + + void _clearProfileRetry(String profileKey) { + _profileRetryTimers.remove(profileKey)?.cancel(); + _profileRetryAttempts.remove(profileKey); + } + + void _scheduleProfileRetry( + api.ShareProfileMessage shared, + String sender, + List targets, + String category, + ) { + final profileKey = shared.cloudKitRecordKey; + if (_profileRetryTimers.containsKey(profileKey)) return; + if (!_isTransientProfileFailure(category)) { + _profileRetryAttempts.remove(profileKey); + Logger.warn("Shared profile fetch skipped retry category=$category transient=false"); + return; + } + + final attempt = _profileRetryAttempts[profileKey] ?? 0; + if (attempt >= _profileRetryDelays.length) { + _profileRetryAttempts.remove(profileKey); + Logger.warn("Shared profile fetch exhausted category=$category attempts=$attempt"); + return; + } + + final delay = _profileRetryDelays[attempt]; + _profileRetryAttempts[profileKey] = attempt + 1; + Logger.warn( + "Shared profile fetch deferred category=$category " + "attempt=${attempt + 1} retry_in_seconds=${delay.inSeconds}", + ); + _profileRetryTimers[profileKey] = Timer(delay, () { + _profileRetryTimers.remove(profileKey); + unawaited(handleSharedProfile(shared, sender, targets)); + }); + } + + Future handleSharedProfile(api.ShareProfileMessage shared, String sender, List targets) async { + final profileKey = shared.cloudKitRecordKey; + if (_profileRetryTimers.containsKey(profileKey) || !profilesDownloading.add(profileKey)) return; + + try { + await _handleSharedProfile(shared, sender, targets); + _clearProfileRetry(profileKey); + } catch (error) { + // Shared profile payloads are optional message metadata. A malformed + // CloudKit plist must not escape an unawaited profile task and disturb + // message delivery. Retry independently so the contact image can recover + // after transient CloudKit, network, or service-initialization failures. + _scheduleProfileRetry(shared, sender, targets, _profileFailureCategory(error)); + } finally { + profilesDownloading.remove(profileKey); + } + } + + Future _handleSharedProfile(api.ShareProfileMessage shared, String sender, List targets) async { var myHandles = await api.getHandles(state: pushService.state!.client); if (myHandles.contains(sender)) { for (var target in targets) { @@ -2841,68 +3252,72 @@ class RustPushService extends GetxService { return; } var profiles = pushService.state?.icloudServices?.profilesClient; - if (profiles == null) return; + if (profiles == null) throw StateError("Profile service unavailable"); // mask with profilesDownloading because iPhones have a nasty habit of sharing once to every handle. We don't want to download 15 times for each handle - if (Contact.findOne(id: shared.cloudKitRecordKey) != null || profilesDownloading.contains(shared.cloudKitRecordKey)) return; // already downloaded - profilesDownloading.add(shared.cloudKitRecordKey); + if (Contact.findOne(id: shared.cloudKitRecordKey) != null) return; // already downloaded - try { - var fetch = await api.fetchProfile(profiles: profiles, message: shared); - var otherHandle = RustPushBBUtils.rustHandleToBB(sender); + var fetch = await api.fetchProfile(profiles: profiles, message: shared); + var otherHandle = RustPushBBUtils.rustHandleToBB(sender); - String? posterPath; - if (fetch.poster != null && !kIsDesktop) { - var decoded = await api.parsePoster(poster: fetch.poster!); - try { - posterPath = await savePoster(decoded); - } catch (e, t) { - Logger.error("Could not decode other poster", error: e, trace: t); - } + String? posterPath; + if (fetch.poster != null && !kIsDesktop) { + var decoded = await api.parsePoster(poster: fetch.poster!); + try { + posterPath = await savePoster(decoded); + } catch (e, t) { + Logger.error("Could not decode other poster", error: e, trace: t); } + } - var existingShared = Contact.findOne(address: otherHandle.address, wantShared: true); - if (existingShared != null) { - if (otherHandle.contactRelation.targetId == existingShared.dbId) { - otherHandle.contactRelation.target = null; - } - if (existingShared.posterPath != null) { - try { - await deletePoster(existingShared.posterPath!); - } catch (e) { /* */ } - } - Database.contacts.remove(existingShared.dbId!); - } - if (otherHandle.getPoster() == null) { - otherHandle.setPoster(posterPath); - posterPath = "alreadyset"; - } - var newId = Database.contacts.put(Contact( - id: shared.cloudKitRecordKey, - displayName: "Maybe: ${fetch.name.name}", - structuredName: StructuredName( - namePrefix: "", - nameSuffix: "", - givenName: fetch.name.first, - middleName: "", - familyName: fetch.name.last, - ), - avatar: fetch.image, - isShared: true, - phones: otherHandle.contact?.phones ?? (otherHandle.address.isEmail ? [] : [otherHandle.address]), - emails: otherHandle.contact?.emails ?? (otherHandle.address.isEmail ? [otherHandle.address] : []), - posterPath: posterPath, - )); - if (otherHandle.contactRelation.target == null) { - otherHandle.contactRelation.targetId = newId; - Database.handles.put(otherHandle); + Uint8List? avatar = fetch.image; + if ((avatar == null || avatar.isEmpty) && posterPath != null) { + final posterPreview = File("$posterPath.jpg"); + if (await posterPreview.exists()) { + avatar = await posterPreview.readAsBytes(); } - final result = (await Chat.findByRust(api.ConversationData(participants: [sender]), "iMessage", soft: true)); - if (result != null) { - cvc(result).updateContactInfo(); + } + + var existingShared = Contact.findOne(address: otherHandle.address, wantShared: true); + if (existingShared != null) { + if (otherHandle.contactRelation.targetId == existingShared.dbId) { + otherHandle.contactRelation.target = null; } - } finally { - profilesDownloading.remove(shared.cloudKitRecordKey); + if (existingShared.posterPath != null) { + try { + await deletePoster(existingShared.posterPath!); + } catch (e) { /* */ } + } + Database.contacts.remove(existingShared.dbId!); + } + if (otherHandle.getPoster() == null) { + otherHandle.setPoster(posterPath); + posterPath = "alreadyset"; + } + var newId = Database.contacts.put(Contact( + id: shared.cloudKitRecordKey, + displayName: "Maybe: ${fetch.name.name}", + structuredName: StructuredName( + namePrefix: "", + nameSuffix: "", + givenName: fetch.name.first, + middleName: "", + familyName: fetch.name.last, + ), + avatar: avatar, + isShared: true, + phones: otherHandle.contact?.phones ?? (otherHandle.address.isEmail ? [] : [otherHandle.address]), + emails: otherHandle.contact?.emails ?? (otherHandle.address.isEmail ? [otherHandle.address] : []), + posterPath: posterPath, + )); + if (otherHandle.contactRelation.target == null) { + otherHandle.contactRelation.targetId = newId; + Database.handles.put(otherHandle); + } + eventDispatcher.emit("refresh-avatar", [otherHandle.address, otherHandle.color]); + final result = (await Chat.findByRust(api.ConversationData(participants: [sender]), "iMessage", soft: true)); + if (result != null) { + cvc(result).updateContactInfo(); } } @@ -2959,7 +3374,7 @@ class RustPushService extends GetxService { String appDocPath = fs.appDocDir.path; - savePosterData(decoded.poster, number); + await savePosterData(decoded.poster, number); var save = await api.parsePosterSave(poster: decoded); File file = File("$appDocPath/avatars/you/poster-$number.jpg"); @@ -2978,7 +3393,7 @@ class RustPushService extends GetxService { String appDocPath = fs.appDocDir.path; - savePosterData(decoded.poster, number); + await savePosterData(decoded.poster, number); var save = await api.transcriptPosterSave(poster: decoded); File file = File("$appDocPath/avatars/you/poster-$number.jpg"); @@ -3140,13 +3555,8 @@ class RustPushService extends GetxService { Chat.softDelete(chat); } - Future handleMsg(api.PushMessage push, bool finalAttempt) async { - try { - await handleMsgInner(push).timeout(const Duration(minutes: 3)); - } catch (e, s) { - if (finalAttempt) markCertified(push); - rethrow; - } + Future handleMsg(api.PushMessage push) async { + await handleMsgInner(push).timeout(const Duration(minutes: 3)); // if we complete successfully, mark delivery "certified" markCertified(push); } @@ -3353,12 +3763,17 @@ class RustPushService extends GetxService { var state = push.field0; if (state is api.RegisterState_Registered) { notifiedFailed = false; + unawaited(scheduleRelayHealthReminder(state.nextS)); if (ss.settings.deviceIsHosted.value) { mixpanel?.track("hosted-register-success"); } handleRegistered(); } + if (state is api.RegisterState_Registering) { + unawaited(cancelRelayHealthReminder()); + } if (state is api.RegisterState_Failed && !notifiedFailed) { + unawaited(cancelRelayHealthReminder()); if (ss.settings.deviceIsHosted.value) { mixpanel?.track("hosted-register-failure"); } @@ -3791,9 +4206,11 @@ class RustPushService extends GetxService { myMsg.target = otherIds.map((element) => api.MessageTarget.uuid(element)).toList(); // forward to other devices await (backend as RustPushBackend).sendMsg(myMsg); } - var msg = (await pushService.reflectMessageDyn(myMsg))!; - msg.temp = true; - msg.forwardIfNessesary(chat); + final msg = await pushService.reflectMessageDyn(myMsg); + if (msg != null) { + msg.temp = true; + await msg.forwardIfNessesary(chat); + } return; } } @@ -3806,16 +4223,23 @@ class RustPushService extends GetxService { return; } } - Logger.info("Reflecting ${myMsg.id}"); + final receiveStopwatch = Stopwatch()..start(); + final receiveId = _diagnosticHash(myMsg.id); + Logger.info("rustpush_receive reflection_start id=$receiveId"); var reflected = await pushService.reflectMessageDyn(myMsg); - Logger.info("Reflect finished ${myMsg.id}"); + Logger.info("rustpush_receive reflection_complete id=$receiveId duration_ms=${_durationMs(receiveStopwatch)} reflected=${reflected != null}"); if (reflected != null) { - Logger.info("Queing"); + final queueStopwatch = Stopwatch()..start(); + final queueCompletion = Completer(); + Logger.info("rustpush_receive incoming_queue_enqueue id=$receiveId pending_count=${inq.items.length}"); await inq.queue(IncomingItem( chat: chat, message: reflected, - type: QueueType.newMessage + type: QueueType.newMessage, + completer: queueCompletion, )); + await queueCompletion.future; + Logger.info("rustpush_receive incoming_queue_complete id=$receiveId duration_ms=${_durationMs(queueStopwatch)} pending_count=${inq.items.length}"); } } @@ -4335,7 +4759,15 @@ class RustPushService extends GetxService { var isInClique = await checkClique(); if (isInClique) return true; - var bottles = await wrapPromise(api.getBottles(keychain: pushService.state!.icloudServices!.keychain!), "Fetching Bottles..."); + var bottles = await wrapPromise( + api.getBottles(keychain: pushService.state!.icloudServices!.keychain!).timeout( + const Duration(seconds: 30), + onTimeout: () => throw TimeoutException( + "Apple did not return recovery data within 30 seconds.", + ), + ), + "Fetching Bottles...", + ); if (bottles.isEmpty) { await promptResetData(true); @@ -4469,37 +4901,39 @@ class RustPushService extends GetxService { } } - Future markAsHandledAfter(String ptr) async { - if (inq.isProcessing.value) { - Logger.info("Marking as handled processing wait $ptr"); - await for (final value in inq.isProcessing.stream) { - if (!value) break; - } - } - Logger.info("Marking as handled commit $ptr"); + Future markAsHandledAfter(String ptr, {required String eventId, required int retry}) async { + final ackStopwatch = Stopwatch()..start(); + // handleMsg awaits the completion for this pointer's queue item. Do not + // wait for unrelated incoming work before acknowledging this message. + Logger.info("rustpush_receive durable_work_complete id=$eventId retry=$retry pending_count=${inq.items.length}"); + Logger.info("rustpush_receive ack_commit id=$eventId retry=$retry"); await api.completeMsg(ptr: ptr); + Logger.info("rustpush_receive ack_complete id=$eventId retry=$retry duration_ms=${_durationMs(ackStopwatch)}"); } Future recievedMsgPointer(String pointer, String retry) async { + final eventId = _diagnosticHash(pointer); + final retryCount = int.tryParse(retry) ?? 3; + final receiveStopwatch = Stopwatch()..start(); var message = await api.ptrToDart(ptr: pointer); if (message == null) { - Logger.info("bad pointer $pointer $retry"); + Logger.info("rustpush_receive pointer_missing id=$eventId retry=$retryCount"); return; } - Logger.info("waitingForInit $pointer $retry"); + final initStopwatch = Stopwatch()..start(); + Logger.info("rustpush_receive aps_init_wait_start id=$eventId retry=$retryCount"); await initFuture; - var isFinal = (int.tryParse(retry) ?? 3) >= 3; + Logger.info("rustpush_receive aps_init_wait_complete id=$eventId retry=$retryCount duration_ms=${_durationMs(initStopwatch)} total_ms=${_durationMs(receiveStopwatch)}"); try { - Logger.info("Handling $pointer $retry"); - await handleMsg(message, isFinal); - Logger.info("Marking as handled $pointer"); - await markAsHandledAfter(pointer); + final handlingStopwatch = Stopwatch()..start(); + Logger.info("rustpush_receive handle_start id=$eventId retry=$retryCount"); + await handleMsg(message); + Logger.info("rustpush_receive handle_complete id=$eventId retry=$retryCount duration_ms=${_durationMs(handlingStopwatch)} total_ms=${_durationMs(receiveStopwatch)}"); + await markAsHandledAfter(pointer, eventId: eventId, retry: retryCount); } catch (e, s) { Logger.error("Handle failed", error: e, trace: s); - if (isFinal) { - Logger.info("Failed; Marking as handled anyways $pointer"); - await markAsHandledAfter(pointer); - } + // Leave the pointer pending so the native bounded retry loop can try + // again. A failed handler must never be acknowledged as delivered. rethrow; } } @@ -4520,7 +4954,7 @@ class RustPushService extends GetxService { if (msg == null) { continue; } - await handleMsg(msg, true); + await handleMsg(msg); } catch (e, t) { // if there was an error somewhere, log it and move on. // don't stop our loop @@ -4864,7 +5298,10 @@ class RustPushService extends GetxService { } if (ss.settings.cloudSyncingEnabled.value) { Logger.info("Doing cloudkit sync!"); - pushService.doCloudKitSync(); + pushService.doCloudKitSync().catchError((error, stackTrace) { + Logger.warn("Initial CloudKit sync failed", + error: error, trace: stackTrace); + }); } } var keychain = pushService.state?.icloudServices?.keychain; @@ -4877,9 +5314,37 @@ class RustPushService extends GetxService { initAppLinks(); initMixPanel(); await initFuture; + try { + await restoreRelayHealthState(); + } catch (e, s) { + Logger.warn("Failed to restore iPhone relay health", + error: e, trace: s); + await clearRelayHealthState(); + } + if (state != null) { + try { + final registrationState = + await api.getRegstate(state: state!.client); + if (registrationState is api.RegisterState_Registered) { + await scheduleRelayHealthReminder(registrationState.nextS); + } + } catch (e, s) { + Logger.warn("Failed to schedule iPhone relay health check", + error: e, trace: s); + } + } Timer(const Duration(seconds: 2), checkIncident); // pre-cache next FT link - if (pushService.state != null) api.getFtLink(facetime: pushService.state!.ftClient, usage: "next"); + if (pushService.state != null) { + api + .getFtLink( + facetime: pushService.state!.ftClient, + usage: "next") + .then((_) {}, onError: (Object error, StackTrace stackTrace) { + Logger.warn("Failed to pre-cache FaceTime link", + error: error, trace: stackTrace); + }); + } Logger.info("initDone"); final sendingProgress = Database.messages.query(Message_.sendingServiceId.notNull()).build().find(); for (var item in sendingProgress) { @@ -4955,6 +5420,14 @@ class RustPushService extends GetxService { var thisState = state; state = null; + final relayHealthCheck = _relayHealthInFlight; + if (relayHealthCheck != null) { + await relayHealthCheck; + } + await cancelRelayHealthReminder(); + if (hw || logout) { + await clearRelayHealthState(); + } if (thisState == null) return; if (logout) { @@ -5036,6 +5509,12 @@ class RustPushService extends GetxService { @override void onClose() { + for (final timer in _profileRetryTimers.values) { + timer.cancel(); + } + _profileRetryTimers.clear(); + _profileRetryAttempts.clear(); + unawaited(cancelRelayHealthReminder()); if (state != null) disposeState(state!, true, false); super.onClose(); } diff --git a/lib/services/ui/attachments_service.dart b/lib/services/ui/attachments_service.dart index b07fa18dd2..da6619aa7a 100644 --- a/lib/services/ui/attachments_service.dart +++ b/lib/services/ui/attachments_service.dart @@ -25,13 +25,19 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:vcf_dart/vcf_dart.dart'; import 'package:video_thumbnail/video_thumbnail.dart'; -AttachmentsService as = Get.isRegistered() ? Get.find() : Get.put(AttachmentsService()); +AttachmentsService as = Get.isRegistered() + ? Get.find() + : Get.put(AttachmentsService()); class AttachmentsService extends GetxService { - - dynamic getContent(Attachment attachment, {String? path, bool? autoDownload, Function(PlatformFile)? onComplete, bool forExtension = false}) { + dynamic getContent(Attachment attachment, + {String? path, + bool? autoDownload, + Function(PlatformFile)? onComplete, + bool forExtension = false}) { if ((attachment.guid?.startsWith("temp") ?? false) && !forExtension) { - final sendProgress = ah.attachmentProgress.firstWhereOrNull((e) => e.item1 == attachment.guid); + final sendProgress = ah.attachmentProgress + .firstWhereOrNull((e) => e.item1 == attachment.guid); if (sendProgress != null) { return sendProgress; } else { @@ -47,8 +53,10 @@ class AttachmentsService extends GetxService { ); } if (kIsWeb || (attachment.guid == null && attachment.bytes != null)) { - if (attachment.bytes == null && (autoDownload ?? ss.settings.autoDownload.value)) { - return attachmentDownloader.startDownload(attachment, onComplete: onComplete); + if (attachment.bytes == null && + (autoDownload ?? ss.settings.autoDownload.value)) { + return attachmentDownloader.startDownload(attachment, + onComplete: onComplete); } else { return PlatformFile( name: attachment.transferName!, @@ -71,7 +79,8 @@ class AttachmentsService extends GetxService { size: attachment.totalBytes ?? 0, ); } else if (autoDownload ?? ss.settings.autoDownload.value) { - return attachmentDownloader.startDownload(attachment, onComplete: onComplete); + return attachmentDownloader.startDownload(attachment, + onComplete: onComplete); } else { return attachment; } @@ -105,29 +114,59 @@ class AttachmentsService extends GetxService { final contact = VCardStack.fromData(appleContact).items.first; final c = Contact( id: randomString(8), - displayName: contact.findFirstProperty(VConstants.formattedName)?.values.firstOrNull ?? "Unknown", + displayName: contact + .findFirstProperty(VConstants.formattedName) + ?.values + .firstOrNull ?? + "Unknown", phones: contact.findFirstProperty(VConstants.phone)?.values ?? [], emails: contact.findFirstProperty(VConstants.email)?.values ?? [], structuredName: StructuredName( - namePrefix: contact.findFirstProperty(VConstants.name)?.values.elementAtOrNull(3) ?? "", - familyName: contact.findFirstProperty(VConstants.name)?.values.elementAtOrNull(0) ?? "", - givenName: contact.findFirstProperty(VConstants.name)?.values.elementAtOrNull(1) ?? "", - middleName: contact.findFirstProperty(VConstants.name)?.values.elementAtOrNull(2) ?? "", - nameSuffix: contact.findFirstProperty(VConstants.name)?.values.elementAtOrNull(4) ?? "", + namePrefix: contact + .findFirstProperty(VConstants.name) + ?.values + .elementAtOrNull(3) ?? + "", + familyName: contact + .findFirstProperty(VConstants.name) + ?.values + .elementAtOrNull(0) ?? + "", + givenName: contact + .findFirstProperty(VConstants.name) + ?.values + .elementAtOrNull(1) ?? + "", + middleName: contact + .findFirstProperty(VConstants.name) + ?.values + .elementAtOrNull(2) ?? + "", + nameSuffix: contact + .findFirstProperty(VConstants.name) + ?.values + .elementAtOrNull(4) ?? + "", ), ); try { // contact_card.dart does real avatar parsing since no plugins can parse the photo correctly when the base64 is multiline - c.avatar = (isNullOrEmpty(contact.findFirstProperty(VConstants.photo)?.values.firstOrNull) ? null : [0]) as Uint8List?; + c.avatar = (isNullOrEmpty( + contact.findFirstProperty(VConstants.photo)?.values.firstOrNull) + ? null + : [0]) as Uint8List?; } catch (_) {} return c; } - Future saveToDisk(PlatformFile file, {bool isAutoDownload = false, bool isDocument = false}) async { + Future saveToDisk(PlatformFile file, + {bool isAutoDownload = false, bool isDocument = false}) async { if (kIsWeb) { final content = base64.encode(file.bytes!); // create a fake download element and "click" it - html.AnchorElement(href: "data:application/octet-stream;charset=utf-16le;base64,$content") + html.AnchorElement( + href: + "data:application/octet-stream;charset=utf-16le;base64,$content") ..setAttribute("download", file.name) ..click(); } else if (kIsDesktop) { @@ -152,17 +191,23 @@ class AttachmentsService extends GetxService { "Confirm save", style: context.theme.textTheme.titleLarge, ), - content: Text("This file already exists.\nAre you sure you want to overwrite it?", style: context.theme.textTheme.bodyLarge), + content: Text( + "This file already exists.\nAre you sure you want to overwrite it?", + style: context.theme.textTheme.bodyLarge), backgroundColor: context.theme.colorScheme.properSurface, actions: [ TextButton( - child: Text("No", style: context.theme.textTheme.bodyLarge!.copyWith(color: context.theme.colorScheme.primary)), + child: Text("No", + style: context.theme.textTheme.bodyLarge! + .copyWith(color: context.theme.colorScheme.primary)), onPressed: () { Navigator.of(context).pop(); }, ), TextButton( - child: Text("Yes", style: context.theme.textTheme.bodyLarge!.copyWith(color: context.theme.colorScheme.primary)), + child: Text("Yes", + style: context.theme.textTheme.bodyLarge! + .copyWith(color: context.theme.colorScheme.primary)), onPressed: () async { if (file.path != null) { await File(file.path!).copy(savePath); @@ -181,7 +226,9 @@ class AttachmentsService extends GetxService { onPressed: () { launchUrl(Uri.file(savePath)); }, - child: Text("OPEN FILE", style: TextStyle(color: Get.theme.colorScheme.onSurfaceVariant)), + child: Text("OPEN FILE", + style: TextStyle( + color: Get.theme.colorScheme.onSurfaceVariant)), ), ); }, @@ -207,13 +254,15 @@ class AttachmentsService extends GetxService { onPressed: () { launchUrl(Uri.file(savePath)); }, - child: Text("OPEN FILE", style: TextStyle(color: Get.theme.colorScheme.onSurfaceVariant)), + child: Text("OPEN FILE", + style: + TextStyle(color: Get.theme.colorScheme.onSurfaceVariant)), ), ); } } else { String? savePath; - + if (ss.settings.askWhereToSave.value && !isAutoDownload) { if (Platform.isAndroid && file.path != null) { await mcs.invokeMethod("create-document", { @@ -222,7 +271,9 @@ class AttachmentsService extends GetxService { "name": file.name, }); } else { - final bytes = file.bytes != null && file.bytes!.isNotEmpty ? file.bytes! : await File(file.path!).readAsBytes(); + final bytes = file.bytes != null && file.bytes!.isNotEmpty + ? file.bytes! + : await File(file.path!).readAsBytes(); await FilePicker.platform.saveFile( initialDirectory: ss.settings.autoSaveDocsLocation.value, dialogTitle: 'Choose a location to save this file', @@ -233,27 +284,39 @@ class AttachmentsService extends GetxService { } } else { try { - if (file.name.toLowerCase().endsWith(".mov")) { - savePath = join("/storage/emulated/0/", ss.settings.autoSavePicsLocation.value); - } else { - if (!isDocument) { - try { - if (file.path == null && file.bytes != null) { - await SaverGallery.saveImage(file.bytes!, quality: 100, name: file.name, androidRelativePath: ss.settings.autoSavePicsLocation.value, androidExistNotSave: false); - } else { - await SaverGallery.saveFile(file: file.path!, name: file.name, androidRelativePath: ss.settings.autoSavePicsLocation.value, androidExistNotSave: false); - } - return showSnackbar('Success', 'Saved attachment to gallery!'); - } catch (_) {} + if (file.name.toLowerCase().endsWith(".mov")) { + savePath = join( + "/storage/emulated/0/", ss.settings.autoSavePicsLocation.value); + } else { + if (!isDocument) { + try { + if (file.path == null && file.bytes != null) { + await SaverGallery.saveImage(file.bytes!, + quality: 100, + name: file.name, + androidRelativePath: + ss.settings.autoSavePicsLocation.value, + androidExistNotSave: false); + } else { + await SaverGallery.saveFile( + file: file.path!, + name: file.name, + androidRelativePath: + ss.settings.autoSavePicsLocation.value, + androidExistNotSave: false); + } + return showSnackbar('Success', 'Saved attachment to gallery!'); + } catch (_) {} + } + savePath = ss.settings.autoSaveDocsLocation.value; } - savePath = ss.settings.autoSaveDocsLocation.value; - } - if (file.bytes != null && file.bytes!.isNotEmpty) { - await File(join(savePath, file.name)).writeAsBytes(file.bytes!); - } else { - await File(file.path!).copy(join(savePath, file.name)); - } - showSnackbar('Success', 'Saved attachment to ${savePath.replaceAll("/storage/emulated/0/", "")} folder!'); + if (file.bytes != null && file.bytes!.isNotEmpty) { + await File(join(savePath, file.name)).writeAsBytes(file.bytes!); + } else { + await File(file.path!).copy(join(savePath, file.name)); + } + showSnackbar('Success', + 'Saved attachment to ${savePath.replaceAll("/storage/emulated/0/", "")} folder!'); } catch (e) { if (Platform.isAndroid && file.path != null) { await mcs.invokeMethod("create-document", { @@ -276,13 +339,15 @@ class AttachmentsService extends GetxService { if (!ss.settings.onlyWifiDownload.value) { return true; } else { - List status = await (Connectivity().checkConnectivity()); + List status = + await (Connectivity().checkConnectivity()); return status.contains(ConnectivityResult.wifi); } } } - Future redownloadAttachment(Attachment attachment, {Function(PlatformFile)? onComplete, Function()? onError}) async { + Future redownloadAttachment(Attachment attachment, + {Function(PlatformFile)? onComplete, Function()? onError}) async { if (!kIsWeb) { final file = File(attachment.path); final pngFile = File(attachment.convertedPath); @@ -294,27 +359,32 @@ class AttachmentsService extends GetxService { await pngFile.delete(); await thumbnail.delete(); await pngThumbnail.delete(); - } catch(_) {} + } catch (_) {} } - Get.put(AttachmentDownloadController( - attachment: attachment, - onComplete: (file) => onComplete?.call(file), - onError: onError - ), tag: attachment.guid); + Get.put( + AttachmentDownloadController( + attachment: attachment, + onComplete: (file) => onComplete?.call(file), + onError: onError), + tag: attachment.guid); } Future getImageSizing(String filePath, Attachment attachment) async { try { dynamic file = File(filePath); - isg.Size size = await isg.ImageSizeGetter.getSizeAsync(AsyncInput(FileInput(file))); - return Size(size.needRotate ? size.height.toDouble() : size.width.toDouble(), size.needRotate ? size.width.toDouble() : size.height.toDouble()); + isg.Size size = + await isg.ImageSizeGetter.getSizeAsync(AsyncInput(FileInput(file))); + return Size( + size.needRotate ? size.height.toDouble() : size.width.toDouble(), + size.needRotate ? size.width.toDouble() : size.height.toDouble()); } catch (ex) { return const Size(0, 0); } } - Future getVideoThumbnail(String filePath, {bool useCachedFile = true}) async { + Future getVideoThumbnail(String filePath, + {bool useCachedFile = true}) async { final cachedFile = File("$filePath.thumbnail"); if (useCachedFile) { try { @@ -325,7 +395,8 @@ class AttachmentsService extends GetxService { final thumbnail = await VideoThumbnail.thumbnailData( video: filePath, imageFormat: ImageFormat.PNG, - maxWidth: 128, // specify the width of the thumbnail, let the height auto-scaled to keep the source aspect ratio + maxWidth: + 128, // specify the width of the thumbnail, let the height auto-scaled to keep the source aspect ratio quality: 25, ); @@ -336,8 +407,13 @@ class AttachmentsService extends GetxService { return thumbnail; } - Future loadAndGetProperties(Attachment attachment, {bool onlyFetchData = false, String? actualPath, bool isPreview = false}) async { - if (kIsWeb || attachment.mimeType == null || !["image", "video"].contains(attachment.mimeStart)) return null; + Future loadAndGetProperties(Attachment attachment, + {bool onlyFetchData = false, + String? actualPath, + bool isPreview = false}) async { + if (kIsWeb || + attachment.mimeType == null || + !["image", "video"].contains(attachment.mimeStart)) return null; final filePath = actualPath ?? attachment.path; File originalFile = File(filePath); @@ -371,7 +447,7 @@ class AttachmentsService extends GetxService { Logger.error("Failed to compress HEIC!"); throw Exception(); } - + originalFile = File("$filePath.png"); } } catch (_) {} @@ -384,15 +460,14 @@ class AttachmentsService extends GetxService { } else { final receivePort = ReceivePort(); await Isolate.spawn( - unsupportedToPngIsolate, - IsolateData( - PlatformFile( - name: randomString(8), - path: originalFile.path, - size: 0, - ), - receivePort.sendPort - ), + unsupportedToPngIsolate, + IsolateData( + PlatformFile( + name: randomString(8), + path: originalFile.path, + size: 0, + ), + receivePort.sendPort), ); // Get the processed image from the isolate. final image = await receivePort.first as Uint8List?; @@ -408,7 +483,7 @@ class AttachmentsService extends GetxService { Uint8List previewData = await originalFile.readAsBytes(); - if (attachment.width != null || attachment.height != null) { + if (attachment.width == null || attachment.height == null) { if (attachment.mimeType == "image/gif") { try { Size size = getGifDimensions(previewData); @@ -418,7 +493,8 @@ class AttachmentsService extends GetxService { } attachment.save(null); } catch (ex, stack) { - Logger.error('Failed to get GIF dimensions!', error: ex, trace: stack); + Logger.error('Failed to get GIF dimensions!', + error: ex, trace: stack); } } else if (attachment.mimeStart == "image") { try { @@ -429,7 +505,8 @@ class AttachmentsService extends GetxService { } attachment.save(null); } catch (ex, stack) { - Logger.error('Failed to get Image Properties!', error: ex, trace: stack); + Logger.error('Failed to get Image Properties!', + error: ex, trace: stack); } } } @@ -451,4 +528,4 @@ class AttachmentsService extends GetxService { return previewData; } -} \ No newline at end of file +} diff --git a/lib/services/ui/chat/chat_manager.dart b/lib/services/ui/chat/chat_manager.dart index 11caeefe2f..6079d66d03 100644 --- a/lib/services/ui/chat/chat_manager.dart +++ b/lib/services/ui/chat/chat_manager.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:bluebubbles/utils/logger/logger.dart'; import 'package:bluebubbles/database/models.dart'; +import 'package:bluebubbles/helpers/group_participant_helpers.dart'; import 'package:bluebubbles/services/services.dart'; import 'package:dio/dio.dart'; import 'package:bluebubbles/services/rustpush/rustpush_service.dart'; @@ -157,18 +158,24 @@ class ChatManager extends GetxService { if (chat == null) { updatedChat.save(); chat = Chat.findOne(guid: chatGuid)!; - } else if (chat.handles.length > updatedChat.participants.length) { - final newAddresses = updatedChat.participants.map((e) => e.address); - final handlesToUse = chat.participants.where((e) => newAddresses.contains(e.address)); - chat.handles.clear(); - chat.handles.addAll(handlesToUse); - chat.handles.applyToDb(); - } else if (chat.handles.length < updatedChat.participants.length) { - final existingAddresses = chat.participants.map((e) => e.address); - final newHandle = updatedChat.participants.firstWhere((e) => !existingAddresses.contains(e.address)); - final handle = Handle.findOne(addressAndService: Tuple2(newHandle.address, chat.isIMessage ? "iMessage" : "SMS")) ?? newHandle.save(); - chat.handles.add(handle); - chat.handles.applyToDb(); + } else { + // Reconcile by address and service, not just list length. A group can + // replace one participant without changing its size, and multiple + // additions must all be reflected in the local relation. + // An incomplete server response must not erase a known participant + // list. A valid empty group is not useful for this client, so retain + // the local list when the response contains no participants. + if (updatedChat.participants.isNotEmpty || chat.participants.isEmpty) { + final handles = reconcileGroupParticipants( + chat.participants, + updatedChat.participants, + ); + if (handles.isNotEmpty) Handle.bulkSave(handles); + chat.handles.clear(); + chat.handles.addAll(handles); + chat.handles.applyToDb(); + chat.getParticipants(); + } } if (!chat.lockChatName) { chat.displayName = updatedChat.displayName; diff --git a/lib/services/ui/chat/conversation_view_controller.dart b/lib/services/ui/chat/conversation_view_controller.dart index a26709b9cc..ae92b6839a 100644 --- a/lib/services/ui/chat/conversation_view_controller.dart +++ b/lib/services/ui/chat/conversation_view_controller.dart @@ -6,11 +6,15 @@ import 'package:bluebubbles/app/components/custom_text_editing_controllers.dart' import 'package:bluebubbles/app/layouts/settings/pages/profile/posterkit.dart'; import 'package:bluebubbles/app/wrappers/stateful_boilerplate.dart'; import 'package:bluebubbles/database/models.dart'; +import 'package:bluebubbles/helpers/memory/bounded_byte_cache.dart'; +import 'package:bluebubbles/helpers/memory/bounded_lru_map.dart'; import 'package:bluebubbles/services/network/backend_service.dart'; +import 'package:bluebubbles/utils/logger/logger.dart'; import 'package:bluebubbles/services/services.dart'; import 'package:emojis/emoji.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart'; import 'package:get/get.dart'; import 'package:google_ml_kit/google_ml_kit.dart' hide Message; @@ -21,10 +25,14 @@ import 'package:universal_io/io.dart'; import 'package:bluebubbles/src/rust/api/api.dart' as api; import 'dart:ui' as ui; -ConversationViewController cvc(Chat chat, {String? tag}) => Get.isRegistered(tag: tag ?? chat.guid) -? Get.find(tag: tag ?? chat.guid) : Get.put(ConversationViewController(chat, tag_: tag), tag: tag ?? chat.guid); +ConversationViewController cvc(Chat chat, {String? tag}) => + Get.isRegistered(tag: tag ?? chat.guid) + ? Get.find(tag: tag ?? chat.guid) + : Get.put(ConversationViewController(chat, tag_: tag), + tag: tag ?? chat.guid); -class ConversationViewController extends StatefulController with GetSingleTickerProviderStateMixin { +class ConversationViewController extends StatefulController + with GetSingleTickerProviderStateMixin { final Chat chat; late final String tag; bool fromChatCreator = false; @@ -38,14 +46,31 @@ class ConversationViewController extends StatefulController with GetSingleTicker } // caching items - final Map imageData = {}; - final List>> imageCacheQueue = []; - final Map> stickerData = {}; - final Map legacyUrlPreviews = {}; + static const int attachmentCacheMaximumBytes = 32 * 1024 * 1024; + static const int attachmentCacheMaximumEntries = 48; + final BoundedByteCache imageData = BoundedByteCache( + maximumSizeBytes: attachmentCacheMaximumBytes, + maximumEntries: attachmentCacheMaximumEntries, + ); + final List<_PendingImageLoad> _imageCacheQueue = []; + final Map> _pendingImageLoads = {}; + _PendingImageLoad? _activeImageLoad; + final BoundedLruMap> + stickerData = BoundedLruMap( + maximumEntries: 64, + maximumWeight: 16 * 1024 * 1024, + weightOf: (stickers) => stickers.values.fold( + 0, + (total, sticker) => total + sticker.$1.lengthInBytes, + ), + ); + final BoundedLruMap legacyUrlPreviews = + BoundedLruMap(maximumEntries: 64); final Map videoPlayers = {}; final Map audioPlayers = {}; final Map audioPlayersDesktop = {}; - final Map> mlKitParsedText = {}; + final BoundedLruMap> mlKitParsedText = + BoundedLruMap(maximumEntries: 256); // message view items final RxList showTypingIndicatorFor = [].obs; @@ -53,15 +78,21 @@ class ConversationViewController extends StatefulController with GetSingleTicker final RxDouble timestampOffset = 0.0.obs; final RxBool inSelectMode = false.obs; final RxList selected = [].obs; - final RxList> editing = >[].obs; + final RxList> + editing = + >[].obs; final GlobalKey focusInfoKey = GlobalKey(); final RxBool recipientNotifsSilenced = false.obs; bool showingOverlays = false; - bool _subjectWasLastFocused = false; // If this is false, then message field was last focused (default) - final Map, Uint8List?)> typingIndicatorData = {}; + bool _subjectWasLastFocused = + false; // If this is false, then message field was last focused (default) + final Map, Uint8List?)> + typingIndicatorData = {}; - FocusNode get lastFocusedNode => _subjectWasLastFocused ? subjectFocusNode : focusNode; - SpellCheckTextEditingController get lastFocusedTextController => _subjectWasLastFocused ? subjectTextController : textController; + FocusNode get lastFocusedNode => + _subjectWasLastFocused ? subjectFocusNode : focusNode; + SpellCheckTextEditingController get lastFocusedTextController => + _subjectWasLastFocused ? subjectTextController : textController; // text field items bool showAttachmentPicker = false; @@ -72,9 +103,12 @@ class ConversationViewController extends StatefulController with GetSingleTicker final subjectFocusNode = FocusNode(); final headerBackFocusNode = FocusNode(); FocusNode? bottomMessageFocusNode; - late final textController = MentionTextEditingController(focusNode: focusNode, supportsFormatting: chat.isIMessage); - late final subjectTextController = SpellCheckTextEditingController(focusNode: subjectFocusNode); - final Rx<(PlatformFile?, PayloadData)?> pickedApp = Rx<(PlatformFile?, PayloadData)?>(null); + late final textController = MentionTextEditingController( + focusNode: focusNode, supportsFormatting: chat.isIMessage); + late final subjectTextController = + SpellCheckTextEditingController(focusNode: subjectFocusNode); + final Rx<(PlatformFile?, PayloadData)?> pickedApp = + Rx<(PlatformFile?, PayloadData)?>(null); final RxBool showRecording = false.obs; final RxList emojiMatches = [].obs; final RxInt emojiSelectedIndex = 0.obs; @@ -82,7 +116,8 @@ class ConversationViewController extends StatefulController with GetSingleTicker final RxInt mentionSelectedIndex = 0.obs; final ScrollController emojiScrollController = ScrollController(); final Rxn scheduledDate = Rxn(null); - final Rxn> _replyToMessage = Rxn>(null); + final Rxn> _replyToMessage = + Rxn>(null); Tuple2? get replyToMessage => _replyToMessage.value; set replyToMessage(Tuple2? m) { _replyToMessage.value = m; @@ -90,23 +125,34 @@ class ConversationViewController extends StatefulController with GetSingleTicker lastFocusedNode.requestFocus(); } } - late final mentionables = chat.participants.map((e) => Mentionable( - handle: e, - )).toList(); + + late final mentionables = chat.participants + .map((e) => Mentionable( + handle: e, + )) + .toList(); final Rxn suggestedContact = Rxn(null); final RxBool suggestShare = false.obs; bool keyboardOpen = false; double _keyboardOffset = 0; Timer? _scrollDownDebounce; - Future Function(Tuple7, AttributedBody, String, String?, int?, String?, PayloadData?>, bool, DateTime?)? sendFunc; + StreamSubscription? keyboardVisibilitySubscription; + Future Function( + Tuple7, AttributedBody, String, String?, int?, String?, + PayloadData?>, + bool, + DateTime?)? sendFunc; bool isProcessingImage = false; - final Rxn backgroundPoster = Rxn(null); + final Rxn backgroundPoster = + Rxn(null); Map images = {}; final RxBool reportJunkAvailable = false.obs; Timer? _debounceTyping; + bool _isClosed = false; + int _posterGeneration = 0; void clearTypingState() { _debounceTyping = null; @@ -114,7 +160,9 @@ class ConversationViewController extends StatefulController with GetSingleTicker void triggerTypingIndicator() { // don't send a bunch of duplicate events for every typing change - if (!ss.settings.enablePrivateAPI.value || !(chat.autoSendTypingIndicators ?? ss.settings.privateSendTypingIndicators.value)) return; + if (!ss.settings.enablePrivateAPI.value || + !(chat.autoSendTypingIndicators ?? + ss.settings.privateSendTypingIndicators.value)) return; _debounceTyping?.cancel(); if (_debounceTyping == null) { var a = pickedApp.value?.$2.appData?.firstOrNull; @@ -133,17 +181,23 @@ class ConversationViewController extends StatefulController with GetSingleTicker if ((chat.participants.first.contact?.isShared ?? false)) { sharedContact = chat.participants.firstOrNull!.contact!; } else { - sharedContact = Contact.findOne(address: chat.participants.firstOrNull!.address, wantShared: true); + sharedContact = Contact.findOne( + address: chat.participants.firstOrNull!.address, wantShared: true); } if (sharedContact != null && !sharedContact.isDismissed) { suggestedContact.value = sharedContact; } // (not in our contacts or contact sharing disabled) and not shared - suggestShare.value = ((chat.participants.first.contact?.isShared ?? true) || !ss.settings.shareContactAutomatically.value) - && !ss.settings.sharedContacts.contains(chat.participants.first.address) - && !ss.settings.dismissedContacts.contains(chat.participants.first.address) - && ss.settings.nameAndPhotoSharing.value && chat.isIMessage; + suggestShare.value = + ((chat.participants.first.contact?.isShared ?? true) || + !ss.settings.shareContactAutomatically.value) && + !ss.settings.sharedContacts + .contains(chat.participants.first.address) && + !ss.settings.dismissedContacts + .contains(chat.participants.first.address) && + ss.settings.nameAndPhotoSharing.value && + chat.isIMessage; } } @@ -153,12 +207,14 @@ class ConversationViewController extends StatefulController with GetSingleTicker void onInit() { super.onInit(); - shareSubscription = ss.settings.shareVersion.listen((s) => updateContactInfo()); + shareSubscription = + ss.settings.shareVersion.listen((s) => updateContactInfo()); updateContactInfo(); textController.mentionables = mentionables; - KeyboardVisibilityController().onChange.listen((bool visible) async { + keyboardVisibilitySubscription = + KeyboardVisibilityController().onChange.listen((bool visible) async { keyboardOpen = visible; if (scrollController.hasClients) { _keyboardOffset = scrollController.offset; @@ -167,9 +223,9 @@ class ConversationViewController extends StatefulController with GetSingleTicker scrollController.addListener(() { if (!scrollController.hasClients) return; - if (keyboardOpen - && ss.settings.hideKeyboardOnScroll.value - && scrollController.offset > _keyboardOffset + 100) { + if (keyboardOpen && + ss.settings.hideKeyboardOnScroll.value && + scrollController.offset > _keyboardOffset + 100) { focusNode.unfocus(); subjectFocusNode.unfocus(); } @@ -179,7 +235,9 @@ class ConversationViewController extends StatefulController with GetSingleTicker if (scrollController.offset >= 500 && !showScrollDown.value) { showScrollDown.value = true; - if (_scrollDownDebounce?.isActive ?? false) _scrollDownDebounce?.cancel(); + if (_scrollDownDebounce?.isActive ?? false) { + _scrollDownDebounce?.cancel(); + } _scrollDownDebounce = Timer(const Duration(seconds: 3), () { showScrollDown.value = false; }); @@ -199,41 +257,97 @@ class ConversationViewController extends StatefulController with GetSingleTicker _subjectWasLastFocused = true; } }); - updatePoster(); } void updatePoster() async { + final generation = ++_posterGeneration; if (chat.transcriptPosterPath == null) { + _disposePosterImages(); backgroundPoster.value = null; return; } var data = await File("${chat.transcriptPosterPath}.jpg").readAsBytes(); var poster = await api.fromTranscriptPosterSave(poster: data); - images = await loadPosterImages(chat.transcriptPosterPath!, poster.poster); + if (_isClosed || generation != _posterGeneration) return; + final loadedImages = + await loadPosterImages(chat.transcriptPosterPath!, poster.poster); + if (_isClosed || generation != _posterGeneration) { + for (final image in loadedImages.values) { + image.dispose(); + } + return; + } + _disposePosterImages(); + images = loadedImages; backgroundPoster.value = poster; } + void _disposePosterImages() { + for (final image in images.values) { + image.dispose(); + } + images = {}; + } + + void pauseMediaPlayers() { + for (final controller in videoPlayers.values) { + unawaited(controller.player.pause()); + } + for (final controller in audioPlayers.values) { + unawaited(controller.pausePlayer()); + } + for (final controller in audioPlayersDesktop.values) { + unawaited(controller.pause()); + } + } + @override void onClose() { - for (PlayerController a in audioPlayers.values) { - a.pausePlayer(); - a.dispose(); + _isClosed = true; + _posterGeneration++; + final activeLoad = _activeImageLoad; + if (activeLoad != null && !activeLoad.completer.isCompleted) { + activeLoad.completer.complete(Uint8List(0)); } - for (Player a in audioPlayersDesktop.values) { - a.dispose(); + for (final queued in _imageCacheQueue) { + if (!queued.completer.isCompleted) { + queued.completer.complete(Uint8List(0)); + } } - for (VideoController a in videoPlayers.values) { - a.player.pause(); - a.player.dispose(); + _imageCacheQueue.clear(); + _pendingImageLoads.clear(); + pauseMediaPlayers(); + videoPlayers.clear(); + audioPlayers.clear(); + audioPlayersDesktop.clear(); + imageData.clear(); + stickerData.clear(); + legacyUrlPreviews.clear(); + mlKitParsedText.clear(); + _disposePosterImages(); + _scrollDownDebounce?.cancel(); + _debounceTyping?.cancel(); + for (final typingState in typingIndicatorData.values) { + unawaited(typingState.$1.cancel()); } + typingIndicatorData.clear(); scrollController.dispose(); + emojiScrollController.dispose(); headerBackFocusNode.dispose(); shareSubscription?.cancel(); + keyboardVisibilitySubscription?.cancel(); super.onClose(); } + void dismissKeyboard() { + focusNode.unfocus(); + subjectFocusNode.unfocus(); + SystemChannels.textInput.invokeMethod('TextInput.hide'); + } + Future scrollToBottom() async { - if (scrollController.positions.isNotEmpty && scrollController.positions.first.extentBefore > 0) { + if (scrollController.positions.isNotEmpty && + scrollController.positions.first.extentBefore > 0) { await scrollController.animateTo( 0.0, curve: Curves.easeOut, @@ -250,8 +364,10 @@ class ConversationViewController extends StatefulController with GetSingleTicker var messages = ms(chat.guid).struct.messages; messages.sort(Message.sort); if (scrollController.positions.isNotEmpty) { - var test = messages.indexWhere((element) => element.chatViewDate?.isBefore(time) ?? false); - await scrollController.scrollToIndex(test, preferPosition: AutoScrollPosition.begin); + var test = messages.indexWhere( + (element) => element.chatViewDate?.isBefore(time) ?? false); + await scrollController.scrollToIndex(test, + preferPosition: AutoScrollPosition.begin); } if (ss.settings.openKeyboardOnSTB.value) { @@ -259,54 +375,100 @@ class ConversationViewController extends StatefulController with GetSingleTicker } } - Future send(List attachments, AttributedBody text, String subject, String? replyGuid, int? replyPart, String? effectId, PayloadData? payload, bool isAudioMessage, DateTime? scheduledDate) async { - sendFunc?.call(Tuple7(attachments, text, subject, replyGuid, replyPart, effectId, payload), isAudioMessage, scheduledDate); + Future send( + List attachments, + AttributedBody text, + String subject, + String? replyGuid, + int? replyPart, + String? effectId, + PayloadData? payload, + bool isAudioMessage, + DateTime? scheduledDate) async { + sendFunc?.call( + Tuple7(attachments, text, subject, replyGuid, replyPart, effectId, + payload), + isAudioMessage, + scheduledDate); } - void queueImage(Tuple4> item) { - imageCacheQueue.add(item); + Future queueImage(Attachment attachment, PlatformFile file) { + final guid = attachment.guid; + if (guid == null) return Future.value(Uint8List(0)); + final cached = imageData[guid]; + if (cached != null) return Future.value(cached); + final pending = _pendingImageLoads[guid]; + if (pending != null) return pending; + + if (_isClosed) { + return Future.value(Uint8List(0)); + } + + final completer = Completer(); + final load = _PendingImageLoad(attachment, file, completer); + _imageCacheQueue.add(load); + _pendingImageLoads[guid] = completer.future; + completer.future.then((_) { + if (identical(_pendingImageLoads[guid], completer.future)) { + _pendingImageLoads.remove(guid); + } + }); if (!isProcessingImage) _processNextImage(); + return completer.future; } Future _processNextImage() async { - if (imageCacheQueue.isEmpty) { - isProcessingImage = false; - return; - } - isProcessingImage = true; - final queued = imageCacheQueue.removeAt(0); - final attachment = queued.item1; - final file = queued.item2; - Uint8List? tmpData; - // If it's an image, compress the image when loading it - if (kIsWeb || file.path == null) { - if (attachment.mimeType?.contains("image/tif") ?? false) { - final receivePort = ReceivePort(); - await Isolate.spawn(unsupportedToPngIsolate, IsolateData(file, receivePort.sendPort)); - // Get the processed image from the isolate. - final image = await receivePort.first as Uint8List?; - tmpData = image; - } else { - tmpData = file.bytes; + try { + while (!_isClosed && _imageCacheQueue.isNotEmpty) { + final queued = _imageCacheQueue.removeAt(0); + _activeImageLoad = queued; + final attachment = queued.attachment; + final file = queued.file; + Uint8List? tmpData; + try { + // If it's an image, compress the image when loading it. + if (kIsWeb || file.path == null) { + if (attachment.mimeType?.contains("image/tif") ?? false) { + final receivePort = ReceivePort(); + await Isolate.spawn(unsupportedToPngIsolate, + IsolateData(file, receivePort.sendPort)); + tmpData = await receivePort.first as Uint8List?; + } else { + tmpData = file.bytes; + } + } else if (attachment.canCompress) { + tmpData = await as.loadAndGetProperties( + attachment, + actualPath: file.path!, + ); + } else { + tmpData = await File(file.path!).readAsBytes(); + } + } catch (error, stackTrace) { + Logger.warn( + "Failed to prepare attachment preview", + error: error, + trace: stackTrace, + ); + } + + if (_isClosed || tmpData == null) { + if (!queued.completer.isCompleted) { + queued.completer.complete(Uint8List(0)); + } + continue; + } + + imageData[attachment.guid!] = tmpData; + if (!queued.completer.isCompleted) { + queued.completer.complete(tmpData); + } } - } else if (attachment.canCompress) { - tmpData = await as.loadAndGetProperties(attachment, actualPath: file.path!); - // All other attachments can be held in memory as bytes - } else { - tmpData = await File(file.path!).readAsBytes(); - } - if (tmpData == null) { - queued.item4.complete(Uint8List.fromList([])); - return; + } finally { + _activeImageLoad = null; + isProcessingImage = false; } - imageData[attachment.guid!] = tmpData; - try { - await precacheImage(MemoryImage(tmpData), queued.item3); - } catch (_) {} - queued.item4.complete(tmpData); - - await _processNextImage(); } bool isSelected(String guid) { @@ -314,7 +476,9 @@ class ConversationViewController extends StatefulController with GetSingleTicker } bool isEditing(String guid, int part) { - return editing.firstWhereOrNull((e) => e.item1.guid == guid && e.item2.part == part) != null; + return editing.firstWhereOrNull( + (e) => e.item1.guid == guid && e.item2.part == part) != + null; } void close() { @@ -325,8 +489,10 @@ class ConversationViewController extends StatefulController with GetSingleTicker Future saveReplyToMessageState() async { if (replyToMessage != null) { - await ss.prefs.setString('replyToMessage_${chat.guid}', replyToMessage!.item1.guid!); - await ss.prefs.setInt('replyToMessagePart_${chat.guid}', replyToMessage!.item2); + await ss.prefs.setString( + 'replyToMessage_${chat.guid}', replyToMessage!.item1.guid!); + await ss.prefs + .setInt('replyToMessagePart_${chat.guid}', replyToMessage!.item2); } else { await ss.prefs.remove('replyToMessage_${chat.guid}'); await ss.prefs.remove('replyToMessagePart_${chat.guid}'); @@ -334,8 +500,10 @@ class ConversationViewController extends StatefulController with GetSingleTicker } Future loadReplyToMessageState() async { - final replyToMessageGuid = ss.prefs.getString('replyToMessage_${chat.guid}'); - final replyToMessagePart = ss.prefs.getInt('replyToMessagePart_${chat.guid}'); + final replyToMessageGuid = + ss.prefs.getString('replyToMessage_${chat.guid}'); + final replyToMessagePart = + ss.prefs.getInt('replyToMessagePart_${chat.guid}'); if (replyToMessageGuid != null && replyToMessagePart != null) { final message = Message.findOne(guid: replyToMessageGuid); if (message != null) { @@ -344,3 +512,11 @@ class ConversationViewController extends StatefulController with GetSingleTicker } } } + +class _PendingImageLoad { + const _PendingImageLoad(this.attachment, this.file, this.completer); + + final Attachment attachment; + final PlatformFile file; + final Completer completer; +} diff --git a/lib/services/ui/message/message_widget_controller.dart b/lib/services/ui/message/message_widget_controller.dart index 2fb6a8edcc..8f60c98076 100644 --- a/lib/services/ui/message/message_widget_controller.dart +++ b/lib/services/ui/message/message_widget_controller.dart @@ -9,10 +9,8 @@ import 'package:bluebubbles/helpers/helpers.dart'; import 'package:bluebubbles/database/database.dart'; import 'package:bluebubbles/database/models.dart'; import 'package:bluebubbles/services/services.dart'; -import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:get/get.dart'; -import 'package:bluebubbles/utils/logger/logger.dart'; MessageWidgetController mwc(Message message) => Get.isRegistered(tag: message.guid) ? Get.find(tag: message.guid) @@ -31,7 +29,7 @@ class MessageWidgetController extends StatefulController with GetSingleTickerPro String? newMessageGuid; ConversationViewController? cvController; late final String tag; - late final StreamSubscription? sub; + StreamSubscription? sub; bool built = false; static const maxBubbleSizeFactor = 0.75; diff --git a/lib/services/ui/message/messages_service.dart b/lib/services/ui/message/messages_service.dart index 83ec39d670..5be3c400a1 100644 --- a/lib/services/ui/message/messages_service.dart +++ b/lib/services/ui/message/messages_service.dart @@ -19,7 +19,7 @@ String? lastReloadedChat() => Get.isRegistered(tag: 'lastReloadedChat') class MessagesService extends GetxController { static final Map cachedBubbleSizes = {}; late Chat chat; - late StreamSubscription countSub; + StreamSubscription? countSub; final ChatMessages struct = ChatMessages(); late Function(Message) newFunc; late Function(Message, {String? oldGuid}) updateFunc; @@ -85,7 +85,7 @@ class MessagesService extends GetxController { @override void onClose() { if (_init) { - countSub.cancel(); + countSub?.cancel(); } _init = false; super.onClose(); @@ -120,15 +120,26 @@ class MessagesService extends GetxController { if (message.amkSessionId != null) { message.fetchAssociatedMessages(); } - // add this as a reaction if needed, update thread originators and associated messages + // Add this as a reaction if needed, update thread originators and + // associated messages. ChatMessages retains a bounded pending set when a + // reaction arrives before its base message. if (message.associatedMessageGuid != null) { - struct.getMessage(message.associatedMessageGuid!)?.associatedMessages.add(message); - getActiveMwc(message.associatedMessageGuid!)?.updateAssociatedMessage(message); + final parent = struct.getMessage(message.associatedMessageGuid!); + if (parent != null) { + getActiveMwc(message.associatedMessageGuid!)?.updateAssociatedMessage(message); + } } if (message.threadOriginatorGuid != null) { getActiveMwc(message.threadOriginatorGuid!)?.updateThreadOriginator(message); } struct.addMessages([message]); + if (message.associatedMessageGuid == null) { + // ChatMessages attaches any reactions that arrived before this message; + // refresh the active bubble after the parent is present in the struct. + for (final reaction in message.associatedMessages) { + getActiveMwc(message.guid!)?.updateAssociatedMessage(reaction); + } + } if (message.associatedMessageGuid == null) { newFunc.call(message); } @@ -182,7 +193,11 @@ class MessagesService extends GetxController { for (Message m in _messages.where((e) => e.threadOriginatorGuid != null)) { // see if the originator is already loaded final guid = m.threadOriginatorGuid!; - if (struct.getMessage(guid) != null) continue; + final loadedOriginator = struct.getMessage(guid); + if (loadedOriginator != null) { + struct.addThreadOriginator(loadedOriginator); + continue; + } // if not, fetch local and add to data final threadOriginator = Message.findOne(guid: guid); if (threadOriginator != null) { @@ -271,4 +286,4 @@ class MessagesService extends GetxController { return completer.future; } -} \ No newline at end of file +} diff --git a/lib/utils/logger/logger.dart b/lib/utils/logger/logger.dart index 1482fd5daf..3278e9c428 100644 --- a/lib/utils/logger/logger.dart +++ b/lib/utils/logger/logger.dart @@ -42,7 +42,9 @@ class BaseLogger extends GetxService { return LoggerFactory.AdvancedFileOutput( path: logDir, maxFileSizeKB: 1024, // 1 MB - + // Prevent a large sync from retaining thousands of formatted log + // messages while the UI is also decoding and storing messages. + maxBufferSize: 200, maxRotatedFilesCount: 5, maxDelay: const Duration(seconds: 5), latestFileName: latestLogName, @@ -300,4 +302,4 @@ class Traceback implements Exception { String toString() { return "Traceback"; } -} \ No newline at end of file +} diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 3252a29bc5..c9bde700b9 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -605,17 +605,6 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" -[[package]] -name = "clearadi" -version = "0.1.0" -dependencies = [ - "cc", - "deku 0.18.1", - "rand 0.8.5", - "sha2", - "thiserror 2.0.16", -] - [[package]] name = "cloudkit-derive" version = "0.1.0" @@ -815,18 +804,8 @@ version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" dependencies = [ - "darling_core 0.14.4", - "darling_macro 0.14.4", -] - -[[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core 0.20.11", - "darling_macro 0.20.11", + "darling_core", + "darling_macro", ] [[package]] @@ -843,42 +822,17 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim 0.11.1", - "syn 2.0.105", -] - [[package]] name = "darling_macro" version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" dependencies = [ - "darling_core 0.14.4", + "darling_core", "quote", "syn 1.0.109", ] -[[package]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core 0.20.11", - "quote", - "syn 2.0.105", -] - [[package]] name = "dart-sys-fork" version = "4.1.1" @@ -904,6 +858,12 @@ dependencies = [ "num_cpus", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "dbl" version = "0.3.2" @@ -920,19 +880,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "819b87cc7a05b3abe3fc38e59b3980a5fd3162f25a247116441a9171d3e84481" dependencies = [ "bitvec", - "deku_derive 0.16.0", -] - -[[package]] -name = "deku" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9711031e209dc1306d66985363b4397d4c7b911597580340b93c9729b55f6eb" -dependencies = [ - "bitvec", - "deku_derive 0.18.1", - "no_std_io2", - "rustversion", + "deku_derive", ] [[package]] @@ -941,26 +889,13 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e2ca12572239215a352a74ad7c776d7e8a914f8a23511c6cbedddd887e5009e" dependencies = [ - "darling 0.14.4", - "proc-macro-crate 1.3.1", + "darling", + "proc-macro-crate", "proc-macro2", "quote", "syn 1.0.109", ] -[[package]] -name = "deku_derive" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58cb0719583cbe4e81fb40434ace2f0d22ccc3e39a74bb3796c22b451b4f139d" -dependencies = [ - "darling 0.20.11", - "proc-macro-crate 3.3.0", - "proc-macro2", - "quote", - "syn 2.0.105", -] - [[package]] name = "delegate-attr" version = "0.3.0" @@ -1007,7 +942,7 @@ dependencies = [ "deluxe-core", "heck 0.4.1", "if_chain", - "proc-macro-crate 1.3.1", + "proc-macro-crate", "proc-macro2", "quote", "syn 2.0.105", @@ -1073,7 +1008,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", - "const-oid 0.9.6", "crypto-common", "subtle", ] @@ -2111,9 +2045,6 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -dependencies = [ - "spin 0.9.8", -] [[package]] name = "libc" @@ -2145,12 +2076,6 @@ dependencies = [ "rle-decode-fast", ] -[[package]] -name = "libm" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" - [[package]] name = "libredox" version = "0.1.3" @@ -2328,15 +2253,6 @@ dependencies = [ "tempfile", ] -[[package]] -name = "no_std_io2" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a3564ce7035b1e4778d8cb6cacebb5d766b5e8fe5a75b9e441e33fb61a872c6" -dependencies = [ - "memchr", -] - [[package]] name = "nom" version = "7.1.3" @@ -2394,23 +2310,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-bigint-dig" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" -dependencies = [ - "byteorder", - "lazy_static", - "libm", - "num-integer", - "num-iter", - "num-traits", - "rand 0.8.5", - "smallvec", - "zeroize", -] - [[package]] name = "num-conv" version = "0.1.0" @@ -2426,17 +2325,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-iter" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -2444,7 +2332,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", - "libm", ] [[package]] @@ -2504,8 +2391,8 @@ dependencies = [ "async-trait", "base64 0.21.7", "chrono", - "clearadi", "dlopen2", + "futures-util", "hex", "libc", "log", @@ -2520,6 +2407,7 @@ dependencies = [ "sha2", "thiserror 1.0.69", "tokio", + "tokio-tungstenite", "uuid", ] @@ -2537,15 +2425,9 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "open-absinthe" -version = "0.1.0" +version = "1.0.0" dependencies = [ - "deku 0.16.0", - "rand 0.8.5", - "rsa", "serde", - "sha1", - "thiserror 1.0.69", - "x509-cert", ] [[package]] @@ -2722,17 +2604,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" -[[package]] -name = "pkcs1" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" -dependencies = [ - "der 0.7.10", - "pkcs8", - "spki 0.7.3", -] - [[package]] name = "pkcs7" version = "0.3.0" @@ -2743,16 +2614,6 @@ dependencies = [ "spki 0.5.4", ] -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der 0.7.10", - "spki 0.7.3", -] - [[package]] name = "pkg-config" version = "0.3.31" @@ -2832,16 +2693,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" dependencies = [ "once_cell", - "toml_edit 0.19.15", -] - -[[package]] -name = "proc-macro-crate" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" -dependencies = [ - "toml_edit 0.22.24", + "toml_edit", ] [[package]] @@ -3274,27 +3126,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" -[[package]] -name = "rsa" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" -dependencies = [ - "const-oid 0.9.6", - "digest", - "num-bigint-dig", - "num-integer", - "num-traits", - "pkcs1", - "pkcs8", - "rand_core 0.6.4", - "sha1", - "signature", - "spki 0.7.3", - "subtle", - "zeroize", -] - [[package]] name = "rust_lib_bluebubbles" version = "0.1.0" @@ -3446,7 +3277,7 @@ dependencies = [ "cloudkit-derive", "cloudkit-proto", "ctr", - "deku 0.16.0", + "deku", "flume", "futures", "hkdf", @@ -3662,16 +3493,6 @@ dependencies = [ "libc", ] -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "digest", - "rand_core 0.6.4", -] - [[package]] name = "siphasher" version = "0.3.11" @@ -4107,6 +3928,21 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "212d5dcb2a1ce06d81107c3d0ffa3121fe974b73f068c8282cb1c32328113b6c" +dependencies = [ + "futures-util", + "log", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", + "tungstenite", + "webpki-roots", +] + [[package]] name = "tokio-util" version = "0.7.13" @@ -4143,18 +3979,7 @@ checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ "indexmap", "toml_datetime", - "winnow 0.5.40", -] - -[[package]] -name = "toml_edit" -version = "0.22.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474" -dependencies = [ - "indexmap", - "toml_datetime", - "winnow 0.7.6", + "winnow", ] [[package]] @@ -4188,6 +4013,26 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e3dac10fd62eaf6617d3a904ae222845979aec67c615d1c842b4002c7666fb9" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.5", + "rustls 0.21.12", + "sha1", + "thiserror 1.0.69", + "url", + "utf-8", +] + [[package]] name = "typenum" version = "1.17.0" @@ -4377,6 +4222,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf16_iter" version = "1.0.5" @@ -4792,15 +4643,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winnow" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63d3fcd9bba44b03821e7d699eeee959f3126dcc4aa8e4ae18ec617c2a5cea10" -dependencies = [ - "memchr", -] - [[package]] name = "winreg" version = "0.50.0" diff --git a/rust/src/api/api.rs b/rust/src/api/api.rs index 10a5922db2..59b1379cca 100644 --- a/rust/src/api/api.rs +++ b/rust/src/api/api.rs @@ -1895,11 +1895,11 @@ pub async fn download_attachment(sink: StreamSink, aps: &APSCo let mut file = std::fs::File::create(path)?; attachment.get_attachment(aps, &mut file, |prog, total| { println!("donwloading file {} of {}", prog, total); - sink.add(TransferProgress { + let _ = sink.add(TransferProgress { prog, total, attachment: None - }).unwrap(); + }); }).await?; file.flush()?; Ok(()) @@ -1914,11 +1914,11 @@ pub async fn download_mmcs(sink: StreamSink, aps: &APSConnecti let mut file = std::fs::File::create(path)?; attachment.get_attachment(aps, &mut file, |prog, total| { - sink.add(TransferProgress { + let _ = sink.add(TransferProgress { prog, total, attachment: None - }).unwrap(); + }); }).await?; file.flush()?; Ok(()) @@ -1928,7 +1928,7 @@ pub async fn download_mmcs(sink: StreamSink, aps: &APSConnecti async fn wrap_sink(sink: &StreamSink, f: impl FnOnce() -> Fut) where Fut: Future> { if let Err(err) = f().await { - sink.add_error(err).unwrap(); + let _ = sink.add_error(err); } } @@ -1945,13 +1945,13 @@ pub async fn upload_mmcs(sink: StreamSink, aps: &APSConnec let prepared = MMCSFile::prepare_put(&mut file).await?; file.rewind()?; let attachment = MMCSFile::new(aps, &prepared, file, |prog, total| { - sink.add(MMCSTransferProgress { + let _ = sink.add(MMCSTransferProgress { prog, total, file: None - }).unwrap(); + }); }).await?; - sink.add(MMCSTransferProgress { prog: 0, total: 0, file: Some(attachment) }).unwrap(); + let _ = sink.add(MMCSTransferProgress { prog: 0, total: 0, file: Some(attachment) }); Ok(()) }).await } @@ -1963,13 +1963,13 @@ pub async fn upload_attachment(sink: StreamSink, aps: &APSConn let prepared = MMCSFile::prepare_put(&mut file).await?; file.rewind()?; let attachment = Attachment::new_mmcs(aps, &prepared, file, &mime, &uti, &name,|prog, total| { - sink.add(TransferProgress { + let _ = sink.add(TransferProgress { prog, total, attachment: None - }).unwrap(); + }); }).await?; - sink.add(TransferProgress { prog: 0, total: 0, attachment: Some(attachment) }).unwrap(); + let _ = sink.add(TransferProgress { prog: 0, total: 0, attachment: Some(attachment) }); Ok(()) }).await } @@ -2306,16 +2306,26 @@ pub async fn auth_phone(conn: &APSConnection, config: &JoinedOSConfig, number: S pub async fn send_2fa_to_devices(state: &Arc>>, conn: &APSConnection) -> anyhow::Result<(CircleClientSession, LoginState, Option)> { let account = state.lock().await; - let spd = account.spd.as_ref().unwrap(); let dsid = spd["DsPrsId"].as_unsigned_integer().unwrap(); - drop(account); let client_session = CircleClientSession::new(dsid, state.clone(), conn.get_token().await).await?; - let sid = client_session.session_id.clone(); - Ok((client_session, LoginState::Needs2FAVerification, sid)) + #[cfg(target_os = "android")] + { + let sid = client_session.session_id.clone(); + Ok((client_session, LoginState::Needs2FAVerification, sid)) + } + + #[cfg(not(target_os = "android"))] + { + // Desktop cannot advertise the BLE proximity service required to + // complete the circle exchange. Keep the session object for bridge + // compatibility, but trigger Apple's standard trusted-device prompt. + let login_state = state.lock().await.send_2fa_to_devices().await?; + Ok((client_session, login_state, None)) + } } #[frb(type_64bit_int)] @@ -2597,24 +2607,34 @@ pub async fn circle_setup_clique(client: &Arc, anisette: &ArcAnisetteClient, os_config: &JoinedOSConfig, account: &Arc>>, watcher: &mut broadcast::Receiver, idms: &Arc, code: String) -> anyhow::Result<(LoginState, Option)> { - client.send_code(&code).await?; - - // todo add timeout - let mut login_state = tokio::time::timeout(Duration::from_secs(30), async { - Ok::<_, PushError>(loop { - let msg = watcher.recv().await.unwrap(); - if let Some(test) = idms.handle(msg)? { - match test { - IdmsMessage::CircleRequest(c, _) => { - if let Some(state) = client.handle_circle_request(&c).await? { - break state; - } - }, - _ => { } + #[cfg(target_os = "android")] + let mut login_state = { + client.send_code(&code).await?; + + tokio::time::timeout(Duration::from_secs(30), async { + loop { + let msg = watcher.recv().await + .map_err(|error| anyhow!("Trusted-device 2FA push listener closed: {error}"))?; + if let Some(test) = idms.handle(msg)? { + match test { + IdmsMessage::CircleRequest(c, _) => { + if let Some(state) = client.handle_circle_request(&c).await? { + break Ok::<_, anyhow::Error>(state); + } + }, + _ => { } + } } } - }) - }).await.map_err(|_| anyhow!("Timed Out!"))??; + }).await.map_err(|_| anyhow!("Timed out waiting for the trusted-device 2FA proximity response"))?? + }; + + #[cfg(not(target_os = "android"))] + let mut login_state = { + // The code shown by Apple's normal trusted-device prompt can be + // verified directly without waiting on an unavailable BLE exchange. + account.lock().await.verify_2fa(code).await? + }; let mut user = None; let pet = account.lock().await.get_pet(); diff --git a/rust/src/lib.rs b/rust/src/lib.rs index adbe8c02b4..53c697f975 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -4,6 +4,7 @@ use flexi_logger::{opt_format, Age, Cleanup, Criterion, FileSpec, Logger, Naming use tokio::runtime::Runtime; use log::info; +static LOGGER_INITIALIZED: OnceLock<()> = OnceLock::new(); uniffi::setup_scaffolding!(); @@ -21,6 +22,7 @@ pub mod bbhwinfo { } pub fn init_logger(path: &Path) { + LOGGER_INITIALIZED.get_or_init(|| { #[cfg(target_os = "android")] let system = android_logger::AndroidLogger::new( android_logger::Config::default().with_max_level(log::LevelFilter::Debug), @@ -34,8 +36,6 @@ pub fn init_logger(path: &Path) { .build() }; - println!("here??"); - let (logger, _) = Logger::try_with_str("debug").expect("No logger?") .log_to_file(FileSpec::default().directory(path.join("logs")).suppress_timestamp()) .append() @@ -45,7 +45,10 @@ pub fn init_logger(path: &Path) { .write_mode(WriteMode::BufferAndFlush) .build().unwrap(); - multi_log::MultiLogger::init(vec![Box::new(system), logger], log::Level::Trace).expect("No init?"); + // Logging is process-global. Background isolates can call this entry + // point again, so repeated initialization must be harmless. + let _ = multi_log::MultiLogger::init(vec![Box::new(system), logger], log::Level::Trace); + }); } mod native; diff --git a/rust/src/native.rs b/rust/src/native.rs index 5ef5584215..f4b45322b0 100644 --- a/rust/src/native.rs +++ b/rust/src/native.rs @@ -154,15 +154,41 @@ pub fn plist_to_string(value: &T) -> Result)>> = LazyLock::new(|| Mutex::new((0, HashMap::new()))); +fn is_terminal_poll_panic(message: &str) -> bool { + let message = message.to_ascii_lowercase(); + message.contains("wrong phase") + || (message.contains("watcher") && message.contains("closed")) + || message.contains("channel closed") +} + +#[cfg(test)] +mod tests { + use super::is_terminal_poll_panic; + + #[test] + fn terminal_watcher_panics_stop_the_receive_loop() { + assert!(is_terminal_poll_panic("Wrong phase!")); + assert!(is_terminal_poll_panic("APS watcher is closed")); + assert!(is_terminal_poll_panic("channel closed")); + } + + #[test] + fn unrelated_panics_remain_retryable() { + assert!(!is_terminal_poll_panic("temporary network failure")); + } +} + #[uniffi::export] impl NativePushState { pub fn start_loop(self: Arc, handler: Arc) { RUNTIME.spawn(async move { let mut watcher = self.watcher.lock().await; + let mut panic_backoff_ms = 250u64; loop { match std::panic::AssertUnwindSafe(recv_wait(&mut watcher, &self.state)).catch_unwind().await { Ok(yes) => { + panic_backoff_ms = 250; match yes { PollResult::Cont(Some(msg)) => { if let PushMessage::TwoFaAuthEvent(event) = &msg { @@ -211,7 +237,14 @@ impl NativePushState { None => None, }, }; - error!("Failed {:?}", panic); + if panic.map(is_terminal_poll_panic).unwrap_or(false) { + warn!("Stopping APS receive loop after terminal watcher panic: {:?}", panic); + break; + } + + error!("Failed {:?}; backing off {}ms", panic, panic_backoff_ms); + tokio::time::sleep(Duration::from_millis(panic_backoff_ms)).await; + panic_backoff_ms = (panic_backoff_ms.saturating_mul(2)).min(5_000); } } } @@ -368,4 +401,4 @@ impl NativePushState { } }); } -} \ No newline at end of file +} diff --git a/rust_builder/cargokit/build_tool/lib/src/android_environment.dart b/rust_builder/cargokit/build_tool/lib/src/android_environment.dart index fa6524331c..13542dbe3d 100644 --- a/rust_builder/cargokit/build_tool/lib/src/android_environment.dart +++ b/rust_builder/cargokit/build_tool/lib/src/android_environment.dart @@ -52,6 +52,14 @@ class AndroidEnvironment { /// Target being built. final Target target; + // OpenSSL's vendored build runs these values through a POSIX make shell. + // Backslashes in Windows paths are treated as escapes there, turning + // `C:\path\clang.exe` into `C:pathclang.exe`. Forward slashes remain valid + // Windows paths and survive both Cargo and make unchanged. + String _buildScriptPath(String value) { + return Platform.isWindows ? value.replaceAll(r'\', '/') : value; + } + bool ndkIsInstalled() { final ndkPath = path.join(sdkPath, 'ndk', ndkVersion); final ndkPackageXml = File(path.join(ndkPath, 'package.xml')); @@ -107,19 +115,19 @@ class AndroidEnvironment { final targetArg = '--target=${target.rust}$minSdkVersion'; final ccKey = 'CC_${target.rust}'; - final ccValue = path.join(toolchainPath, 'clang$exe'); + final ccValue = _buildScriptPath(path.join(toolchainPath, 'clang$exe')); final cfFlagsKey = 'CFLAGS_${target.rust}'; final cFlagsValue = targetArg; final cxxKey = 'CXX_${target.rust}'; - final cxxValue = path.join(toolchainPath, 'clang++$exe'); + final cxxValue = _buildScriptPath(path.join(toolchainPath, 'clang++$exe')); final cxxfFlagsKey = 'CXXFLAGS_${target.rust}'; final cxxFlagsValue = targetArg; final linkerKey = 'cargo_target_${target.rust.replaceAll('-', '_')}_linker'.toUpperCase(); final ranlibKey = 'RANLIB_${target.rust}'; - final ranlibValue = path.join(toolchainPath, 'llvm-ranlib$exe'); + final ranlibValue = _buildScriptPath(path.join(toolchainPath, 'llvm-ranlib$exe')); final ndkVersionParsed = Version.parse(ndkVersion); final rustFlagsKey = 'CARGO_ENCODED_RUSTFLAGS'; @@ -141,7 +149,7 @@ class AndroidEnvironment { final toolTempDir = Platform.environment['CARGOKIT_TOOL_TEMP_DIR'] ?? targetTempDir; return { - arKey: arValue, + arKey: _buildScriptPath(arValue), ccKey: ccValue, cfFlagsKey: cFlagsValue, cxxKey: cxxValue, diff --git a/rust_builder/cargokit/build_tool/lib/src/target.dart b/rust_builder/cargokit/build_tool/lib/src/target.dart index 913af19852..9b8bb1f1fd 100644 --- a/rust_builder/cargokit/build_tool/lib/src/target.dart +++ b/rust_builder/cargokit/build_tool/lib/src/target.dart @@ -46,6 +46,10 @@ class Target { rust: 'x86_64-pc-windows-msvc', flutter: 'windows-x64', ), + Target( + rust: 'aarch64-pc-windows-msvc', + flutter: 'windows-arm64', + ), Target( rust: 'x86_64-unknown-linux-gnu', flutter: 'linux-x64', diff --git a/rustpush b/rustpush index a7fab473e7..e5e76919ce 160000 --- a/rustpush +++ b/rustpush @@ -1 +1 @@ -Subproject commit a7fab473e7a33325a760635285db2860de8e1cb0 +Subproject commit e5e76919ceb9bf6e2fc05c8ad641430ffcfeb588 diff --git a/test/helpers/chat_messages_test.dart b/test/helpers/chat_messages_test.dart new file mode 100644 index 0000000000..c769fce9de --- /dev/null +++ b/test/helpers/chat_messages_test.dart @@ -0,0 +1,102 @@ +import 'package:bluebubbles/database/models.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('attaches a reaction that arrives before its base message', () { + final messages = ChatMessages(); + final reaction = Message( + guid: 'reaction-1', + associatedMessageGuid: 'base-1', + associatedMessageType: 'like', + ); + final base = Message(guid: 'base-1', text: 'hello'); + + messages.addMessages([reaction]); + messages.addMessages([base]); + + expect(messages.getMessage('base-1'), same(base)); + expect(base.hasReactions, isTrue); + expect(base.associatedMessages.map((item) => item.guid).toList(), ['reaction-1']); + }); + + test('does not duplicate a reaction when the event is replayed', () { + final messages = ChatMessages(); + final base = Message(guid: 'base-2', text: 'hello'); + final reaction = Message( + guid: 'reaction-2', + associatedMessageGuid: 'base-2', + associatedMessageType: 'like', + ); + + messages.addMessages([base, reaction, reaction]); + + expect(base.associatedMessages, hasLength(1)); + expect(base.associatedMessages.single, same(reaction)); + }); + + test('includes the originator when it is loaded before its replies', () { + final messages = ChatMessages(); + final originator = Message(guid: 'thread-1', text: 'originator'); + final reply = Message( + guid: 'reply-1', + text: 'reply', + threadOriginatorGuid: 'thread-1', + threadOriginatorPart: '0', + ); + + messages.addMessages([originator, reply]); + + expect( + messages.threads('thread-1', 0).map((message) => message.guid), + containsAll(['thread-1', 'reply-1']), + ); + }); + + test('includes the originator when it is loaded after its replies', () { + final messages = ChatMessages(); + final originator = Message(guid: 'thread-2', text: 'originator'); + final reply = Message( + guid: 'reply-2', + text: 'reply', + threadOriginatorGuid: 'thread-2', + threadOriginatorPart: '0', + ); + + messages.addMessages([reply, originator]); + + expect( + messages.threads('thread-2', 0).map((message) => message.guid), + containsAll(['thread-2', 'reply-2']), + ); + }); + + test('returns every message in a lengthy reply chain', () { + final messages = ChatMessages(); + final originator = Message(guid: 'thread-long', text: 'originator'); + final replies = List.generate( + 100, + (index) => Message( + guid: 'reply-long-$index', + text: 'reply $index', + threadOriginatorGuid: 'thread-long', + threadOriginatorPart: '0', + ), + ); + + messages.addMessages([originator, ...replies]); + + final thread = messages.threads('thread-long', 0); + expect(thread, hasLength(101)); + expect(thread.map((message) => message.guid).toSet(), hasLength(101)); + }); + + test('keeps multi-digit reply part indexes intact', () { + final reply = Message( + guid: 'reply-part-12', + threadOriginatorGuid: 'thread-multipart', + threadOriginatorPart: '12:4:8', + ); + + expect(reply.normalizedThreadPart, 12); + }); +} diff --git a/test/helpers/group_participant_helpers_test.dart b/test/helpers/group_participant_helpers_test.dart new file mode 100644 index 0000000000..1c2d04eb5f --- /dev/null +++ b/test/helpers/group_participant_helpers_test.dart @@ -0,0 +1,47 @@ +import 'package:bluebubbles/database/models.dart'; +import 'package:bluebubbles/helpers/group_participant_helpers.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('reconcileGroupParticipants', () { + test('replaces a participant when group size is unchanged', () { + final existing = Handle(address: 'old@example.com'); + final retained = Handle(address: 'kept@example.com'); + final replacement = Handle(address: 'new@example.com'); + + final result = reconcileGroupParticipants( + [existing, retained], + [replacement, Handle(address: 'kept@example.com')], + ); + + expect(result, hasLength(2)); + expect(result[0], same(replacement)); + expect(result[1], same(retained)); + }); + + test('keeps every new participant and removes duplicate identities', () { + final result = reconcileGroupParticipants( + [], + [ + Handle(address: 'one@example.com'), + Handle(address: 'two@example.com'), + Handle(address: 'two@example.com'), + Handle(address: 'three@example.com'), + ], + ); + + expect(result.map((handle) => handle.address), + ['one@example.com', 'two@example.com', 'three@example.com']); + }); + + test('distinguishes the same address on different services', () { + final sms = Handle(address: '+15550000001', service: 'SMS'); + final imessage = Handle(address: '+15550000001', service: 'iMessage'); + + final result = reconcileGroupParticipants([sms], [imessage]); + + expect(result, hasLength(1)); + expect(result.single, same(imessage)); + }); + }); +} diff --git a/test/helpers/memory/bounded_byte_cache_test.dart b/test/helpers/memory/bounded_byte_cache_test.dart new file mode 100644 index 0000000000..2eeb977277 --- /dev/null +++ b/test/helpers/memory/bounded_byte_cache_test.dart @@ -0,0 +1,70 @@ +import 'dart:typed_data'; + +import 'package:bluebubbles/helpers/memory/bounded_byte_cache.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Uint8List bytes(int length, int marker) => + Uint8List.fromList(List.filled(length, marker)); + + test('evicts least recently used bytes when the byte budget is exceeded', () { + final cache = BoundedByteCache(maximumSizeBytes: 10, maximumEntries: 10); + + cache['first'] = bytes(4, 1); + cache['second'] = bytes(4, 2); + expect(cache['first'], isNotNull); // Make second the least-recently used. + cache['third'] = bytes(4, 3); + + expect(cache['first'], isNotNull); + expect(cache['second'], isNull); + expect(cache['third'], isNotNull); + expect(cache.currentSizeBytes, 8); + }); + + test('evicts least recently used entries when the entry limit is exceeded', + () { + final cache = BoundedByteCache(maximumSizeBytes: 100, maximumEntries: 2); + + cache['first'] = bytes(1, 1); + cache['second'] = bytes(1, 2); + cache['third'] = bytes(1, 3); + + expect(cache['first'], isNull); + expect(cache['second'], isNotNull); + expect(cache['third'], isNotNull); + expect(cache.length, 2); + }); + + test('replacement and removal keep byte accounting accurate', () { + final cache = BoundedByteCache(maximumSizeBytes: 20, maximumEntries: 4); + + cache['item'] = bytes(8, 1); + cache['item'] = bytes(3, 2); + expect(cache.currentSizeBytes, 3); + + expect(cache.remove('item'), isNotNull); + expect(cache.currentSizeBytes, 0); + expect(cache.isEmpty, isTrue); + }); + + test('does not retain one item larger than the entire cache budget', () { + final cache = BoundedByteCache(maximumSizeBytes: 10, maximumEntries: 4); + + cache['small'] = bytes(4, 1); + cache['oversized'] = bytes(11, 2); + + expect(cache['small'], isNotNull); + expect(cache['oversized'], isNull); + expect(cache.currentSizeBytes, 4); + }); + + test('oversized replacement removes the stale same-key value', () { + final cache = BoundedByteCache(maximumSizeBytes: 10, maximumEntries: 4); + + cache['item'] = bytes(4, 1); + cache['item'] = bytes(11, 2); + + expect(cache['item'], isNull); + expect(cache.currentSizeBytes, 0); + }); +} diff --git a/test/helpers/memory/bounded_lru_map_test.dart b/test/helpers/memory/bounded_lru_map_test.dart new file mode 100644 index 0000000000..057d36684e --- /dev/null +++ b/test/helpers/memory/bounded_lru_map_test.dart @@ -0,0 +1,102 @@ +import 'package:bluebubbles/helpers/memory/bounded_lru_map.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('reads update recency before entry-count eviction', () { + final cache = BoundedLruMap(maximumEntries: 2); + + cache['first'] = 'one'; + cache['second'] = 'two'; + expect(cache['first'], 'one'); + cache['third'] = 'three'; + + expect(cache['first'], 'one'); + expect(cache['second'], isNull); + expect(cache['third'], 'three'); + }); + + test('weight budget evicts the least recently used values', () { + final cache = BoundedLruMap( + maximumEntries: 10, + maximumWeight: 8, + weightOf: (value) => value.length, + ); + + cache['first'] = '1234'; + cache['second'] = '5678'; + cache['third'] = 'abc'; + + expect(cache['first'], isNull); + expect(cache['second'], '5678'); + expect(cache['third'], 'abc'); + expect(cache.currentWeight, 7); + }); + + test('replacement, removal, and clear maintain weight accounting', () { + final cache = BoundedLruMap( + maximumEntries: 4, + maximumWeight: 20, + weightOf: (value) => value.length, + ); + + cache['item'] = '12345678'; + cache['item'] = '123'; + expect(cache.currentWeight, 3); + + expect(cache.remove('item'), '123'); + expect(cache.currentWeight, 0); + + cache['first'] = '1'; + cache['second'] = '22'; + cache.clear(); + expect(cache.length, 0); + expect(cache.currentWeight, 0); + }); + + test('does not retain a value larger than the entire weight budget', () { + final cache = BoundedLruMap( + maximumEntries: 4, + maximumWeight: 5, + weightOf: (value) => value.length, + ); + + cache['small'] = '123'; + cache['oversized'] = '123456'; + + expect(cache['small'], '123'); + expect(cache['oversized'], isNull); + expect(cache.currentWeight, 3); + }); + + test('copy-on-write replacement keeps nested weight accurate', () { + final cache = BoundedLruMap>( + maximumEntries: 4, + maximumWeight: 10, + weightOf: (value) => + value.values.fold(0, (total, item) => total + item.length), + ); + + cache['message'] = {'first': '1234'}; + final replacement = Map.from(cache['message'] ?? const {}); + replacement['second'] = '567'; + cache['message'] = replacement; + + expect(cache['message'], containsPair('first', '1234')); + expect(cache['message'], containsPair('second', '567')); + expect(cache.currentWeight, 7); + }); + + test('oversized replacement removes the stale same-key value', () { + final cache = BoundedLruMap( + maximumEntries: 4, + maximumWeight: 5, + weightOf: (value) => value.length, + ); + + cache['item'] = '123'; + cache['item'] = '123456'; + + expect(cache['item'], isNull); + expect(cache.currentWeight, 0); + }); +} diff --git a/test/helpers/message_helper_test.dart b/test/helpers/message_helper_test.dart new file mode 100644 index 0000000000..76f318f2c4 --- /dev/null +++ b/test/helpers/message_helper_test.dart @@ -0,0 +1,15 @@ +import 'package:bluebubbles/helpers/types/helpers/message_helper.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('MessageHelper.getReactionFallbackText', () { + test('uses a human-safe reaction label for missing text', () { + expect(MessageHelper.getReactionFallbackText('Someone', null), 'Someone reacted to a message'); + expect(MessageHelper.getReactionFallbackText('Someone', ' '), 'Someone reacted to a message'); + }); + + test('preserves a populated fallback reaction text', () { + expect(MessageHelper.getReactionFallbackText('Someone', 'liked a message'), 'Someone liked a message'); + }); + }); +} diff --git a/test/helpers/message_widget_helpers_test.dart b/test/helpers/message_widget_helpers_test.dart new file mode 100644 index 0000000000..0978b0db48 --- /dev/null +++ b/test/helpers/message_widget_helpers_test.dart @@ -0,0 +1,23 @@ +import 'package:bluebubbles/helpers/ui/message_widget_helpers.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('safeMessageSubstring', () { + test('rejects missing text and malformed ranges', () { + expect(safeMessageSubstring(null, [0, 1]), isNull); + expect(safeMessageSubstring('hello', []), isNull); + expect(safeMessageSubstring('hello', [3]), isNull); + }); + + test('clamps cloud annotation ranges to available text', () { + expect(safeMessageSubstring('hello', [-4, 2]), 'he'); + expect(safeMessageSubstring('hello', [3, 20]), 'lo'); + expect(safeMessageSubstring('hello', [5, 20]), isNull); + expect(safeMessageSubstring('hello', [4, 2]), isNull); + }); + + test('returns valid annotated text', () { + expect(safeMessageSubstring('hello world', [6, 11]), 'world'); + }); + }); +} diff --git a/test/services/conversation_view_controller_test.dart b/test/services/conversation_view_controller_test.dart new file mode 100644 index 0000000000..f278b29979 --- /dev/null +++ b/test/services/conversation_view_controller_test.dart @@ -0,0 +1,26 @@ +import 'package:bluebubbles/database/models.dart'; +import 'package:bluebubbles/services/ui/chat/conversation_view_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('dismissKeyboard releases the conversation composer focus', (tester) async { + final controller = ConversationViewController(Chat(guid: 'iMessage;-;keyboard-test')); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: TextField(focusNode: controller.focusNode), + ), + ), + ); + controller.focusNode.requestFocus(); + await tester.pump(); + expect(controller.focusNode.hasFocus, isTrue); + + controller.dismissKeyboard(); + await tester.pump(); + + expect(controller.focusNode.hasFocus, isFalse); + }); +} diff --git a/test/services/queue_impl_test.dart b/test/services/queue_impl_test.dart new file mode 100644 index 0000000000..84a63d6d78 --- /dev/null +++ b/test/services/queue_impl_test.dart @@ -0,0 +1,57 @@ +import 'dart:async'; + +import 'package:bluebubbles/database/models.dart'; +import 'package:bluebubbles/services/backend/queue/queue_impl.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _TestItem extends QueueItem { + _TestItem({Completer? completer}) + : super(type: QueueType.newMessage, completer: completer); +} + +class _TestQueue extends Queue { + int active = 0; + int maxActive = 0; + bool fail = false; + + @override + Future prepItem(QueueItem item) async {} + + @override + Future handleQueueItem(QueueItem item) async { + active++; + maxActive = active > maxActive ? active : maxActive; + await Future.delayed(const Duration(milliseconds: 10)); + active--; + if (fail) throw StateError('expected test failure'); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('runs queued items with one active runner', () async { + final queue = _TestQueue(); + final first = Completer(); + final second = Completer(); + + await Future.wait([ + queue.queue(_TestItem(completer: first)), + queue.queue(_TestItem(completer: second)), + ]); + await Future.wait([first.future, second.future]); + + expect(queue.maxActive, 1); + expect(queue.isProcessing.value, isFalse); + }); + + test('completes a failed item with an error', () async { + final queue = _TestQueue()..fail = true; + final completion = Completer(); + + await queue.queue(_TestItem(completer: completion)); + + await expectLater(completion.future, throwsStateError); + expect(queue.isProcessing.value, isFalse); + }); +} diff --git a/test/wrappers/stateful_boilerplate_test.dart b/test/wrappers/stateful_boilerplate_test.dart new file mode 100644 index 0000000000..2ed7a30ef8 --- /dev/null +++ b/test/wrappers/stateful_boilerplate_test.dart @@ -0,0 +1,35 @@ +import 'package:bluebubbles/app/wrappers/stateful_boilerplate.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _TestController extends StatefulController {} + +class _TestWidget extends CustomStateful<_TestController> { + const _TestWidget({required super.parentController}); + + @override + State<_TestWidget> createState() => _TestWidgetState(); +} + +class _TestWidgetState extends CustomState<_TestWidget, void, _TestController> { + @override + void initState() { + forceDelete = false; + super.initState(); + } + + @override + Widget build(BuildContext context) => const SizedBox.shrink(); +} + +void main() { + testWidgets('rebinds updates when a retained controller row is remounted', (tester) async { + final controller = _TestController(); + + await tester.pumpWidget(MaterialApp(home: _TestWidget(parentController: controller))); + await tester.pumpWidget(const MaterialApp(home: SizedBox.shrink())); + await tester.pumpWidget(MaterialApp(home: _TestWidget(parentController: controller))); + + expect(tester.takeException(), isNull); + }); +} diff --git a/tooling/android/build_verified_alpha.ps1 b/tooling/android/build_verified_alpha.ps1 new file mode 100644 index 0000000000..afb67be743 --- /dev/null +++ b/tooling/android/build_verified_alpha.ps1 @@ -0,0 +1,168 @@ +[CmdletBinding()] +param( + [ValidateSet('profile', 'debug')] + [string]$Mode = 'profile', + [string]$FlutterCommand = 'flutter', + [string]$AndroidSdkRoot = $env:ANDROID_SDK_ROOT, + [string]$CargoHome = $env:CARGO_HOME, + [string]$RustupHome = $env:RUSTUP_HOME, + [string]$ProtocPath = $env:PROTOC, + [string]$PerlExecutable, + [string]$PerlModuleRoot, + [string]$MakeExecutable +) + +$ErrorActionPreference = 'Stop' + +function Resolve-Tool { + param( + [Parameter(Mandatory = $true)] + [string]$Value, + [Parameter(Mandatory = $true)] + [string]$DisplayName + ) + + if (Test-Path -LiteralPath $Value -PathType Leaf) { + return (Resolve-Path -LiteralPath $Value).Path + } + + $command = Get-Command $Value -ErrorAction SilentlyContinue + if ($null -eq $command) { + throw "$DisplayName was not found: $Value" + } + + return $command.Source +} + +function Add-ToolDirectoryToPath { + param( + [Parameter(Mandatory = $true)] + [string]$Executable + ) + + $directory = Split-Path -Parent $Executable + if (($env:Path -split ';') -notcontains $directory) { + $env:Path = "$directory;$env:Path" + } +} + +function Convert-ToPosixUncPath { + param( + [Parameter(Mandatory = $true)] + [string]$WindowsPath + ) + + $resolved = (Resolve-Path -LiteralPath $WindowsPath).Path + if ($resolved -match '^([A-Za-z]):\\(.*)$') { + $drive = $Matches[1].ToUpperInvariant() + $remainder = $Matches[2].Replace('\', '/') + return "//localhost/$drive`$/$remainder" + } + + return $resolved.Replace('\', '/') +} + +$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$flutter = Resolve-Tool -Value $FlutterCommand -DisplayName 'Flutter' +$apk = Join-Path $repositoryRoot "build\app\outputs\flutter-apk\app-alpha-$Mode.apk" + +if ([string]::IsNullOrWhiteSpace($AndroidSdkRoot)) { + $AndroidSdkRoot = $env:ANDROID_HOME +} +if ( + [string]::IsNullOrWhiteSpace($AndroidSdkRoot) -and + -not [string]::IsNullOrWhiteSpace($env:LOCALAPPDATA) +) { + $AndroidSdkRoot = Join-Path $env:LOCALAPPDATA 'Android\Sdk' +} +if ( + [string]::IsNullOrWhiteSpace($AndroidSdkRoot) -or + -not (Test-Path -LiteralPath $AndroidSdkRoot -PathType Container) +) { + throw "Android SDK was not found at $AndroidSdkRoot" +} +$AndroidSdkRoot = (Resolve-Path -LiteralPath $AndroidSdkRoot).Path + +# Never allow Flutter to leave a previous APK looking like a successful build. +if (Test-Path -LiteralPath $apk -PathType Leaf) { + Remove-Item -LiteralPath $apk -Force +} + +$env:ANDROID_HOME = $AndroidSdkRoot +$env:ANDROID_SDK_ROOT = $AndroidSdkRoot +if (-not [string]::IsNullOrWhiteSpace($CargoHome)) { + $env:CARGO_HOME = (Resolve-Path -LiteralPath $CargoHome).Path + Add-ToolDirectoryToPath -Executable ( + Join-Path $env:CARGO_HOME 'bin\cargo.exe' + ) +} +if (-not [string]::IsNullOrWhiteSpace($RustupHome)) { + $env:RUSTUP_HOME = (Resolve-Path -LiteralPath $RustupHome).Path +} + +$protoc = if ([string]::IsNullOrWhiteSpace($ProtocPath)) { + Resolve-Tool -Value 'protoc' -DisplayName 'protoc' +} else { + Resolve-Tool -Value $ProtocPath -DisplayName 'protoc' +} +$env:PROTOC = $protoc +Add-ToolDirectoryToPath -Executable $protoc + +if (-not [string]::IsNullOrWhiteSpace($PerlExecutable)) { + $perl = Resolve-Tool -Value $PerlExecutable -DisplayName 'Perl' + Add-ToolDirectoryToPath -Executable $perl +} +if (-not [string]::IsNullOrWhiteSpace($PerlModuleRoot)) { + if (-not (Test-Path -LiteralPath $PerlModuleRoot -PathType Container)) { + throw "Perl module root was not found at $PerlModuleRoot" + } + # OpenSSL executes Perl through a POSIX shell. A UNC path prevents the + # drive-letter colon from being parsed as a PERL5LIB separator. + $env:PERL5LIB = Convert-ToPosixUncPath -WindowsPath $PerlModuleRoot +} +if (-not [string]::IsNullOrWhiteSpace($MakeExecutable)) { + $make = Resolve-Tool -Value $MakeExecutable -DisplayName 'GNU Make' + Add-ToolDirectoryToPath -Executable $make +} + +Push-Location $repositoryRoot +try { + & $flutter build apk --flavor alpha "--$Mode" --target-platform android-arm64 + if ($LASTEXITCODE -ne 0) { + throw "Flutter build failed with exit code $LASTEXITCODE" + } +} finally { + Pop-Location +} + +if (-not (Test-Path -LiteralPath $apk -PathType Leaf)) { + throw "Flutter reported success but did not create $apk" +} + +Add-Type -AssemblyName System.IO.Compression.FileSystem +$archive = [System.IO.Compression.ZipFile]::OpenRead($apk) +$verificationError = $null +try { + $requiredEntries = @( + 'lib/arm64-v8a/libflutter.so' + 'lib/arm64-v8a/libapp.so' + 'lib/arm64-v8a/librust_lib_bluebubbles.so' + ) + foreach ($entryName in $requiredEntries) { + $entry = $archive.GetEntry($entryName) + if ($null -eq $entry -or $entry.Length -le 0) { + throw "APK verification failed: missing native entry $entryName" + } + } +} catch { + $verificationError = $_ +} finally { + $archive.Dispose() +} +if ($null -ne $verificationError) { + Remove-Item -LiteralPath $apk -Force + throw $verificationError +} + +$apkInfo = Get-Item -LiteralPath $apk +Write-Output "Verified $($apkInfo.FullName) ($($apkInfo.Length) bytes)"