fix(apk): preserve local sharing during update checks

This commit is contained in:
Moe Hamade 2026-08-02 17:44:20 +03:00
parent b60b5121aa
commit 2c19d00d17
19 changed files with 1181 additions and 160 deletions

View File

@ -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-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-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.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-android:1.11.4=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-android:1.11.4=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-core:1.11.4=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.animation:animation:1.11.4=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-android:1.11.4=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-android:1.11.4=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-layout:1.11.4=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.foundation:foundation:1.11.4=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-android:1.4.0=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:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,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.material:material-icons-core-android:1.7.8=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,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-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-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-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-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-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-android:1.11.4=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.material:material-ripple:1.11.4=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-android:1.11.4=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-android:1.11.4=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-annotation:1.11.4=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-android:1.11.4=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-retain:1.11.4=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-android:1.11.4=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-saveable:1.11.4=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.runtime:runtime:1.11.4=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-android:1.11.4=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-android:1.11.4=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-geometry:1.11.4=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-android:1.11.4=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-graphics:1.11.4=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-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.compose.ui:ui-test-junit4-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath androidx.compose.ui:ui-test-junit4-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.compose.ui:ui-test-junit4:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath androidx.compose.ui:ui-test-junit4:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.compose.ui:ui-test-manifest:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath androidx.compose.ui:ui-test-manifest:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.compose.ui:ui-test:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath androidx.compose.ui:ui-test:1.11.4=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-android:1.11.4=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-text:1.11.4=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-android:1.11.4=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-android:1.11.4=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-data:1.11.4=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-android:1.11.4=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-preview:1.11.4=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-tooling:1.11.4=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-android:1.11.4=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-unit:1.11.4=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-android:1.11.4=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-util:1.11.4=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.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.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.1.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.concurrent:concurrent-futures-ktx:1.2.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath 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.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-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.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.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-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.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.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.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-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.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-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-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-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-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-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-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 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-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-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.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-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-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 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-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-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.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.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.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 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.0.0=debugAndroidTestCompileClasspath
androidx.tracing:tracing:1.1.0=debugUnitTestCompileClasspath androidx.tracing:tracing:1.1.0=debugUnitTestCompileClasspath
androidx.tracing:tracing:1.3.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath 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-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.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 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.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.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.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-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.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 com.squareup.okio:okio-jvm:3.17.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath

View File

@ -10,7 +10,17 @@ import java.util.concurrent.atomic.AtomicReference
* Centralized OkHttp provider to ensure all network traffic honors Tor settings. * Centralized OkHttp provider to ensure all network traffic honors Tor settings.
*/ */
object OkHttpProvider { object OkHttpProvider {
private val httpClientRef = AtomicReference<OkHttpClient?>(null) enum class Route {
DIRECT,
TOR
}
data class RoutedClient(
val client: OkHttpClient,
val route: Route
)
private val httpClientRef = AtomicReference<RoutedClient?>(null)
private val wsClientRef = AtomicReference<OkHttpClient?>(null) private val wsClientRef = AtomicReference<OkHttpClient?>(null)
fun reset() { fun reset() {
@ -18,20 +28,30 @@ object OkHttpProvider {
wsClientRef.set(null) 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 } httpClientRef.get()?.let { return it }
val client = baseBuilderForCurrentProxy() val (builder, route) = baseBuilderForCurrentProxy()
val client = builder
.callTimeout(15, TimeUnit.SECONDS) .callTimeout(15, TimeUnit.SECONDS)
.connectTimeout(10, TimeUnit.SECONDS) .connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS) .readTimeout(15, TimeUnit.SECONDS)
.build() .build()
httpClientRef.set(client) val routedClient = RoutedClient(client, route)
return client httpClientRef.set(routedClient)
return routedClient
} }
fun webSocketClient(): OkHttpClient { fun webSocketClient(): OkHttpClient {
wsClientRef.get()?.let { return it } wsClientRef.get()?.let { return it }
val client = baseBuilderForCurrentProxy() val client = baseBuilderForCurrentProxy().first
.connectTimeout(10, TimeUnit.SECONDS) .connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.SECONDS) .readTimeout(0, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS) .writeTimeout(10, TimeUnit.SECONDS)
@ -40,7 +60,7 @@ object OkHttpProvider {
return client return client
} }
private fun baseBuilderForCurrentProxy(): OkHttpClient.Builder { private fun baseBuilderForCurrentProxy(): Pair<OkHttpClient.Builder, Route> {
val builder = OkHttpClient.Builder() val builder = OkHttpClient.Builder()
val torProvider = ArtiTorManager.getInstance() val torProvider = ArtiTorManager.getInstance()
val socks: InetSocketAddress? = torProvider.currentSocksAddress() val socks: InetSocketAddress? = torProvider.currentSocksAddress()
@ -50,6 +70,6 @@ object OkHttpProvider {
val proxy = Proxy(Proxy.Type.SOCKS, socks) val proxy = Proxy(Proxy.Type.SOCKS, socks)
builder.proxy(proxy) builder.proxy(proxy)
} }
return builder return builder to if (socks == null) Route.DIRECT else Route.TOR
} }
} }

