From 2c19d00d17ed41e1a411cd7dd2d3313b9ee927ad Mon Sep 17 00:00:00 2001 From: Moe Hamade <69801237+moehamade@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:44:20 +0300 Subject: [PATCH] fix(apk): preserve local sharing during update checks --- app/gradle.lockfile | 104 +++---- .../com/bitchat/android/net/OkHttpProvider.kt | 36 ++- .../java/com/bitchat/android/ui/AboutSheet.kt | 118 ++++++-- .../android/ui/ApkDownloadViewModel.kt | 268 ++++++++++++++---- .../android/ui/ApkPrepareRowControls.kt | 22 +- .../bitchat/android/util/ApkDownloadSource.kt | 10 + .../bitchat/android/util/ApkDownloadWorker.kt | 2 + .../com/bitchat/android/util/ApkDownloader.kt | 3 +- .../bitchat/android/util/ApkRateLimitStore.kt | 73 +++++ .../android/util/GitHubReleaseClient.kt | 256 +++++++++++++++++ .../android/util/UniversalApkManager.kt | 62 +++- .../android/util/WorkManagerApkDownloader.kt | 6 +- app/src/main/res/values/strings.xml | 4 + .../android/ui/ApkDownloadViewModelTest.kt | 142 ++++++++++ .../android/ui/PrepareRowTapActionTest.kt | 57 +++- .../android/util/ApkDownloadSourceTest.kt | 27 ++ .../android/util/GitHubReleaseClientTest.kt | 134 +++++++++ gradle/libs.versions.toml | 9 +- gradle/verification-metadata.xml | 8 + 19 files changed, 1181 insertions(+), 160 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/util/ApkRateLimitStore.kt create mode 100644 app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/ui/ApkDownloadViewModelTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/util/GitHubReleaseClientTest.kt diff --git a/app/gradle.lockfile b/app/gradle.lockfile index 6cb53d65..986979d0 100644 --- a/app/gradle.lockfile +++ b/app/gradle.lockfile @@ -24,57 +24,55 @@ androidx.camera:camera-lifecycle:1.6.1=debugAndroidTestCompileClasspath,debugAnd androidx.collection:collection-jvm:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.collection:collection-ktx:1.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.collection:collection:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.animation:animation-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.animation:animation-core-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.animation:animation-core:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.animation:animation:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.foundation:foundation-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.foundation:foundation-layout-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.foundation:foundation-layout:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.foundation:foundation:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.material3:material3-android:1.5.0-alpha25=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.material3:material3-ripple-android:1.5.0-alpha25=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.material3:material3-ripple:1.5.0-alpha25=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.material3:material3:1.5.0-alpha25=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.animation:animation-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.animation:animation-core-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.animation:animation-core:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.animation:animation:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.foundation:foundation-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.foundation:foundation-layout-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.foundation:foundation-layout:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.foundation:foundation:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.material3:material3-android:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.material3:material3:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.material:material-icons-core-android:1.7.8=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath androidx.compose.material:material-icons-core-desktop:1.7.8=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugUnitTestLintChecksClasspath,releaseLintChecksClasspath androidx.compose.material:material-icons-core:1.7.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.material:material-icons-extended-android:1.7.8=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath androidx.compose.material:material-icons-extended-desktop:1.7.8=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugUnitTestLintChecksClasspath,releaseLintChecksClasspath androidx.compose.material:material-icons-extended:1.7.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.material:material-ripple-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.material:material-ripple:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.runtime:runtime-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.runtime:runtime-annotation-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.runtime:runtime-annotation:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.runtime:runtime-retain-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.runtime:runtime-retain:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.runtime:runtime-saveable-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.runtime:runtime-saveable:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.runtime:runtime:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-geometry-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-geometry:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-graphics-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-graphics:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-test-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath -androidx.compose.ui:ui-test-junit4-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath -androidx.compose.ui:ui-test-junit4:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath -androidx.compose.ui:ui-test-manifest:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath -androidx.compose.ui:ui-test:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath -androidx.compose.ui:ui-text-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-text:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-tooling-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath -androidx.compose.ui:ui-tooling-data-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath -androidx.compose.ui:ui-tooling-data:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath -androidx.compose.ui:ui-tooling-preview-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-tooling-preview:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-tooling:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath -androidx.compose.ui:ui-unit-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-unit:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-util-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-util:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.material:material-ripple-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.material:material-ripple:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-annotation-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-annotation:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-retain-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-retain:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-saveable-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-saveable:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-geometry-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-geometry:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-graphics-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-graphics:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-test-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.compose.ui:ui-test-junit4-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.compose.ui:ui-test-junit4:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.compose.ui:ui-test-manifest:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.compose.ui:ui-test:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.compose.ui:ui-text-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-text:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-tooling-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.compose.ui:ui-tooling-data-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.compose.ui:ui-tooling-data:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.compose.ui:ui-tooling-preview-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-tooling-preview:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-tooling:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.compose.ui:ui-unit-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-unit:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-util-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-util:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose:compose-bom:2026.06.01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.concurrent:concurrent-futures-ktx:1.1.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.concurrent:concurrent-futures-ktx:1.2.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath @@ -87,22 +85,22 @@ androidx.core:core:1.19.0=debugAndroidTestCompileClasspath,debugAndroidTestLintC androidx.cursoradapter:cursoradapter:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.customview:customview-poolingcontainer:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.customview:customview:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.documentfile:documentfile:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.drawerlayout:drawerlayout:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.dynamicanimation:dynamicanimation:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.emoji2:emoji2-views-helper:1.4.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.emoji2:emoji2:1.4.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.exifinterface:exifinterface:1.4.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.fragment:fragment:1.5.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.graphics:graphics-path:1.0.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.graphics:graphics-shapes-android:1.0.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath -androidx.graphics:graphics-shapes-desktop:1.0.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugUnitTestLintChecksClasspath,releaseLintChecksClasspath -androidx.graphics:graphics-shapes:1.0.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.interpolator:interpolator:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.legacy:legacy-support-core-utils:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.lifecycle:lifecycle-common-java8:2.11.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.lifecycle:lifecycle-common-jvm:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.lifecycle:lifecycle-common:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.lifecycle:lifecycle-livedata-core-ktx:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-livedata-core-ktx:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.lifecycle:lifecycle-livedata-core:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.lifecycle:lifecycle-livedata:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-livedata:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.lifecycle:lifecycle-process:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.lifecycle:lifecycle-runtime-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.lifecycle:lifecycle-runtime-compose-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath @@ -118,7 +116,8 @@ androidx.lifecycle:lifecycle-viewmodel-ktx:2.11.0=debugAndroidTestCompileClasspa androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.lifecycle:lifecycle-viewmodel-savedstate:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.lifecycle:lifecycle-viewmodel:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.loader:loader:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.loader:loader:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.localbroadcastmanager:localbroadcastmanager:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.navigation:navigation-common-android:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.navigation:navigation-common:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.navigation:navigation-compose-android:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath @@ -129,6 +128,7 @@ androidx.navigationevent:navigationevent-android:1.0.0=debugAndroidTestCompileCl androidx.navigationevent:navigationevent-compose-android:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.navigationevent:navigationevent-compose:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.navigationevent:navigationevent:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.print:print:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.profileinstaller:profileinstaller:1.4.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.resourceinspection:resourceinspection-annotation:1.0.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.room:room-common:2.6.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath @@ -164,6 +164,7 @@ androidx.tracing:tracing-ktx:1.3.0=debugAndroidTestLintChecksClasspath,debugLint androidx.tracing:tracing:1.0.0=debugAndroidTestCompileClasspath androidx.tracing:tracing:1.1.0=debugUnitTestCompileClasspath androidx.tracing:tracing:1.3.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.transition:transition:1.6.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.vectordrawable:vectordrawable-animated:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.vectordrawable:vectordrawable:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.versionedparcelable:versionedparcelable:1.1.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath @@ -282,6 +283,7 @@ com.google.testing.platform:launcher:0.0.9-alpha04=unified-test-platform-gradle- com.google.testparameterinjector:test-parameter-injector:1.18=debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath com.google.zxing:core:3.5.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.ibm.icu:icu4j:77.1=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +com.squareup.okhttp3:mockwebserver3:5.4.0=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath com.squareup.okhttp3:okhttp-android:5.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.squareup.okhttp3:okhttp:5.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.squareup.okio:okio-jvm:3.17.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath diff --git a/app/src/main/java/com/bitchat/android/net/OkHttpProvider.kt b/app/src/main/java/com/bitchat/android/net/OkHttpProvider.kt index aafc0d10..7f4d7c29 100644 --- a/app/src/main/java/com/bitchat/android/net/OkHttpProvider.kt +++ b/app/src/main/java/com/bitchat/android/net/OkHttpProvider.kt @@ -10,7 +10,17 @@ import java.util.concurrent.atomic.AtomicReference * Centralized OkHttp provider to ensure all network traffic honors Tor settings. */ object OkHttpProvider { - private val httpClientRef = AtomicReference(null) + enum class Route { + DIRECT, + TOR + } + + data class RoutedClient( + val client: OkHttpClient, + val route: Route + ) + + private val httpClientRef = AtomicReference(null) private val wsClientRef = AtomicReference(null) fun reset() { @@ -18,20 +28,30 @@ object OkHttpProvider { wsClientRef.set(null) } - fun httpClient(): OkHttpClient { + fun httpClient(): OkHttpClient = routedHttpClient().client + + /** + * Returns the client and the route it was actually built with as one snapshot. + * + * The selected Tor mode can change while an existing client is still cached. Consumers that + * key cooldowns by network identity must use this value rather than re-reading the preference. + */ + fun routedHttpClient(): RoutedClient { httpClientRef.get()?.let { return it } - val client = baseBuilderForCurrentProxy() + val (builder, route) = baseBuilderForCurrentProxy() + val client = builder .callTimeout(15, TimeUnit.SECONDS) .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(15, TimeUnit.SECONDS) .build() - httpClientRef.set(client) - return client + val routedClient = RoutedClient(client, route) + httpClientRef.set(routedClient) + return routedClient } fun webSocketClient(): OkHttpClient { wsClientRef.get()?.let { return it } - val client = baseBuilderForCurrentProxy() + val client = baseBuilderForCurrentProxy().first .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(0, TimeUnit.SECONDS) .writeTimeout(10, TimeUnit.SECONDS) @@ -40,7 +60,7 @@ object OkHttpProvider { return client } - private fun baseBuilderForCurrentProxy(): OkHttpClient.Builder { + private fun baseBuilderForCurrentProxy(): Pair { val builder = OkHttpClient.Builder() val torProvider = ArtiTorManager.getInstance() val socks: InetSocketAddress? = torProvider.currentSocksAddress() @@ -50,6 +70,6 @@ object OkHttpProvider { val proxy = Proxy(Proxy.Type.SOCKS, socks) builder.proxy(proxy) } - return builder + return builder to if (socks == null) Route.DIRECT else Route.TOR } } diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index 7ad0b345..5db34876 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -620,7 +620,18 @@ fun AboutSheet( val apkViewModel: ApkDownloadViewModel = viewModel() val apkUiState by apkViewModel.state.collectAsStateWithLifecycle() val apkStatus = apkUiState.apkStatus + val releaseStatus = apkUiState.releaseStatus val downloadProgress = apkUiState.downloadProgress + val shareableApk = when (apkStatus) { + is ApkPreparationStatus.Ready -> apkStatus + is ApkPreparationStatus.Downloading -> + apkStatus.shareableFallback + else -> null + } + val availableUpdate = (releaseStatus as? ApkReleaseStatus.Known) + ?.takeIf { it.isNewerThanSharedApk } + val downloadRetryBlocked = apkUiState.downloadRetryAtMillis + ?.let { it > System.currentTimeMillis() } == true // Handle one-shot effects (navigation, toasts, share intents) LaunchedEffect(Unit) { @@ -659,7 +670,11 @@ fun AboutSheet( // does, so the row can never look tappable and do // nothing. .clickable( - enabled = prepareRowTapAction(apkStatus) != null + enabled = prepareRowTapAction( + apkStatus, + releaseStatus, + apkUiState.downloadRetryAtMillis + ) != null ) { apkViewModel.onEvent(ApkUiEvent.PrepareRowClicked) } @@ -667,7 +682,7 @@ fun AboutSheet( verticalAlignment = Alignment.CenterVertically ) { Icon( - imageVector = if (apkStatus is ApkPreparationStatus.Ready) { + imageVector = if (shareableApk != null) { Icons.Default.Share } else { Icons.Default.CloudDownload @@ -683,16 +698,53 @@ fun AboutSheet( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp) ) { - Text( - text = if (apkStatus is ApkPreparationStatus.Ready) { - stringResource(R.string.prepare_apk_ready_title) - } else { - stringResource(R.string.prepare_apk_title) - }, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - color = colorScheme.onSurface - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = if (shareableApk != null) { + stringResource(R.string.prepare_apk_ready_title) + } else { + stringResource(R.string.prepare_apk_title) + }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = colorScheme.onSurface + ) + if (availableUpdate != null) { + TooltipBox( + positionProvider = TooltipDefaults + .rememberTooltipPositionProvider(), + tooltip = { + PlainTooltip { + Text( + stringResource( + R.string.prepare_apk_update_available, + availableUpdate.version + ) + ) + } + }, + state = rememberTooltipState() + ) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = stringResource( + R.string.prepare_apk_update_warning + ), + tint = colorScheme.tertiary, + modifier = Modifier + .padding(start = 6.dp) + .size(18.dp) + .clickable( + enabled = !downloadRetryBlocked + ) { + apkViewModel.onEvent( + ApkUiEvent.DownloadUniversalClicked + ) + } + ) + } + } + } Text( text = when (val status = apkStatus) { is ApkPreparationStatus.Loading -> stringResource(R.string.checking) @@ -764,7 +816,10 @@ fun AboutSheet( } ) is ApkPreparationStatus.Ready -> { - if (apkStatus.variant == ShareableApkVariant.ARM64) { + if ( + apkStatus.source == + UniversalApkManager.ApkSource.INSTALLED + ) { ApkPrepareRowIconButton( icon = Icons.Default.CloudDownload, description = stringResource( @@ -775,6 +830,7 @@ fun AboutSheet( ApkUiEvent.DownloadUniversalClicked ) }, + enabled = !downloadRetryBlocked, tint = colorScheme.primary ) } else if ( @@ -807,6 +863,11 @@ fun AboutSheet( ApkUiEvent.PrepareRowClicked ) }, + enabled = prepareRowTapAction( + apkStatus, + releaseStatus, + apkUiState.downloadRetryAtMillis + ) != null, tint = colorScheme.primary ) else -> {} @@ -820,16 +881,28 @@ fun AboutSheet( title = { Text( text = stringResource( - R.string.prepare_apk_dialog_title + if (availableUpdate != null) { + R.string.prepare_apk_update_dialog_title + } else { + R.string.prepare_apk_dialog_title + } ), style = MaterialTheme.typography.titleLarge ) }, text = { Text( - text = stringResource( - R.string.prepare_apk_dialog_message_unknown_size - ), + text = if (availableUpdate != null) { + stringResource( + R.string.prepare_apk_update_dialog_message, + availableUpdate.version, + availableUpdate.sizeMB + ) + } else { + stringResource( + R.string.prepare_apk_dialog_message_unknown_size + ) + }, style = MaterialTheme.typography.bodyMedium ) }, @@ -875,7 +948,11 @@ fun AboutSheet( containerColor = colorScheme.error ) ) { - Text("Delete") + Text( + stringResource( + R.string.prepare_apk_button_delete + ) + ) } }, dismissButton = { @@ -887,8 +964,9 @@ fun AboutSheet( ) } - // Show sharing rows only when APK is ready - val canShareAPK = apkStatus is ApkPreparationStatus.Ready + // A GitHub update is optional. Keep sharing visible while the + // replacement downloads or while metadata refreshes. + val canShareAPK = shareableApk != null AnimatedVisibility( visible = canShareAPK, diff --git a/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt index ed8de52a..a76b8ecb 100644 --- a/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt @@ -7,11 +7,16 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.bitchat.android.R import com.bitchat.android.util.ApkDownloader +import com.bitchat.android.util.AppVersion +import com.bitchat.android.util.GitHubReleaseClient +import com.bitchat.android.util.LatestReleaseProvider import com.bitchat.android.util.ShareableApkVariant import com.bitchat.android.util.UniversalApkManager import com.bitchat.android.util.WorkManagerApkDownloader import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -33,14 +38,32 @@ sealed class ApkPreparationStatus { ) : ApkPreparationStatus() /** [phase] is what the operation is actually doing; only a transfer has a real percentage. */ data class Downloading( - val phase: ApkDownloader.DownloadPhase = ApkDownloader.DownloadPhase.SelectingSource + val phase: ApkDownloader.DownloadPhase = ApkDownloader.DownloadPhase.SelectingSource, + val shareableFallback: Ready? = null ) : ApkPreparationStatus() - data class Resumable(val progressPercent: Int, val message: String) : ApkPreparationStatus() - data class Error(val message: String) : ApkPreparationStatus() + data class Resumable( + val progressPercent: Int, + val message: String, + val retryAtMillis: Long? = null + ) : ApkPreparationStatus() + data class Error(val message: String, val retryAtMillis: Long? = null) : ApkPreparationStatus() +} + +sealed class ApkReleaseStatus { + object Unknown : ApkReleaseStatus() + object Checking : ApkReleaseStatus() + data class Known( + val version: String, + val sizeMB: Int, + val isNewerThanSharedApk: Boolean, + val fromStaleCache: Boolean + ) : ApkReleaseStatus() } data class ApkUiState( val apkStatus: ApkPreparationStatus = ApkPreparationStatus.Loading, + val releaseStatus: ApkReleaseStatus = ApkReleaseStatus.Unknown, + val downloadRetryAtMillis: Long? = null, val downloadProgress: Int = 0, val showPrepareDialog: Boolean = false, val showDeleteDialog: Boolean = false, @@ -80,12 +103,24 @@ internal enum class PrepareRowTapAction { * and has to stay in step with them. Deriving both the tap handler and the row's `enabled` flag * from this one function keeps the row from looking clickable while doing nothing. */ -internal fun prepareRowTapAction(status: ApkPreparationStatus): PrepareRowTapAction? = when { +internal fun prepareRowTapAction( + status: ApkPreparationStatus, + releaseStatus: ApkReleaseStatus = ApkReleaseStatus.Unknown, + downloadRetryAtMillis: Long? = null, + nowMillis: Long = System.currentTimeMillis() +): PrepareRowTapAction? = when { + downloadRetryAtMillis != null && downloadRetryAtMillis > nowMillis -> null status is ApkPreparationStatus.NotDownloaded -> PrepareRowTapAction.OpenPrepareDialog // Consent was already given for these; resuming straight away avoids a redundant prompt. - status is ApkPreparationStatus.Resumable -> PrepareRowTapAction.StartDownload - status is ApkPreparationStatus.Error -> PrepareRowTapAction.StartDownload - status is ApkPreparationStatus.Ready && status.variant == ShareableApkVariant.ARM64 -> + status is ApkPreparationStatus.Resumable && + (status.retryAtMillis == null || status.retryAtMillis <= nowMillis) -> + PrepareRowTapAction.StartDownload + status is ApkPreparationStatus.Error && + (status.retryAtMillis == null || status.retryAtMillis <= nowMillis) -> + PrepareRowTapAction.StartDownload + status is ApkPreparationStatus.Ready && + (status.source == UniversalApkManager.ApkSource.INSTALLED || + (releaseStatus as? ApkReleaseStatus.Known)?.isNewerThanSharedApk == true) -> PrepareRowTapAction.OpenPrepareDialog else -> null } @@ -102,21 +137,33 @@ sealed class ApkUiEffect { * ViewModel for APK download/status/share logic following MVI pattern. * UI sends [ApkUiEvent], observes [ApkUiState], and collects [ApkUiEffect]. */ -class ApkDownloadViewModel(application: Application) : AndroidViewModel(application) { +class ApkDownloadViewModel internal constructor( + application: Application, + private val apkManager: UniversalApkManager, + private val downloader: ApkDownloader, + private val latestReleaseProvider: LatestReleaseProvider +) : AndroidViewModel(application) { + + constructor(application: Application) : this( + application = application, + apkManager = UniversalApkManager(application), + downloader = WorkManagerApkDownloader(application), + latestReleaseProvider = GitHubReleaseClient(application) + ) companion object { private const val TAG = "ApkDownloadVM" } - private val apkManager = UniversalApkManager(application) - private val downloader: ApkDownloader = WorkManagerApkDownloader(application) - private val _state = MutableStateFlow(ApkUiState()) val state: StateFlow = _state.asStateFlow() private val _effect = Channel(Channel.BUFFERED) val effect = _effect.receiveAsFlow() + private var metadataRefreshJob: Job? = null + private var retryUnlockJob: Job? = null + init { observeDownloader() } @@ -140,7 +187,13 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat } private fun onPrepareRowClicked() { - when (prepareRowTapAction(_state.value.apkStatus)) { + when ( + prepareRowTapAction( + _state.value.apkStatus, + _state.value.releaseStatus, + _state.value.downloadRetryAtMillis + ) + ) { PrepareRowTapAction.OpenPrepareDialog -> _state.update { it.copy(showPrepareDialog = true) } PrepareRowTapAction.StartDownload -> startDownload() @@ -154,9 +207,14 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat } private fun onDownloadUniversalClicked() { + if (_state.value.downloadRetryAtMillis?.let { it > System.currentTimeMillis() } == true) { + return + } val status = _state.value.apkStatus + val hasUpdate = (_state.value.releaseStatus as? ApkReleaseStatus.Known) + ?.isNewerThanSharedApk == true if (status is ApkPreparationStatus.Ready && - status.variant == ShareableApkVariant.ARM64 + (status.source == UniversalApkManager.ApkSource.INSTALLED || hasUpdate) ) { _state.update { it.copy(showPrepareDialog = true) } } @@ -212,20 +270,39 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat private fun onCancelDownload() { downloader.cancelDownload() - // Leave Downloading immediately. The cancelled job maps to Idle, which - // the observer intentionally ignores because local status is resolved below. + val fallback = (_state.value.apkStatus as? ApkPreparationStatus.Downloading) + ?.shareableFallback _state.update { - it.copy(apkStatus = ApkPreparationStatus.Loading, downloadProgress = 0) + it.copy( + apkStatus = fallback ?: ApkPreparationStatus.Loading, + downloadProgress = 0 + ) } - - checkStatus() + if (fallback == null) checkStatus() } private fun startDownload() { + val current = _state.value.apkStatus + val stateRetryAt = _state.value.downloadRetryAtMillis + val retryAt = when (current) { + is ApkPreparationStatus.Resumable -> current.retryAtMillis + is ApkPreparationStatus.Error -> current.retryAtMillis + else -> null + } + if ((stateRetryAt ?: retryAt)?.let { it > System.currentTimeMillis() } == true) return + + val fallback = when (current) { + is ApkPreparationStatus.Ready -> current + is ApkPreparationStatus.Downloading -> current.shareableFallback + else -> null + } val partial = apkManager.getPartialDownloadProgress() _state.update { it.copy( - apkStatus = ApkPreparationStatus.Downloading(), + apkStatus = ApkPreparationStatus.Downloading( + shareableFallback = fallback + ), + downloadRetryAtMillis = null, downloadProgress = partial ?: 0 ) } @@ -251,6 +328,36 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat current.copy(apkStatus = resolvedStatus, downloadProgress = 0) } } + // Local availability is resolved and published before this independent network task + // starts. Metadata can add a freshness warning, but can never hide sharing. + refreshReleaseMetadata() + } + } + + private fun refreshReleaseMetadata() { + if (metadataRefreshJob?.isActive == true) return + metadataRefreshJob = viewModelScope.launch { + _state.update { it.copy(releaseStatus = ApkReleaseStatus.Checking) } + latestReleaseProvider.latestRelease() + .onSuccess { snapshot -> + val shared = shareableReady(_state.value.apkStatus) + _state.update { + it.copy( + releaseStatus = ApkReleaseStatus.Known( + version = snapshot.release.versionName, + sizeMB = (snapshot.release.universalApkSize / 1024 / 1024).toInt(), + isNewerThanSharedApk = shared?.let { ready -> + AppVersion.isNewer(ready.version, snapshot.release.versionName) + } ?: false, + fromStaleCache = snapshot.isStale + ) + ) + } + } + .onFailure { + // Metadata is an optional enhancement. Keep the locally resolved APK state. + _state.update { it.copy(releaseStatus = ApkReleaseStatus.Unknown) } + } } } @@ -259,12 +366,28 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat downloader.downloadState.collect { downloadState -> when (downloadState) { is ApkDownloader.DownloadState.Idle -> { - // Don't overwrite — status set by checkStatus() + val downloading = _state.value.apkStatus as? ApkPreparationStatus.Downloading + if (downloading != null) { + val fallback = downloading.shareableFallback + _state.update { + it.copy( + apkStatus = fallback ?: ApkPreparationStatus.Loading, + downloadProgress = 0 + ) + } + if (fallback == null) checkStatus() + } } is ApkDownloader.DownloadState.Downloading -> { _state.update { + val fallback = (it.apkStatus as? ApkPreparationStatus.Downloading) + ?.shareableFallback + ?: (it.apkStatus as? ApkPreparationStatus.Ready) it.copy( - apkStatus = ApkPreparationStatus.Downloading(downloadState.phase), + apkStatus = ApkPreparationStatus.Downloading( + phase = downloadState.phase, + shareableFallback = fallback + ), downloadProgress = downloadState.progressPercent ) } @@ -272,31 +395,33 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat is ApkDownloader.DownloadState.Success -> { val info = apkManager.getCachedApkInfo() _state.update { + val ready = ApkPreparationStatus.Ready( + version = info?.version ?: downloadState.version, + sizeMB = info?.let { cached -> + (cached.size / 1024 / 1024).toInt() + } ?: downloadState.sizeMB, + source = info?.source ?: UniversalApkManager.ApkSource.DOWNLOADED, + variant = info?.variant ?: ShareableApkVariant.UNIVERSAL + ) it.copy( - apkStatus = ApkPreparationStatus.Ready( - version = info?.version ?: downloadState.version, - sizeMB = info?.let { cached -> - (cached.size / 1024 / 1024).toInt() - } ?: downloadState.sizeMB, - source = info?.source ?: UniversalApkManager.ApkSource.DOWNLOADED, - variant = info?.variant ?: ShareableApkVariant.UNIVERSAL - ), + apkStatus = ready, + releaseStatus = releaseStatusFor(ready, it.releaseStatus), + downloadRetryAtMillis = null, downloadProgress = 100 ) } } is ApkDownloader.DownloadState.Failed -> { - val localArm64 = apkManager.getCachedApkInfo() - ?.takeIf { it.variant == ShareableApkVariant.ARM64 } - if (localArm64 != null) { + val fallback = (_state.value.apkStatus as? ApkPreparationStatus.Downloading) + ?.shareableFallback + ?: apkManager.getCachedApkInfo()?.toReady() + if (fallback != null) { _state.update { it.copy( - apkStatus = ApkPreparationStatus.Ready( - version = localArm64.version, - sizeMB = (localArm64.size / 1024 / 1024).toInt(), - source = localArm64.source, - variant = localArm64.variant - ) + apkStatus = fallback, + releaseStatus = releaseStatusFor(fallback, it.releaseStatus), + downloadRetryAtMillis = downloadState.retryAtMillis, + downloadProgress = 0 ) } _effect.send(ApkUiEffect.ShowToast(failureMessage(downloadState))) @@ -307,17 +432,24 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat it.copy( apkStatus = ApkPreparationStatus.Resumable( progressPercent = downloadState.resumablePercent, - message = message + message = message, + retryAtMillis = downloadState.retryAtMillis ), + downloadRetryAtMillis = downloadState.retryAtMillis, downloadProgress = downloadState.resumablePercent ) } else { it.copy( - apkStatus = ApkPreparationStatus.Error(message) + apkStatus = ApkPreparationStatus.Error( + message, + downloadState.retryAtMillis + ), + downloadRetryAtMillis = downloadState.retryAtMillis ) } } } + scheduleRetryUnlock(downloadState.retryAtMillis) } } } @@ -339,21 +471,57 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat * this resolves it, so the message follows the device locale rather than the worker's. */ private fun failureMessage(state: ApkDownloader.DownloadState.Failed): String = - getApplication().getString( - state.reason.messageRes, - *state.messageArgs.toTypedArray() - ) + runCatching { + getApplication().getString( + state.reason.messageRes, + *state.messageArgs.toTypedArray() + ) + }.getOrElse { + getString(R.string.prepare_apk_error_generic) + } + + private fun scheduleRetryUnlock(retryAtMillis: Long?) { + if (retryAtMillis == null) return + retryUnlockJob?.cancel() + retryUnlockJob = viewModelScope.launch { + delay((retryAtMillis - System.currentTimeMillis()).coerceAtLeast(0L)) + _state.update { + val unlocked = when (val status = it.apkStatus) { + is ApkPreparationStatus.Resumable -> status.copy(retryAtMillis = null) + is ApkPreparationStatus.Error -> status.copy(retryAtMillis = null) + else -> status + } + it.copy(apkStatus = unlocked, downloadRetryAtMillis = null) + } + } + } + + private fun shareableReady(status: ApkPreparationStatus): ApkPreparationStatus.Ready? = + when (status) { + is ApkPreparationStatus.Ready -> status + is ApkPreparationStatus.Downloading -> status.shareableFallback + else -> null + } + + private fun releaseStatusFor( + ready: ApkPreparationStatus.Ready, + releaseStatus: ApkReleaseStatus + ): ApkReleaseStatus = (releaseStatus as? ApkReleaseStatus.Known)?.let { + it.copy(isNewerThanSharedApk = AppVersion.isNewer(ready.version, it.version)) + } ?: releaseStatus + + private fun UniversalApkManager.ApkInfo.toReady() = ApkPreparationStatus.Ready( + version = version, + sizeMB = (size / 1024 / 1024).toInt(), + source = source, + variant = variant + ) private suspend fun resolveApkStatus(): ApkPreparationStatus = withContext(Dispatchers.IO) { try { val info = apkManager.prepareLocalApkInfo() if (info != null) { - ApkPreparationStatus.Ready( - version = info.version, - sizeMB = (info.size / 1024 / 1024).toInt(), - source = info.source, - variant = info.variant - ) + info.toReady() } else { val partial = apkManager.getPartialDownloadProgress() if (partial != null) { diff --git a/app/src/main/java/com/bitchat/android/ui/ApkPrepareRowControls.kt b/app/src/main/java/com/bitchat/android/ui/ApkPrepareRowControls.kt index b79060a9..7e20edda 100644 --- a/app/src/main/java/com/bitchat/android/ui/ApkPrepareRowControls.kt +++ b/app/src/main/java/com/bitchat/android/ui/ApkPrepareRowControls.kt @@ -4,10 +4,9 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearWavyProgressIndicator +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.PlainTooltip import androidx.compose.material3.Text @@ -26,10 +25,9 @@ import androidx.compose.ui.unit.dp * This sits under the row's subtitle so the trailing slot is free to hold a single control. Which * of the three renderings applies is decided entirely by [status]; the caller does not choose. * - * The wave is not decoration. A moving wave means bytes are moving, so a stalled download draws a - * flat line at the fraction it reached rather than a bar indistinguishable from a live one. + * Determinate transfer/resume progress and indeterminate non-transfer phases use the stable + * Material 3 progress API. The expressive wavy variant can be introduced independently later. */ -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable internal fun ApkDownloadProgressBar( status: ApkPreparationStatus, @@ -46,19 +44,17 @@ internal fun ApkDownloadProgressBar( status is ApkPreparationStatus.Downloading && status.phase.hasMeasurableProgress && progressPercent > 0 -> - LinearWavyProgressIndicator( + LinearProgressIndicator( progress = { progressPercent.asProgressFraction() }, modifier = barModifier ) status is ApkPreparationStatus.Downloading -> - LinearWavyProgressIndicator(modifier = barModifier) + LinearProgressIndicator(modifier = barModifier) - // Flat: how far it got, and that it is not getting further on its own. status is ApkPreparationStatus.Resumable -> - LinearWavyProgressIndicator( + LinearProgressIndicator( progress = { status.progressPercent.asProgressFraction() }, - amplitude = { 0f }, modifier = barModifier ) } @@ -84,22 +80,24 @@ internal fun ApkPrepareRowIconButton( description: String, onClick: () -> Unit, modifier: Modifier = Modifier, + enabled: Boolean = true, tint: Color = MaterialTheme.colorScheme.onSurfaceVariant ) { TooltipBox( - positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + positionProvider = TooltipDefaults.rememberTooltipPositionProvider(), tooltip = { PlainTooltip { Text(description) } }, state = rememberTooltipState(), modifier = modifier ) { IconButton( onClick = onClick, + enabled = enabled, modifier = Modifier.size(48.dp) ) { Icon( imageVector = icon, contentDescription = description, - tint = tint, + tint = if (enabled) tint else tint.copy(alpha = 0.38f), modifier = Modifier.size(20.dp) ) } diff --git a/app/src/main/java/com/bitchat/android/util/ApkDownloadSource.kt b/app/src/main/java/com/bitchat/android/util/ApkDownloadSource.kt index 98e4ca1f..cf0f5e90 100644 --- a/app/src/main/java/com/bitchat/android/util/ApkDownloadSource.kt +++ b/app/src/main/java/com/bitchat/android/util/ApkDownloadSource.kt @@ -2,6 +2,8 @@ package com.bitchat.android.util import androidx.annotation.StringRes import com.bitchat.android.R +import java.io.File +import java.io.FileOutputStream import java.io.IOException import java.time.Instant import java.time.ZonedDateTime @@ -244,6 +246,14 @@ internal data class ContentRange( val total: Long? ) +/** + * Makes a response body safe to append after resume metadata is updated. + * A full 200 replacement must discard bytes from the release that supplied the Range request. + */ +internal fun prepareApkTempFileForResponse(tempFile: File, appendResponse: Boolean) { + if (!appendResponse) FileOutputStream(tempFile, false).use { } +} + internal fun parseContentRange(value: String?): ContentRange? { if (value == null) return null val match = Regex("""bytes\s+(\d+)-(\d+)/(\d+|\*)""", RegexOption.IGNORE_CASE) diff --git a/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt b/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt index 68c8c487..ade699b5 100644 --- a/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt +++ b/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt @@ -39,6 +39,7 @@ class ApkDownloadWorker( const val KEY_ERROR_REASON = "error_reason" const val KEY_ERROR_ARGS = "error_args" const val KEY_RESUMABLE_PERCENT = "resumable_percent" + const val KEY_RETRY_AT = "retry_at" private const val CHANNEL_ID = "apk_download" private const val NOTIFICATION_ID = 4201 @@ -120,6 +121,7 @@ class ApkDownloadWorker( failure?.messageArgs.orEmpty().toTypedArray() ) .putInt(KEY_RESUMABLE_PERCENT, partial ?: -1) + .apply { failure?.retryAtMillis?.let { putLong(KEY_RETRY_AT, it) } } .build() Result.failure(outputData) } diff --git a/app/src/main/java/com/bitchat/android/util/ApkDownloader.kt b/app/src/main/java/com/bitchat/android/util/ApkDownloader.kt index 8e16620f..75cd6b14 100644 --- a/app/src/main/java/com/bitchat/android/util/ApkDownloader.kt +++ b/app/src/main/java/com/bitchat/android/util/ApkDownloader.kt @@ -42,7 +42,8 @@ interface ApkDownloader { data class Failed( val reason: ApkDownloadFailureReason, val messageArgs: List, - val resumablePercent: Int? + val resumablePercent: Int?, + val retryAtMillis: Long? = null ) : DownloadState() } diff --git a/app/src/main/java/com/bitchat/android/util/ApkRateLimitStore.kt b/app/src/main/java/com/bitchat/android/util/ApkRateLimitStore.kt new file mode 100644 index 00000000..0a0f6a0f --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/ApkRateLimitStore.kt @@ -0,0 +1,73 @@ +package com.bitchat.android.util + +import android.content.Context +import androidx.core.content.edit +import com.bitchat.android.net.OkHttpProvider +import kotlin.math.ceil + +/** Persistent, route-specific cooldowns for APK-related network requests. */ +internal class ApkRateLimitStore(context: Context) { + companion object { + private const val PREFS_NAME = "apk_network_cooldowns" + private const val FALLBACK_COOLDOWN_MILLIS = 60_000L + private const val MAX_COOLDOWN_MILLIS = 60 * 60_000L + } + + private val preferences = context.applicationContext.getSharedPreferences( + PREFS_NAME, + Context.MODE_PRIVATE + ) + + fun retryAtMillis( + scope: String, + route: OkHttpProvider.Route, + nowMillis: Long = System.currentTimeMillis() + ): Long? { + val key = key(scope, route) + val deadline = preferences.getLong(key, 0L) + if (deadline <= nowMillis) { + if (deadline != 0L) preferences.edit { remove(key) } + return null + } + return deadline + } + + fun recordRateLimit( + scope: String, + route: OkHttpProvider.Route, + serverRetryAtMillis: Long?, + nowMillis: Long = System.currentTimeMillis() + ): Long { + val fallback = nowMillis + FALLBACK_COOLDOWN_MILLIS + val maximum = nowMillis + MAX_COOLDOWN_MILLIS + val deadline = (serverRetryAtMillis ?: fallback).coerceIn(nowMillis + 1_000L, maximum) + // Persist before reporting the failure so a process restart cannot bypass the cooldown. + preferences.edit(commit = true) { putLong(key(scope, route), deadline) } + return deadline + } + + fun clear(scope: String, route: OkHttpProvider.Route) { + preferences.edit { remove(key(scope, route)) } + } + + fun blockedException( + source: ApkDownloadSource, + retryAtMillis: Long, + nowMillis: Long = System.currentTimeMillis() + ): ApkDownloadException { + val minutes = ceil( + (retryAtMillis - nowMillis).coerceAtLeast(1L) / 60_000.0 + ).toLong() + return ApkDownloadException( + message = "${source.id} is in a persisted rate-limit cooldown until $retryAtMillis", + reason = ApkDownloadFailureReason.RateLimitedWithWait, + messageArgs = listOf(source.displayName, minutes.toString()), + retryable = false, + sourceId = source.id, + retryAtMillis = retryAtMillis + ) + } + + private fun key(scope: String, route: OkHttpProvider.Route): String = + "${scope}_${route.name.lowercase()}" +} diff --git a/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt b/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt new file mode 100644 index 00000000..dbb27c5d --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt @@ -0,0 +1,256 @@ +package com.bitchat.android.util + +import android.content.Context +import android.util.Log +import androidx.core.content.edit +import com.bitchat.android.net.ArtiTorManager +import com.bitchat.android.net.OkHttpProvider +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import okhttp3.Call +import okhttp3.Callback +import okhttp3.Request +import okhttp3.Response +import org.json.JSONObject +import java.io.IOException +import java.util.concurrent.TimeUnit + +internal interface LatestReleaseProvider { + suspend fun latestRelease(): Result +} + +/** Fetches GitHub release metadata without participating in APK availability. */ +internal class GitHubReleaseClient( + context: Context, + private val apiUrl: String = GITHUB_API_URL, + private val nowMillis: () -> Long = System::currentTimeMillis, + private val routedClient: () -> OkHttpProvider.RoutedClient = OkHttpProvider::routedHttpClient, + private val awaitRoute: suspend () -> Boolean = { + ArtiTorManager.getInstance().awaitSelectedRoute(ROUTE_READY_TIMEOUT_MILLIS) + }, + private val rateLimits: ApkRateLimitStore = ApkRateLimitStore(context) +) : LatestReleaseProvider { + companion object { + private const val TAG = "GitHubRelease" + private const val GITHUB_API_URL = + "https://api.github.com/repos/permissionlesstech/bitchat-android/releases/latest" + private const val ROUTE_READY_TIMEOUT_MILLIS = 60_000L + private const val CACHE_TTL_MILLIS = 30 * 60_000L + private const val PREFS_NAME = "apk_release_metadata" + private const val RATE_LIMIT_SCOPE = "github_release_metadata" + private const val USER_AGENT = "BitChat-Android" + + private val SOURCE = ApkDownloadSource( + id = DefaultApkDownloadSources.GITHUB_ID, + displayName = "GitHub Releases", + latestApkUrl = "https://github.com/permissionlesstech/bitchat-android/releases/latest/" + + "download/bitchat-android-universal.apk" + ) + + internal fun parseRelease(jsonString: String): Release? = runCatching { + val json = JSONObject(jsonString) + val tagName = json.optString("tag_name") + val versionName = tagName.removePrefix("v").trim() + if (versionName.isBlank()) return null + + val assets = json.optJSONArray("assets") ?: return null + for (index in 0 until assets.length()) { + val asset = assets.getJSONObject(index) + val name = asset.optString("name") + val url = asset.optString("browser_download_url") + if (name.contains("universal", ignoreCase = true) && + name.endsWith(".apk", ignoreCase = true) && + url.startsWith("https://") + ) { + return Release( + versionName = versionName, + universalApkSize = asset.optLong("size", 0L), + universalApkUrl = url, + universalApkName = name + ) + } + } + null + }.getOrNull() + } + + private val appContext = context.applicationContext + private val preferences = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + private val mutex = Mutex() + + override suspend fun latestRelease(): Result = withContext(Dispatchers.IO) { + mutex.withLock { + val cached = readCache() + val now = nowMillis() + val cacheAge = cached?.let { now - it.fetchedAtMillis } + if (cached != null && cacheAge != null && cacheAge in 0 until CACHE_TTL_MILLIS) { + return@withLock Result.success(ReleaseSnapshot(cached.release, isStale = false)) + } + + if (!awaitRoute()) return@withLock cached.orRouteFailure() + + val routeSnapshot = routedClient() + rateLimits.retryAtMillis(RATE_LIMIT_SCOPE, routeSnapshot.route, now)?.let { deadline -> + return@withLock cached.orFailure( + rateLimits.blockedException(SOURCE, deadline, now) + ) + } + + val request = Request.Builder() + .url(apiUrl) + .addHeader("User-Agent", USER_AGENT) + .addHeader("Accept", "application/vnd.github+json") + .addHeader("X-GitHub-Api-Version", "2022-11-28") + .apply { cached?.etag?.let { addHeader("If-None-Match", it) } } + .build() + val client = routeSnapshot.client.newBuilder() + .callTimeout(45, TimeUnit.SECONDS) + .connectTimeout(20, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + try { + client.newCall(request).awaitResponse().use { response -> + if (apiUrl.startsWith("https://") && !response.request.url.isHttps) { + return@withLock cached.orFailure( + IOException("GitHub redirected release metadata to an insecure URL") + ) + } + if (response.code == 304 && cached != null) { + val refreshed = cached.copy(fetchedAtMillis = now) + writeCache(refreshed) + rateLimits.clear(RATE_LIMIT_SCOPE, routeSnapshot.route) + return@withLock Result.success( + ReleaseSnapshot(refreshed.release, isStale = false) + ) + } + if (!response.isSuccessful) { + val failure = ApkDownloadHttpErrors.fromResponse( + source = SOURCE, + code = response.code, + responseMessage = response.message, + retryAfter = response.header("Retry-After"), + rateLimitRemaining = response.header("X-RateLimit-Remaining"), + rateLimitResetEpochSeconds = response.header("X-RateLimit-Reset"), + nowMillis = now + ) + val persistedFailure = if ( + failure.reason == ApkDownloadFailureReason.RateLimited || + failure.reason == ApkDownloadFailureReason.RateLimitedWithWait + ) { + val deadline = rateLimits.recordRateLimit( + RATE_LIMIT_SCOPE, + routeSnapshot.route, + failure.retryAtMillis, + now + ) + rateLimits.blockedException(SOURCE, deadline, now) + } else { + failure + } + return@withLock cached.orFailure(persistedFailure) + } + + val rawBody = response.body.string() + val release = parseRelease(rawBody) + ?: return@withLock cached.orFailure( + IOException("GitHub's latest release has no universal APK asset") + ) + val entry = CachedRelease( + release = release, + etag = response.header("ETag"), + fetchedAtMillis = now + ) + writeCache(entry) + rateLimits.clear(RATE_LIMIT_SCOPE, routeSnapshot.route) + Result.success(ReleaseSnapshot(release, isStale = false)) + } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + Log.w(TAG, "Could not refresh release metadata; using cache when available", error) + cached.orFailure(error) + } + } + } + + private fun CachedRelease?.orRouteFailure(): Result = orFailure( + IOException("The selected network route is not ready") + ) + + private fun CachedRelease?.orFailure(error: Throwable): Result = + if (this != null) { + Result.success(ReleaseSnapshot(release, isStale = true)) + } else { + Result.failure(error) + } + + private fun readCache(): CachedRelease? = runCatching { + val version = preferences.getString("version", null)?.takeIf { it.isNotBlank() } ?: return null + val url = preferences.getString("url", null)?.takeIf { it.startsWith("https://") } ?: return null + val name = preferences.getString("name", null)?.takeIf { it.isNotBlank() } ?: return null + CachedRelease( + release = Release( + versionName = version, + universalApkSize = preferences.getLong("size", 0L), + universalApkUrl = url, + universalApkName = name + ), + etag = preferences.getString("etag", null), + fetchedAtMillis = preferences.getLong("fetched_at", 0L) + ) + }.getOrNull() + + private fun writeCache(entry: CachedRelease) { + preferences.edit(commit = true) { + putString("version", entry.release.versionName) + putLong("size", entry.release.universalApkSize) + putString("url", entry.release.universalApkUrl) + putString("name", entry.release.universalApkName) + putString("etag", entry.etag) + putLong("fetched_at", entry.fetchedAtMillis) + } + } + + private suspend fun Call.awaitResponse(): Response = + suspendCancellableCoroutine { continuation -> + continuation.invokeOnCancellation { cancel() } + enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + if (continuation.isActive) { + continuation.resumeWith(Result.failure(e)) + } + } + + override fun onResponse(call: Call, response: Response) { + if (continuation.isActive) { + continuation.resumeWith(Result.success(response)) + } else { + response.close() + } + } + }) + } + + data class Release( + val versionName: String, + val universalApkSize: Long, + val universalApkUrl: String, + val universalApkName: String + ) + + data class ReleaseSnapshot( + val release: Release, + val isStale: Boolean + ) + + private data class CachedRelease( + val release: Release, + val etag: String?, + val fetchedAtMillis: Long + ) +} diff --git a/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt b/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt index b296d22e..df44ab83 100644 --- a/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt +++ b/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt @@ -56,14 +56,19 @@ class UniversalApkManager( private val metadataFile: File get() = File(cacheDir, METADATA_FILE_NAME) private val progressFile: File get() = File(cacheDir, PROGRESS_FILE_NAME) + private val rateLimits = ApkRateLimitStore(context) - // Download client: inherits Tor proxy settings but with no call timeout - // for large file downloads that can take minutes - private val downloadClient - get() = OkHttpProvider.httpClient().newBuilder() - .callTimeout(0, java.util.concurrent.TimeUnit.SECONDS) - .readTimeout(60, java.util.concurrent.TimeUnit.SECONDS) - .build() + // Download client: inherits the current client's actual route but has no call timeout for + // large files that can take minutes. Keep the route attached for route-specific cooldowns. + private fun downloadClient(): OkHttpProvider.RoutedClient { + val routed = OkHttpProvider.routedHttpClient() + return routed.copy( + client = routed.client.newBuilder() + .callTimeout(0, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(60, java.util.concurrent.TimeUnit.SECONDS) + .build() + ) + } /** * Get information about the cached sharing APK, if it exists. @@ -228,6 +233,9 @@ class UniversalApkManager( val safeVersion = version.replace(Regex("[^A-Za-z0-9._-]"), "_") val finalFileName = "$APK_FILE_PREFIX$safeVersion.apk" val finalFile = File(cacheDir, finalFileName) + // Package parsing above is blocking. A cancellation arriving while it runs + // must not promote the verified temporary file into the shareable slot. + ensureActive() replaceFileSafely(tempFile, finalFile) progressFile.delete() @@ -360,6 +368,13 @@ class UniversalApkManager( resume: ResumeInfo?, progressCallback: ((Int) -> Unit)? ) { + val routedClient = downloadClient() + val rateLimitScope = "apk_asset_${source.id}" + val now = System.currentTimeMillis() + rateLimits.retryAtMillis(rateLimitScope, routedClient.route, now)?.let { deadline -> + throw rateLimits.blockedException(source, deadline, now) + } + val request = Request.Builder() // Always start from the configured source endpoint. If a release changed, // If-Range makes the server return 200 and we overwrite the partial. @@ -374,8 +389,10 @@ class UniversalApkManager( .build() downloadToTempFile( - call = downloadClient.newCall(request), + call = routedClient.client.newCall(request), source = source, + rateLimitScope = rateLimitScope, + route = routedClient.route, endpointUrl = endpointUrl, tempFile = tempFile, existingBytes = existingBytes, @@ -391,6 +408,8 @@ class UniversalApkManager( private suspend fun downloadToTempFile( call: Call, source: ApkDownloadSource, + rateLimitScope: String, + route: OkHttpProvider.Route, endpointUrl: String, tempFile: File, existingBytes: Long, @@ -454,7 +473,7 @@ class UniversalApkManager( ) } if (!response.isSuccessful) { - throw ApkDownloadHttpErrors.fromResponse( + val failure = ApkDownloadHttpErrors.fromResponse( source = source, code = response.code, responseMessage = response.message, @@ -463,8 +482,23 @@ class UniversalApkManager( rateLimitResetEpochSeconds = response.header("X-RateLimit-Reset") ) + if (failure.reason == ApkDownloadFailureReason.RateLimited || + failure.reason == ApkDownloadFailureReason.RateLimitedWithWait + ) { + val now = System.currentTimeMillis() + val deadline = rateLimits.recordRateLimit( + rateLimitScope, + route, + failure.retryAtMillis, + now + ) + throw rateLimits.blockedException(source, deadline, now) + } + throw failure } + rateLimits.clear(rateLimitScope, route) + val body = response.body val range = if (response.code == 206) { parseContentRange(response.header("Content-Range")) @@ -491,6 +525,12 @@ class UniversalApkManager( val validator = response.header("ETag") ?: response.header("Last-Modified") ?: previousResume?.validator?.takeIf { append } + + // A server may ignore Range and return a replacement 200 response. + // Remove the old bytes before recording the new validator. If the + // process dies at any later point, old and new release bytes cannot be + // combined on the next resume. + prepareApkTempFileForResponse(tempFile, append) if (validator != null) { saveResumeInfo( ResumeInfo( @@ -511,7 +551,9 @@ class UniversalApkManager( } body.byteStream().use { input -> - FileOutputStream(tempFile, append).use { output -> + // Non-resume responses were already truncated above; always append + // after resume metadata is safely committed. + FileOutputStream(tempFile, true).use { output -> val buffer = ByteArray(BUFFER_SIZE) var bytesRead: Int var totalBytesRead = resumedBytes diff --git a/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt b/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt index 6d482c34..50bb2b1c 100644 --- a/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt +++ b/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt @@ -92,7 +92,11 @@ class WorkManagerApkDownloader(context: Context) : ApkDownloader { ApkDownloader.DownloadState.Failed( reason = reason, messageArgs = args, - resumablePercent = if (resumable >= 0) resumable else null + resumablePercent = if (resumable >= 0) resumable else null, + retryAtMillis = workInfo.outputData.getLong( + ApkDownloadWorker.KEY_RETRY_AT, + 0L + ).takeIf { it > 0L } ) } WorkInfo.State.CANCELLED -> { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 074ce540..18925640 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -246,6 +246,8 @@ Sharing source: this installed APK Sharing source: this installed APK • ARM64 devices only Sharing source: verified downloaded universal APK + Version %1$s is available. You can keep sharing this APK or download the update. + New version available Download universal APK Retry download @@ -262,7 +264,9 @@ Delete Version %1$s • %2$d MB Download Universal APK? + Download Newer Universal APK? This will download a verified universal APK from a configured source. You only need to do this once. + Version %1$s is available from GitHub (%2$d MB). Your current APK remains shareable during the download. Download Downloading Universal APK Downloading %1$d MB… diff --git a/app/src/test/kotlin/com/bitchat/android/ui/ApkDownloadViewModelTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/ApkDownloadViewModelTest.kt new file mode 100644 index 00000000..6b4998c7 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/ui/ApkDownloadViewModelTest.kt @@ -0,0 +1,142 @@ +package com.bitchat.android.ui + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import com.bitchat.android.util.ApkDownloader +import com.bitchat.android.util.GitHubReleaseClient +import com.bitchat.android.util.LatestReleaseProvider +import com.bitchat.android.util.ShareableApkVariant +import com.bitchat.android.util.UniversalApkManager +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import java.io.File + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class ApkDownloadViewModelTest { + private lateinit var application: Application + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + application = ApplicationProvider.getApplicationContext() + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `local apk is shareable before release metadata starts`() = runTest { + val manager = managerWithLocalApk() + val downloader = FakeDownloader() + lateinit var viewModel: ApkDownloadViewModel + val statusWhenMetadataStarted = CompletableDeferred() + val metadata = object : LatestReleaseProvider { + override suspend fun latestRelease(): Result { + statusWhenMetadataStarted.complete(viewModel.state.value.apkStatus) + return Result.failure(IllegalStateException("synthetic offline response")) + } + } + viewModel = ApkDownloadViewModel(application, manager, downloader, metadata) + + viewModel.onEvent(ApkUiEvent.CheckStatus) + val ready = awaitReady(viewModel) + + assertEquals("1.7.5", ready.version) + assertTrue(statusWhenMetadataStarted.await() is ApkPreparationStatus.Ready) + } + + @Test + fun `notification cancellation returning idle restores shareable fallback`() = runTest { + val manager = managerWithLocalApk() + val downloader = FakeDownloader() + val metadata = object : LatestReleaseProvider { + override suspend fun latestRelease(): Result = + Result.failure(IllegalStateException("synthetic offline response")) + } + val viewModel = ApkDownloadViewModel(application, manager, downloader, metadata) + + viewModel.onEvent(ApkUiEvent.CheckStatus) + val originalReady = awaitReady(viewModel) + viewModel.onEvent(ApkUiEvent.PrepareRowClicked) + viewModel.onEvent(ApkUiEvent.ConfirmDownload) + + val downloading = viewModel.state.value.apkStatus as ApkPreparationStatus.Downloading + assertSame(originalReady, downloading.shareableFallback) + assertEquals(1, downloader.startCount) + + downloader.emit(ApkDownloader.DownloadState.Idle) + val restored = awaitReady(viewModel) + assertEquals(originalReady, restored) + } + + private suspend fun managerWithLocalApk(): UniversalApkManager { + val manager = mock() + whenever(manager.prepareLocalApkInfo()).thenReturn( + UniversalApkManager.ApkInfo( + version = "1.7.5", + downloadDate = 1_700_000_000_000L, + size = 12L * 1024 * 1024, + file = File(application.cacheDir, "synthetic-shareable.apk"), + source = UniversalApkManager.ApkSource.INSTALLED, + variant = ShareableApkVariant.UNIVERSAL, + downloadSourceId = null + ) + ) + whenever(manager.getPartialDownloadProgress()).thenReturn(null) + return manager + } + + private suspend fun awaitReady( + viewModel: ApkDownloadViewModel + ): ApkPreparationStatus.Ready = withTimeout(5_000L) { + while (true) { + (viewModel.state.value.apkStatus as? ApkPreparationStatus.Ready)?.let { return@withTimeout it } + delay(1L) + } + error("unreachable") + } + + private class FakeDownloader : ApkDownloader { + private val mutableState = MutableStateFlow( + ApkDownloader.DownloadState.Idle + ) + override val downloadState = mutableState.asStateFlow() + var startCount = 0 + + override fun startDownload() { + startCount += 1 + mutableState.value = ApkDownloader.DownloadState.Downloading( + progressPercent = 0, + phase = ApkDownloader.DownloadPhase.SelectingSource + ) + } + + override fun cancelDownload() = Unit + + fun emit(state: ApkDownloader.DownloadState) { + mutableState.value = state + } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/ui/PrepareRowTapActionTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/PrepareRowTapActionTest.kt index 07b4ed23..d1e3a431 100644 --- a/app/src/test/kotlin/com/bitchat/android/ui/PrepareRowTapActionTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/ui/PrepareRowTapActionTest.kt @@ -34,8 +34,15 @@ class PrepareRowTapActionTest { } @Test - fun `nothing left to fetch means the row is inert`() { - assertNull(prepareRowTapAction(ready(ShareableApkVariant.UNIVERSAL))) + fun `a standalone installed universal apk can optionally be replaced from github`() { + assertEquals( + PrepareRowTapAction.OpenPrepareDialog, + prepareRowTapAction(ready(ShareableApkVariant.UNIVERSAL)) + ) + } + + @Test + fun `a current downloaded universal apk leaves the row inert`() { assertNull( prepareRowTapAction( ready(ShareableApkVariant.UNIVERSAL, UniversalApkManager.ApkSource.DOWNLOADED) @@ -43,6 +50,22 @@ class PrepareRowTapActionTest { ) } + @Test + fun `a stale downloaded apk opens the update dialog without blocking sharing`() { + assertEquals( + PrepareRowTapAction.OpenPrepareDialog, + prepareRowTapAction( + ready(ShareableApkVariant.UNIVERSAL, UniversalApkManager.ApkSource.DOWNLOADED), + ApkReleaseStatus.Known( + version = "1.7.6", + sizeMB = 24, + isNewerThanSharedApk = true, + fromStaleCache = false + ) + ) + ) + } + @Test fun `a missing apk asks before spending the bytes`() { assertEquals( @@ -72,4 +95,34 @@ class PrepareRowTapActionTest { } assertNull(prepareRowTapAction(ApkPreparationStatus.Loading)) } + + @Test + fun `a persisted rate limit disables manual retry until its deadline`() { + val retryAt = 50_000L + assertNull( + prepareRowTapAction( + ApkPreparationStatus.Error("Rate limited", retryAtMillis = retryAt), + nowMillis = retryAt - 1 + ) + ) + assertEquals( + PrepareRowTapAction.StartDownload, + prepareRowTapAction( + ApkPreparationStatus.Error("Rate limited", retryAtMillis = retryAt), + nowMillis = retryAt + ) + ) + } + + @Test + fun `rate limit also disables optional update while an apk remains shareable`() { + val retryAt = 50_000L + assertNull( + prepareRowTapAction( + ready(ShareableApkVariant.UNIVERSAL), + downloadRetryAtMillis = retryAt, + nowMillis = retryAt - 1 + ) + ) + } } diff --git a/app/src/test/kotlin/com/bitchat/android/util/ApkDownloadSourceTest.kt b/app/src/test/kotlin/com/bitchat/android/util/ApkDownloadSourceTest.kt index db448d59..0b0eaf03 100644 --- a/app/src/test/kotlin/com/bitchat/android/util/ApkDownloadSourceTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/util/ApkDownloadSourceTest.kt @@ -4,11 +4,16 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue +import org.junit.Rule import org.junit.Test +import org.junit.rules.TemporaryFolder import java.io.IOException class ApkDownloadSourceTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + private val source = ApkDownloadSource( id = "mirror-one", displayName = "Mirror One", @@ -120,6 +125,28 @@ class ApkDownloadSourceTest { assertNull(parseContentRange("bytes 90-100/100")) } + @Test + fun `a full response discards bytes from the release that was being resumed`() { + val tempFile = temporaryFolder.newFile("download-temp.apk") + tempFile.writeBytes("old-release-prefix".toByteArray()) + + prepareApkTempFileForResponse(tempFile, appendResponse = false) + tempFile.appendBytes("new-release".toByteArray()) + + assertEquals("new-release", tempFile.readText()) + } + + @Test + fun `a valid partial response keeps resumable bytes`() { + val tempFile = temporaryFolder.newFile("download-temp.apk") + tempFile.writeBytes("first-".toByteArray()) + + prepareApkTempFileForResponse(tempFile, appendResponse = true) + tempFile.appendBytes("second".toByteArray()) + + assertEquals("first-second", tempFile.readText()) + } + @Test fun `version comparison is host independent`() { assertTrue(AppVersion.isNewer("1.7.4", "1.7.5")) diff --git a/app/src/test/kotlin/com/bitchat/android/util/GitHubReleaseClientTest.kt b/app/src/test/kotlin/com/bitchat/android/util/GitHubReleaseClientTest.kt new file mode 100644 index 00000000..399ae3a2 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/util/GitHubReleaseClientTest.kt @@ -0,0 +1,134 @@ +package com.bitchat.android.util + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.bitchat.android.net.OkHttpProvider +import kotlinx.coroutines.test.runTest +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import okhttp3.OkHttpClient +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class GitHubReleaseClientTest { + private lateinit var context: Context + private lateinit var server: MockWebServer + private var nowMillis = 1_700_000_000_000L + private var route = OkHttpProvider.Route.DIRECT + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + context.getSharedPreferences("apk_release_metadata", Context.MODE_PRIVATE) + .edit().clear().commit() + context.getSharedPreferences("apk_network_cooldowns", Context.MODE_PRIVATE) + .edit().clear().commit() + server = MockWebServer() + server.start() + } + + @After + fun tearDown() { + server.close() + } + + @Test + fun `cached metadata is conditionally refreshed with its etag`() = runTest { + server.enqueue(successResponse(etag = "release-v1")) + val client = client() + + val first = client.latestRelease().getOrThrow() + assertEquals("1.7.6", first.release.versionName) + assertFalse(first.isStale) + + nowMillis += 31 * 60_000L + server.enqueue( + MockResponse.Builder() + .code(304) + .build() + ) + val refreshed = client.latestRelease().getOrThrow() + + assertFalse(refreshed.isStale) + server.takeRequest() + assertEquals("release-v1", server.takeRequest().headers["If-None-Match"]) + } + + @Test + fun `rate limit serves stale metadata and suppresses repeated requests`() = runTest { + server.enqueue(successResponse(etag = "release-v1")) + val client = client() + client.latestRelease().getOrThrow() + + nowMillis += 31 * 60_000L + server.enqueue( + MockResponse.Builder() + .code(429) + .build() + ) + val stale = client.latestRelease().getOrThrow() + val stillStale = client.latestRelease().getOrThrow() + + assertTrue(stale.isStale) + assertTrue(stillStale.isStale) + assertEquals(2, server.requestCount) + } + + @Test + fun `cooldown follows the actual client route`() = runTest { + server.enqueue( + MockResponse.Builder() + .code(429) + .addHeader("Retry-After", "120") + .build() + ) + val client = client() + assertTrue(client.latestRelease().isFailure) + assertTrue(client.latestRelease().isFailure) + assertEquals(1, server.requestCount) + + route = OkHttpProvider.Route.TOR + server.enqueue(successResponse(etag = "release-v1")) + assertTrue(client.latestRelease().isSuccess) + assertEquals(2, server.requestCount) + } + + private fun client() = GitHubReleaseClient( + context = context, + apiUrl = server.url("/releases/latest").toString(), + nowMillis = { nowMillis }, + routedClient = { + OkHttpProvider.RoutedClient( + client = OkHttpClient.Builder().build(), + route = route + ) + }, + awaitRoute = { true } + ) + + private fun successResponse(etag: String): MockResponse = MockResponse.Builder() + .code(200) + .addHeader("ETag", etag) + .body( + """ + { + "tag_name": "v1.7.6", + "assets": [ + { + "name": "bitchat-android-universal.apk", + "browser_download_url": "https://downloads.example/bitchat-universal.apk", + "size": 25165824 + } + ] + } + """.trimIndent() + ) + .build() +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 965476bf..405f6dc3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,9 +16,6 @@ appcompat = "1.7.1" # Compose compose-bom = "2026.06.01" compose-icons-extended = "1.7.8" -# Overrides the BOM's 1.4.0. The expressive wavy progress indicators are Compose-only in the -# 1.5.0 line, which has no stable release yet; drop this override once 1.5.0 ships. -compose-material3 = "1.5.0-alpha25" # Navigation navigation-compose = "2.9.8" @@ -92,7 +89,7 @@ androidx-compose-ui = { module = "androidx.compose.ui:ui" } androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } -androidx-compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "compose-material3" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3" } androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "compose-icons-extended" } # Lifecycle @@ -124,6 +121,7 @@ nordic-ble = { module = "no.nordicsemi.android:ble", version.ref = "nordic-ble" # WebSocket okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver3", version.ref = "okhttp" } tor-android-binary = { module = "org.torproject:tor-android-binary", version.ref = "tor-android-binary" } # Tor (embed) intentionally not pinned yet; add once repo is chosen @@ -195,7 +193,8 @@ testing = [ "mockito-kotlin", "mockito-core", "roboelectric", - "kotlinx-coroutines-test" + "kotlinx-coroutines-test", + "okhttp-mockwebserver" ] compose-testing = [ diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index a13ea01e..e0d0d5bd 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -3658,6 +3658,14 @@ + + + + + + + +