View File

@ -620,7 +620,18 @@ fun AboutSheet(
val apkViewModel: ApkDownloadViewModel = viewModel() val apkViewModel: ApkDownloadViewModel = viewModel()
val apkUiState by apkViewModel.state.collectAsStateWithLifecycle() val apkUiState by apkViewModel.state.collectAsStateWithLifecycle()
val apkStatus = apkUiState.apkStatus val apkStatus = apkUiState.apkStatus
val releaseStatus = apkUiState.releaseStatus
val downloadProgress = apkUiState.downloadProgress 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) // Handle one-shot effects (navigation, toasts, share intents)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@ -659,7 +670,11 @@ fun AboutSheet(
// does, so the row can never look tappable and do // does, so the row can never look tappable and do
// nothing. // nothing.
.clickable( .clickable(
enabled = prepareRowTapAction(apkStatus) != null enabled = prepareRowTapAction(
apkStatus,
releaseStatus,
apkUiState.downloadRetryAtMillis
) != null
) { ) {
apkViewModel.onEvent(ApkUiEvent.PrepareRowClicked) apkViewModel.onEvent(ApkUiEvent.PrepareRowClicked)
} }
@ -667,7 +682,7 @@ fun AboutSheet(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( Icon(
imageVector = if (apkStatus is ApkPreparationStatus.Ready) { imageVector = if (shareableApk != null) {
Icons.Default.Share Icons.Default.Share
} else { } else {
Icons.Default.CloudDownload Icons.Default.CloudDownload
@ -683,16 +698,53 @@ fun AboutSheet(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp) verticalArrangement = Arrangement.spacedBy(2.dp)
) { ) {
Text( Row(verticalAlignment = Alignment.CenterVertically) {
text = if (apkStatus is ApkPreparationStatus.Ready) { Text(
stringResource(R.string.prepare_apk_ready_title) text = if (shareableApk != null) {
} else { stringResource(R.string.prepare_apk_ready_title)
stringResource(R.string.prepare_apk_title) } else {
}, stringResource(R.string.prepare_apk_title)
style = MaterialTheme.typography.bodyMedium, },
fontWeight = FontWeight.Medium, style = MaterialTheme.typography.bodyMedium,
color = colorScheme.onSurface 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(
text = when (val status = apkStatus) { text = when (val status = apkStatus) {
is ApkPreparationStatus.Loading -> stringResource(R.string.checking) is ApkPreparationStatus.Loading -> stringResource(R.string.checking)
@ -764,7 +816,10 @@ fun AboutSheet(
} }
) )
is ApkPreparationStatus.Ready -> { is ApkPreparationStatus.Ready -> {
if (apkStatus.variant == ShareableApkVariant.ARM64) { if (
apkStatus.source ==
UniversalApkManager.ApkSource.INSTALLED
) {
ApkPrepareRowIconButton( ApkPrepareRowIconButton(
icon = Icons.Default.CloudDownload, icon = Icons.Default.CloudDownload,
description = stringResource( description = stringResource(
@ -775,6 +830,7 @@ fun AboutSheet(
ApkUiEvent.DownloadUniversalClicked ApkUiEvent.DownloadUniversalClicked
) )
}, },
enabled = !downloadRetryBlocked,
tint = colorScheme.primary tint = colorScheme.primary
) )
} else if ( } else if (
@ -807,6 +863,11 @@ fun AboutSheet(
ApkUiEvent.PrepareRowClicked ApkUiEvent.PrepareRowClicked
) )
}, },
enabled = prepareRowTapAction(
apkStatus,
releaseStatus,
apkUiState.downloadRetryAtMillis
) != null,
tint = colorScheme.primary tint = colorScheme.primary
) )
else -> {} else -> {}
@ -820,16 +881,28 @@ fun AboutSheet(
title = { title = {
Text( Text(
text = stringResource( 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 style = MaterialTheme.typography.titleLarge
) )
}, },
text = { text = {
Text( Text(
text = stringResource( text = if (availableUpdate != null) {
R.string.prepare_apk_dialog_message_unknown_size 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 style = MaterialTheme.typography.bodyMedium
) )
}, },
@ -875,7 +948,11 @@ fun AboutSheet(
containerColor = colorScheme.error containerColor = colorScheme.error
) )
) { ) {
Text("Delete") Text(
stringResource(
R.string.prepare_apk_button_delete
)
)
} }
}, },
dismissButton = { dismissButton = {
@ -887,8 +964,9 @@ fun AboutSheet(
) )
} }
// Show sharing rows only when APK is ready // A GitHub update is optional. Keep sharing visible while the
val canShareAPK = apkStatus is ApkPreparationStatus.Ready // replacement downloads or while metadata refreshes.
val canShareAPK = shareableApk != null
AnimatedVisibility( AnimatedVisibility(
visible = canShareAPK, visible = canShareAPK,

View File

@ -7,11 +7,16 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.bitchat.android.R import com.bitchat.android.R
import com.bitchat.android.util.ApkDownloader 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.ShareableApkVariant
import com.bitchat.android.util.UniversalApkManager import com.bitchat.android.util.UniversalApkManager
import com.bitchat.android.util.WorkManagerApkDownloader import com.bitchat.android.util.WorkManagerApkDownloader
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -33,14 +38,32 @@ sealed class ApkPreparationStatus {
) : ApkPreparationStatus() ) : ApkPreparationStatus()
/** [phase] is what the operation is actually doing; only a transfer has a real percentage. */ /** [phase] is what the operation is actually doing; only a transfer has a real percentage. */
data class Downloading( data class Downloading(
val phase: ApkDownloader.DownloadPhase = ApkDownloader.DownloadPhase.SelectingSource val phase: ApkDownloader.DownloadPhase = ApkDownloader.DownloadPhase.SelectingSource,
val shareableFallback: Ready? = null
) : ApkPreparationStatus() ) : ApkPreparationStatus()
data class Resumable(val progressPercent: Int, val message: String) : ApkPreparationStatus() data class Resumable(
data class Error(val message: String) : ApkPreparationStatus() 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( data class ApkUiState(
val apkStatus: ApkPreparationStatus = ApkPreparationStatus.Loading, val apkStatus: ApkPreparationStatus = ApkPreparationStatus.Loading,
val releaseStatus: ApkReleaseStatus = ApkReleaseStatus.Unknown,
val downloadRetryAtMillis: Long? = null,
val downloadProgress: Int = 0, val downloadProgress: Int = 0,
val showPrepareDialog: Boolean = false, val showPrepareDialog: Boolean = false,
val showDeleteDialog: 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 * 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. * 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 status is ApkPreparationStatus.NotDownloaded -> PrepareRowTapAction.OpenPrepareDialog
// Consent was already given for these; resuming straight away avoids a redundant prompt. // Consent was already given for these; resuming straight away avoids a redundant prompt.
status is ApkPreparationStatus.Resumable -> PrepareRowTapAction.StartDownload status is ApkPreparationStatus.Resumable &&
status is ApkPreparationStatus.Error -> PrepareRowTapAction.StartDownload (status.retryAtMillis == null || status.retryAtMillis <= nowMillis) ->
status is ApkPreparationStatus.Ready && status.variant == ShareableApkVariant.ARM64 -> 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 PrepareRowTapAction.OpenPrepareDialog
else -> null else -> null
} }
@ -102,21 +137,33 @@ sealed class ApkUiEffect {
* ViewModel for APK download/status/share logic following MVI pattern. * ViewModel for APK download/status/share logic following MVI pattern.
* UI sends [ApkUiEvent], observes [ApkUiState], and collects [ApkUiEffect]. * 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 { companion object {
private const val TAG = "ApkDownloadVM" private const val TAG = "ApkDownloadVM"
} }
private val apkManager = UniversalApkManager(application)
private val downloader: ApkDownloader = WorkManagerApkDownloader(application)
private val _state = MutableStateFlow(ApkUiState()) private val _state = MutableStateFlow(ApkUiState())
val state: StateFlow<ApkUiState> = _state.asStateFlow() val state: StateFlow<ApkUiState> = _state.asStateFlow()
private val _effect = Channel<ApkUiEffect>(Channel.BUFFERED) private val _effect = Channel<ApkUiEffect>(Channel.BUFFERED)
val effect = _effect.receiveAsFlow() val effect = _effect.receiveAsFlow()
private var metadataRefreshJob: Job? = null
private var retryUnlockJob: Job? = null
init { init {
observeDownloader() observeDownloader()
} }
@ -140,7 +187,13 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat
} }
private fun onPrepareRowClicked() { private fun onPrepareRowClicked() {
when (prepareRowTapAction(_state.value.apkStatus)) { when (
prepareRowTapAction(
_state.value.apkStatus,
_state.value.releaseStatus,
_state.value.downloadRetryAtMillis
)
) {
PrepareRowTapAction.OpenPrepareDialog -> PrepareRowTapAction.OpenPrepareDialog ->
_state.update { it.copy(showPrepareDialog = true) } _state.update { it.copy(showPrepareDialog = true) }
PrepareRowTapAction.StartDownload -> startDownload() PrepareRowTapAction.StartDownload -> startDownload()
@ -154,9 +207,14 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat
} }
private fun onDownloadUniversalClicked() { private fun onDownloadUniversalClicked() {
if (_state.value.downloadRetryAtMillis?.let { it > System.currentTimeMillis() } == true) {
return
}
val status = _state.value.apkStatus val status = _state.value.apkStatus
val hasUpdate = (_state.value.releaseStatus as? ApkReleaseStatus.Known)
?.isNewerThanSharedApk == true
if (status is ApkPreparationStatus.Ready && if (status is ApkPreparationStatus.Ready &&
status.variant == ShareableApkVariant.ARM64 (status.source == UniversalApkManager.ApkSource.INSTALLED || hasUpdate)
) { ) {
_state.update { it.copy(showPrepareDialog = true) } _state.update { it.copy(showPrepareDialog = true) }
} }
@ -212,20 +270,39 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat
private fun onCancelDownload() { private fun onCancelDownload() {
downloader.cancelDownload() downloader.cancelDownload()
// Leave Downloading immediately. The cancelled job maps to Idle, which val fallback = (_state.value.apkStatus as? ApkPreparationStatus.Downloading)
// the observer intentionally ignores because local status is resolved below. ?.shareableFallback
_state.update { _state.update {
it.copy(apkStatus = ApkPreparationStatus.Loading, downloadProgress = 0) it.copy(
apkStatus = fallback ?: ApkPreparationStatus.Loading,
downloadProgress = 0
)
} }
if (fallback == null) checkStatus()
checkStatus()
} }
private fun startDownload() { 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() val partial = apkManager.getPartialDownloadProgress()
_state.update { _state.update {
it.copy( it.copy(
apkStatus = ApkPreparationStatus.Downloading(), apkStatus = ApkPreparationStatus.Downloading(
shareableFallback = fallback
),
downloadRetryAtMillis = null,
downloadProgress = partial ?: 0 downloadProgress = partial ?: 0
) )
} }
@ -251,6 +328,36 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat
current.copy(apkStatus = resolvedStatus, downloadProgress = 0) 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 -> downloader.downloadState.collect { downloadState ->
when (downloadState) { when (downloadState) {
is ApkDownloader.DownloadState.Idle -> { 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 -> { is ApkDownloader.DownloadState.Downloading -> {
_state.update { _state.update {
val fallback = (it.apkStatus as? ApkPreparationStatus.Downloading)
?.shareableFallback
?: (it.apkStatus as? ApkPreparationStatus.Ready)
it.copy( it.copy(
apkStatus = ApkPreparationStatus.Downloading(downloadState.phase), apkStatus = ApkPreparationStatus.Downloading(
phase = downloadState.phase,
shareableFallback = fallback
),
downloadProgress = downloadState.progressPercent downloadProgress = downloadState.progressPercent
) )
} }
@ -272,31 +395,33 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat
is ApkDownloader.DownloadState.Success -> { is ApkDownloader.DownloadState.Success -> {
val info = apkManager.getCachedApkInfo() val info = apkManager.getCachedApkInfo()
_state.update { _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( it.copy(
apkStatus = ApkPreparationStatus.Ready( apkStatus = ready,
version = info?.version ?: downloadState.version, releaseStatus = releaseStatusFor(ready, it.releaseStatus),
sizeMB = info?.let { cached -> downloadRetryAtMillis = null,
(cached.size / 1024 / 1024).toInt()
} ?: downloadState.sizeMB,
source = info?.source ?: UniversalApkManager.ApkSource.DOWNLOADED,
variant = info?.variant ?: ShareableApkVariant.UNIVERSAL
),
downloadProgress = 100 downloadProgress = 100
) )
} }
} }
is ApkDownloader.DownloadState.Failed -> { is ApkDownloader.DownloadState.Failed -> {
val localArm64 = apkManager.getCachedApkInfo() val fallback = (_state.value.apkStatus as? ApkPreparationStatus.Downloading)
?.takeIf { it.variant == ShareableApkVariant.ARM64 } ?.shareableFallback
if (localArm64 != null) { ?: apkManager.getCachedApkInfo()?.toReady()
if (fallback != null) {
_state.update { _state.update {
it.copy( it.copy(
apkStatus = ApkPreparationStatus.Ready( apkStatus = fallback,
version = localArm64.version, releaseStatus = releaseStatusFor(fallback, it.releaseStatus),
sizeMB = (localArm64.size / 1024 / 1024).toInt(), downloadRetryAtMillis = downloadState.retryAtMillis,
source = localArm64.source, downloadProgress = 0
variant = localArm64.variant
)
) )
} }
_effect.send(ApkUiEffect.ShowToast(failureMessage(downloadState))) _effect.send(ApkUiEffect.ShowToast(failureMessage(downloadState)))
@ -307,17 +432,24 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat
it.copy( it.copy(
apkStatus = ApkPreparationStatus.Resumable( apkStatus = ApkPreparationStatus.Resumable(
progressPercent = downloadState.resumablePercent, progressPercent = downloadState.resumablePercent,
message = message message = message,
retryAtMillis = downloadState.retryAtMillis
), ),
downloadRetryAtMillis = downloadState.retryAtMillis,
downloadProgress = downloadState.resumablePercent downloadProgress = downloadState.resumablePercent
) )
} else { } else {
it.copy( 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. * this resolves it, so the message follows the device locale rather than the worker's.
*/ */
private fun failureMessage(state: ApkDownloader.DownloadState.Failed): String = private fun failureMessage(state: ApkDownloader.DownloadState.Failed): String =
getApplication<Application>().getString( runCatching {
state.reason.messageRes, getApplication<Application>().getString(
*state.messageArgs.toTypedArray() 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) { private suspend fun resolveApkStatus(): ApkPreparationStatus = withContext(Dispatchers.IO) {
try { try {
val info = apkManager.prepareLocalApkInfo() val info = apkManager.prepareLocalApkInfo()
if (info != null) { if (info != null) {
ApkPreparationStatus.Ready( info.toReady()
version = info.version,
sizeMB = (info.size / 1024 / 1024).toInt(),
source = info.source,
variant = info.variant
)
} else { } else {
val partial = apkManager.getPartialDownloadProgress() val partial = apkManager.getPartialDownloadProgress()
if (partial != null) { if (partial != null) {

View File

@ -4,10 +4,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearWavyProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PlainTooltip import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Text 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 * 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. * 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 * Determinate transfer/resume progress and indeterminate non-transfer phases use the stable
* flat line at the fraction it reached rather than a bar indistinguishable from a live one. * Material 3 progress API. The expressive wavy variant can be introduced independently later.
*/ */
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
internal fun ApkDownloadProgressBar( internal fun ApkDownloadProgressBar(
status: ApkPreparationStatus, status: ApkPreparationStatus,
@ -46,19 +44,17 @@ internal fun ApkDownloadProgressBar(
status is ApkPreparationStatus.Downloading && status is ApkPreparationStatus.Downloading &&
status.phase.hasMeasurableProgress && status.phase.hasMeasurableProgress &&
progressPercent > 0 -> progressPercent > 0 ->
LinearWavyProgressIndicator( LinearProgressIndicator(
progress = { progressPercent.asProgressFraction() }, progress = { progressPercent.asProgressFraction() },
modifier = barModifier modifier = barModifier
) )
status is ApkPreparationStatus.Downloading -> 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 -> status is ApkPreparationStatus.Resumable ->
LinearWavyProgressIndicator( LinearProgressIndicator(
progress = { status.progressPercent.asProgressFraction() }, progress = { status.progressPercent.asProgressFraction() },
amplitude = { 0f },
modifier = barModifier modifier = barModifier
) )
} }
@ -84,22 +80,24 @@ internal fun ApkPrepareRowIconButton(
description: String, description: String,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true,
tint: Color = MaterialTheme.colorScheme.onSurfaceVariant tint: Color = MaterialTheme.colorScheme.onSurfaceVariant
) { ) {
TooltipBox( TooltipBox(
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), positionProvider = TooltipDefaults.rememberTooltipPositionProvider(),
tooltip = { PlainTooltip { Text(description) } }, tooltip = { PlainTooltip { Text(description) } },
state = rememberTooltipState(), state = rememberTooltipState(),
modifier = modifier modifier = modifier
) { ) {
IconButton( IconButton(
onClick = onClick, onClick = onClick,
enabled = enabled,
modifier = Modifier.size(48.dp) modifier = Modifier.size(48.dp)
) { ) {
Icon( Icon(
imageVector = icon, imageVector = icon,
contentDescription = description, contentDescription = description,
tint = tint, tint = if (enabled) tint else tint.copy(alpha = 0.38f),
modifier = Modifier.size(20.dp) modifier = Modifier.size(20.dp)
) )
} }

View File

@ -2,6 +2,8 @@ package com.bitchat.android.util
import androidx.annotation.StringRes import androidx.annotation.StringRes
import com.bitchat.android.R import com.bitchat.android.R
import java.io.File
import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import java.time.Instant import java.time.Instant
import java.time.ZonedDateTime import java.time.ZonedDateTime
@ -244,6 +246,14 @@ internal data class ContentRange(
val total: Long? 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? { internal fun parseContentRange(value: String?): ContentRange? {
if (value == null) return null if (value == null) return null
val match = Regex("""bytes\s+(\d+)-(\d+)/(\d+|\*)""", RegexOption.IGNORE_CASE) val match = Regex("""bytes\s+(\d+)-(\d+)/(\d+|\*)""", RegexOption.IGNORE_CASE)

View File

@ -39,6 +39,7 @@ class ApkDownloadWorker(
const val KEY_ERROR_REASON = "error_reason" const val KEY_ERROR_REASON = "error_reason"
const val KEY_ERROR_ARGS = "error_args" const val KEY_ERROR_ARGS = "error_args"
const val KEY_RESUMABLE_PERCENT = "resumable_percent" const val KEY_RESUMABLE_PERCENT = "resumable_percent"
const val KEY_RETRY_AT = "retry_at"
private const val CHANNEL_ID = "apk_download" private const val CHANNEL_ID = "apk_download"
private const val NOTIFICATION_ID = 4201 private const val NOTIFICATION_ID = 4201
@ -120,6 +121,7 @@ class ApkDownloadWorker(
failure?.messageArgs.orEmpty().toTypedArray() failure?.messageArgs.orEmpty().toTypedArray()
) )
.putInt(KEY_RESUMABLE_PERCENT, partial ?: -1) .putInt(KEY_RESUMABLE_PERCENT, partial ?: -1)
.apply { failure?.retryAtMillis?.let { putLong(KEY_RETRY_AT, it) } }
.build() .build()
Result.failure(outputData) Result.failure(outputData)
} }

View File

@ -42,7 +42,8 @@ interface ApkDownloader {
data class Failed( data class Failed(
val reason: ApkDownloadFailureReason, val reason: ApkDownloadFailureReason,
val messageArgs: List<String>, val messageArgs: List<String>,
val resumablePercent: Int? val resumablePercent: Int?,
val retryAtMillis: Long? = null
) : DownloadState() ) : DownloadState()
} }

View File

@ -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()}"
}

View File

@ -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<GitHubReleaseClient.ReleaseSnapshot>
}
/** 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<ReleaseSnapshot> = 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<ReleaseSnapshot> = orFailure(
IOException("The selected network route is not ready")
)
private fun CachedRelease?.orFailure(error: Throwable): Result<ReleaseSnapshot> =
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
)
}

View File

@ -56,14 +56,19 @@ class UniversalApkManager(
private val metadataFile: File get() = File(cacheDir, METADATA_FILE_NAME) private val metadataFile: File get() = File(cacheDir, METADATA_FILE_NAME)
private val progressFile: File get() = File(cacheDir, PROGRESS_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 // Download client: inherits the current client's actual route but has no call timeout for
// for large file downloads that can take minutes // large files that can take minutes. Keep the route attached for route-specific cooldowns.
private val downloadClient private fun downloadClient(): OkHttpProvider.RoutedClient {
get() = OkHttpProvider.httpClient().newBuilder() val routed = OkHttpProvider.routedHttpClient()
.callTimeout(0, java.util.concurrent.TimeUnit.SECONDS) return routed.copy(
.readTimeout(60, java.util.concurrent.TimeUnit.SECONDS) client = routed.client.newBuilder()
.build() .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. * 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 safeVersion = version.replace(Regex("[^A-Za-z0-9._-]"), "_")
val finalFileName = "$APK_FILE_PREFIX$safeVersion.apk" val finalFileName = "$APK_FILE_PREFIX$safeVersion.apk"
val finalFile = File(cacheDir, finalFileName) 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) replaceFileSafely(tempFile, finalFile)
progressFile.delete() progressFile.delete()
@ -360,6 +368,13 @@ class UniversalApkManager(
resume: ResumeInfo?, resume: ResumeInfo?,
progressCallback: ((Int) -> Unit)? 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() val request = Request.Builder()
// Always start from the configured source endpoint. If a release changed, // Always start from the configured source endpoint. If a release changed,
// If-Range makes the server return 200 and we overwrite the partial. // If-Range makes the server return 200 and we overwrite the partial.
@ -374,8 +389,10 @@ class UniversalApkManager(
.build() .build()
downloadToTempFile( downloadToTempFile(
call = downloadClient.newCall(request), call = routedClient.client.newCall(request),
source = source, source = source,
rateLimitScope = rateLimitScope,
route = routedClient.route,
endpointUrl = endpointUrl, endpointUrl = endpointUrl,
tempFile = tempFile, tempFile = tempFile,
existingBytes = existingBytes, existingBytes = existingBytes,
@ -391,6 +408,8 @@ class UniversalApkManager(
private suspend fun downloadToTempFile( private suspend fun downloadToTempFile(
call: Call, call: Call,
source: ApkDownloadSource, source: ApkDownloadSource,
rateLimitScope: String,
route: OkHttpProvider.Route,
endpointUrl: String, endpointUrl: String,
tempFile: File, tempFile: File,
existingBytes: Long, existingBytes: Long,
@ -454,7 +473,7 @@ class UniversalApkManager(
) )
} }
if (!response.isSuccessful) { if (!response.isSuccessful) {
throw ApkDownloadHttpErrors.fromResponse( val failure = ApkDownloadHttpErrors.fromResponse(
source = source, source = source,
code = response.code, code = response.code,
responseMessage = response.message, responseMessage = response.message,
@ -463,8 +482,23 @@ class UniversalApkManager(
rateLimitResetEpochSeconds = rateLimitResetEpochSeconds =
response.header("X-RateLimit-Reset") 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 body = response.body
val range = if (response.code == 206) { val range = if (response.code == 206) {
parseContentRange(response.header("Content-Range")) parseContentRange(response.header("Content-Range"))
@ -491,6 +525,12 @@ class UniversalApkManager(
val validator = response.header("ETag") val validator = response.header("ETag")
?: response.header("Last-Modified") ?: response.header("Last-Modified")
?: previousResume?.validator?.takeIf { append } ?: 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) { if (validator != null) {
saveResumeInfo( saveResumeInfo(
ResumeInfo( ResumeInfo(
@ -511,7 +551,9 @@ class UniversalApkManager(
} }
body.byteStream().use { input -> 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) val buffer = ByteArray(BUFFER_SIZE)
var bytesRead: Int var bytesRead: Int
var totalBytesRead = resumedBytes var totalBytesRead = resumedBytes

View File

@ -92,7 +92,11 @@ class WorkManagerApkDownloader(context: Context) : ApkDownloader {
ApkDownloader.DownloadState.Failed( ApkDownloader.DownloadState.Failed(
reason = reason, reason = reason,
messageArgs = args, 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 -> { WorkInfo.State.CANCELLED -> {

View File

@ -246,6 +246,8 @@
<string name="prepare_apk_source_installed">Sharing source: this installed APK</string> <string name="prepare_apk_source_installed">Sharing source: this installed APK</string>
<string name="prepare_apk_source_installed_arm64">Sharing source: this installed APK • ARM64 devices only</string> <string name="prepare_apk_source_installed_arm64">Sharing source: this installed APK • ARM64 devices only</string>
<string name="prepare_apk_source_downloaded">Sharing source: verified downloaded universal APK</string> <string name="prepare_apk_source_downloaded">Sharing source: verified downloaded universal APK</string>
<string name="prepare_apk_update_available">Version %1$s is available. You can keep sharing this APK or download the update.</string>
<string name="prepare_apk_update_warning">New version available</string>
<!-- Now the accessibility label and tooltip for an icon-only button, not a visible label. --> <!-- Now the accessibility label and tooltip for an icon-only button, not a visible label. -->
<string name="prepare_apk_get_universal">Download universal APK</string> <string name="prepare_apk_get_universal">Download universal APK</string>
<string name="prepare_apk_retry">Retry download</string> <string name="prepare_apk_retry">Retry download</string>
@ -262,7 +264,9 @@
<string name="prepare_apk_button_delete">Delete</string> <string name="prepare_apk_button_delete">Delete</string>
<string name="prepare_apk_info">Version %1$s • %2$d MB</string> <string name="prepare_apk_info">Version %1$s • %2$d MB</string>
<string name="prepare_apk_dialog_title">Download Universal APK?</string> <string name="prepare_apk_dialog_title">Download Universal APK?</string>
<string name="prepare_apk_update_dialog_title">Download Newer Universal APK?</string>
<string name="prepare_apk_dialog_message_unknown_size">This will download a verified universal APK from a configured source. You only need to do this once.</string> <string name="prepare_apk_dialog_message_unknown_size">This will download a verified universal APK from a configured source. You only need to do this once.</string>
<string name="prepare_apk_update_dialog_message">Version %1$s is available from GitHub (%2$d MB). Your current APK remains shareable during the download.</string>
<string name="prepare_apk_dialog_confirm">Download</string> <string name="prepare_apk_dialog_confirm">Download</string>
<string name="prepare_apk_downloading_title">Downloading Universal APK</string> <string name="prepare_apk_downloading_title">Downloading Universal APK</string>
<string name="prepare_apk_downloading_message">Downloading %1$d MB…</string> <string name="prepare_apk_downloading_message">Downloading %1$d MB…</string>

View File

@ -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<ApkPreparationStatus>()
val metadata = object : LatestReleaseProvider {
override suspend fun latestRelease(): Result<GitHubReleaseClient.ReleaseSnapshot> {
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<GitHubReleaseClient.ReleaseSnapshot> =
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<UniversalApkManager>()
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>(
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
}
}
}

View File

@ -34,8 +34,15 @@ class PrepareRowTapActionTest {
} }
@Test @Test
fun `nothing left to fetch means the row is inert`() { fun `a standalone installed universal apk can optionally be replaced from github`() {
assertNull(prepareRowTapAction(ready(ShareableApkVariant.UNIVERSAL))) assertEquals(
PrepareRowTapAction.OpenPrepareDialog,
prepareRowTapAction(ready(ShareableApkVariant.UNIVERSAL))
)
}
@Test
fun `a current downloaded universal apk leaves the row inert`() {
assertNull( assertNull(
prepareRowTapAction( prepareRowTapAction(
ready(ShareableApkVariant.UNIVERSAL, UniversalApkManager.ApkSource.DOWNLOADED) 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 @Test
fun `a missing apk asks before spending the bytes`() { fun `a missing apk asks before spending the bytes`() {
assertEquals( assertEquals(
@ -72,4 +95,34 @@ class PrepareRowTapActionTest {
} }
assertNull(prepareRowTapAction(ApkPreparationStatus.Loading)) 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
)
)
}
} }

View File

@ -4,11 +4,16 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.IOException import java.io.IOException
class ApkDownloadSourceTest { class ApkDownloadSourceTest {
@get:Rule
val temporaryFolder = TemporaryFolder()
private val source = ApkDownloadSource( private val source = ApkDownloadSource(
id = "mirror-one", id = "mirror-one",
displayName = "Mirror One", displayName = "Mirror One",
@ -120,6 +125,28 @@ class ApkDownloadSourceTest {
assertNull(parseContentRange("bytes 90-100/100")) 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 @Test
fun `version comparison is host independent`() { fun `version comparison is host independent`() {
assertTrue(AppVersion.isNewer("1.7.4", "1.7.5")) assertTrue(AppVersion.isNewer("1.7.4", "1.7.5"))

View File

@ -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()
}

View File

@ -16,9 +16,6 @@ appcompat = "1.7.1"
# Compose # Compose
compose-bom = "2026.06.01" compose-bom = "2026.06.01"
compose-icons-extended = "1.7.8" 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
navigation-compose = "2.9.8" 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-graphics = { module = "androidx.compose.ui:ui-graphics" }
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } 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" } androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "compose-icons-extended" }
# Lifecycle # Lifecycle
@ -124,6 +121,7 @@ nordic-ble = { module = "no.nordicsemi.android:ble", version.ref = "nordic-ble"
# WebSocket # WebSocket
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } 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-android-binary = { module = "org.torproject:tor-android-binary", version.ref = "tor-android-binary" }
# Tor (embed) intentionally not pinned yet; add once repo is chosen # Tor (embed) intentionally not pinned yet; add once repo is chosen
@ -195,7 +193,8 @@ testing = [
"mockito-kotlin", "mockito-kotlin",
"mockito-core", "mockito-core",
"roboelectric", "roboelectric",
"kotlinx-coroutines-test" "kotlinx-coroutines-test",
"okhttp-mockwebserver"
] ]
compose-testing = [ compose-testing = [

View File

@ -3658,6 +3658,14 @@
<sha256 value="e1abd7f1116cf5e0c59947693e2189208ec94296b2a3394c959e3511d399a7b0" origin="Generated by Gradle"/> <sha256 value="e1abd7f1116cf5e0c59947693e2189208ec94296b2a3394c959e3511d399a7b0" origin="Generated by Gradle"/>
</artifact> </artifact>
</component> </component>
<component group="com.squareup.okhttp3" name="mockwebserver3" version="5.4.0">
<artifact name="mockwebserver3-5.4.0.jar">
<sha256 value="e1e1d51a57567d6db834f93d98831ab10bb0220b91a67580894d9ab36cc9f065" origin="Generated by Gradle"/>
</artifact>
<artifact name="mockwebserver3-5.4.0.module">
<sha256 value="1161099d541033926bc0886a0bc2b4fb92a99a4fe9410180baf377159a996bbb" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.squareup.okhttp3" name="okhttp" version="5.4.0"> <component group="com.squareup.okhttp3" name="okhttp" version="5.4.0">
<artifact name="okhttp-5.4.0.module"> <artifact name="okhttp-5.4.0.module">
<sha256 value="63973ba755ba6c77de1e82f5efa4195569800be00c93c0130ce4238a9b56c93c" origin="Generated by Gradle"/> <sha256 value="63973ba755ba6c77de1e82f5efa4195569800be00c93c0130ce4238a9b56c93c" origin="Generated by Gradle"/>