diff --git a/.github/actions/setup-env/action.yml b/.github/actions/setup-env/action.yml index 9e13baa8..1d9bbd4e 100644 --- a/.github/actions/setup-env/action.yml +++ b/.github/actions/setup-env/action.yml @@ -1,33 +1,39 @@ name: 'Setup Environment' -description: 'Sets up JDK and creates necessary files from secrets' +description: 'Sets up Java and Gradle, then creates build-only Firebase configuration files' inputs: google-services-json: - required: true - description: 'JSON string for google-services.json written by the workflow.' + required: false + default: '' + description: 'Optional trusted google-services.json; CI uses a non-production fixture otherwise.' + gradle-cache-read-only: + required: false + default: 'true' + description: 'Whether Gradle may only restore caches and must not write them.' runs: using: "composite" steps: - - name: Create files + - name: Create Firebase configuration shell: bash env: GOOGLE_SERVICES_JSON: ${{ inputs.google-services-json }} run: | - # Fail fast if secrets are missing - if [ -z "${GOOGLE_SERVICES_JSON:-}" ]; then - echo "Missing GOOGLE_SERVICES_JSON" >&2 - exit 1 - fi - - # Ensure directories mkdir -p app - - # Safely write files - printf '%s' "$GOOGLE_SERVICES_JSON" > google-services.json - printf '%s' "$GOOGLE_SERVICES_JSON" > app/google-services.json + if [ -n "${GOOGLE_SERVICES_JSON:-}" ]; then + printf '%s' "$GOOGLE_SERVICES_JSON" > google-services.json + printf '%s' "$GOOGLE_SERVICES_JSON" > app/google-services.json + else + cp .github/fixtures/google-services.ci.json google-services.json + cp .github/fixtures/google-services.ci.json app/google-services.json + fi - name: Set up JDK 23 uses: actions/setup-java@v5 with: java-version: 23 distribution: 'zulu' - cache: 'gradle' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@90ddb51e90a5fd9ba75f40cf85156b7b41bf76a3 # v6 + with: + validate-wrappers: true + cache-read-only: ${{ inputs.gradle-cache-read-only }} diff --git a/.github/fixtures/google-services.ci.json b/.github/fixtures/google-services.ci.json new file mode 100644 index 00000000..e7142caa --- /dev/null +++ b/.github/fixtures/google-services.ci.json @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "000000000000", + "project_id": "adaptive-theme-ci", + "storage_bucket": "adaptive-theme-ci.invalid" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:000000000000:android:0000000000000000000000", + "android_client_info": { + "package_name": "dev.lexip.hecate" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyAdaptiveThemeCiFixtureNotForProduction" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 62edb873..fb605b07 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,12 +1,24 @@ name: CI + on: - push: { } + push: pull_request: - types: [ opened, synchronize, reopened ] + types: [opened, synchronize, reopened] + schedule: + - cron: '30 2 * * *' + workflow_dispatch: + +concurrency: + group: android-ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: lint: name: Lint + if: github.event_name == 'push' || github.event_name == 'pull_request' runs-on: ubuntu-latest + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} permissions: contents: read steps: @@ -18,7 +30,8 @@ jobs: uses: ./.github/actions/setup-env with: google-services-json: ${{ secrets.GOOGLE_SERVICES_JSON }} - mock-dark-theme-handler: ${{ secrets.MOCK_DARK_THEME_HANDLER }} + # Long-lived branches seed shared Gradle dependencies for later jobs and runs. + gradle-cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testing' }} - name: Cache SonarCloud packages uses: actions/cache@v6 @@ -27,27 +40,52 @@ jobs: key: ${{ runner.os }}-sonar restore-keys: ${{ runner.os }}-sonar - - name: Run Lint and Build - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: ./gradlew lint assembleFossRelease --info + - name: Run JVM tests, coverage, and lint + run: >- + ./gradlew + testFossDebugUnitTest + verifyFossDebugCoverage + lint + --info + + - name: Upload JVM test reports + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: foss-jvm-test-reports + retention-days: 14 + if-no-files-found: warn + path: | + app/build/reports/tests/testFossDebugUnitTest/ + app/build/test-results/testFossDebugUnitTest/ + + - name: Upload unit coverage + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: foss-unit-coverage + retention-days: 14 + if-no-files-found: warn + path: app/build/reports/coverage/test/foss/debug/ - name: SonarCloud Scan + if: env.SONAR_TOKEN != '' uses: SonarSource/sonarqube-scan-action@v8 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} with: args: >- -Dsonar.projectKey=xLexip_Hecate -Dsonar.projectVersion=1.3.0 -Dsonar.organization=xlexip -Dsonar.host.url=https://sonarcloud.io - -Dsonar.androidLint.reportPaths=app/build/reports/lint-results-fossDebug.html - + -Dsonar.androidLint.reportPaths=app/build/reports/lint-results-fossDebug.xml + -Dsonar.junit.reportPaths=app/build/test-results/testFossDebugUnitTest + -Dsonar.coverage.jacoco.xmlReportPaths=app/build/reports/coverage/test/foss/debug/report.xml build_play: name: Build Play + if: github.event_name == 'push' || github.event_name == 'pull_request' permissions: contents: read runs-on: ubuntu-latest @@ -58,13 +96,13 @@ jobs: uses: ./.github/actions/setup-env with: google-services-json: ${{ secrets.GOOGLE_SERVICES_JSON }} - mock-dark-theme-handler: ${{ secrets.MOCK_DARK_THEME_HANDLER }} - name: Build Play run: ./gradlew assemblePlayRelease --info build_foss: name: Build FOSS + if: github.event_name == 'push' || github.event_name == 'pull_request' runs-on: ubuntu-latest permissions: contents: read @@ -75,7 +113,60 @@ jobs: uses: ./.github/actions/setup-env with: google-services-json: ${{ secrets.GOOGLE_SERVICES_JSON }} - mock-dark-theme-handler: ${{ secrets.MOCK_DARK_THEME_HANDLER }} - name: Build FOSS - run: ./gradlew assembleFossRelease + run: ./gradlew assembleFossRelease --info + + device: + name: Device API ${{ matrix.api }} + if: >- + github.event_name == 'workflow_dispatch' || + github.event_name == 'schedule' || + (github.event_name == 'pull_request' && github.base_ref == 'main') || + (github.event_name == 'push' && github.ref == 'refs/heads/testing') + strategy: + fail-fast: false + matrix: + include: + - api: 34 + task: pixelApi34FossDebugAndroidTest + - api: 35 + task: pixelApi35FossDebugAndroidTest + - api: 36 + task: pixelApi36FossDebugAndroidTest + - api: 37 + task: pixelApi37FossDebugAndroidTest + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + + - name: Setup Environment + uses: ./.github/actions/setup-env + with: + # One matrix leg writes; the other APIs restore the shared cache without duplicating it. + gradle-cache-read-only: ${{ matrix.api != 37 || (github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testing') }} + + - name: Enable KVM + run: sudo chmod 666 /dev/kvm + + - name: Run API ${{ matrix.api }} Compose tests + run: >- + ./gradlew + ${{ matrix.task }} + -Pandroid.testoptions.manageddevices.emulator.gpu=swiftshader_indirect + --info + + - name: Upload API ${{ matrix.api }} reports and logcat + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: foss-device-api-${{ matrix.api }} + retention-days: 14 + if-no-files-found: warn + path: | + app/build/reports/androidTests/managedDevice/ + app/build/outputs/androidTest-results/managedDevice/ + app/build/outputs/managed_device_android_test_additional_output/ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 87cd7a7f..7848c132 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,4 +1,5 @@ plugins { + jacoco alias(libs.plugins.google.services) alias(libs.plugins.android.application) alias(libs.plugins.kotlin.compose) @@ -6,6 +7,59 @@ plugins { alias(libs.plugins.google.firebase.crashlytics) } +abstract class VerifyJacocoCoverageTask : DefaultTask() { + @get:org.gradle.api.tasks.InputFile + abstract val reportFile: org.gradle.api.file.RegularFileProperty + + @get:org.gradle.api.tasks.Input + abstract val requiredClasses: org.gradle.api.provider.ListProperty + + @org.gradle.api.tasks.TaskAction + fun verifyCoverage() { + val report = reportFile.get().asFile + check(report.isFile) { + "JaCoCo XML report was not generated at ${report.absolutePath}" + } + + val documentFactory = javax.xml.parsers.DocumentBuilderFactory.newInstance().apply { + setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false) + setFeature("http://xml.org/sax/features/validation", false) + } + val document = documentFactory.newDocumentBuilder().parse(report) + val classNodes = document.getElementsByTagName("class") + + requiredClasses.get().forEach { className -> + var coveredInstructions: Int? = null + for (classIndex in 0 until classNodes.length) { + val classElement = classNodes.item(classIndex) as org.w3c.dom.Element + if (classElement.getAttribute("name") != className) continue + + val counters = classElement.getElementsByTagName("counter") + for (counterIndex in 0 until counters.length) { + val counter = counters.item(counterIndex) as org.w3c.dom.Element + if (counter.getAttribute("type") == "INSTRUCTION") { + coveredInstructions = counter.getAttribute("covered").toInt() + break + } + } + break + } + + check(coveredInstructions != null) { + "Required class $className is missing from the JaCoCo report." + } + check(coveredInstructions > 0) { + "Required class $className has zero covered instructions. " + + "Robolectric coverage instrumentation is not working." + } + } + } +} + +jacoco { + toolVersion = "0.8.13" +} + android { namespace = "dev.lexip.hecate" compileSdk = 37 @@ -51,6 +105,7 @@ android { debug { versionNameSuffix = "-debug" isDebuggable = true + enableUnitTestCoverage = true ndk { debugSymbolLevel = "FULL" } @@ -81,6 +136,79 @@ android { compose = true buildConfig = true } + testOptions { + animationsDisabled = true + unitTests { + isIncludeAndroidResources = true + all { + it.jvmArgs( + "--add-opens=java.base/java.lang=ALL-UNNAMED", + "--add-opens=java.base/java.util=ALL-UNNAMED", + "--add-opens=java.base/java.io=ALL-UNNAMED", + "--add-opens=java.base/java.net=ALL-UNNAMED", + "--add-opens=java.base/java.security=ALL-UNNAMED", + "--add-opens=java.base/java.text=ALL-UNNAMED", + "--add-opens=java.base/jdk.internal.access=ALL-UNNAMED", + "--add-opens=java.desktop/java.awt.font=ALL-UNNAMED", + "--add-opens=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED" + ) + } + } + managedDevices { + localDevices { + create("pixelApi34") { + device = "Pixel 2" + sdkVersion = 34 + systemImageSource = "aosp-atd" + } + create("pixelApi35") { + device = "Pixel 2" + sdkVersion = 35 + systemImageSource = "aosp-atd" + } + create("pixelApi36") { + device = "Pixel 2" + sdkVersion = 36 + systemImageSource = "aosp" + } + create("pixelApi37") { + device = "Pixel 2" + sdkVersion = 37 + systemImageSource = "google" + } + } + } + } + + sourceSets { + getByName("main") { + resources { + srcDirs("src/main/resources", "src/main/kotlin/components") + } + } + } +} + +tasks.withType().configureEach { + extensions.configure { + isIncludeNoLocationClasses = true + excludes = listOf("jdk.internal.*") + } +} + +tasks.register("verifyFossDebugCoverage") { + group = "verification" + description = "Verifies that Robolectric-backed FOSS tests are represented in JaCoCo XML." + dependsOn("createFossDebugUnitTestCoverageReport") + reportFile.set(layout.buildDirectory.file("reports/coverage/test/foss/debug/report.xml")) + requiredClasses.set( + listOf( + "dev/lexip/hecate/ui/MainViewModel", + "dev/lexip/hecate/data/UserPreferencesRepository", + "dev/lexip/hecate/util/WallpaperHandler", + "dev/lexip/hecate/services/MonitoringPreferencesCoordinator" + ) + ) } dependencies { @@ -111,10 +239,14 @@ dependencies { "playImplementation"(libs.review) "playImplementation"(libs.review.ktx) testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.test.core.ktx) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.ui.test.junit4) + androidTestImplementation(libs.androidx.ui.test.junit4.accessibility) debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) } diff --git a/app/gradle.lockfile b/app/gradle.lockfile index dc41a946..47df2aad 100644 --- a/app/gradle.lockfile +++ b/app/gradle.lockfile @@ -8,12 +8,12 @@ androidx.activity:activity:1.13.0=fossBetaCompileClasspath,fossBetaLintChecksCla androidx.annotation:annotation-experimental:1.4.1=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.annotation:annotation-jvm:1.10.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.annotation:annotation:1.10.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath -androidx.appcompat:appcompat-resources:1.7.1=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath -androidx.appcompat:appcompat:1.7.1=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath +androidx.appcompat:appcompat-resources:1.7.1=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath +androidx.appcompat:appcompat:1.7.1=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.arch.core:core-common:2.2.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.arch.core:core-runtime:2.2.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.autofill:autofill:1.0.0=fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath -androidx.cardview:cardview:1.0.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath +androidx.cardview:cardview:1.0.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.collection:collection-jvm:1.5.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.collection:collection-ktx:1.5.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.collection:collection:1.5.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath @@ -49,6 +49,8 @@ androidx.compose.ui:ui-geometry:1.11.4=fossBetaCompileClasspath,fossBetaLintChec androidx.compose.ui:ui-graphics-android:1.11.4=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.compose.ui:ui-graphics:1.11.4=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.compose.ui:ui-test-android:1.11.4=fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath +androidx.compose.ui:ui-test-junit4-accessibility-android:1.11.4=fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath +androidx.compose.ui:ui-test-junit4-accessibility:1.11.4=fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath androidx.compose.ui:ui-test-junit4-android:1.11.4=fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath androidx.compose.ui:ui-test-junit4:1.11.4=fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath androidx.compose.ui:ui-test-manifest:1.11.4=fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath @@ -67,19 +69,19 @@ androidx.compose.ui:ui-util-android:1.11.4=fossBetaCompileClasspath,fossBetaLint androidx.compose.ui:ui-util:1.11.4=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.compose.ui:ui:1.11.4=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.compose:compose-bom:2026.06.01=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath -androidx.concurrent:concurrent-futures-ktx:1.2.0=fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath -androidx.concurrent:concurrent-futures:1.1.0=fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestRuntimeClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath -androidx.concurrent:concurrent-futures:1.2.0=fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath -androidx.constraintlayout:constraintlayout-core:1.1.1=fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestLintChecksClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath -androidx.constraintlayout:constraintlayout:2.2.1=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath -androidx.coordinatorlayout:coordinatorlayout:1.1.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath +androidx.concurrent:concurrent-futures-ktx:1.2.0=fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath +androidx.concurrent:concurrent-futures:1.1.0=fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestRuntimeClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath +androidx.concurrent:concurrent-futures:1.2.0=fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath +androidx.constraintlayout:constraintlayout-core:1.1.1=fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath +androidx.constraintlayout:constraintlayout:2.2.1=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath +androidx.coordinatorlayout:coordinatorlayout:1.1.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.core:core-ktx:1.19.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.core:core-splashscreen:1.2.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.core:core-viewtree:1.0.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.core:core:1.19.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath -androidx.cursoradapter:cursoradapter:1.0.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath +androidx.cursoradapter:cursoradapter:1.0.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.customview:customview-poolingcontainer:1.0.0=fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath -androidx.customview:customview:1.2.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath +androidx.customview:customview:1.2.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.datastore:datastore-android:1.2.1=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.datastore:datastore-core-android:1.2.1=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.datastore:datastore-core-okio-jvm:1.2.1=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath @@ -93,24 +95,24 @@ androidx.datastore:datastore-preferences-proto:1.2.1=fossBetaLintChecksClasspath androidx.datastore:datastore-preferences:1.2.1=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.datastore:datastore:1.2.1=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.documentfile:documentfile:1.0.0=playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath -androidx.drawerlayout:drawerlayout:1.1.1=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath +androidx.drawerlayout:drawerlayout:1.1.1=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.dynamicanimation:dynamicanimation:1.1.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath -androidx.emoji2:emoji2-views-helper:1.4.0=fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestLintChecksClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath +androidx.emoji2:emoji2-views-helper:1.4.0=fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.emoji2:emoji2:1.4.0=fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.fragment:fragment-ktx:1.3.6=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath -androidx.fragment:fragment:1.5.4=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath +androidx.fragment:fragment:1.5.4=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.graphics:graphics-path:1.0.1=fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath -androidx.graphics:graphics-shapes-android:1.0.1=fossBetaCompileClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugCompileClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugCompileClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseRuntimeClasspath +androidx.graphics:graphics-shapes-android:1.0.1=fossBetaCompileClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseRuntimeClasspath androidx.graphics:graphics-shapes-desktop:1.0.1=fossBetaLintChecksClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugLintChecksClasspath,fossDebugUnitTestLintChecksClasspath,fossReleaseLintChecksClasspath,playBetaLintChecksClasspath,playDebugAndroidTestLintChecksClasspath,playDebugLintChecksClasspath,playDebugUnitTestLintChecksClasspath,playReleaseLintChecksClasspath -androidx.graphics:graphics-shapes:1.0.1=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath +androidx.graphics:graphics-shapes:1.0.1=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.interpolator:interpolator:1.0.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.legacy:legacy-support-core-utils:1.0.0=playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.lifecycle:lifecycle-common-java8:2.11.0=fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestLintChecksClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.lifecycle:lifecycle-common-jvm:2.11.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.lifecycle:lifecycle-common:2.11.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath -androidx.lifecycle:lifecycle-livedata-core-ktx:2.11.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath +androidx.lifecycle:lifecycle-livedata-core-ktx:2.11.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.lifecycle:lifecycle-livedata-core:2.11.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath -androidx.lifecycle:lifecycle-livedata:2.11.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath +androidx.lifecycle:lifecycle-livedata:2.11.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.lifecycle:lifecycle-process:2.11.0=fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.lifecycle:lifecycle-runtime-android:2.11.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.lifecycle:lifecycle-runtime-compose-android:2.11.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath @@ -125,7 +127,7 @@ androidx.lifecycle:lifecycle-viewmodel-ktx:2.11.0=fossBetaCompileClasspath,fossB androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.11.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.lifecycle:lifecycle-viewmodel-savedstate:2.11.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.lifecycle:lifecycle-viewmodel:2.11.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath -androidx.loader:loader:1.0.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath +androidx.loader:loader:1.0.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.localbroadcastmanager:localbroadcastmanager:1.1.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.navigation:navigation-common-android:2.9.8=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.navigation:navigation-common:2.9.8=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath @@ -142,8 +144,8 @@ androidx.print:print:1.0.0=playBetaCompileClasspath,playBetaLintChecksClasspath, androidx.privacysandbox.ads:ads-adservices-java:1.1.0-beta11=playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.privacysandbox.ads:ads-adservices:1.1.0-beta11=playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.profileinstaller:profileinstaller:1.4.0=fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath -androidx.recyclerview:recyclerview:1.2.1=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath -androidx.resourceinspection:resourceinspection-annotation:1.0.1=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath +androidx.recyclerview:recyclerview:1.2.1=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath +androidx.resourceinspection:resourceinspection-annotation:1.0.1=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.savedstate:savedstate-android:1.4.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.savedstate:savedstate-compose-android:1.4.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.savedstate:savedstate-compose:1.4.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath @@ -152,24 +154,27 @@ androidx.savedstate:savedstate:1.4.0=fossBetaCompileClasspath,fossBetaLintChecks androidx.slidingpanelayout:slidingpanelayout:1.2.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.startup:startup-runtime:1.1.1=fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.test.espresso:espresso-core:3.7.0=fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath -androidx.test.espresso:espresso-idling-resource:3.7.0=fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath +androidx.test.espresso:espresso-idling-resource:3.7.0=fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath androidx.test.ext:junit:1.3.0=fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath androidx.test.services:storage:1.6.0=fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath -androidx.test:core:1.7.0=fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath -androidx.test:monitor:1.8.0=fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath +androidx.test:core-ktx:1.7.0=fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath +androidx.test:core:1.7.0=fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath +androidx.test:monitor:1.8.0=fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath +androidx.test:rules:1.4.0=fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath androidx.test:runner:1.7.0=fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath -androidx.tracing:tracing:1.1.0=fossDebugAndroidTestCompileClasspath,playDebugAndroidTestCompileClasspath +androidx.tracing:tracing:1.1.0=fossDebugAndroidTestCompileClasspath,fossDebugUnitTestCompileClasspath,playDebugAndroidTestCompileClasspath,playDebugUnitTestCompileClasspath androidx.tracing:tracing:1.2.0=fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.transition:transition:1.5.0=fossBetaCompileClasspath,fossDebugAndroidTestCompileClasspath,fossDebugCompileClasspath,fossDebugUnitTestCompileClasspath,fossReleaseCompileClasspath,playBetaCompileClasspath,playDebugAndroidTestCompileClasspath,playDebugCompileClasspath,playDebugUnitTestCompileClasspath,playReleaseCompileClasspath androidx.transition:transition:1.6.0=fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath -androidx.vectordrawable:vectordrawable-animated:1.1.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath -androidx.vectordrawable:vectordrawable:1.1.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath +androidx.vectordrawable:vectordrawable-animated:1.1.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath +androidx.vectordrawable:vectordrawable:1.1.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.versionedparcelable:versionedparcelable:1.1.1=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath -androidx.viewpager2:viewpager2:1.0.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath -androidx.viewpager:viewpager:1.0.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath +androidx.viewpager2:viewpager2:1.0.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath +androidx.viewpager:viewpager:1.0.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.window:window-core-android:1.5.0=fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.window:window-core:1.5.0=fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath androidx.window:window:1.5.0=fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath +com.almworks.sqlite4java:sqlite4java:1.0.392=fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath com.android.tools.analytics-library:protos:32.2.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle com.android.tools.analytics-library:shared:32.2.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle com.android.tools.analytics-library:tracker:32.2.1=androidLintTool @@ -213,6 +218,7 @@ com.android.tools:play-sdk-proto:32.2.1=androidLintTool com.android.tools:repository:32.2.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle com.android.tools:sdk-common:32.2.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle com.android.tools:sdklib:32.2.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +com.google.android.apps.common.testing.accessibility.framework:accessibility-test-framework:4.1.1=fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath com.google.android.datatransport:transport-api:3.2.0=playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestLintChecksClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath com.google.android.datatransport:transport-backend-cct:3.3.0=playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestLintChecksClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath com.google.android.datatransport:transport-runtime:3.3.0=playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestLintChecksClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath @@ -227,7 +233,7 @@ com.google.android.gms:play-services-measurement-sdk:23.2.0=playBetaCompileClass com.google.android.gms:play-services-measurement:23.2.0=playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath com.google.android.gms:play-services-stats:17.0.2=playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath com.google.android.gms:play-services-tasks:18.4.0=playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath -com.google.android.material:material:1.14.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath +com.google.android.material:material:1.14.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath com.google.android.play:app-update-ktx:2.1.0=playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath com.google.android.play:app-update:2.1.0=playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath com.google.android.play:core-common:2.0.4=playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath @@ -238,19 +244,21 @@ com.google.api.grpc:proto-google-common-protos:2.17.0=unified-test-platform-andr com.google.api.grpc:proto-google-common-protos:2.48.0=unified-test-platform-android-test-plugin-host-emulator-control com.google.auto.service:auto-service-annotations:1.1.1=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle com.google.auto.service:auto-service:1.1.1=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle +com.google.auto.value:auto-value-annotations:1.11.1=fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath com.google.auto:auto-common:1.2.1=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle -com.google.code.findbugs:jsr305:3.0.2=androidLintTool,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher +com.google.code.findbugs:jsr305:3.0.2=androidLintTool,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher com.google.code.gson:gson:2.10.1=unified-test-platform-core,unified-test-platform-gradle-work-action com.google.code.gson:gson:2.11.0=androidLintTool,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-result-listener-gradle com.google.code.gson:gson:2.8.9=unified-test-platform-android-driver-instrumentation,unified-test-platform-launcher com.google.crypto.tink:tink:1.18.0=unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-gradle-work-action com.google.dagger:dagger:2.48=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action -com.google.errorprone:error_prone_annotations:2.11.0=playBetaCompileClasspath,playDebugCompileClasspath,playDebugUnitTestCompileClasspath,playReleaseCompileClasspath -com.google.errorprone:error_prone_annotations:2.15.0=fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.11.0=playBetaCompileClasspath,playDebugCompileClasspath,playReleaseCompileClasspath +com.google.errorprone:error_prone_annotations:2.15.0=fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath com.google.errorprone:error_prone_annotations:2.23.0=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher -com.google.errorprone:error_prone_annotations:2.26.0=playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestRuntimeClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.26.0=playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestRuntimeClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath com.google.errorprone:error_prone_annotations:2.28.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action com.google.errorprone:error_prone_annotations:2.30.0=fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,unified-test-platform-android-test-plugin-host-emulator-control +com.google.errorprone:error_prone_annotations:2.47.0=fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath com.google.firebase:firebase-analytics:23.2.0=playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath com.google.firebase:firebase-annotations:17.0.0=playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath com.google.firebase:firebase-bom:34.16.0=playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath @@ -266,21 +274,26 @@ com.google.firebase:firebase-installations-interop:17.3.0=playBetaCompileClasspa com.google.firebase:firebase-installations:19.1.2=playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath com.google.firebase:firebase-measurement-connector:20.0.1=playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath com.google.firebase:firebase-sessions:3.0.7=playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath -com.google.guava:failureaccess:1.0.1=playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher +com.google.guava:failureaccess:1.0.1=fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher com.google.guava:failureaccess:1.0.2=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action -com.google.guava:guava:31.1-android=playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath +com.google.guava:failureaccess:1.0.3=fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath +com.google.guava:guava:31.0.1-android=fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath +com.google.guava:guava:31.1-android=playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath com.google.guava:guava:32.0.1-jre=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher com.google.guava:guava:33.3.1-jre=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action -com.google.guava:listenablefuture:1.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath -com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=androidLintTool,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher -com.google.j2objc:j2objc-annotations:1.3=playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath +com.google.guava:guava:33.6.0-jre=fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath +com.google.guava:listenablefuture:1.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=androidLintTool,fossDebugAndroidTestLintChecksClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher +com.google.j2objc:j2objc-annotations:1.3=fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath com.google.j2objc:j2objc-annotations:2.8=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher com.google.j2objc:j2objc-annotations:3.0.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +com.google.j2objc:j2objc-annotations:3.1=fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath com.google.jimfs:jimfs:1.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle com.google.protobuf:protobuf-java-util:3.22.3=unified-test-platform-core com.google.protobuf:protobuf-java-util:4.28.3=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-gradle-work-action,unified-test-platform-launcher com.google.protobuf:protobuf-java:3.25.5=androidLintTool com.google.protobuf:protobuf-java:4.28.3=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher +com.google.protobuf:protobuf-javalite:3.19.1=fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath com.google.protobuf:protobuf-kotlin:4.28.3=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher com.google.testing.platform:android-device-provider-local:0.0.9-alpha04=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle com.google.testing.platform:android-driver-instrumentation:0.0.9-alpha04=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-emulator-control @@ -288,6 +301,8 @@ com.google.testing.platform:android-test-plugin:0.0.9-alpha04=unified-test-platf com.google.testing.platform:core-proto:0.0.9-alpha04=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action com.google.testing.platform:core:0.0.9-alpha04=unified-test-platform-core com.google.testing.platform:launcher:0.0.9-alpha04=unified-test-platform-gradle-work-action,unified-test-platform-launcher +com.google.testparameterinjector:test-parameter-injector:1.22=fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath +com.ibm.icu:icu4j:78.3=fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath com.squareup.okio:okio-jvm:3.9.1=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath com.squareup.okio:okio:3.9.1=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath com.sun.istack:istack-commons-runtime:3.0.8=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle @@ -343,8 +358,8 @@ io.perfmark:perfmark-api:0.26.0=unified-test-platform-core io.perfmark:perfmark-api:0.27.0=unified-test-platform-android-test-plugin-host-emulator-control jakarta.activation:jakarta.activation-api:1.2.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle jakarta.xml.bind:jakarta.xml.bind-api:2.3.2=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle -javax.annotation:javax.annotation-api:1.3.2=unified-test-platform-android-test-plugin-host-emulator-control -javax.inject:javax.inject:1=androidLintTool,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action +javax.annotation:javax.annotation-api:1.3.2=fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,unified-test-platform-android-test-plugin-host-emulator-control +javax.inject:javax.inject:1=androidLintTool,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action junit:junit:4.13.2=fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath net.java.dev.jna:jna-platform:5.6.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action net.java.dev.jna:jna:5.6.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action @@ -356,19 +371,27 @@ org.apache.httpcomponents:httpcore:4.4.16=androidLintTool,unified-test-platform- org.apache.httpcomponents:httpmime:4.5.6=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle org.bouncycastle:bcpkix-jdk18on:1.79=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle org.bouncycastle:bcprov-jdk18on:1.79=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +org.bouncycastle:bcprov-jdk18on:1.85=fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath org.bouncycastle:bcutil-jdk18on:1.79=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle -org.checkerframework:checker-qual:3.12.0=playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath +org.checkerframework:checker-compat-qual:2.5.5=fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath +org.checkerframework:checker-qual:3.12.0=playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath +org.checkerframework:checker-qual:3.22.1=fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,playDebugAndroidTestLintChecksClasspath org.checkerframework:checker-qual:3.33.0=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher org.checkerframework:checker-qual:3.43.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action org.codehaus.groovy:groovy:3.0.22=androidLintTool org.codehaus.mojo:animal-sniffer-annotations:1.23=unified-test-platform-core org.codehaus.mojo:animal-sniffer-annotations:1.24=unified-test-platform-android-test-plugin-host-emulator-control +org.conscrypt:conscrypt-openjdk-uber:2.6.0=fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath org.glassfish.jaxb:jaxb-runtime:2.3.2=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle org.glassfish.jaxb:txw2:2.3.2=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle org.hamcrest:hamcrest-core:1.3=fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath org.hamcrest:hamcrest-library:1.3=fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath +org.jacoco:org.jacoco.agent:0.8.14=androidJacocoAnt,jacocoAgent,jacocoAnt +org.jacoco:org.jacoco.ant:0.8.14=androidJacocoAnt,jacocoAnt +org.jacoco:org.jacoco.core:0.8.14=androidJacocoAnt,jacocoAnt +org.jacoco:org.jacoco.report:0.8.14=androidJacocoAnt,jacocoAnt org.jetbrains.kotlin:compose-group-mapping:2.4.10=composeMappingProducerClasspath -org.jetbrains.kotlin:kotlin-bom:1.8.22=fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestLintChecksClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath +org.jetbrains.kotlin:kotlin-bom:1.8.22=fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath org.jetbrains.kotlin:kotlin-build-tools-api:2.4.0=kotlinAbiValidationCompatClasspath org.jetbrains.kotlin:kotlin-build-tools-api:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath org.jetbrains.kotlin:kotlin-build-tools-compat:2.4.10=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath @@ -422,8 +445,8 @@ org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0=androidLintTool,fossBeta org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0=androidLintTool,fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath,unified-test-platform-android-test-plugin-result-listener-gradle org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0=playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath -org.jetbrains.kotlinx:kotlinx-coroutines-test-jvm:1.9.0=fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath -org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0=fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-test-jvm:1.9.0=fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0=fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath org.jetbrains.kotlinx:kotlinx-serialization-bom:1.11.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.11.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath org.jetbrains.kotlinx:kotlinx-serialization-core:1.11.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath @@ -431,12 +454,30 @@ org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.11.0=fossBetaCompileClass org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath org.jetbrains:annotations:13.0=composeMappingProducerClasspath,kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath org.jetbrains:annotations:23.0.0=androidLintTool,fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher +org.jsoup:jsoup:1.15.1=fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath org.jspecify:jspecify:1.0.0=fossBetaCompileClasspath,fossBetaLintChecksClasspath,fossBetaRuntimeClasspath,fossDebugAndroidTestCompileClasspath,fossDebugAndroidTestLintChecksClasspath,fossDebugAndroidTestRuntimeClasspath,fossDebugCompileClasspath,fossDebugLintChecksClasspath,fossDebugRuntimeClasspath,fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,fossReleaseCompileClasspath,fossReleaseLintChecksClasspath,fossReleaseRuntimeClasspath,playBetaCompileClasspath,playBetaLintChecksClasspath,playBetaRuntimeClasspath,playDebugAndroidTestCompileClasspath,playDebugAndroidTestLintChecksClasspath,playDebugAndroidTestRuntimeClasspath,playDebugCompileClasspath,playDebugLintChecksClasspath,playDebugRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath,playReleaseCompileClasspath,playReleaseLintChecksClasspath,playReleaseRuntimeClasspath org.jvnet.staxex:stax-ex:1.8.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle org.ow2.asm:asm-analysis:9.9=androidLintTool -org.ow2.asm:asm-commons:9.9=androidLintTool -org.ow2.asm:asm-tree:9.9=androidLintTool +org.ow2.asm:asm-commons:9.10.1=fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath +org.ow2.asm:asm-commons:9.9=androidJacocoAnt,androidLintTool,jacocoAnt +org.ow2.asm:asm-tree:9.10.1=fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath +org.ow2.asm:asm-tree:9.9=androidJacocoAnt,androidLintTool,jacocoAnt org.ow2.asm:asm-tree:9.9.1=composeMappingProducerClasspath -org.ow2.asm:asm:9.9=androidLintTool +org.ow2.asm:asm:9.10.1=fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath +org.ow2.asm:asm:9.9=androidJacocoAnt,androidLintTool,jacocoAnt org.ow2.asm:asm:9.9.1=composeMappingProducerClasspath -empty=androidJdkImage,androidTestBetaImplementationDependenciesMetadata,androidTestDebugImplementationDependenciesMetadata,androidTestFossDebugImplementationDependenciesMetadata,androidTestFossImplementationDependenciesMetadata,androidTestImplementationDependenciesMetadata,androidTestPlayDebugImplementationDependenciesMetadata,androidTestPlayImplementationDependenciesMetadata,androidTestReleaseImplementationDependenciesMetadata,androidTestUtil,betaImplementationDependenciesMetadata,coreLibraryDesugaring,debugImplementationDependenciesMetadata,fossBetaAnnotationProcessorClasspath,fossBetaImplementationDependenciesMetadata,fossBetaReverseMetadataValues,fossDebugAndroidTestAnnotationProcessorClasspath,fossDebugAndroidTestImplementationDependenciesMetadata,fossDebugAnnotationProcessorClasspath,fossDebugImplementationDependenciesMetadata,fossDebugReverseMetadataValues,fossDebugUnitTestAnnotationProcessorClasspath,fossDebugUnitTestImplementationDependenciesMetadata,fossImplementationDependenciesMetadata,fossReleaseAnnotationProcessorClasspath,fossReleaseImplementationDependenciesMetadata,fossReleaseReverseMetadataValues,implementationDependenciesMetadata,kotlinCompilerPluginClasspath,kotlinNativeCompilerPluginClasspath,lintChecks,lintPublish,playBetaAnnotationProcessorClasspath,playBetaImplementationDependenciesMetadata,playBetaReverseMetadataValues,playDebugAndroidTestAnnotationProcessorClasspath,playDebugAndroidTestImplementationDependenciesMetadata,playDebugAnnotationProcessorClasspath,playDebugImplementationDependenciesMetadata,playDebugReverseMetadataValues,playDebugUnitTestAnnotationProcessorClasspath,playDebugUnitTestImplementationDependenciesMetadata,playImplementationDependenciesMetadata,playReleaseAnnotationProcessorClasspath,playReleaseImplementationDependenciesMetadata,playReleaseReverseMetadataValues,releaseImplementationDependenciesMetadata,testBetaImplementationDependenciesMetadata,testDebugImplementationDependenciesMetadata,testFixturesBetaImplementationDependenciesMetadata,testFixturesDebugImplementationDependenciesMetadata,testFixturesFossImplementationDependenciesMetadata,testFixturesImplementationDependenciesMetadata,testFixturesPlayImplementationDependenciesMetadata,testFixturesReleaseImplementationDependenciesMetadata,testFossDebugImplementationDependenciesMetadata,testFossImplementationDependenciesMetadata,testImplementationDependenciesMetadata,testPlayDebugImplementationDependenciesMetadata,testPlayImplementationDependenciesMetadata,testReleaseImplementationDependenciesMetadata +org.robolectric:annotations:4.17-beta-2=fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath +org.robolectric:junit:4.17-beta-2=fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath +org.robolectric:nativeruntime-dist-compat:1.0.19=fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath +org.robolectric:nativeruntime:4.17-beta-2=fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath +org.robolectric:pluginapi:4.17-beta-2=fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath +org.robolectric:plugins-maven-dependency-resolver:4.17-beta-2=fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath +org.robolectric:resources:4.17-beta-2=fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath +org.robolectric:robolectric:4.17-beta-2=fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath +org.robolectric:sandbox:4.17-beta-2=fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath +org.robolectric:shadowapi:4.17-beta-2=fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath +org.robolectric:shadows-framework:4.17-beta-2=fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath +org.robolectric:utils-reflector:4.17-beta-2=fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath +org.robolectric:utils:4.17-beta-2=fossDebugUnitTestCompileClasspath,fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,playDebugUnitTestCompileClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath +org.yaml:snakeyaml:2.6=fossDebugUnitTestLintChecksClasspath,fossDebugUnitTestRuntimeClasspath,playDebugUnitTestLintChecksClasspath,playDebugUnitTestRuntimeClasspath +empty=androidApis,androidJdkImage,androidTestBetaImplementationDependenciesMetadata,androidTestDebugImplementationDependenciesMetadata,androidTestFossDebugImplementationDependenciesMetadata,androidTestFossImplementationDependenciesMetadata,androidTestImplementationDependenciesMetadata,androidTestPlayDebugImplementationDependenciesMetadata,androidTestPlayImplementationDependenciesMetadata,androidTestReleaseImplementationDependenciesMetadata,androidTestUtil,betaImplementationDependenciesMetadata,coreLibraryDesugaring,debugImplementationDependenciesMetadata,fossBetaAnnotationProcessorClasspath,fossBetaImplementationDependenciesMetadata,fossBetaReverseMetadataValues,fossDebugAndroidTestAnnotationProcessorClasspath,fossDebugAndroidTestImplementationDependenciesMetadata,fossDebugAnnotationProcessorClasspath,fossDebugImplementationDependenciesMetadata,fossDebugReverseMetadataValues,fossDebugUnitTestAnnotationProcessorClasspath,fossDebugUnitTestImplementationDependenciesMetadata,fossImplementationDependenciesMetadata,fossReleaseAnnotationProcessorClasspath,fossReleaseImplementationDependenciesMetadata,fossReleaseReverseMetadataValues,implementationDependenciesMetadata,kotlinCompilerPluginClasspath,kotlinNativeCompilerPluginClasspath,lintChecks,lintPublish,playBetaAnnotationProcessorClasspath,playBetaImplementationDependenciesMetadata,playBetaReverseMetadataValues,playDebugAndroidTestAnnotationProcessorClasspath,playDebugAndroidTestImplementationDependenciesMetadata,playDebugAnnotationProcessorClasspath,playDebugImplementationDependenciesMetadata,playDebugReverseMetadataValues,playDebugUnitTestAnnotationProcessorClasspath,playDebugUnitTestImplementationDependenciesMetadata,playImplementationDependenciesMetadata,playReleaseAnnotationProcessorClasspath,playReleaseImplementationDependenciesMetadata,playReleaseReverseMetadataValues,releaseImplementationDependenciesMetadata,testBetaImplementationDependenciesMetadata,testDebugImplementationDependenciesMetadata,testFixturesBetaImplementationDependenciesMetadata,testFixturesDebugImplementationDependenciesMetadata,testFixturesFossImplementationDependenciesMetadata,testFixturesImplementationDependenciesMetadata,testFixturesPlayImplementationDependenciesMetadata,testFixturesReleaseImplementationDependenciesMetadata,testFossDebugImplementationDependenciesMetadata,testFossImplementationDependenciesMetadata,testImplementationDependenciesMetadata,testPlayDebugImplementationDependenciesMetadata,testPlayImplementationDependenciesMetadata,testReleaseImplementationDependenciesMetadata diff --git a/app/src/androidTest/kotlin/dev/lexip/hecate/ui/MainActivitySmokeTest.kt b/app/src/androidTest/kotlin/dev/lexip/hecate/ui/MainActivitySmokeTest.kt new file mode 100644 index 00000000..8c2f49a8 --- /dev/null +++ b/app/src/androidTest/kotlin/dev/lexip/hecate/ui/MainActivitySmokeTest.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2026 xLexip + * + * Licensed under the GNU General Public License, Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Please see the License for specific terms regarding permissions and limitations. + */ + +package dev.lexip.hecate.ui + +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dev.lexip.hecate.R +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MainActivitySmokeTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun activityLaunchesMainContent() { + composeRule.onNodeWithText(composeRule.activity.getString(R.string.app_name)) + .assertExists() + } +} diff --git a/app/src/androidTest/kotlin/dev/lexip/hecate/ui/MainScreenContentTest.kt b/app/src/androidTest/kotlin/dev/lexip/hecate/ui/MainScreenContentTest.kt new file mode 100644 index 00000000..26ea9672 --- /dev/null +++ b/app/src/androidTest/kotlin/dev/lexip/hecate/ui/MainScreenContentTest.kt @@ -0,0 +1,385 @@ +/* + * Copyright (C) 2026 xLexip + * + * Licensed under the GNU General Public License, Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Please see the License for specific terms regarding permissions and limitations. + */ + +package dev.lexip.hecate.ui + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import dev.lexip.hecate.R +import dev.lexip.hecate.ui.theme.HecateTheme +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +private const val DAY_WALLPAPER_URI = "content://wallpaper/day" + +@RunWith(AndroidJUnit4::class) +class MainScreenContentTest { + + @get:Rule + val composeRule = createComposeRule() + + private val context + get() = InstrumentationRegistry.getInstrumentation().targetContext + + @Before + fun enableAccessibilityValidation() { + composeRule.mainClock.autoAdvance = true + composeRule.enableAccessibilityChecks() + } + + @Test + fun setupRequiredInactiveStateShowsSetupAndRequestsSetupFromSwitch() { + var toggleRequest: Pair? = null + setMainContent( + uiState = MainUiState(), + hasPermission = false, + callbacks = callbacks( + onToggle = { checked, hasPermission -> + toggleRequest = checked to hasPermission + false + } + ) + ) + + composeRule.onNodeWithText(context.getString(R.string.setup_required_title)) + .assertIsDisplayed() + composeRule.onNodeWithText(adaptiveThemeAction()).performClick() + + assertEquals(null, toggleRequest) + } + + @Test + fun activeStateExposesThresholdAndAdvancedSettings() { + setMainContent( + uiState = MainUiState( + adaptiveThemeEnabled = true, + adaptiveThemeThresholdLux = 100f + ), + hasPermission = true + ) + + composeRule.onNodeWithText(context.getString(R.string.title_brightness_threshold)) + .assertExists() + composeRule.onNodeWithText(context.getString(R.string.action_advanced_settings)) + .assertExists() + } + + @Test + fun customThresholdAndNightLockStateAreRendered() { + setMainContent( + uiState = MainUiState( + adaptiveThemeEnabled = true, + adaptiveThemeThresholdLux = 321f, + customAdaptiveThemeThresholdLux = 321f, + stayDarkAtNightEnabled = true, + nightStartMinutes = 22 * 60, + nightEndMinutes = 5 * 60 + ), + hasPermission = true + ) + + composeRule.onNodeWithText(context.getString(R.string.adaptive_threshold_custom)) + .assertExists() + composeRule.onNodeWithText(context.getString(R.string.title_night_dark_lock)) + .assertExists() + } + + @Test + fun permissionGrantedSwitchForwardsCallback() { + var toggleRequest: Pair? = null + setMainContent( + uiState = MainUiState(), + hasPermission = true, + callbacks = callbacks( + onToggle = { checked, hasPermission -> + toggleRequest = checked to hasPermission + true + } + ) + ) + + composeRule.onNodeWithText(adaptiveThemeAction()).performClick() + + assertEquals(true to true, toggleRequest) + } + + @Test + fun advancedSettingsExpandAndCollapseAndRequestReviewOnce() { + var reviewCalls = 0 + val advancedSettingsText = context.getString(R.string.action_advanced_settings) + val collapseText = context.getString(R.string.action_collapse) + setMainContent( + uiState = MainUiState(adaptiveThemeEnabled = true), + hasPermission = true, + callbacks = callbacks(onReview = { reviewCalls++ }) + ) + + expandAdvancedSettings() + scrollToText(collapseText) + composeRule.onNodeWithText(collapseText) + .assertIsDisplayed() + .performClick() + composeRule.waitForIdle() + + scrollToText(advancedSettingsText) + composeRule.onNodeWithText(advancedSettingsText) + .assertIsDisplayed() + composeRule.onAllNodesWithText(collapseText) + .assertCountEquals(0) + + assertEquals(1, reviewCalls) + } + + @Test + fun successfulServiceDisableCollapsesAdvancedSettings() { + setMainContent( + uiState = MainUiState(adaptiveThemeEnabled = true), + hasPermission = true, + callbacks = callbacks(onToggle = { _, _ -> true }) + ) + expandAdvancedSettings() + + clickTextAfterScroll(adaptiveThemeAction()) + + scrollToText(context.getString(R.string.action_advanced_settings)) + composeRule.onNodeWithText(context.getString(R.string.action_advanced_settings)) + .assertIsDisplayed() + composeRule.onAllNodesWithText(context.getString(R.string.action_collapse)) + .assertCountEquals(0) + } + + @Test + fun rejectedServiceDisableKeepsAdvancedSettingsExpanded() { + setMainContent( + uiState = MainUiState(adaptiveThemeEnabled = true), + hasPermission = true, + callbacks = callbacks(onToggle = { _, _ -> false }) + ) + expandAdvancedSettings() + + clickTextAfterScroll(adaptiveThemeAction()) + + scrollToText(context.getString(R.string.action_collapse)) + composeRule.onNodeWithText(context.getString(R.string.action_collapse)) + .assertIsDisplayed() + } + + @Test + fun enabledWallpaperSyncInitiallyExpandsAndShowsStatusAndPickerActions() { + var daySelectionCalls = 0 + var nightSelectionCalls = 0 + setMainContent( + uiState = MainUiState( + adaptiveThemeEnabled = true, + wallpaperSyncEnabled = true, + dayWallpaperUri = DAY_WALLPAPER_URI, + nightWallpaperUri = null + ), + hasPermission = true, + callbacks = callbacks( + onSelectDay = { daySelectionCalls++ }, + onSelectNight = { nightSelectionCalls++ } + ) + ) + + scrollToText(context.getString(R.string.title_wallpaper_sync)) + composeRule.onNodeWithText(context.getString(R.string.title_wallpaper_sync)) + .assertIsDisplayed() + scrollToText(context.getString(R.string.label_beta)) + composeRule.onNodeWithText(context.getString(R.string.label_beta)) + .assertIsDisplayed() + clickTextAfterScroll(wallpaperButtonText(day = true, isSet = true)) + clickTextAfterScroll(wallpaperButtonText(day = false, isSet = false)) + + assertEquals(1, daySelectionCalls) + assertEquals(1, nightSelectionCalls) + } + + @Test + fun wallpaperSyncRejectsIncompleteSelection() { + val toggleRequests = mutableListOf() + setMainContent( + uiState = MainUiState(adaptiveThemeEnabled = true), + hasPermission = true, + callbacks = callbacks(onWallpaperToggle = toggleRequests::add) + ) + expandAdvancedSettings() + clickTextAfterScroll(context.getString(R.string.title_wallpaper_sync)) + + assertEquals(emptyList(), toggleRequests) + } + + @Test + fun wallpaperSyncEnablesWhenBothImagesAreSelected() { + val toggleRequests = mutableListOf() + setMainContent( + uiState = MainUiState( + adaptiveThemeEnabled = true, + dayWallpaperUri = DAY_WALLPAPER_URI, + nightWallpaperUri = "content://wallpaper/night" + ), + hasPermission = true, + callbacks = callbacks(onWallpaperToggle = toggleRequests::add) + ) + expandAdvancedSettings() + clickTextAfterScroll(context.getString(R.string.title_wallpaper_sync)) + + assertEquals(listOf(true), toggleRequests) + } + + @Test + fun wallpaperSyncDispatchesDisable() { + val toggleRequests = mutableListOf() + setMainContent( + uiState = MainUiState( + adaptiveThemeEnabled = true, + wallpaperSyncEnabled = true, + dayWallpaperUri = DAY_WALLPAPER_URI, + nightWallpaperUri = "content://wallpaper/night" + ), + hasPermission = true, + callbacks = callbacks(onWallpaperToggle = toggleRequests::add) + ) + clickTextAfterScroll(context.getString(R.string.title_wallpaper_sync)) + + assertEquals(listOf(false), toggleRequests) + } + + @Test + fun liveWallpaperWarningDispatchesConfirm() { + var confirmCalls = 0 + var dismissCalls = 0 + setMainContent( + uiState = MainUiState(showLiveWallpaperWarningDialog = true), + hasPermission = true, + callbacks = callbacks( + onConfirmLiveWallpaper = { confirmCalls++ }, + onDismissLiveWallpaper = { dismissCalls++ } + ) + ) + + composeRule.onNodeWithText(context.getString(R.string.live_wallpaper_warning_title)) + .assertIsDisplayed() + composeRule.onNodeWithText(context.getString(R.string.action_continue)).performClick() + assertEquals(1, confirmCalls) + assertEquals(0, dismissCalls) + } + + @Test + fun liveWallpaperWarningDispatchesDismiss() { + var confirmCalls = 0 + var dismissCalls = 0 + setMainContent( + uiState = MainUiState(showLiveWallpaperWarningDialog = true), + hasPermission = true, + callbacks = callbacks( + onConfirmLiveWallpaper = { confirmCalls++ }, + onDismissLiveWallpaper = { dismissCalls++ } + ) + ) + composeRule.onNodeWithText(context.getString(R.string.action_cancel)).performClick() + assertEquals(0, confirmCalls) + assertEquals(1, dismissCalls) + } + + private fun setMainContent( + uiState: MainUiState, + hasPermission: Boolean, + callbacks: MainScreenCallbacks = callbacks() + ) { + composeRule.setContent { + HecateTheme { + MainScreenContent( + uiState = uiState, + currentSensorLux = 25f, + isDeviceCovered = false, + isBatterySaverActive = false, + hasWriteSecureSettingsPermission = hasPermission, + packageName = context.packageName, + callbacks = callbacks + ) + } + } + } + + private fun callbacks( + onToggle: (Boolean, Boolean) -> Boolean = { _, _ -> true }, + onReview: () -> Unit = {}, + onWallpaperToggle: (Boolean) -> Unit = {}, + onSelectDay: () -> Unit = {}, + onSelectNight: () -> Unit = {}, + onConfirmLiveWallpaper: () -> Unit = {}, + onDismissLiveWallpaper: () -> Unit = {} + ): MainScreenCallbacks = MainScreenCallbacks( + onServiceToggleRequested = onToggle, + onThresholdSelected = { _, _ -> }, + onCheckReviewPrompt = onReview, + onStayDarkAtNightChanged = {}, + onWallpaperSyncToggleRequested = onWallpaperToggle, + onSelectDayWallpaper = onSelectDay, + onSelectNightWallpaper = onSelectNight, + onConfirmLiveWallpaper = onConfirmLiveWallpaper, + onDismissLiveWallpaperWarning = onDismissLiveWallpaper, + onCustomThresholdConfirmed = {}, + onNightWindowChanged = { _, _, _ -> } + ) + + private fun adaptiveThemeAction(): String = context.getString( + R.string.action_use_adaptive_theme, + context.getString(R.string.app_name) + ) + + private fun expandAdvancedSettings() { + clickTextAfterScroll(context.getString(R.string.action_advanced_settings)) + composeRule.waitForIdle() + waitForText(context.getString(R.string.action_collapse)) + } + + private fun clickTextAfterScroll(text: String) { + scrollToText(text) + composeRule.onNodeWithText(text).performClick() + } + + private fun scrollToText(text: String) { + waitForText(text) + composeRule.onNodeWithText(text).performScrollTo() + } + + private fun waitForText(text: String) { + composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty() + } + } + + private fun wallpaperButtonText(day: Boolean, isSet: Boolean): String { + val label = context.getString( + if (day) R.string.action_select_day_wallpaper + else R.string.action_select_night_wallpaper + ) + val status = context.getString( + if (isSet) R.string.wallpaper_status_set + else R.string.wallpaper_status_not_set + ) + return "$label\n$status" + } +} diff --git a/app/src/androidTest/kotlin/dev/lexip/hecate/ui/components/PreferenceDialogsTest.kt b/app/src/androidTest/kotlin/dev/lexip/hecate/ui/components/PreferenceDialogsTest.kt new file mode 100644 index 00000000..6c845399 --- /dev/null +++ b/app/src/androidTest/kotlin/dev/lexip/hecate/ui/components/PreferenceDialogsTest.kt @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2026 xLexip + * + * Licensed under the GNU General Public License, Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Please see the License for specific terms regarding permissions and limitations. + */ + +package dev.lexip.hecate.ui.components + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.accessibility.disableAccessibilityChecks +import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckResultUtils.matchesCheck +import com.google.android.apps.common.testing.accessibility.framework.checks.TouchTargetSizeCheck +import com.google.android.apps.common.testing.accessibility.framework.integrations.espresso.AccessibilityValidator +import dev.lexip.hecate.R +import dev.lexip.hecate.ui.components.preferences.CustomThresholdDialog +import dev.lexip.hecate.ui.components.preferences.TimePickerPreferenceDialog +import dev.lexip.hecate.ui.theme.HecateTheme +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PreferenceDialogsTest { + + @get:Rule + val composeRule = createComposeRule() + + private val context + get() = InstrumentationRegistry.getInstrumentation().targetContext + + @Before + fun enableAccessibilityValidation() { + composeRule.enableAccessibilityChecks() + } + + @Test + fun customThresholdRejectsInvalidTextAndAcceptsValidLux() { + var confirmed: Float? = null + composeRule.setContent { + HecateTheme { + CustomThresholdDialog( + show = true, + currentLux = 100f, + onConfirm = { confirmed = it }, + onDismiss = {} + ) + } + } + val field = composeRule.onNode( + hasText(context.getString(R.string.hint_custom_threshold_value)) + .and(hasSetTextAction()) + ) + field.performTextClearance() + field.performTextInput("not-a-number") + composeRule.onNodeWithText(context.getString(R.string.action_set)).performClick() + + assertNull(confirmed) + composeRule.onNodeWithText(context.getString(R.string.error_invalid_lux_value)) + .assertIsDisplayed() + + field.performTextClearance() + field.performTextInput("321") + composeRule.onNodeWithText(context.getString(R.string.action_set)).performClick() + assertEquals(321f, confirmed) + } + + @Test + fun timePickerConfirmsInitialTimeDeterministically() { + // Material3 1.4.0 splits its fixed 80 dp AM/PM selector into two 40 dp + // targets. Suppress only that upstream finding in this focused test. + composeRule.disableAccessibilityChecks() + composeRule.enableAccessibilityChecks( + AccessibilityValidator() + .setRunChecksFromRootView(true) + .setSuppressingResultMatcher(matchesCheck(TouchTargetSizeCheck::class.java)) + ) + + var confirmedMinutes: Int? = null + composeRule.setContent { + HecateTheme { + TimePickerPreferenceDialog( + show = true, + title = "Night start", + initialMinutes = 22 * 60 + 15, + onConfirm = { confirmedMinutes = it }, + onDismiss = {} + ) + } + } + + composeRule.onNodeWithText(context.getString(R.string.action_set)).performClick() + + assertEquals(22 * 60 + 15, confirmedMinutes) + } +} diff --git a/app/src/androidTest/kotlin/dev/lexip/hecate/ui/setup/SetupScreensTest.kt b/app/src/androidTest/kotlin/dev/lexip/hecate/ui/setup/SetupScreensTest.kt new file mode 100644 index 00000000..696f4611 --- /dev/null +++ b/app/src/androidTest/kotlin/dev/lexip/hecate/ui/setup/SetupScreensTest.kt @@ -0,0 +1,247 @@ +/* + * Copyright (C) 2026 xLexip + * + * Licensed under the GNU General Public License, Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Please see the License for specific terms regarding permissions and limitations. + */ + +package dev.lexip.hecate.ui.setup + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import dev.lexip.hecate.R +import dev.lexip.hecate.ui.setup.screens.A_DeveloperModeScreen +import dev.lexip.hecate.ui.setup.screens.B_ConnectUsbScreen +import dev.lexip.hecate.ui.setup.screens.C_GrantPermissionScreen +import dev.lexip.hecate.ui.setup.components.ForExpertsSectionCard +import dev.lexip.hecate.ui.theme.HecateTheme +import androidx.compose.ui.unit.dp +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SetupScreensTest { + + @get:Rule + val composeRule = createComposeRule() + + private val context + get() = InstrumentationRegistry.getInstrumentation().targetContext + + @Before + fun enableAccessibilityValidation() { + composeRule.enableAccessibilityChecks() + } + + @Test + fun developerStepDisablesContinueWhileRequirementsAreIncomplete() { + setDeveloperScreen(SetupUiState()) + composeRule.onNodeWithText(context.getString(R.string.action_continue)) + .assertIsNotEnabled() + } + + @Test + fun developerStepContinueInvokesCallback() { + var nextCalls = 0 + setDeveloperScreen( + state = SetupUiState( + isDeveloperOptionsEnabled = true, + isUsbDebuggingEnabled = true + ), + onNext = { nextCalls++ } + ) + + composeRule.onNodeWithText(context.getString(R.string.action_continue)).performClick() + + assertEquals(1, nextCalls) + } + + @Test + fun connectStepOffersSkipWhenUsbIsNotConnected() { + var nextCalls = 0 + setConnectScreen(SetupUiState(), onNext = { nextCalls++ }) + composeRule.onNodeWithText(context.getString(R.string.action_skip)).performClick() + assertEquals(1, nextCalls) + } + + @Test + fun connectStepOffersContinueWhenUsbIsConnected() { + var nextCalls = 0 + setConnectScreen( + SetupUiState(isUsbConnected = true), + onNext = { nextCalls++ } + ) + composeRule.onNodeWithText(context.getString(R.string.action_continue)) + .assertIsEnabled() + .performClick() + assertEquals(1, nextCalls) + } + + @Test + fun grantStepDisablesFinishWithoutPermission() { + setGrantScreen(SetupUiState()) + composeRule.onNodeWithText(context.getString(R.string.action_finish)) + .assertIsNotEnabled() + } + + @Test + fun grantStepEnablesFinishAfterPermissionAndInvokesCallback() { + var finishCalls = 0 + setGrantScreen( + SetupUiState(hasWriteSecureSettings = true), + onFinish = { finishCalls++ } + ) + composeRule.onNodeWithText(context.getString(R.string.action_finish)) + .assertIsEnabled() + .performClick() + assertEquals(1, finishCalls) + } + + @Test + fun expertSectionReportsExpansionStateAndOnlyCallsExpansionHookWhenOpening() { + var expansionCalls = 0 + composeRule.setContent { + HecateTheme { + ForExpertsSectionCard(onExpansionStarted = { expansionCalls++ }) + } + } + val section = composeRule.onNodeWithText( + context.getString(R.string.setup_alternative_methods) + ) + + section.assert( + SemanticsMatcher.expectValue( + SemanticsProperties.StateDescription, + context.getString(R.string.state_collapsed) + ) + ) + section.performClick() + composeRule.waitForIdle() + section.assert( + SemanticsMatcher.expectValue( + SemanticsProperties.StateDescription, + context.getString(R.string.state_expanded) + ) + ) + section.performClick() + composeRule.waitForIdle() + + section.assert( + SemanticsMatcher.expectValue( + SemanticsProperties.StateDescription, + context.getString(R.string.state_collapsed) + ) + ) + assertEquals(1, expansionCalls) + } + + @Test + fun expandingAlternativeMethodsAutoScrollsExpertActionsIntoView() { + composeRule.setContent { + HecateTheme { + Box( + modifier = Modifier + .width(360.dp) + .height(500.dp) + ) { + B_ConnectUsbScreen( + uiState = SetupUiState(), + onGrantViaShizuku = {}, + onNext = {}, + onBack = {}, + onShareExpertCommand = {}, + onUseRoot = {}, + onInstallShizuku = {} + ) + } + } + } + + composeRule.onNodeWithText(context.getString(R.string.setup_alternative_methods)) + .performScrollTo() + .performClick() + composeRule.waitForIdle() + + composeRule.onNodeWithText(context.getString(R.string.setup_action_adb_command)) + .assertIsDisplayed() + } + + private fun setDeveloperScreen( + state: SetupUiState, + onNext: () -> Unit = {} + ) { + composeRule.setContent { + HecateTheme { + A_DeveloperModeScreen( + uiState = state, + onGrantViaShizuku = {}, + onNext = onNext, + onExit = {}, + onOpenSettings = {}, + onOpenDeveloperSettings = {} + ) + } + } + } + + private fun setConnectScreen( + state: SetupUiState, + onNext: () -> Unit = {} + ) { + composeRule.setContent { + HecateTheme { + B_ConnectUsbScreen( + uiState = state, + onGrantViaShizuku = {}, + onNext = onNext, + onBack = {}, + onShareExpertCommand = {}, + onUseRoot = {}, + onInstallShizuku = {} + ) + } + } + } + + private fun setGrantScreen( + state: SetupUiState, + onFinish: () -> Unit = {} + ) { + composeRule.setContent { + HecateTheme { + C_GrantPermissionScreen( + uiState = state, + onShareSetupUrl = {}, + onShareExpertCommand = {}, + onFinish = onFinish, + onBack = {}, + onUseRoot = {}, + onInstallShizuku = {} + ) + } + } + } +} diff --git a/app/src/main/kotlin/dev/lexip/hecate/broadcasts/ScreenOnCoordinator.kt b/app/src/main/kotlin/dev/lexip/hecate/broadcasts/ScreenOnCoordinator.kt new file mode 100644 index 00000000..d4205754 --- /dev/null +++ b/app/src/main/kotlin/dev/lexip/hecate/broadcasts/ScreenOnCoordinator.kt @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2026 xLexip + * + * Licensed under the GNU General Public License, Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Please see the License for specific terms regarding permissions and limitations. + */ + +package dev.lexip.hecate.broadcasts + +import dev.lexip.hecate.util.MinuteProvider +import dev.lexip.hecate.util.ProximitySensorReader +import dev.lexip.hecate.util.SensorReader +import dev.lexip.hecate.util.ThemeController +import dev.lexip.hecate.util.ThemeDecisionPolicy + +internal const val COVERED_DISTANCE_CENTIMETERS = 5f + +class ScreenOnCoordinator( + private val proximitySensor: ProximitySensorReader, + private val lightSensor: SensorReader, + private val themeController: ThemeController, + private val minuteProvider: MinuteProvider, + var adaptiveThemeThresholdLux: Float, + var stayDarkAtNightEnabled: Boolean, + var nightStartMinutes: Int, + var nightEndMinutes: Int +) { + private var proximityReadingPending = false + private var lightReadingPending = false + + fun onScreenOn() { + if (proximityReadingPending || lightReadingPending) return + + if (!proximitySensor.hasProximitySensor) { + readLightAndApplyTheme() + return + } + + proximityReadingPending = true + proximitySensor.startListening( + callback = { distance -> + if (!proximityReadingPending) return@startListening + proximityReadingPending = false + proximitySensor.stopListening() + if (distance >= COVERED_DISTANCE_CENTIMETERS) { + readLightAndApplyTheme() + } + } + ) + } + + private fun readLightAndApplyTheme() { + if (lightReadingPending) return + lightReadingPending = true + lightSensor.startListening( + callback = { lightValue -> + if (!lightReadingPending) return@startListening + lightReadingPending = false + lightSensor.stopListening() + themeController.setDarkTheme( + ThemeDecisionPolicy.shouldUseDarkTheme( + lightValue = lightValue, + thresholdLux = adaptiveThemeThresholdLux, + stayDarkAtNightEnabled = stayDarkAtNightEnabled, + nightStartMinutes = nightStartMinutes, + nightEndMinutes = nightEndMinutes, + nowMinutes = minuteProvider.currentMinutes() + ) + ) + } + ) + } +} diff --git a/app/src/main/kotlin/dev/lexip/hecate/broadcasts/ScreenOnReceiver.kt b/app/src/main/kotlin/dev/lexip/hecate/broadcasts/ScreenOnReceiver.kt index e37e6774..13987002 100644 --- a/app/src/main/kotlin/dev/lexip/hecate/broadcasts/ScreenOnReceiver.kt +++ b/app/src/main/kotlin/dev/lexip/hecate/broadcasts/ScreenOnReceiver.kt @@ -15,75 +15,94 @@ package dev.lexip.hecate.broadcasts import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import android.util.Log +import dev.lexip.hecate.util.AdaptiveAppearanceController import dev.lexip.hecate.util.AdaptiveAppearanceHandler import dev.lexip.hecate.util.LightSensorManager -import dev.lexip.hecate.util.NightWindowPolicy +import dev.lexip.hecate.util.MinuteProvider import dev.lexip.hecate.util.ProximitySensorManager +import dev.lexip.hecate.util.ProximitySensorReader +import dev.lexip.hecate.util.SensorReader +import dev.lexip.hecate.util.SystemMinuteProvider +import dev.lexip.hecate.util.ThemeController -private const val TAG = "ScreenOnReceiver" +internal interface ScreenOnReceiverSettings { + var adaptiveThemeThresholdLux: Float + var stayDarkAtNightEnabled: Boolean + var nightStartMinutes: Int + var nightEndMinutes: Int +} /** * Adaptive theme switching logic. Executes when the screen is turned on. * The theme is switched based on the environment brightness and proximity sensor values. */ class ScreenOnReceiver( - private val proximitySensorManager: ProximitySensorManager, - private val lightSensorManager: LightSensorManager, - private val adaptiveAppearanceHandler: AdaptiveAppearanceHandler, - var adaptiveThemeThresholdLux: Float, - var stayDarkAtNightEnabled: Boolean, - var nightStartMinutes: Int, - var nightEndMinutes: Int -) : BroadcastReceiver() { + proximitySensorManager: ProximitySensorReader, + lightSensorManager: SensorReader, + themeController: ThemeController, + adaptiveThemeThresholdLux: Float, + stayDarkAtNightEnabled: Boolean, + nightStartMinutes: Int, + nightEndMinutes: Int, + minuteProvider: MinuteProvider = SystemMinuteProvider +) : BroadcastReceiver(), ScreenOnReceiverSettings { - private fun shouldUseDarkTheme(lightValue: Float): Boolean { - if (stayDarkAtNightEnabled && - NightWindowPolicy.isInNightWindow( - nowMinutes = NightWindowPolicy.currentMinutes(), - startMinutes = nightStartMinutes, - endMinutes = nightEndMinutes - ) - ) { - Log.d(TAG, "Night lock active, forcing dark theme.") - return true - } + constructor( + proximitySensorManager: ProximitySensorManager, + lightSensorManager: LightSensorManager, + adaptiveAppearanceHandler: AdaptiveAppearanceHandler, + adaptiveThemeThresholdLux: Float, + stayDarkAtNightEnabled: Boolean, + nightStartMinutes: Int, + nightEndMinutes: Int + ) : this( + proximitySensorManager = proximitySensorManager, + lightSensorManager = lightSensorManager, + themeController = AdaptiveAppearanceController(adaptiveAppearanceHandler), + adaptiveThemeThresholdLux = adaptiveThemeThresholdLux, + stayDarkAtNightEnabled = stayDarkAtNightEnabled, + nightStartMinutes = nightStartMinutes, + nightEndMinutes = nightEndMinutes + ) - return lightValue < adaptiveThemeThresholdLux - } + private val coordinator = ScreenOnCoordinator( + proximitySensor = proximitySensorManager, + lightSensor = lightSensorManager, + themeController = themeController, + minuteProvider = minuteProvider, + adaptiveThemeThresholdLux = adaptiveThemeThresholdLux, + stayDarkAtNightEnabled = stayDarkAtNightEnabled, + nightStartMinutes = nightStartMinutes, + nightEndMinutes = nightEndMinutes + ) - override fun onReceive(context: Context, intent: Intent) { - if (intent.action == Intent.ACTION_SCREEN_ON) { - Log.d(TAG, "Screen turned on, checking adaptive theme conditions...") + override var adaptiveThemeThresholdLux: Float + get() = coordinator.adaptiveThemeThresholdLux + set(value) { + coordinator.adaptiveThemeThresholdLux = value + } - // If no proximity sensor is available, fall back to light-sensor-only behavior - if (!proximitySensorManager.hasProximitySensor) { - Log.d( - TAG, - "No proximity sensor available; using light sensor only for adaptive theme." - ) - lightSensorManager.startListening({ lightValue: Float -> - lightSensorManager.stopListening() - adaptiveAppearanceHandler.applyAppearance(shouldUseDarkTheme(lightValue)) - }) - return - } + override var stayDarkAtNightEnabled: Boolean + get() = coordinator.stayDarkAtNightEnabled + set(value) { + coordinator.stayDarkAtNightEnabled = value + } - // Check if the device is covered using the proximity sensor - Log.d(TAG, "Starting proximity sensor check for adaptive theme.") - proximitySensorManager.startListening({ distance: Float -> - proximitySensorManager.stopListening() + override var nightStartMinutes: Int + get() = coordinator.nightStartMinutes + set(value) { + coordinator.nightStartMinutes = value + } - // If the device is not covered, change the device theme based on the environment - if (distance >= 5f) { - lightSensorManager.startListening({ lightValue: Float -> - lightSensorManager.stopListening() - adaptiveAppearanceHandler.applyAppearance(shouldUseDarkTheme(lightValue)) - }) - } else { - Log.d(TAG, "Device is covered, skipping adaptive theme checks.") - } - }) + override var nightEndMinutes: Int + get() = coordinator.nightEndMinutes + set(value) { + coordinator.nightEndMinutes = value + } + + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == Intent.ACTION_SCREEN_ON) { + coordinator.onScreenOn() } } } diff --git a/app/src/main/kotlin/dev/lexip/hecate/data/UserPreferencesRepository.kt b/app/src/main/kotlin/dev/lexip/hecate/data/UserPreferencesRepository.kt index 7354e4b9..5f024803 100644 --- a/app/src/main/kotlin/dev/lexip/hecate/data/UserPreferencesRepository.kt +++ b/app/src/main/kotlin/dev/lexip/hecate/data/UserPreferencesRepository.kt @@ -44,7 +44,30 @@ data class UserPreferences( val nightWallpaperUri: String? = null ) -class UserPreferencesRepository(private val dataStore: DataStore) { +interface UserPreferencesDataSource { + val userPreferencesFlow: Flow + + suspend fun fetchInitialPreferences(): UserPreferences + suspend fun ensureAdaptiveThemeThresholdDefault(default: Float = AdaptiveThreshold.DAYLIGHT.lux) + suspend fun ensureNightDefaults( + defaultStartMinutes: Int = DEFAULT_NIGHT_START_MINUTES, + defaultEndMinutes: Int = DEFAULT_NIGHT_END_MINUTES + ) + + suspend fun updateAdaptiveThemeEnabled(enabled: Boolean) + suspend fun updateAdaptiveThemeThresholdLux(lux: Float) + suspend fun updateCustomAdaptiveThemeThresholdLux(lux: Float) + suspend fun updateSetupCompleted(completed: Boolean) + suspend fun updateStayDarkAtNightEnabled(enabled: Boolean) + suspend fun updateNightWindow(startMinutes: Int, endMinutes: Int): Boolean + suspend fun updateWallpaperSyncEnabled(enabled: Boolean) + suspend fun updateDayWallpaperUri(uri: String?) + suspend fun updateNightWallpaperUri(uri: String?) +} + +class UserPreferencesRepository( + private val dataStore: DataStore +) : UserPreferencesDataSource { private object PreferencesKeys { val ADAPTIVE_THEME_ENABLED = booleanPreferencesKey("adaptive_theme_enabled") @@ -60,7 +83,7 @@ class UserPreferencesRepository(private val dataStore: DataStore) { val NIGHT_WALLPAPER_URI = stringPreferencesKey("night_wallpaper_uri") } - val userPreferencesFlow: Flow = dataStore.data + override val userPreferencesFlow: Flow = dataStore.data .catch { exception -> // dataStore.data throws an IOException when an error is encountered when reading data if (exception is IOException) { @@ -73,11 +96,11 @@ class UserPreferencesRepository(private val dataStore: DataStore) { mapUserPreferences(preferences) } - suspend fun fetchInitialPreferences() = + override suspend fun fetchInitialPreferences() = mapUserPreferences(dataStore.data.first().toPreferences()) - suspend fun ensureAdaptiveThemeThresholdDefault(default: Float = AdaptiveThreshold.DAYLIGHT.lux) { + override suspend fun ensureAdaptiveThemeThresholdDefault(default: Float) { dataStore.edit { preferences -> if (preferences[PreferencesKeys.ADAPTIVE_THEME_THRESHOLD_LUX] == null) { preferences[PreferencesKeys.ADAPTIVE_THEME_THRESHOLD_LUX] = default @@ -85,9 +108,9 @@ class UserPreferencesRepository(private val dataStore: DataStore) { } } - suspend fun ensureNightDefaults( - defaultStartMinutes: Int = DEFAULT_NIGHT_START_MINUTES, - defaultEndMinutes: Int = DEFAULT_NIGHT_END_MINUTES + override suspend fun ensureNightDefaults( + defaultStartMinutes: Int, + defaultEndMinutes: Int ) { dataStore.edit { preferences -> if (preferences[PreferencesKeys.NIGHT_START_MINUTES] == null) { @@ -130,20 +153,20 @@ class UserPreferencesRepository(private val dataStore: DataStore) { ) } - suspend fun updateAdaptiveThemeEnabled(enabled: Boolean) { + override suspend fun updateAdaptiveThemeEnabled(enabled: Boolean) { dataStore.edit { preferences -> preferences[PreferencesKeys.ADAPTIVE_THEME_ENABLED] = enabled } } - suspend fun updateAdaptiveThemeThresholdLux(lux: Float) { + override suspend fun updateAdaptiveThemeThresholdLux(lux: Float) { dataStore.edit { preferences -> preferences[PreferencesKeys.ADAPTIVE_THEME_THRESHOLD_LUX] = lux preferences.remove(PreferencesKeys.CUSTOM_ADAPTIVE_THEME_THRESHOLD_LUX) } } - suspend fun updateCustomAdaptiveThemeThresholdLux(lux: Float) { + override suspend fun updateCustomAdaptiveThemeThresholdLux(lux: Float) { dataStore.edit { preferences -> preferences[PreferencesKeys.ADAPTIVE_THEME_THRESHOLD_LUX] = lux preferences[PreferencesKeys.CUSTOM_ADAPTIVE_THEME_THRESHOLD_LUX] = lux @@ -151,25 +174,25 @@ class UserPreferencesRepository(private val dataStore: DataStore) { } - suspend fun updateSetupCompleted(completed: Boolean) { + override suspend fun updateSetupCompleted(completed: Boolean) { dataStore.edit { preferences -> preferences[PreferencesKeys.SETUP_COMPLETED] = completed } } - suspend fun updateStayDarkAtNightEnabled(enabled: Boolean) { + override suspend fun updateStayDarkAtNightEnabled(enabled: Boolean) { dataStore.edit { preferences -> preferences[PreferencesKeys.STAY_DARK_AT_NIGHT_ENABLED] = enabled } } - suspend fun updateWallpaperSyncEnabled(enabled: Boolean) { + override suspend fun updateWallpaperSyncEnabled(enabled: Boolean) { dataStore.edit { preferences -> preferences[PreferencesKeys.WALLPAPER_SYNC_ENABLED] = enabled } } - suspend fun updateDayWallpaperUri(uri: String?) { + override suspend fun updateDayWallpaperUri(uri: String?) { dataStore.edit { preferences -> if (uri == null) { preferences.remove(PreferencesKeys.DAY_WALLPAPER_URI) @@ -179,7 +202,7 @@ class UserPreferencesRepository(private val dataStore: DataStore) { } } - suspend fun updateNightWallpaperUri(uri: String?) { + override suspend fun updateNightWallpaperUri(uri: String?) { dataStore.edit { preferences -> if (uri == null) { preferences.remove(PreferencesKeys.NIGHT_WALLPAPER_URI) @@ -192,7 +215,7 @@ class UserPreferencesRepository(private val dataStore: DataStore) { /** * @return true when values are valid and persisted, false when rejected. */ - suspend fun updateNightWindow(startMinutes: Int, endMinutes: Int): Boolean { + override suspend fun updateNightWindow(startMinutes: Int, endMinutes: Int): Boolean { if (!isValidMinute(startMinutes) || !isValidMinute(endMinutes) || startMinutes == endMinutes) { Log.w( TAG, diff --git a/app/src/main/kotlin/dev/lexip/hecate/services/AdaptiveThemeServiceController.kt b/app/src/main/kotlin/dev/lexip/hecate/services/AdaptiveThemeServiceController.kt new file mode 100644 index 00000000..e2f26db2 --- /dev/null +++ b/app/src/main/kotlin/dev/lexip/hecate/services/AdaptiveThemeServiceController.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2024-2026 xLexip + * + * Licensed under the GNU General Public License, Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Please see the License for specific terms regarding permissions and limitations. + */ + +package dev.lexip.hecate.services + +import android.content.Context +import android.content.Intent +import androidx.core.content.ContextCompat + +interface AdaptiveThemeServiceController { + fun start(enableMonitoring: Boolean = false) + fun stop() +} + +class AndroidAdaptiveThemeServiceController( + private val context: Context +) : AdaptiveThemeServiceController { + + override fun start(enableMonitoring: Boolean) { + val intent = Intent(context, BroadcastReceiverService::class.java) + if (enableMonitoring) { + intent.putExtra(EXTRA_ENABLE_MONITORING, true) + } + ContextCompat.startForegroundService(context, intent) + } + + override fun stop() { + context.stopService(Intent(context, BroadcastReceiverService::class.java)) + } +} diff --git a/app/src/main/kotlin/dev/lexip/hecate/services/BroadcastReceiverService.kt b/app/src/main/kotlin/dev/lexip/hecate/services/BroadcastReceiverService.kt index 8f2cb431..159d94b6 100644 --- a/app/src/main/kotlin/dev/lexip/hecate/services/BroadcastReceiverService.kt +++ b/app/src/main/kotlin/dev/lexip/hecate/services/BroadcastReceiverService.kt @@ -27,6 +27,7 @@ import androidx.core.app.NotificationCompat import dev.lexip.hecate.Application import dev.lexip.hecate.R import dev.lexip.hecate.broadcasts.ScreenOnReceiver +import dev.lexip.hecate.data.UserPreferences import dev.lexip.hecate.data.UserPreferencesRepository import dev.lexip.hecate.logging.Logger import dev.lexip.hecate.util.AdaptiveAppearanceHandler @@ -51,6 +52,7 @@ class BroadcastReceiverService : Service() { private lateinit var adaptiveAppearanceHandler: AdaptiveAppearanceHandler private lateinit var lightSensorManager: LightSensorManager private lateinit var proximitySensorManager: ProximitySensorManager + private lateinit var monitoringPreferencesCoordinator: MonitoringPreferencesCoordinator // Service-bound scope private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) @@ -113,17 +115,7 @@ class BroadcastReceiverService : Service() { // Create screen-on receiver if adaptive theme is enabled val forceEnable = intent?.getBooleanExtra(EXTRA_ENABLE_MONITORING, false) == true if (userPreferences.adaptiveThemeEnabled || forceEnable) { - adaptiveAppearanceHandler.configureWallpaperSync( - enabled = userPreferences.wallpaperSyncEnabled, - dayWallpaperUri = userPreferences.dayWallpaperUri, - nightWallpaperUri = userPreferences.nightWallpaperUri - ) - createScreenOnReceiver( - adaptiveThemeThresholdLux = userPreferences.adaptiveThemeThresholdLux, - stayDarkAtNightEnabled = userPreferences.stayDarkAtNightEnabled, - nightStartMinutes = userPreferences.nightStartMinutes, - nightEndMinutes = userPreferences.nightEndMinutes - ) + createScreenOnReceiver(userPreferences) } // Abort service start when there is no receiver to handle @@ -138,15 +130,7 @@ class BroadcastReceiverService : Service() { serviceScope.launch { val userPreferencesRepository = UserPreferencesRepository(dataStore) userPreferencesRepository.userPreferencesFlow.collect { prefs -> - screenOnReceiver?.adaptiveThemeThresholdLux = prefs.adaptiveThemeThresholdLux - screenOnReceiver?.stayDarkAtNightEnabled = prefs.stayDarkAtNightEnabled - screenOnReceiver?.nightStartMinutes = prefs.nightStartMinutes - screenOnReceiver?.nightEndMinutes = prefs.nightEndMinutes - adaptiveAppearanceHandler.configureWallpaperSync( - enabled = prefs.wallpaperSyncEnabled, - dayWallpaperUri = prefs.dayWallpaperUri, - nightWallpaperUri = prefs.nightWallpaperUri - ) + monitoringPreferencesCoordinator.apply(prefs, screenOnReceiver) } } @@ -219,24 +203,24 @@ class BroadcastReceiverService : Service() { manager?.createNotificationChannel(serviceChannel) } - private fun createScreenOnReceiver( - adaptiveThemeThresholdLux: Float, - stayDarkAtNightEnabled: Boolean, - nightStartMinutes: Int, - nightEndMinutes: Int - ) { - screenOnReceiver = screenOnReceiver ?: ScreenOnReceiver( + private fun createScreenOnReceiver(preferences: UserPreferences) { + val isNewReceiver = screenOnReceiver == null + val receiver = screenOnReceiver ?: ScreenOnReceiver( proximitySensorManager, lightSensorManager, adaptiveAppearanceHandler, - adaptiveThemeThresholdLux, - stayDarkAtNightEnabled, - nightStartMinutes, - nightEndMinutes + preferences.adaptiveThemeThresholdLux, + preferences.stayDarkAtNightEnabled, + preferences.nightStartMinutes, + preferences.nightEndMinutes ) + monitoringPreferencesCoordinator.apply(preferences, receiver) + screenOnReceiver = receiver + if (!isNewReceiver) return + Log.d(TAG, "Registering screen-on receiver...") registerReceiver( - screenOnReceiver, + receiver, IntentFilter(Intent.ACTION_SCREEN_ON), RECEIVER_EXPORTED // It's a protected broadcast, EXPORTED allows to deliver it ) @@ -249,6 +233,11 @@ class BroadcastReceiverService : Service() { lightSensorManager = LightSensorManager(this) if (!this::proximitySensorManager.isInitialized) proximitySensorManager = ProximitySensorManager(this) + if (!this::monitoringPreferencesCoordinator.isInitialized) { + monitoringPreferencesCoordinator = MonitoringPreferencesCoordinator( + adaptiveAppearanceHandler::configureWallpaperSync + ) + } // Log proximity sensor availability for debugging and transparency if (proximitySensorManager.hasProximitySensor) { diff --git a/app/src/main/kotlin/dev/lexip/hecate/services/MonitoringPreferencesCoordinator.kt b/app/src/main/kotlin/dev/lexip/hecate/services/MonitoringPreferencesCoordinator.kt new file mode 100644 index 00000000..04cb0710 --- /dev/null +++ b/app/src/main/kotlin/dev/lexip/hecate/services/MonitoringPreferencesCoordinator.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2026 xLexip + * + * Licensed under the GNU General Public License, Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + */ + +package dev.lexip.hecate.services + +import dev.lexip.hecate.broadcasts.ScreenOnReceiverSettings +import dev.lexip.hecate.data.UserPreferences + +internal class MonitoringPreferencesCoordinator( + private val configureWallpaperSync: ( + enabled: Boolean, + dayWallpaperUri: String?, + nightWallpaperUri: String? + ) -> Unit +) { + fun apply( + preferences: UserPreferences, + receiver: ScreenOnReceiverSettings? + ) { + receiver?.adaptiveThemeThresholdLux = preferences.adaptiveThemeThresholdLux + receiver?.stayDarkAtNightEnabled = preferences.stayDarkAtNightEnabled + receiver?.nightStartMinutes = preferences.nightStartMinutes + receiver?.nightEndMinutes = preferences.nightEndMinutes + configureWallpaperSync( + preferences.wallpaperSyncEnabled, + preferences.dayWallpaperUri, + preferences.nightWallpaperUri + ) + } +} diff --git a/app/src/main/kotlin/dev/lexip/hecate/services/QuickSettingsTileService.kt b/app/src/main/kotlin/dev/lexip/hecate/services/QuickSettingsTileService.kt index ba6e4dab..83f63b0c 100644 --- a/app/src/main/kotlin/dev/lexip/hecate/services/QuickSettingsTileService.kt +++ b/app/src/main/kotlin/dev/lexip/hecate/services/QuickSettingsTileService.kt @@ -13,12 +13,10 @@ package dev.lexip.hecate.services import android.Manifest -import android.content.Intent import android.content.pm.PackageManager import android.service.quicksettings.Tile import android.service.quicksettings.TileService import android.util.Log -import androidx.core.content.ContextCompat import dev.lexip.hecate.Application import dev.lexip.hecate.data.UserPreferencesRepository import dev.lexip.hecate.logging.Logger @@ -56,7 +54,10 @@ class QuickSettingsTileService : TileService() { val tile = qsTile ?: return if (!hasWriteSecureSettingsPermission()) { - tile.state = Tile.STATE_UNAVAILABLE + tile.state = QuickSettingsStatePolicy.from( + hasPermission = false, + adaptiveThemeEnabled = false + ).toTileState() tile.updateTile() return } @@ -67,7 +68,10 @@ class QuickSettingsTileService : TileService() { val dataStore = (applicationContext as Application).userPreferencesDataStore val repo = UserPreferencesRepository(dataStore) val prefs = repo.fetchInitialPreferences() - tile.state = if (prefs.adaptiveThemeEnabled) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE + tile.state = QuickSettingsStatePolicy.from( + hasPermission = true, + adaptiveThemeEnabled = prefs.adaptiveThemeEnabled + ).toTileState() tile.updateTile() } } @@ -95,38 +99,35 @@ class QuickSettingsTileService : TileService() { toggleJob = serviceScope.launch(dispatchers.io) { val dataStore = (applicationContext as Application).userPreferencesDataStore val repo = UserPreferencesRepository(dataStore) + val coordinator = QuickSettingsToggleCoordinator( + preferences = repo, + serviceController = AndroidAdaptiveThemeServiceController(applicationContext) + ) - // Start/stop the service - val intent = Intent(applicationContext, BroadcastReceiverService::class.java) - if (newEnabled) { - intent.putExtra(EXTRA_ENABLE_MONITORING, true) - try { - ContextCompat.startForegroundService(applicationContext, intent) + when (val result = coordinator.toggle(currentlyEnabled = isEnabled)) { + QuickSettingsToggleResult.Enabled -> { Logger.logServiceEnabled( applicationContext, source = "quick_settings_tile" ) - } catch (e: Exception) { - Log.e(TAG, "Failed to start service", e) - // Revert UI if service start fails + } + + QuickSettingsToggleResult.Disabled -> { + Logger.logServiceDisabled( + applicationContext, + source = "quick_settings_tile" + ) + } + + is QuickSettingsToggleResult.StartFailed -> { + Log.e(TAG, "Failed to start service", result.cause) launch(dispatchers.main) { tile.state = Tile.STATE_INACTIVE tile.updateTile() } return@launch } - } else { - applicationContext.stopService(intent) - Logger.logServiceDisabled( - applicationContext, - source = "quick_settings_tile" - ) - } - - if (newEnabled) { - repo.ensureAdaptiveThemeThresholdDefault() } - repo.updateAdaptiveThemeEnabled(newEnabled) } } @@ -137,3 +138,9 @@ class QuickSettingsTileService : TileService() { } } + +private fun QuickSettingsState.toTileState(): Int = when (this) { + QuickSettingsState.UNAVAILABLE -> Tile.STATE_UNAVAILABLE + QuickSettingsState.INACTIVE -> Tile.STATE_INACTIVE + QuickSettingsState.ACTIVE -> Tile.STATE_ACTIVE +} diff --git a/app/src/main/kotlin/dev/lexip/hecate/services/QuickSettingsToggleCoordinator.kt b/app/src/main/kotlin/dev/lexip/hecate/services/QuickSettingsToggleCoordinator.kt new file mode 100644 index 00000000..6cc98eb5 --- /dev/null +++ b/app/src/main/kotlin/dev/lexip/hecate/services/QuickSettingsToggleCoordinator.kt @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2025-2026 xLexip + * + * Licensed under the GNU General Public License, Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Please see the License for specific terms regarding permissions and limitations. + */ + +package dev.lexip.hecate.services + +import dev.lexip.hecate.data.UserPreferencesDataSource + +enum class QuickSettingsState { + UNAVAILABLE, + INACTIVE, + ACTIVE +} + +object QuickSettingsStatePolicy { + fun from(hasPermission: Boolean, adaptiveThemeEnabled: Boolean): QuickSettingsState { + if (!hasPermission) return QuickSettingsState.UNAVAILABLE + return if (adaptiveThemeEnabled) QuickSettingsState.ACTIVE else QuickSettingsState.INACTIVE + } +} + +sealed interface QuickSettingsToggleResult { + data object Enabled : QuickSettingsToggleResult + data object Disabled : QuickSettingsToggleResult + data class StartFailed(val cause: Exception) : QuickSettingsToggleResult +} + +class QuickSettingsToggleCoordinator( + private val preferences: UserPreferencesDataSource, + private val serviceController: AdaptiveThemeServiceController +) { + suspend fun toggle(currentlyEnabled: Boolean): QuickSettingsToggleResult { + val enable = !currentlyEnabled + if (enable) { + try { + serviceController.start(enableMonitoring = true) + } catch (exception: Exception) { + return QuickSettingsToggleResult.StartFailed(exception) + } + preferences.ensureAdaptiveThemeThresholdDefault() + preferences.updateAdaptiveThemeEnabled(true) + return QuickSettingsToggleResult.Enabled + } + + serviceController.stop() + preferences.updateAdaptiveThemeEnabled(false) + return QuickSettingsToggleResult.Disabled + } +} diff --git a/app/src/main/kotlin/dev/lexip/hecate/ui/MainScreen.kt b/app/src/main/kotlin/dev/lexip/hecate/ui/MainScreen.kt index 01613c1f..b8117a88 100644 --- a/app/src/main/kotlin/dev/lexip/hecate/ui/MainScreen.kt +++ b/app/src/main/kotlin/dev/lexip/hecate/ui/MainScreen.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024-2025 xLexip + * Copyright (C) 2024-2026 xLexip * * Licensed under the GNU General Public License, Version 3.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,106 +17,36 @@ import android.app.Activity import android.content.ClipData import android.content.ClipboardManager import android.content.pm.PackageManager -import android.text.format.DateFormat -import android.widget.Toast -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.tween -import androidx.compose.animation.core.updateTransition -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Clear -import androidx.compose.material.icons.filled.KeyboardArrowDown -import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.AssistChip -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow -import androidx.compose.runtime.withFrameNanos -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.layout.boundsInWindow -import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat -import dev.lexip.hecate.R -import dev.lexip.hecate.data.AdaptiveThreshold -import dev.lexip.hecate.ui.components.MainSwitchPreferenceCard -import dev.lexip.hecate.ui.components.SetupRequiredCard -import dev.lexip.hecate.ui.components.ThreeDotMenu -import dev.lexip.hecate.ui.components.preferences.CustomThresholdDialog -import dev.lexip.hecate.ui.components.preferences.DetailPreferenceCard -import dev.lexip.hecate.ui.components.preferences.ProgressDetailCard -import dev.lexip.hecate.ui.components.preferences.SliderDetailCard -import dev.lexip.hecate.ui.components.preferences.TimePickerPreferenceDialog -import dev.lexip.hecate.ui.theme.hecateTopAppBarColors import dev.lexip.hecate.util.InAppReviewHandler import dev.lexip.hecate.util.shizuku.ShizukuAvailability -import java.util.Calendar -private val ScreenHorizontalMargin = 20.dp -private val horizontalOffsetPadding = 8.dp - -private fun formatMinutesAsLocalTime(context: android.content.Context, totalMinutes: Int): String { - val formatter = DateFormat.getTimeFormat(context) - val calendar = Calendar.getInstance().apply { - set(Calendar.HOUR_OF_DAY, totalMinutes / 60) - set(Calendar.MINUTE, totalMinutes % 60) - set(Calendar.SECOND, 0) - set(Calendar.MILLISECOND, 0) - } - return formatter.format(calendar.time) -} +data class MainScreenCallbacks( + val onServiceToggleRequested: (checked: Boolean, hasPermission: Boolean) -> Boolean, + val onThresholdSelected: (index: Int, lux: Float) -> Unit, + val onCheckReviewPrompt: () -> Unit, + val onStayDarkAtNightChanged: (Boolean) -> Unit, + val onWallpaperSyncToggleRequested: (Boolean) -> Unit = {}, + val onSelectDayWallpaper: () -> Unit = {}, + val onSelectNightWallpaper: () -> Unit = {}, + val onConfirmLiveWallpaper: () -> Unit = {}, + val onDismissLiveWallpaperWarning: () -> Unit = {}, + val onCustomThresholdConfirmed: (Float) -> Unit, + val onNightWindowChanged: ( + startMinutes: Int, + endMinutes: Int, + onRejected: (() -> Unit)? + ) -> Unit +) @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -125,18 +55,17 @@ fun MainScreen( mainViewModel: MainViewModel ) { val context = LocalContext.current - val haptic = LocalHapticFeedback.current - val packageName = context.packageName - val contentScrollState = rememberScrollState() - var isLargeTitleVisible by remember { mutableStateOf(true) } - val internalUiState by mainViewModel.uiState.collectAsState() - - val isSystemDark = isSystemInDarkTheme() + val currentLux by mainViewModel.currentSensorLuxFlow.collectAsState( + initial = mainViewModel.currentSensorLux + ) + val hasWriteSecureSettingsPermission = ContextCompat.checkSelfPermission( + context, + Manifest.permission.WRITE_SECURE_SETTINGS + ) == PackageManager.PERMISSION_GRANTED LaunchedEffect(Unit) { - val installed = ShizukuAvailability.isShizukuInstalled(context) - mainViewModel.setShizukuInstalled(installed) + mainViewModel.setShizukuInstalled(ShizukuAvailability.isShizukuInstalled(context)) } val dayWallpaperPicker = rememberLauncherForActivityResult( @@ -155,697 +84,53 @@ fun MainScreen( } } - val showCustomDialog = remember { mutableStateOf(false) } - val showNightStartPicker = remember { mutableStateOf(false) } - val showNightEndPicker = remember { mutableStateOf(false) } - var isAdvancedSettingsExpanded by remember { - mutableStateOf((uiState.stayDarkAtNightEnabled || uiState.wallpaperSyncEnabled) && uiState.adaptiveThemeEnabled) - } - var autoScrollAdvancedSettingsTransition by remember { mutableStateOf(false) } - val advancedSettingsTransition = updateTransition( - targetState = isAdvancedSettingsExpanded, - label = "Advanced settings visibility" - ) - val setupShakeKey = remember { mutableIntStateOf(0) } - val textShakeKey = remember { mutableIntStateOf(0) } - val wallpaperButtonsShakeKey = remember { mutableIntStateOf(0) } - - // Shake animation for wallpaper buttons when attempting to enable without set wallpapers - val wallpaperButtonsOffsetAnim = remember { Animatable(0f) } - LaunchedEffect(wallpaperButtonsShakeKey.intValue) { - if (wallpaperButtonsShakeKey.intValue > 0) { - val offsets = listOf(-4f, 4f, -3f, 3f, -1.5f, 1.5f, 0f) - for (o in offsets) { - wallpaperButtonsOffsetAnim.animateTo(o, animationSpec = tween(durationMillis = 60)) - } - } - } - - // Text shake animation for the conditions text (shown when the threshold changes) - val textOffsetAnim = remember { Animatable(0f) } - LaunchedEffect(textShakeKey.intValue) { - if (textShakeKey.intValue > 0) { - val offsets = listOf(-3f, 3f, -2f, 2f, -1f, 1f, -0.5f, 0.5f, 0f) - for (o in offsets) { - textOffsetAnim.animateTo(o, animationSpec = tween(durationMillis = 80)) - } - } - } - - LaunchedEffect(autoScrollAdvancedSettingsTransition) { - if (autoScrollAdvancedSettingsTransition) { - withFrameNanos { } - snapshotFlow { - Triple( - advancedSettingsTransition.isRunning, - advancedSettingsTransition.currentState == advancedSettingsTransition.targetState, - contentScrollState.maxValue - ) - }.collect { (isRunning, isSettled, maxScrollValue) -> - contentScrollState.scrollTo(maxScrollValue) - if (!isRunning && isSettled) { - contentScrollState.scrollTo(contentScrollState.maxValue) - autoScrollAdvancedSettingsTransition = false - } - } - } - } - LaunchedEffect(mainViewModel) { mainViewModel.uiEvents.collect { event -> when (event) { is CopyToClipboard -> { val clipboard = context.getSystemService(ClipboardManager::class.java) - val clip = ClipData.newPlainText("ADB Command", event.text) - clipboard?.setPrimaryClip(clip) + clipboard?.setPrimaryClip(ClipData.newPlainText("ADB Command", event.text)) } - is NavigateToSetup -> { - // Handled by MainActivity - } + is NavigateToSetup -> Unit is RequestInAppReview -> { - val activity = context as? Activity - if (activity != null) { - InAppReviewHandler.triggerReview(activity) - } + (context as? Activity)?.let(InAppReviewHandler::triggerReview) } } } } - Scaffold( - modifier = Modifier - .fillMaxSize(), - containerColor = MaterialTheme.colorScheme.surfaceContainer, - topBar = { - val showCollapsedTitle = !isLargeTitleVisible - TopAppBar( - modifier = Modifier - .padding(start = ScreenHorizontalMargin - 8.dp) - .padding(top = 12.dp), - colors = hecateTopAppBarColors(), - title = { - AnimatedVisibility( - visible = showCollapsedTitle, - enter = fadeIn(animationSpec = tween(180)) + - slideInHorizontally( - initialOffsetX = { fullWidth -> -fullWidth / 2 }, - animationSpec = tween(220) - ), - exit = fadeOut(animationSpec = tween(120)) + - slideOutHorizontally( - targetOffsetX = { fullWidth -> -fullWidth / 3 }, - animationSpec = tween(160) - ) - ) { - Text( - text = stringResource(id = R.string.app_name), - style = MaterialTheme.typography.titleLarge - ) - } - }, - actions = { - ThreeDotMenu( - isAdaptiveThemeEnabled = uiState.adaptiveThemeEnabled, - packageName = packageName, - isInstalledFromPlayStore = uiState.isInstalledFromPlayStore, - onShowCustomThresholdDialog = { showCustomDialog.value = true } - ) - } - ) - } - ) { innerPadding -> - val hasWriteSecureSettingsPermission = ContextCompat.checkSelfPermission( - context, - Manifest.permission.WRITE_SECURE_SETTINGS - ) == PackageManager.PERMISSION_GRANTED - - Column( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - .padding(horizontal = ScreenHorizontalMargin) - .verticalScroll(contentScrollState), - verticalArrangement = Arrangement.spacedBy(29.dp) - - ) { - val showBatterySaverWarning = - internalUiState.isBatterySaverActive && uiState.adaptiveThemeEnabled - - Text( - modifier = Modifier - .padding(horizontal = horizontalOffsetPadding) - .padding(top = 12.dp) - .onGloballyPositioned { coordinates -> - isLargeTitleVisible = coordinates.boundsInWindow().bottom > 0f - }, - text = stringResource(id = R.string.app_name), - style = MaterialTheme.typography.displaySmall - ) - - Column { - Text( - modifier = Modifier.padding(horizontal = horizontalOffsetPadding), - text = stringResource(id = R.string.description_service_purpose), - style = MaterialTheme.typography.bodyLarge.copy(lineHeight = 21.sp) - ) - Spacer(modifier = Modifier.padding(top = 8.dp)) - Text( - modifier = Modifier - .padding(horizontal = horizontalOffsetPadding) - .offset { IntOffset(textOffsetAnim.value.dp.roundToPx(), 0) }, - text = stringResource(id = R.string.description_switching_conditions), - style = MaterialTheme.typography.bodyLarge.copy(lineHeight = 21.sp) + MainScreenContent( + uiState = uiState, + currentSensorLux = currentLux, + isDeviceCovered = internalUiState.isDeviceCovered, + isBatterySaverActive = internalUiState.isBatterySaverActive, + hasWriteSecureSettingsPermission = hasWriteSecureSettingsPermission, + packageName = context.packageName, + callbacks = MainScreenCallbacks( + onServiceToggleRequested = mainViewModel::onServiceToggleRequested, + onThresholdSelected = { index, lux -> + mainViewModel.setPendingCustomSliderLux(lux) + mainViewModel.onSliderValueCommitted(index) + }, + onCheckReviewPrompt = mainViewModel::checkReviewPrompt, + onStayDarkAtNightChanged = mainViewModel::updateStayDarkAtNightEnabled, + onWallpaperSyncToggleRequested = mainViewModel::onWallpaperSyncToggleRequested, + onSelectDayWallpaper = { + dayWallpaperPicker.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) ) - } - - // Battery saver warning - AnimatedVisibility( - visible = showBatterySaverWarning, - enter = fadeIn() + slideInHorizontally(initialOffsetX = { it }), - exit = fadeOut() + slideOutHorizontally(targetOffsetX = { it }) - ) { - Card( - modifier = Modifier - .fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - contentColor = MaterialTheme.colorScheme.onTertiaryContainer - ), - shape = RoundedCornerShape(20.dp) - ) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = stringResource(id = R.string.battery_saver_title), - style = MaterialTheme.typography.titleMedium - ) - Spacer(modifier = Modifier.padding(top = 4.dp)) - Text( - text = stringResource(id = R.string.battery_saver_message), - style = MaterialTheme.typography.bodyMedium - ) - } - } - } - - // Device-covered warning when the proximity sensor reports covered - AnimatedVisibility( - visible = internalUiState.isDeviceCovered && uiState.adaptiveThemeEnabled && !showBatterySaverWarning, - enter = fadeIn() + slideInHorizontally(initialOffsetX = { it }), - exit = fadeOut() + slideOutHorizontally(targetOffsetX = { it }) - ) { - Card( - modifier = Modifier - .fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer - ), - shape = RoundedCornerShape(20.dp) - ) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = stringResource(id = R.string.device_covered_title), - style = MaterialTheme.typography.titleMedium - ) - Spacer(modifier = Modifier.padding(top = 4.dp)) - Text( - text = stringResource(id = R.string.device_covered_message), - style = MaterialTheme.typography.bodyMedium - ) - } - } - } - - // Setup card shown when the required permission has not been granted yet - if (!hasWriteSecureSettingsPermission) { - SetupRequiredCard( - modifier = Modifier.fillMaxWidth(), - title = stringResource(id = R.string.setup_required_title), - message = stringResource( - id = R.string.setup_required_message, - stringResource(id = R.string.app_name) - ), - onLaunchSetup = { - mainViewModel.onServiceToggleRequested( - checked = true, - hasPermission = false - ) - }, - shakeKey = setupShakeKey.intValue, + }, + onSelectNightWallpaper = { + nightWallpaperPicker.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) ) - } - - MainSwitchPreferenceCard( - text = stringResource( - id = R.string.action_use_adaptive_theme, - stringResource(id = R.string.app_name) - ), - isChecked = uiState.adaptiveThemeEnabled, - onCheckedChange = { checked -> - // Shake animation when user tries to enable service without permission - if (checked && !hasWriteSecureSettingsPermission) { - setupShakeKey.intValue += 1 - haptic.performHapticFeedback(HapticFeedbackType.Reject) - } else { - mainViewModel.onServiceToggleRequested( - checked, - hasWriteSecureSettingsPermission - ).also { wasToggled -> - if (wasToggled) { - if (!checked) { - isAdvancedSettingsExpanded = false - autoScrollAdvancedSettingsTransition = false - } - haptic.performHapticFeedback( - if (checked) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff - ) - } else { - haptic.performHapticFeedback(HapticFeedbackType.Reject) - } - } - } - - } - ) - - val customLabel = stringResource(id = R.string.adaptive_threshold_custom) - val labels = mainViewModel.getDisplayLabels( - AdaptiveThreshold.entries.map { stringResource(id = it.labelRes) }, - customLabel - ) - val baseLux = AdaptiveThreshold.entries.map { it.lux } - val lux = mainViewModel.getDisplayLuxSteps(baseLux) - val currentLux by mainViewModel.currentSensorLuxFlow.collectAsState(initial = mainViewModel.currentSensorLux) - - Column( - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - Column( - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - SliderDetailCard( - title = stringResource(id = R.string.title_brightness_threshold), - valueIndex = mainViewModel.getIndexForCurrentLux(), - steps = labels.size, - labels = labels, - lux = lux, - onValueChange = { index -> - mainViewModel.setPendingCustomSliderLux(lux[index]) - mainViewModel.onSliderValueCommitted(index) - - // Shake the description text when the user could expect an immediate theme switch - if ((currentLux > lux[index]) == isSystemDark) { - textShakeKey.intValue += 1 - } - }, - enabled = uiState.adaptiveThemeEnabled, - firstCard = true, - lastCard = false - ) - - ProgressDetailCard( - title = stringResource(id = R.string.title_current_brightness), - currentLux = currentLux, - luxSteps = lux, - enabled = uiState.adaptiveThemeEnabled, - firstCard = false, - lastCard = !advancedSettingsTransition.currentState && - !advancedSettingsTransition.targetState - ) - } - - advancedSettingsTransition.AnimatedVisibility( - visible = { expanded -> !expanded }, - enter = fadeIn(animationSpec = tween(220)) + expandVertically( - animationSpec = tween( - durationMillis = 260, - easing = FastOutSlowInEasing - ) - ), - exit = fadeOut(animationSpec = tween(150)) + shrinkVertically( - animationSpec = tween( - durationMillis = 220, - easing = FastOutSlowInEasing - ) - ) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), - horizontalArrangement = Arrangement.Center - ) { - AssistChip( - onClick = { - autoScrollAdvancedSettingsTransition = true - isAdvancedSettingsExpanded = true - haptic.performHapticFeedback(HapticFeedbackType.ToggleOn) - mainViewModel.checkReviewPrompt() - }, - enabled = uiState.adaptiveThemeEnabled, - shape = RoundedCornerShape(20.dp), - label = { - Text(text = stringResource(id = R.string.action_advanced_settings)) - }, - leadingIcon = { - Icon( - imageVector = Icons.Filled.KeyboardArrowDown, - contentDescription = null - ) - } - ) - } - } - - advancedSettingsTransition.AnimatedVisibility( - visible = { expanded -> expanded }, - enter = fadeIn(animationSpec = tween(220)) + expandVertically( - animationSpec = tween( - durationMillis = 360, - easing = FastOutSlowInEasing - ) - ), - exit = fadeOut(animationSpec = tween(150)) + shrinkVertically( - animationSpec = tween( - durationMillis = 240, - easing = FastOutSlowInEasing - ) - ) - ) { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - DetailPreferenceCard( - title = stringResource(id = R.string.title_night_dark_lock), - enabled = uiState.adaptiveThemeEnabled, - firstCard = false, - lastCard = false, - toggleableValue = uiState.stayDarkAtNightEnabled, - onToggle = { enabled -> - mainViewModel.updateStayDarkAtNightEnabled(enabled) - } - ) { - Row( - modifier = Modifier - .fillMaxWidth(), - verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = stringResource(id = R.string.description_night_dark_lock), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f) - ) - Switch( - modifier = Modifier - .padding(start = 14.dp, end = 4.dp) - .offset(y = (-6).dp) - .align(Alignment.Top), - checked = uiState.stayDarkAtNightEnabled, - enabled = uiState.adaptiveThemeEnabled, - onCheckedChange = null, - thumbContent = if (uiState.stayDarkAtNightEnabled) { - { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = null, - modifier = Modifier.size(SwitchDefaults.IconSize) - ) - } - } else { - { - Icon( - imageVector = Icons.Filled.Clear, - contentDescription = null, - modifier = Modifier.size(SwitchDefaults.IconSize) - ) - } - } - ) - } - - if (uiState.stayDarkAtNightEnabled && uiState.adaptiveThemeEnabled) { - val startText = - formatMinutesAsLocalTime(context, uiState.nightStartMinutes) - val endText = - formatMinutesAsLocalTime(context, uiState.nightEndMinutes) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - OutlinedButton( - modifier = Modifier.weight(1f), - onClick = { showNightStartPicker.value = true } - ) { - Text( - text = stringResource( - id = R.string.action_night_from_time, - startText - ) - ) - } - - OutlinedButton( - modifier = Modifier.weight(1f), - onClick = { showNightEndPicker.value = true } - ) { - Text( - text = stringResource( - id = R.string.action_night_to_time, - endText - ) - ) - } - } - } - } - - val isDaySet = !uiState.dayWallpaperUri.isNullOrEmpty() - val isNightSet = !uiState.nightWallpaperUri.isNullOrEmpty() - val bothWallpapersSet = isDaySet && isNightSet - - DetailPreferenceCard( - title = stringResource(id = R.string.title_wallpaper_sync), - enabled = uiState.adaptiveThemeEnabled, - firstCard = false, - lastCard = true, - toggleableValue = uiState.wallpaperSyncEnabled, - onToggle = { enabled -> - if (enabled && !bothWallpapersSet) { - wallpaperButtonsShakeKey.intValue += 1 - haptic.performHapticFeedback(HapticFeedbackType.Reject) - } else { - mainViewModel.onWallpaperSyncToggleRequested(enabled) - } - }, - titleTrailingContent = { - Surface( - modifier = Modifier.padding(start = 8.dp), - shape = RoundedCornerShape(50), - color = MaterialTheme.colorScheme.tertiaryContainer, - contentColor = MaterialTheme.colorScheme.onTertiaryContainer - ) { - Text( - text = stringResource(id = R.string.label_beta), - modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), - style = MaterialTheme.typography.labelSmall - ) - } - } - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = stringResource(id = R.string.description_wallpaper_sync), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f) - ) - Switch( - modifier = Modifier - .padding(start = 14.dp, end = 4.dp) - .offset(y = (-6).dp) - .align(Alignment.Top), - checked = uiState.wallpaperSyncEnabled, - enabled = uiState.adaptiveThemeEnabled, - onCheckedChange = { checked -> - if (checked && !bothWallpapersSet) { - wallpaperButtonsShakeKey.intValue += 1 - haptic.performHapticFeedback(HapticFeedbackType.Reject) - } else { - mainViewModel.onWallpaperSyncToggleRequested(checked) - } - }, - thumbContent = if (uiState.wallpaperSyncEnabled) { - { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = null, - modifier = Modifier.size(SwitchDefaults.IconSize) - ) - } - } else { - { - Icon( - imageVector = Icons.Filled.Clear, - contentDescription = null, - modifier = Modifier.size(SwitchDefaults.IconSize) - ) - } - } - ) - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp) - .offset { IntOffset(wallpaperButtonsOffsetAnim.value.dp.roundToPx(), 0) }, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - val dayStatus = stringResource( - if (isDaySet) R.string.wallpaper_status_set else R.string.wallpaper_status_not_set - ) - val nightStatus = stringResource( - if (isNightSet) R.string.wallpaper_status_set else R.string.wallpaper_status_not_set - ) - - OutlinedButton( - modifier = Modifier.weight(1f), - enabled = uiState.adaptiveThemeEnabled, - onClick = { - dayWallpaperPicker.launch( - PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) - ) - } - ) { - Text( - text = "${stringResource(id = R.string.action_select_day_wallpaper)}\n$dayStatus", - textAlign = TextAlign.Center - ) - } - - OutlinedButton( - modifier = Modifier.weight(1f), - enabled = uiState.adaptiveThemeEnabled, - onClick = { - nightWallpaperPicker.launch( - PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) - ) - } - ) { - Text( - text = "${stringResource(id = R.string.action_select_night_wallpaper)}\n$nightStatus", - textAlign = TextAlign.Center - ) - } - } - } - - AssistChip( - modifier = Modifier.align(Alignment.CenterHorizontally), - onClick = { - autoScrollAdvancedSettingsTransition = true - isAdvancedSettingsExpanded = false - haptic.performHapticFeedback(HapticFeedbackType.ToggleOff) - }, - enabled = uiState.adaptiveThemeEnabled, - shape = RoundedCornerShape(20.dp), - label = { - Text(text = stringResource(id = R.string.action_collapse)) - }, - leadingIcon = { - Icon( - imageVector = Icons.Filled.KeyboardArrowUp, - contentDescription = null - ) - } - ) - } - } - - } - - Spacer(modifier = Modifier.padding(bottom = 4.dp)) - } - } - - - CustomThresholdDialog( - show = showCustomDialog.value, - currentLux = uiState.customAdaptiveThemeThresholdLux ?: uiState.adaptiveThemeThresholdLux, - onConfirm = { luxValue: Float -> - mainViewModel.setCustomAdaptiveThemeThreshold(luxValue) - showCustomDialog.value = false - if (luxValue.toInt() == 42) { - Toast.makeText( - context, - "The answer to the ultimate question of life, the universe, and everything.", - Toast.LENGTH_LONG - ).show() - } - }, - onDismiss = { showCustomDialog.value = false } - ) - - TimePickerPreferenceDialog( - show = showNightStartPicker.value, - title = stringResource(id = R.string.title_night_start_time_picker), - initialMinutes = uiState.nightStartMinutes, - onConfirm = { selectedMinutes -> - mainViewModel.updateNightWindow( - startMinutes = selectedMinutes, - endMinutes = uiState.nightEndMinutes, - onRejected = { - Toast.makeText(context, R.string.error_invalid_night_period, Toast.LENGTH_SHORT) - .show() - } - ) - showNightStartPicker.value = false - }, - onDismiss = { showNightStartPicker.value = false } - ) - - TimePickerPreferenceDialog( - show = showNightEndPicker.value, - title = stringResource(id = R.string.title_night_end_time_picker), - initialMinutes = uiState.nightEndMinutes, - onConfirm = { selectedMinutes -> - mainViewModel.updateNightWindow( - startMinutes = uiState.nightStartMinutes, - endMinutes = selectedMinutes, - onRejected = { - Toast.makeText(context, R.string.error_invalid_night_period, Toast.LENGTH_SHORT) - .show() - } - ) - showNightEndPicker.value = false - }, - onDismiss = { showNightEndPicker.value = false } - ) - - if (uiState.showLiveWallpaperWarningDialog) { - AlertDialog( - onDismissRequest = { mainViewModel.dismissLiveWallpaperWarningDialog() }, - title = { Text(text = stringResource(id = R.string.live_wallpaper_warning_title)) }, - text = { Text(text = stringResource(id = R.string.live_wallpaper_warning_message)) }, - confirmButton = { - TextButton( - onClick = { mainViewModel.confirmEnableWithLiveWallpaper() } - ) { - Text(text = stringResource(id = R.string.action_continue)) - } }, - dismissButton = { - TextButton( - onClick = { mainViewModel.dismissLiveWallpaperWarningDialog() } - ) { - Text(text = stringResource(id = R.string.action_cancel)) - } - } + onConfirmLiveWallpaper = mainViewModel::confirmEnableWithLiveWallpaper, + onDismissLiveWallpaperWarning = mainViewModel::dismissLiveWallpaperWarningDialog, + onCustomThresholdConfirmed = mainViewModel::setCustomAdaptiveThemeThreshold, + onNightWindowChanged = mainViewModel::updateNightWindow ) - } + ) } diff --git a/app/src/main/kotlin/dev/lexip/hecate/ui/MainScreenContent.kt b/app/src/main/kotlin/dev/lexip/hecate/ui/MainScreenContent.kt new file mode 100644 index 00000000..bff4bafe --- /dev/null +++ b/app/src/main/kotlin/dev/lexip/hecate/ui/MainScreenContent.kt @@ -0,0 +1,1001 @@ +/* + * Copyright (C) 2024-2026 xLexip + * + * Licensed under the GNU General Public License, Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Please see the License for specific terms regarding permissions and limitations. + */ + +package dev.lexip.hecate.ui + +import android.content.Context +import android.text.format.DateFormat +import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Transition +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedback +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.lexip.hecate.R +import dev.lexip.hecate.data.AdaptiveThreshold +import dev.lexip.hecate.ui.components.MainSwitchPreferenceCard +import dev.lexip.hecate.ui.components.SetupRequiredCard +import dev.lexip.hecate.ui.components.ThreeDotMenu +import dev.lexip.hecate.ui.components.preferences.CustomThresholdDialog +import dev.lexip.hecate.ui.components.preferences.DetailPreferenceCard +import dev.lexip.hecate.ui.components.preferences.ProgressDetailCard +import dev.lexip.hecate.ui.components.preferences.SliderDetailCard +import dev.lexip.hecate.ui.components.preferences.TimePickerPreferenceDialog +import dev.lexip.hecate.ui.theme.hecateTopAppBarColors +import java.util.Calendar + +private val ScreenHorizontalMargin = 20.dp +private val horizontalOffsetPadding = 8.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainScreenContent( + uiState: MainUiState, + currentSensorLux: Float, + isDeviceCovered: Boolean, + isBatterySaverActive: Boolean, + hasWriteSecureSettingsPermission: Boolean, + packageName: String, + callbacks: MainScreenCallbacks +) { + val haptic = LocalHapticFeedback.current + val scrollState = rememberScrollState() + var isLargeTitleVisible by remember { mutableStateOf(true) } + var showCustomDialog by remember { mutableStateOf(false) } + var showNightStartPicker by remember { mutableStateOf(false) } + var showNightEndPicker by remember { mutableStateOf(false) } + var isAdvancedSettingsExpanded by remember { + mutableStateOf( + (uiState.stayDarkAtNightEnabled || uiState.wallpaperSyncEnabled) && + uiState.adaptiveThemeEnabled + ) + } + var autoScrollAdvancedSettingsTransition by remember { mutableStateOf(false) } + val advancedSettingsTransition = updateTransition( + targetState = isAdvancedSettingsExpanded, + label = "Advanced settings visibility" + ) + val setupShakeKey = remember { mutableIntStateOf(0) } + val textShakeKey = remember { mutableIntStateOf(0) } + val wallpaperButtonsShakeKey = remember { mutableIntStateOf(0) } + val textOffset = rememberShakeOffset( + key = textShakeKey.intValue, + offsets = listOf(-3f, 3f, -2f, 2f, -1f, 1f, -0.5f, 0.5f, 0f), + durationMillis = 80 + ) + val wallpaperButtonsOffset = rememberShakeOffset( + key = wallpaperButtonsShakeKey.intValue, + offsets = listOf(-4f, 4f, -3f, 3f, -1.5f, 1.5f, 0f), + durationMillis = 60 + ) + + AdvancedSettingsAutoScrollEffect( + enabled = autoScrollAdvancedSettingsTransition, + transition = advancedSettingsTransition, + scrollState = scrollState, + onFinished = { autoScrollAdvancedSettingsTransition = false } + ) + + Scaffold( + modifier = Modifier.fillMaxSize(), + containerColor = MaterialTheme.colorScheme.surfaceContainer, + topBar = { + MainScreenTopBar( + showCollapsedTitle = !isLargeTitleVisible, + uiState = uiState, + packageName = packageName, + onShowCustomThresholdDialog = { showCustomDialog = true } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(horizontal = ScreenHorizontalMargin) + .verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(29.dp) + ) { + MainHeader( + textOffset = textOffset, + onLargeTitleVisibilityChanged = { isLargeTitleVisible = it } + ) + StatusWarnings( + showBatterySaverWarning = isBatterySaverActive && uiState.adaptiveThemeEnabled, + showDeviceCoveredWarning = isDeviceCovered && uiState.adaptiveThemeEnabled + ) + ServiceControls( + uiState = uiState, + hasPermission = hasWriteSecureSettingsPermission, + setupShakeKey = setupShakeKey.intValue, + haptic = haptic, + onMissingPermission = { setupShakeKey.intValue += 1 }, + onServiceToggleRequested = callbacks.onServiceToggleRequested, + onServiceDisabled = { + isAdvancedSettingsExpanded = false + autoScrollAdvancedSettingsTransition = false + } + ) + ThresholdAndAdvancedSettings( + uiState = uiState, + currentSensorLux = currentSensorLux, + wallpaperButtonsOffset = wallpaperButtonsOffset, + transition = advancedSettingsTransition, + haptic = haptic, + callbacks = callbacks, + onTextShake = { textShakeKey.intValue += 1 }, + onWallpaperButtonsShake = { wallpaperButtonsShakeKey.intValue += 1 }, + onExpand = { + autoScrollAdvancedSettingsTransition = true + isAdvancedSettingsExpanded = true + }, + onCollapse = { + autoScrollAdvancedSettingsTransition = true + isAdvancedSettingsExpanded = false + }, + onShowNightStartPicker = { showNightStartPicker = true }, + onShowNightEndPicker = { showNightEndPicker = true } + ) + Spacer(modifier = Modifier.padding(bottom = 4.dp)) + } + } + + MainScreenDialogs( + uiState = uiState, + showCustomDialog = showCustomDialog, + showNightStartPicker = showNightStartPicker, + showNightEndPicker = showNightEndPicker, + callbacks = callbacks, + onDismissCustomDialog = { showCustomDialog = false }, + onDismissNightStartPicker = { showNightStartPicker = false }, + onDismissNightEndPicker = { showNightEndPicker = false } + ) +} + +@Composable +private fun rememberShakeOffset( + key: Int, + offsets: List, + durationMillis: Int +): Float { + val animation = remember { Animatable(0f) } + LaunchedEffect(key) { + if (key > 0) { + offsets.forEach { offset -> + animation.animateTo(offset, animationSpec = tween(durationMillis)) + } + } + } + return animation.value +} + +@Composable +private fun AdvancedSettingsAutoScrollEffect( + enabled: Boolean, + transition: Transition, + scrollState: androidx.compose.foundation.ScrollState, + onFinished: () -> Unit +) { + LaunchedEffect(enabled) { + if (enabled) { + withFrameNanos { } + snapshotFlow { + Triple( + transition.isRunning, + transition.currentState == transition.targetState, + scrollState.maxValue + ) + }.collect { (isRunning, isSettled, maxScrollValue) -> + scrollState.scrollTo(maxScrollValue) + if (!isRunning && isSettled) { + scrollState.scrollTo(scrollState.maxValue) + onFinished() + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun MainScreenTopBar( + showCollapsedTitle: Boolean, + uiState: MainUiState, + packageName: String, + onShowCustomThresholdDialog: () -> Unit +) { + TopAppBar( + modifier = Modifier + .padding(start = ScreenHorizontalMargin - 8.dp) + .padding(top = 12.dp), + colors = hecateTopAppBarColors(), + title = { + AnimatedVisibility( + visible = showCollapsedTitle, + enter = fadeIn(animationSpec = tween(180)) + + slideInHorizontally( + initialOffsetX = { fullWidth -> -fullWidth / 2 }, + animationSpec = tween(220) + ), + exit = fadeOut(animationSpec = tween(120)) + + slideOutHorizontally( + targetOffsetX = { fullWidth -> -fullWidth / 3 }, + animationSpec = tween(160) + ) + ) { + Text( + text = stringResource(id = R.string.app_name), + style = MaterialTheme.typography.titleLarge + ) + } + }, + actions = { + ThreeDotMenu( + isAdaptiveThemeEnabled = uiState.adaptiveThemeEnabled, + packageName = packageName, + isInstalledFromPlayStore = uiState.isInstalledFromPlayStore, + onShowCustomThresholdDialog = onShowCustomThresholdDialog + ) + } + ) +} + +@Composable +private fun MainHeader( + textOffset: Float, + onLargeTitleVisibilityChanged: (Boolean) -> Unit +) { + Text( + modifier = Modifier + .padding(horizontal = horizontalOffsetPadding) + .padding(top = 12.dp) + .onGloballyPositioned { coordinates -> + onLargeTitleVisibilityChanged(coordinates.boundsInWindow().bottom > 0f) + }, + text = stringResource(id = R.string.app_name), + style = MaterialTheme.typography.displaySmall + ) + Column { + Text( + modifier = Modifier.padding(horizontal = horizontalOffsetPadding), + text = stringResource(id = R.string.description_service_purpose), + style = MaterialTheme.typography.bodyLarge.copy(lineHeight = 21.sp) + ) + Spacer(modifier = Modifier.padding(top = 8.dp)) + Text( + modifier = Modifier + .padding(horizontal = horizontalOffsetPadding) + .offset { IntOffset(textOffset.dp.roundToPx(), 0) }, + text = stringResource(id = R.string.description_switching_conditions), + style = MaterialTheme.typography.bodyLarge.copy(lineHeight = 21.sp) + ) + } +} + +@Composable +private fun StatusWarnings( + showBatterySaverWarning: Boolean, + showDeviceCoveredWarning: Boolean +) { + AnimatedVisibility( + visible = showBatterySaverWarning, + enter = fadeIn() + slideInHorizontally(initialOffsetX = { it }), + exit = fadeOut() + slideOutHorizontally(targetOffsetX = { it }) + ) { + StatusWarningCard( + title = stringResource(id = R.string.battery_saver_title), + message = stringResource(id = R.string.battery_saver_message), + isError = false + ) + } + AnimatedVisibility( + visible = showDeviceCoveredWarning && !showBatterySaverWarning, + enter = fadeIn() + slideInHorizontally(initialOffsetX = { it }), + exit = fadeOut() + slideOutHorizontally(targetOffsetX = { it }) + ) { + StatusWarningCard( + title = stringResource(id = R.string.device_covered_title), + message = stringResource(id = R.string.device_covered_message), + isError = true + ) + } +} + +@Composable +private fun StatusWarningCard(title: String, message: String, isError: Boolean) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = if (isError) { + MaterialTheme.colorScheme.errorContainer + } else { + MaterialTheme.colorScheme.tertiaryContainer + }, + contentColor = if (isError) { + MaterialTheme.colorScheme.onErrorContainer + } else { + MaterialTheme.colorScheme.onTertiaryContainer + } + ), + shape = RoundedCornerShape(20.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text(text = title, style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.padding(top = 4.dp)) + Text(text = message, style = MaterialTheme.typography.bodyMedium) + } + } +} + +@Composable +private fun ServiceControls( + uiState: MainUiState, + hasPermission: Boolean, + setupShakeKey: Int, + haptic: HapticFeedback, + onMissingPermission: () -> Unit, + onServiceToggleRequested: (Boolean, Boolean) -> Boolean, + onServiceDisabled: () -> Unit +) { + if (!hasPermission) { + SetupRequiredCard( + modifier = Modifier.fillMaxWidth(), + title = stringResource(id = R.string.setup_required_title), + message = stringResource( + id = R.string.setup_required_message, + stringResource(id = R.string.app_name) + ), + onLaunchSetup = { onServiceToggleRequested(true, false) }, + shakeKey = setupShakeKey + ) + } + MainSwitchPreferenceCard( + text = stringResource( + id = R.string.action_use_adaptive_theme, + stringResource(id = R.string.app_name) + ), + isChecked = uiState.adaptiveThemeEnabled, + onCheckedChange = { checked -> + handleServiceToggle( + checked = checked, + hasPermission = hasPermission, + haptic = haptic, + onMissingPermission = onMissingPermission, + onServiceToggleRequested = onServiceToggleRequested, + onServiceDisabled = onServiceDisabled + ) + } + ) +} + +private fun handleServiceToggle( + checked: Boolean, + hasPermission: Boolean, + haptic: HapticFeedback, + onMissingPermission: () -> Unit, + onServiceToggleRequested: (Boolean, Boolean) -> Boolean, + onServiceDisabled: () -> Unit +) { + if (checked && !hasPermission) { + onMissingPermission() + haptic.performHapticFeedback(HapticFeedbackType.Reject) + return + } + if (!onServiceToggleRequested(checked, hasPermission)) { + haptic.performHapticFeedback(HapticFeedbackType.Reject) + return + } + if (!checked) { + onServiceDisabled() + } + haptic.performHapticFeedback( + if (checked) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff + ) +} + +@Composable +private fun ThresholdAndAdvancedSettings( + uiState: MainUiState, + currentSensorLux: Float, + wallpaperButtonsOffset: Float, + transition: Transition, + haptic: HapticFeedback, + callbacks: MainScreenCallbacks, + onTextShake: () -> Unit, + onWallpaperButtonsShake: () -> Unit, + onExpand: () -> Unit, + onCollapse: () -> Unit, + onShowNightStartPicker: () -> Unit, + onShowNightEndPicker: () -> Unit +) { + val customLabel = stringResource(id = R.string.adaptive_threshold_custom) + val currentThresholdIndex = + AdaptiveThreshold.fromLux(uiState.adaptiveThemeThresholdLux).ordinal + val labels = AdaptiveThreshold.entries.mapIndexed { index, threshold -> + if (uiState.customAdaptiveThemeThresholdLux != null && index == currentThresholdIndex) { + customLabel + } else { + stringResource(id = threshold.labelRes) + } + } + val baseLux = AdaptiveThreshold.entries.map { it.lux } + val lux = uiState.customAdaptiveThemeThresholdLux?.let { customLux -> + baseLux.mapIndexed { index, value -> + if (index == currentThresholdIndex) customLux else value + } + } ?: baseLux + + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + ThresholdCards( + uiState = uiState, + currentSensorLux = currentSensorLux, + currentThresholdIndex = currentThresholdIndex, + labels = labels, + lux = lux, + isSystemDark = isSystemInDarkTheme(), + transition = transition, + callbacks = callbacks, + onTextShake = onTextShake + ) + CollapsedAdvancedSettingsControl( + transition = transition, + enabled = uiState.adaptiveThemeEnabled, + haptic = haptic, + onExpand = onExpand, + onCheckReviewPrompt = callbacks.onCheckReviewPrompt + ) + ExpandedAdvancedSettings( + uiState = uiState, + wallpaperButtonsOffset = wallpaperButtonsOffset, + transition = transition, + haptic = haptic, + callbacks = callbacks, + onWallpaperButtonsShake = onWallpaperButtonsShake, + onCollapse = onCollapse, + onShowNightStartPicker = onShowNightStartPicker, + onShowNightEndPicker = onShowNightEndPicker + ) + } +} + +@Composable +private fun ThresholdCards( + uiState: MainUiState, + currentSensorLux: Float, + currentThresholdIndex: Int, + labels: List, + lux: List, + isSystemDark: Boolean, + transition: Transition, + callbacks: MainScreenCallbacks, + onTextShake: () -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + SliderDetailCard( + title = stringResource(id = R.string.title_brightness_threshold), + valueIndex = currentThresholdIndex, + steps = labels.size, + labels = labels, + lux = lux, + onValueChange = { index -> + callbacks.onThresholdSelected(index, lux[index]) + if ((currentSensorLux > lux[index]) == isSystemDark) { + onTextShake() + } + }, + enabled = uiState.adaptiveThemeEnabled, + firstCard = true, + lastCard = false + ) + ProgressDetailCard( + title = stringResource(id = R.string.title_current_brightness), + currentLux = currentSensorLux, + luxSteps = lux, + enabled = uiState.adaptiveThemeEnabled, + firstCard = false, + lastCard = !transition.currentState && !transition.targetState + ) + } +} + +@Composable +private fun CollapsedAdvancedSettingsControl( + transition: Transition, + enabled: Boolean, + haptic: HapticFeedback, + onExpand: () -> Unit, + onCheckReviewPrompt: () -> Unit +) { + transition.AnimatedVisibility( + visible = { expanded -> !expanded }, + enter = fadeIn(animationSpec = tween(220)) + + expandVertically(animationSpec = tween(260, easing = FastOutSlowInEasing)), + exit = fadeOut(animationSpec = tween(150)) + + shrinkVertically(animationSpec = tween(220, easing = FastOutSlowInEasing)) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.Center + ) { + AssistChip( + onClick = { + onExpand() + haptic.performHapticFeedback(HapticFeedbackType.ToggleOn) + onCheckReviewPrompt() + }, + enabled = enabled, + shape = RoundedCornerShape(20.dp), + label = { Text(text = stringResource(id = R.string.action_advanced_settings)) }, + leadingIcon = { + Icon( + imageVector = Icons.Filled.KeyboardArrowDown, + contentDescription = null + ) + } + ) + } + } +} + +@Composable +private fun ExpandedAdvancedSettings( + uiState: MainUiState, + wallpaperButtonsOffset: Float, + transition: Transition, + haptic: HapticFeedback, + callbacks: MainScreenCallbacks, + onWallpaperButtonsShake: () -> Unit, + onCollapse: () -> Unit, + onShowNightStartPicker: () -> Unit, + onShowNightEndPicker: () -> Unit +) { + transition.AnimatedVisibility( + visible = { expanded -> expanded }, + enter = fadeIn(animationSpec = tween(220)) + + expandVertically(animationSpec = tween(360, easing = FastOutSlowInEasing)), + exit = fadeOut(animationSpec = tween(150)) + + shrinkVertically(animationSpec = tween(240, easing = FastOutSlowInEasing)) + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + NightWindowPreference( + uiState = uiState, + onStayDarkAtNightChanged = callbacks.onStayDarkAtNightChanged, + onShowNightStartPicker = onShowNightStartPicker, + onShowNightEndPicker = onShowNightEndPicker + ) + WallpaperSyncPreference( + uiState = uiState, + wallpaperButtonsOffset = wallpaperButtonsOffset, + haptic = haptic, + callbacks = callbacks, + onWallpaperButtonsShake = onWallpaperButtonsShake + ) + AssistChip( + modifier = Modifier.align(Alignment.CenterHorizontally), + onClick = { + onCollapse() + haptic.performHapticFeedback(HapticFeedbackType.ToggleOff) + }, + enabled = uiState.adaptiveThemeEnabled, + shape = RoundedCornerShape(20.dp), + label = { Text(text = stringResource(id = R.string.action_collapse)) }, + leadingIcon = { + Icon( + imageVector = Icons.Filled.KeyboardArrowUp, + contentDescription = null + ) + } + ) + } + } +} + +@Composable +private fun NightWindowPreference( + uiState: MainUiState, + onStayDarkAtNightChanged: (Boolean) -> Unit, + onShowNightStartPicker: () -> Unit, + onShowNightEndPicker: () -> Unit +) { + val context = LocalContext.current + DetailPreferenceCard( + title = stringResource(id = R.string.title_night_dark_lock), + enabled = uiState.adaptiveThemeEnabled, + firstCard = false, + lastCard = false, + toggleableValue = uiState.stayDarkAtNightEnabled, + onToggle = onStayDarkAtNightChanged + ) { + PreferenceDescriptionSwitch( + description = stringResource(id = R.string.description_night_dark_lock), + checked = uiState.stayDarkAtNightEnabled, + enabled = uiState.adaptiveThemeEnabled, + onCheckedChange = null + ) + if (uiState.stayDarkAtNightEnabled && uiState.adaptiveThemeEnabled) { + NightWindowButtons( + startText = formatMinutesAsLocalTime(context, uiState.nightStartMinutes), + endText = formatMinutesAsLocalTime(context, uiState.nightEndMinutes), + onShowNightStartPicker = onShowNightStartPicker, + onShowNightEndPicker = onShowNightEndPicker + ) + } + } +} + +@Composable +private fun NightWindowButtons( + startText: String, + endText: String, + onShowNightStartPicker: () -> Unit, + onShowNightEndPicker: () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton(modifier = Modifier.weight(1f), onClick = onShowNightStartPicker) { + Text(text = stringResource(id = R.string.action_night_from_time, startText)) + } + OutlinedButton(modifier = Modifier.weight(1f), onClick = onShowNightEndPicker) { + Text(text = stringResource(id = R.string.action_night_to_time, endText)) + } + } +} + +@Composable +private fun WallpaperSyncPreference( + uiState: MainUiState, + wallpaperButtonsOffset: Float, + haptic: HapticFeedback, + callbacks: MainScreenCallbacks, + onWallpaperButtonsShake: () -> Unit +) { + val isDaySet = !uiState.dayWallpaperUri.isNullOrEmpty() + val isNightSet = !uiState.nightWallpaperUri.isNullOrEmpty() + val bothWallpapersSet = isDaySet && isNightSet + val requestToggle = { enabled: Boolean -> + requestWallpaperSyncToggle( + enabled = enabled, + bothWallpapersSet = bothWallpapersSet, + haptic = haptic, + onWallpaperButtonsShake = onWallpaperButtonsShake, + onToggleRequested = callbacks.onWallpaperSyncToggleRequested + ) + } + + DetailPreferenceCard( + title = stringResource(id = R.string.title_wallpaper_sync), + enabled = uiState.adaptiveThemeEnabled, + firstCard = false, + lastCard = true, + toggleableValue = uiState.wallpaperSyncEnabled, + onToggle = requestToggle, + titleTrailingContent = { BetaLabel() } + ) { + PreferenceDescriptionSwitch( + description = stringResource(id = R.string.description_wallpaper_sync), + checked = uiState.wallpaperSyncEnabled, + enabled = uiState.adaptiveThemeEnabled, + onCheckedChange = requestToggle + ) + WallpaperSelectionButtons( + isDaySet = isDaySet, + isNightSet = isNightSet, + enabled = uiState.adaptiveThemeEnabled, + offset = wallpaperButtonsOffset, + onSelectDayWallpaper = callbacks.onSelectDayWallpaper, + onSelectNightWallpaper = callbacks.onSelectNightWallpaper + ) + } +} + +private fun requestWallpaperSyncToggle( + enabled: Boolean, + bothWallpapersSet: Boolean, + haptic: HapticFeedback, + onWallpaperButtonsShake: () -> Unit, + onToggleRequested: (Boolean) -> Unit +) { + if (enabled && !bothWallpapersSet) { + onWallpaperButtonsShake() + haptic.performHapticFeedback(HapticFeedbackType.Reject) + } else { + onToggleRequested(enabled) + } +} + +@Composable +private fun PreferenceDescriptionSwitch( + description: String, + checked: Boolean, + enabled: Boolean, + onCheckedChange: ((Boolean) -> Unit)? +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f) + ) + Switch( + modifier = Modifier + .padding(start = 14.dp, end = 4.dp) + .offset(y = (-6).dp) + .align(Alignment.Top), + checked = checked, + enabled = enabled, + onCheckedChange = onCheckedChange, + thumbContent = { + Icon( + imageVector = if (checked) Icons.Filled.Check else Icons.Filled.Clear, + contentDescription = null, + modifier = Modifier.size(SwitchDefaults.IconSize) + ) + } + ) + } +} + +@Composable +private fun BetaLabel() { + Surface( + modifier = Modifier.padding(start = 8.dp), + shape = RoundedCornerShape(50), + color = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer + ) { + Text( + text = stringResource(id = R.string.label_beta), + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), + style = MaterialTheme.typography.labelSmall + ) + } +} + +@Composable +private fun WallpaperSelectionButtons( + isDaySet: Boolean, + isNightSet: Boolean, + enabled: Boolean, + offset: Float, + onSelectDayWallpaper: () -> Unit, + onSelectNightWallpaper: () -> Unit +) { + val dayStatus = stringResource( + if (isDaySet) R.string.wallpaper_status_set else R.string.wallpaper_status_not_set + ) + val nightStatus = stringResource( + if (isNightSet) R.string.wallpaper_status_set else R.string.wallpaper_status_not_set + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + .offset { IntOffset(offset.dp.roundToPx(), 0) }, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + WallpaperSelectionButton( + text = "${stringResource(id = R.string.action_select_day_wallpaper)}\n$dayStatus", + enabled = enabled, + onClick = onSelectDayWallpaper + ) + WallpaperSelectionButton( + text = "${stringResource(id = R.string.action_select_night_wallpaper)}\n$nightStatus", + enabled = enabled, + onClick = onSelectNightWallpaper + ) + } +} + +@Composable +private fun androidx.compose.foundation.layout.RowScope.WallpaperSelectionButton( + text: String, + enabled: Boolean, + onClick: () -> Unit +) { + OutlinedButton( + modifier = Modifier.weight(1f), + enabled = enabled, + onClick = onClick + ) { + Text(text = text, textAlign = TextAlign.Center) + } +} + +@Composable +private fun MainScreenDialogs( + uiState: MainUiState, + showCustomDialog: Boolean, + showNightStartPicker: Boolean, + showNightEndPicker: Boolean, + callbacks: MainScreenCallbacks, + onDismissCustomDialog: () -> Unit, + onDismissNightStartPicker: () -> Unit, + onDismissNightEndPicker: () -> Unit +) { + val context = LocalContext.current + CustomThresholdDialog( + show = showCustomDialog, + currentLux = uiState.customAdaptiveThemeThresholdLux ?: uiState.adaptiveThemeThresholdLux, + onConfirm = { luxValue -> + callbacks.onCustomThresholdConfirmed(luxValue) + onDismissCustomDialog() + showThresholdEasterEgg(context, luxValue) + }, + onDismiss = onDismissCustomDialog + ) + NightTimePickerDialogs( + context = context, + uiState = uiState, + showNightStartPicker = showNightStartPicker, + showNightEndPicker = showNightEndPicker, + onNightWindowChanged = callbacks.onNightWindowChanged, + onDismissNightStartPicker = onDismissNightStartPicker, + onDismissNightEndPicker = onDismissNightEndPicker + ) + if (uiState.showLiveWallpaperWarningDialog) { + LiveWallpaperWarningDialog(callbacks) + } +} + +private fun showThresholdEasterEgg(context: Context, luxValue: Float) { + if (luxValue.toInt() == 42) { + Toast.makeText( + context, + "The answer to the ultimate question of life, the universe, and everything.", + Toast.LENGTH_LONG + ).show() + } +} + +@Composable +private fun NightTimePickerDialogs( + context: Context, + uiState: MainUiState, + showNightStartPicker: Boolean, + showNightEndPicker: Boolean, + onNightWindowChanged: (Int, Int, (() -> Unit)?) -> Unit, + onDismissNightStartPicker: () -> Unit, + onDismissNightEndPicker: () -> Unit +) { + TimePickerPreferenceDialog( + show = showNightStartPicker, + title = stringResource(id = R.string.title_night_start_time_picker), + initialMinutes = uiState.nightStartMinutes, + onConfirm = { selectedMinutes -> + onNightWindowChanged( + selectedMinutes, + uiState.nightEndMinutes, + { showInvalidNightPeriodToast(context) } + ) + onDismissNightStartPicker() + }, + onDismiss = onDismissNightStartPicker + ) + TimePickerPreferenceDialog( + show = showNightEndPicker, + title = stringResource(id = R.string.title_night_end_time_picker), + initialMinutes = uiState.nightEndMinutes, + onConfirm = { selectedMinutes -> + onNightWindowChanged( + uiState.nightStartMinutes, + selectedMinutes, + { showInvalidNightPeriodToast(context) } + ) + onDismissNightEndPicker() + }, + onDismiss = onDismissNightEndPicker + ) +} + +private fun showInvalidNightPeriodToast(context: Context) { + Toast.makeText(context, R.string.error_invalid_night_period, Toast.LENGTH_SHORT).show() +} + +@Composable +private fun LiveWallpaperWarningDialog(callbacks: MainScreenCallbacks) { + AlertDialog( + onDismissRequest = callbacks.onDismissLiveWallpaperWarning, + title = { Text(text = stringResource(id = R.string.live_wallpaper_warning_title)) }, + text = { Text(text = stringResource(id = R.string.live_wallpaper_warning_message)) }, + confirmButton = { + TextButton(onClick = callbacks.onConfirmLiveWallpaper) { + Text(text = stringResource(id = R.string.action_continue)) + } + }, + dismissButton = { + TextButton(onClick = callbacks.onDismissLiveWallpaperWarning) { + Text(text = stringResource(id = R.string.action_cancel)) + } + } + ) +} + +private fun formatMinutesAsLocalTime(context: Context, totalMinutes: Int): String { + val formatter = DateFormat.getTimeFormat(context) + val calendar = Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, totalMinutes / 60) + set(Calendar.MINUTE, totalMinutes % 60) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + } + return formatter.format(calendar.time) +} diff --git a/app/src/main/kotlin/dev/lexip/hecate/ui/MainViewModel.kt b/app/src/main/kotlin/dev/lexip/hecate/ui/MainViewModel.kt index fac36a40..2f36df6c 100644 --- a/app/src/main/kotlin/dev/lexip/hecate/ui/MainViewModel.kt +++ b/app/src/main/kotlin/dev/lexip/hecate/ui/MainViewModel.kt @@ -17,6 +17,7 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.hardware.SensorManager +import android.net.Uri import android.os.PowerManager import android.util.Log import androidx.core.content.ContextCompat @@ -25,14 +26,19 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import dev.lexip.hecate.Application import dev.lexip.hecate.data.AdaptiveThreshold +import dev.lexip.hecate.data.UserPreferencesDataSource import dev.lexip.hecate.data.UserPreferencesRepository import dev.lexip.hecate.logging.Logger -import dev.lexip.hecate.services.BroadcastReceiverService -import android.net.Uri -import dev.lexip.hecate.util.WallpaperHandler -import dev.lexip.hecate.util.InstallSourceChecker +import dev.lexip.hecate.services.AdaptiveThemeServiceController +import dev.lexip.hecate.services.AndroidAdaptiveThemeServiceController +import dev.lexip.hecate.util.AndroidInstallMetadataProvider +import dev.lexip.hecate.util.InstallMetadataProvider import dev.lexip.hecate.util.LightSensorManager import dev.lexip.hecate.util.ProximitySensorManager +import dev.lexip.hecate.util.ProximitySensorReader +import dev.lexip.hecate.util.SensorReader +import dev.lexip.hecate.util.WallpaperHandler +import dev.lexip.hecate.util.WallpaperPlatform import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -72,9 +78,19 @@ data class MainUiState( val showLiveWallpaperWarningDialog: Boolean = false ) -class MainViewModel( +class MainViewModel internal constructor( private val application: Application, - private val userPreferencesRepository: UserPreferencesRepository, + private val userPreferencesRepository: UserPreferencesDataSource, + private val lightSensorManager: SensorReader = + LightSensorManager(application.applicationContext), + private val proximitySensorManager: ProximitySensorReader = + ProximitySensorManager(application.applicationContext), + private val serviceController: AdaptiveThemeServiceController = + AndroidAdaptiveThemeServiceController(application.applicationContext), + private val installMetadataProvider: InstallMetadataProvider = + AndroidInstallMetadataProvider(application.applicationContext), + private val wallpaperPlatform: WallpaperPlatform = + WallpaperHandler(application.applicationContext), private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main ) : ViewModel() { @@ -98,7 +114,6 @@ class MainViewModel( val uiEvents = _uiEvents.asSharedFlow() // Light Sensor - private val lightSensorManager = LightSensorManager(application.applicationContext) private var isListeningToSensor = false private val _currentSensorLux = MutableStateFlow(0f) @@ -110,7 +125,6 @@ class MainViewModel( } // Proximity Sensor - private val proximitySensorManager = ProximitySensorManager(application.applicationContext) private var isListeningToProximity = false private var coveredJob: Job? = null private var batterySaverReceiver: BroadcastReceiver? = null @@ -241,7 +255,7 @@ class MainViewModel( init { viewModelScope.launch(ioDispatcher) { - val fromPlayStore = InstallSourceChecker.fromPlayStore(application) + val fromPlayStore = installMetadataProvider.isInstalledFromPlayStore() _uiState.value = _uiState.value.copy(isInstalledFromPlayStore = fromPlayStore) } @@ -290,7 +304,6 @@ class MainViewModel( override fun onCleared() { stopSensors() - super.onCleared() } /** @@ -333,8 +346,7 @@ class MainViewModel( } private fun shouldPromptForReview(): Boolean { - val daysSinceFirstInstall = - InstallSourceChecker.getDaysSinceFirstInstall(application.applicationContext) + val daysSinceFirstInstall = installMetadataProvider.daysSinceFirstInstall() return !reviewRequestedInSession && serviceEnabledAtStart == true && daysSinceFirstInstall >= 2 } @@ -440,15 +452,10 @@ class MainViewModel( } } - private val wallpaperHandler = WallpaperHandler(application.applicationContext) - fun onDayWallpaperPicked(uri: Uri) { viewModelScope.launch(ioDispatcher) { try { - application.contentResolver.takePersistableUriPermission( - uri, - Intent.FLAG_GRANT_READ_URI_PERMISSION - ) + wallpaperPlatform.takePersistableReadPermission(uri) } catch (e: Exception) { Log.w(TAG, "Failed to take persistable URI permission for day wallpaper", e) } @@ -460,10 +467,7 @@ class MainViewModel( fun onNightWallpaperPicked(uri: Uri) { viewModelScope.launch(ioDispatcher) { try { - application.contentResolver.takePersistableUriPermission( - uri, - Intent.FLAG_GRANT_READ_URI_PERMISSION - ) + wallpaperPlatform.takePersistableReadPermission(uri) } catch (e: Exception) { Log.w(TAG, "Failed to take persistable URI permission for night wallpaper", e) } @@ -474,7 +478,7 @@ class MainViewModel( fun onWallpaperSyncToggleRequested(enabled: Boolean) { if (enabled) { - if (wallpaperHandler.isLiveWallpaperActive()) { + if (wallpaperPlatform.isLiveWallpaperActive()) { _uiState.value = _uiState.value.copy(showLiveWallpaperWarningDialog = true) } else { enableWallpaperSync() @@ -508,13 +512,11 @@ class MainViewModel( } private fun startBroadcastReceiverService() { - val intent = Intent(application.applicationContext, BroadcastReceiverService::class.java) - ContextCompat.startForegroundService(application.applicationContext, intent) + serviceController.start() } private fun stopBroadcastReceiverService() { - val intent = Intent(application.applicationContext, BroadcastReceiverService::class.java) - application.applicationContext.stopService(intent) + serviceController.stop() } } diff --git a/app/src/main/kotlin/dev/lexip/hecate/ui/setup/SetupEnvironmentProvider.kt b/app/src/main/kotlin/dev/lexip/hecate/ui/setup/SetupEnvironmentProvider.kt new file mode 100644 index 00000000..5a675fa6 --- /dev/null +++ b/app/src/main/kotlin/dev/lexip/hecate/ui/setup/SetupEnvironmentProvider.kt @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2025-2026 xLexip + * + * Licensed under the GNU General Public License, Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Please see the License for specific terms regarding permissions and limitations. + */ + +package dev.lexip.hecate.ui.setup + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.hardware.usb.UsbManager +import android.provider.Settings +import androidx.core.content.ContextCompat +import dev.lexip.hecate.util.shizuku.ShizukuAvailability + +data class SetupEnvironmentSnapshot( + val isShizukuInstalled: Boolean, + val isDeveloperOptionsEnabled: Boolean, + val isUsbDebuggingEnabled: Boolean, + val hasWriteSecureSettings: Boolean, + val isUsbConnected: Boolean +) + +fun interface SetupEnvironmentProvider { + fun snapshot(): SetupEnvironmentSnapshot +} + +class AndroidSetupEnvironmentProvider( + private val context: Context +) : SetupEnvironmentProvider { + override fun snapshot(): SetupEnvironmentSnapshot = SetupEnvironmentSnapshot( + isShizukuInstalled = ShizukuAvailability.isShizukuInstalled(context), + isDeveloperOptionsEnabled = readGlobalSetting( + Settings.Global.DEVELOPMENT_SETTINGS_ENABLED + ), + isUsbDebuggingEnabled = readGlobalSetting(Settings.Global.ADB_ENABLED), + hasWriteSecureSettings = ContextCompat.checkSelfPermission( + context, + Manifest.permission.WRITE_SECURE_SETTINGS + ) == PackageManager.PERMISSION_GRANTED, + isUsbConnected = isUsbConnected() + ) + + private fun readGlobalSetting(key: String): Boolean = try { + Settings.Global.getInt(context.contentResolver, key, 0) == 1 + } catch (_: Exception) { + false + } + + private fun isUsbConnected(): Boolean = try { + val usbManager = context.getSystemService(Context.USB_SERVICE) as? UsbManager + usbManager?.deviceList?.isNotEmpty() == true + } catch (_: Exception) { + false + } +} diff --git a/app/src/main/kotlin/dev/lexip/hecate/ui/setup/SetupPermissionGrantController.kt b/app/src/main/kotlin/dev/lexip/hecate/ui/setup/SetupPermissionGrantController.kt new file mode 100644 index 00000000..972685d5 --- /dev/null +++ b/app/src/main/kotlin/dev/lexip/hecate/ui/setup/SetupPermissionGrantController.kt @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2025-2026 xLexip + * + * Licensed under the GNU General Public License, Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Please see the License for specific terms regarding permissions and limitations. + */ + +package dev.lexip.hecate.ui.setup + +import android.content.Context +import android.content.pm.PackageManager +import dev.lexip.hecate.logging.Logger +import dev.lexip.hecate.util.shizuku.ShizukuManager +import rikka.shizuku.Shizuku +import java.io.DataOutputStream + +sealed interface SetupGrantResult { + data object Success : SetupGrantResult + data object ServiceNotRunning : SetupGrantResult + data object NotAuthorized : SetupGrantResult + data class Failed(val reason: String) : SetupGrantResult + data class Unexpected(val throwableName: String) : SetupGrantResult +} + +fun interface SetupPermissionListenerRegistration { + fun unregister() +} + +interface SetupPermissionGrantController { + fun registerShizukuPermissionListener( + listener: (requestCode: Int, granted: Boolean) -> Unit + ): SetupPermissionListenerRegistration + + fun isShizukuBinderReady(): Boolean + fun hasShizukuPermission(context: Context): Boolean + fun requestShizukuPermission() + suspend fun grantViaShizuku(context: Context, packageName: String): SetupGrantResult + suspend fun grantViaRoot(packageName: String): SetupGrantResult +} + +class AndroidSetupPermissionGrantController : SetupPermissionGrantController { + override fun registerShizukuPermissionListener( + listener: (requestCode: Int, granted: Boolean) -> Unit + ): SetupPermissionListenerRegistration { + val platformListener = + Shizuku.OnRequestPermissionResultListener { requestCode, grantResult -> + listener(requestCode, grantResult == PackageManager.PERMISSION_GRANTED) + } + Shizuku.addRequestPermissionResultListener(platformListener) + return SetupPermissionListenerRegistration { + Shizuku.removeRequestPermissionResultListener(platformListener) + } + } + + override fun isShizukuBinderReady(): Boolean = ShizukuManager.isBinderReady() + + override fun hasShizukuPermission(context: Context): Boolean = + ShizukuManager.hasPermission(context) + + override fun requestShizukuPermission() { + ShizukuManager.requestPermission() + } + + override suspend fun grantViaShizuku( + context: Context, + packageName: String + ): SetupGrantResult { + val result = ShizukuManager.executeGrantViaShizuku(context, packageName) + Logger.logShizukuGrantResult(context, result, packageName) + return when (result) { + is ShizukuManager.GrantResult.Success -> SetupGrantResult.Success + is ShizukuManager.GrantResult.ServiceNotRunning -> SetupGrantResult.ServiceNotRunning + is ShizukuManager.GrantResult.NotAuthorized -> SetupGrantResult.NotAuthorized + is ShizukuManager.GrantResult.ShellCommandFailed -> + SetupGrantResult.Failed(result.toString()) + + is ShizukuManager.GrantResult.Unexpected -> + SetupGrantResult.Unexpected(result.toString()) + } + } + + override suspend fun grantViaRoot(packageName: String): SetupGrantResult { + for ((index, command) in ShizukuManager.buildAllGrantCommands(packageName).withIndex()) { + when (val result = executeSingleRootCommand(command)) { + RootCommandResult.Success -> Unit + is RootCommandResult.Failure -> { + return SetupGrantResult.Failed( + "command_${index + 1}_exit_${result.exitCode}: ${result.command}" + ) + } + + is RootCommandResult.Unexpected -> { + return SetupGrantResult.Unexpected(result.throwableName) + } + } + } + return SetupGrantResult.Success + } + + private fun executeSingleRootCommand(command: String): RootCommandResult = try { + val process = ProcessBuilder("su").start() + DataOutputStream(process.outputStream).use { output -> + output.writeBytes("$command\n") + output.writeBytes("exit\n") + output.flush() + } + val exitCode = process.waitFor() + if (exitCode == 0) { + RootCommandResult.Success + } else { + RootCommandResult.Failure(command = command, exitCode = exitCode) + } + } catch (exception: Exception) { + RootCommandResult.Unexpected( + command = command, + throwableName = exception.javaClass.simpleName + ) + } + + private sealed interface RootCommandResult { + data object Success : RootCommandResult + data class Failure(val command: String, val exitCode: Int) : RootCommandResult + data class Unexpected(val command: String, val throwableName: String) : RootCommandResult + } +} diff --git a/app/src/main/kotlin/dev/lexip/hecate/ui/setup/SetupViewModel.kt b/app/src/main/kotlin/dev/lexip/hecate/ui/setup/SetupViewModel.kt index 6bb6ee88..f5ee3b32 100644 --- a/app/src/main/kotlin/dev/lexip/hecate/ui/setup/SetupViewModel.kt +++ b/app/src/main/kotlin/dev/lexip/hecate/ui/setup/SetupViewModel.kt @@ -12,32 +12,29 @@ package dev.lexip.hecate.ui.setup -import android.Manifest import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.content.pm.PackageManager -import android.hardware.usb.UsbManager import android.os.Bundle import android.provider.Settings import android.util.Log import android.widget.Toast -import androidx.core.content.ContextCompat import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import dev.lexip.hecate.Application import dev.lexip.hecate.R +import dev.lexip.hecate.data.UserPreferencesDataSource import dev.lexip.hecate.data.UserPreferencesRepository import dev.lexip.hecate.logging.Logger -import dev.lexip.hecate.services.BroadcastReceiverService +import dev.lexip.hecate.services.AdaptiveThemeServiceController +import dev.lexip.hecate.services.AndroidAdaptiveThemeServiceController import dev.lexip.hecate.ui.navigation.NavigationEvent import dev.lexip.hecate.ui.navigation.NavigationManager import dev.lexip.hecate.ui.navigation.SetupRoute -import dev.lexip.hecate.util.shizuku.ShizukuAvailability -import dev.lexip.hecate.util.shizuku.ShizukuManager +import dev.lexip.hecate.util.shizuku.GrantCommandBuilder import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -47,7 +44,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import rikka.shizuku.Shizuku import java.util.concurrent.atomic.AtomicBoolean private const val AUTO_ADVANCE_DELAY = 2 @@ -83,8 +79,14 @@ data class SetupUiState( */ class SetupViewModel( private val application: Application, - private val userPreferencesRepository: UserPreferencesRepository, + private val userPreferencesRepository: UserPreferencesDataSource, private val navigationManager: NavigationManager, + private val environmentProvider: SetupEnvironmentProvider = + AndroidSetupEnvironmentProvider(application.applicationContext), + private val permissionGrantController: SetupPermissionGrantController = + AndroidSetupPermissionGrantController(), + private val serviceController: AdaptiveThemeServiceController = + AndroidAdaptiveThemeServiceController(application.applicationContext), private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main ) : ViewModel() { @@ -92,7 +94,7 @@ class SetupViewModel( private val _uiState = MutableStateFlow(SetupUiState()) val uiState: StateFlow = _uiState.asStateFlow() - private var registeredShizukuListener: Shizuku.OnRequestPermissionResultListener? = null + private var registeredShizukuListener: SetupPermissionListenerRegistration? = null private var usbReceiver: BroadcastReceiver? = null private val setupCompletionHandled = AtomicBoolean(false) private var isMonitoring = false @@ -115,25 +117,28 @@ class SetupViewModel( private fun initializeState() { val context = application.applicationContext - val hasShizuku = ShizukuAvailability.isShizukuInstalled(context) + val environment = environmentProvider.snapshot() val packageName = context.packageName _uiState.update { current -> current.copy( - isShizukuInstalled = hasShizuku, - pendingAdbCommand = "adb shell pm grant $packageName android.permission.WRITE_SECURE_SETTINGS", - isDeveloperOptionsEnabled = checkDeveloperOptionsEnabled(context), - isUsbDebuggingEnabled = checkUsbDebuggingEnabled(context), - hasWriteSecureSettings = checkWriteSecureSettingsPermission(context) + isShizukuInstalled = environment.isShizukuInstalled, + pendingAdbCommand = GrantCommandBuilder.adbGrantWriteSecureSettings(packageName), + isDeveloperOptionsEnabled = environment.isDeveloperOptionsEnabled, + isUsbDebuggingEnabled = environment.isUsbDebuggingEnabled, + isUsbConnected = environment.isUsbConnected, + hasWriteSecureSettings = environment.hasWriteSecureSettings ) } } private fun determineInitialStep() { val context = application.applicationContext - val step1Done = checkDeveloperOptionsEnabled(context) && checkUsbDebuggingEnabled(context) + val environment = environmentProvider.snapshot() + val step1Done = + environment.isDeveloperOptionsEnabled && environment.isUsbDebuggingEnabled // We don't skip step 2 based on USB connection alone, as the user might need to see the instructions. - val step3Done = checkWriteSecureSettingsPermission(context) + val step3Done = environment.hasWriteSecureSettings if (step3Done) { _uiState.update { @@ -157,8 +162,8 @@ class SetupViewModel( } private fun registerShizukuListener() { - val listener = Shizuku.OnRequestPermissionResultListener { requestCode, grantResult -> - val granted = grantResult == PackageManager.PERMISSION_GRANTED + registeredShizukuListener = + permissionGrantController.registerShizukuPermissionListener { requestCode, granted -> if (granted && requestCode == REQUEST_CODE_SHIZUKU) { Logger.logServiceEnabled( application.applicationContext, @@ -174,8 +179,6 @@ class SetupViewModel( openShizukuAppIfInstalled() } } - registeredShizukuListener = listener - Shizuku.addRequestPermissionResultListener(listener) } private fun logSetupStarted() { @@ -307,13 +310,13 @@ class SetupViewModel( } private fun updateEnvironmentState() { - val context = application.applicationContext + val environment = environmentProvider.snapshot() _uiState.update { current -> current.copy( - isDeveloperOptionsEnabled = checkDeveloperOptionsEnabled(context), - isUsbDebuggingEnabled = checkUsbDebuggingEnabled(context), - hasWriteSecureSettings = checkWriteSecureSettingsPermission(context), - isUsbConnected = current.isUsbConnected || checkUsbConnectedViaManager(context) + isDeveloperOptionsEnabled = environment.isDeveloperOptionsEnabled, + isUsbDebuggingEnabled = environment.isUsbDebuggingEnabled, + hasWriteSecureSettings = environment.hasWriteSecureSettings, + isUsbConnected = current.isUsbConnected || environment.isUsbConnected ) } } @@ -329,45 +332,6 @@ class SetupViewModel( return connected && (configured || dataConnected || adb || hostConnected) } - private fun checkDeveloperOptionsEnabled(context: Context): Boolean { - return try { - Settings.Global.getInt( - context.contentResolver, - Settings.Global.DEVELOPMENT_SETTINGS_ENABLED, - 0 - ) == 1 - } catch (_: Exception) { - false - } - } - - private fun checkUsbDebuggingEnabled(context: Context): Boolean { - return try { - Settings.Global.getInt( - context.contentResolver, - Settings.Global.ADB_ENABLED, - 0 - ) == 1 - } catch (_: Exception) { - false - } - } - - private fun checkWriteSecureSettingsPermission(context: Context): Boolean { - return ContextCompat.checkSelfPermission( - context, Manifest.permission.WRITE_SECURE_SETTINGS - ) == PackageManager.PERMISSION_GRANTED - } - - private fun checkUsbConnectedViaManager(context: Context): Boolean { - return try { - val usbManager = context.getSystemService(Context.USB_SERVICE) as? UsbManager - usbManager?.deviceList?.isNotEmpty() == true - } catch (_: Exception) { - false - } - } - /** * Navigate to next step (Next/Continue buttons). */ @@ -439,11 +403,8 @@ class SetupViewModel( userPreferencesRepository.ensureAdaptiveThemeThresholdDefault() userPreferencesRepository.updateAdaptiveThemeEnabled(true) - // Start Service - val intent = - Intent(application.applicationContext, BroadcastReceiverService::class.java) try { - ContextCompat.startForegroundService(application.applicationContext, intent) + serviceController.start() } catch (e: Exception) { Logger.logException(e) } @@ -505,8 +466,7 @@ class SetupViewModel( } fun checkPermissionAndComplete() { - val context = application.applicationContext - val nowGranted = checkWriteSecureSettingsPermission(context) + val nowGranted = environmentProvider.snapshot().hasWriteSecureSettings _uiState.update { it.copy(hasWriteSecureSettings = nowGranted) } if (nowGranted) { @@ -530,7 +490,7 @@ class SetupViewModel( val context = application.applicationContext val packageName = application.packageName - if (!ShizukuManager.isBinderReady()) { + if (!permissionGrantController.isShizukuBinderReady()) { Toast.makeText( context, context.getString(R.string.shizuku_not_running), @@ -540,26 +500,25 @@ class SetupViewModel( return } - if (!ShizukuManager.hasPermission(context)) { + if (!permissionGrantController.hasShizukuPermission(context)) { Toast.makeText( context, context.getString(R.string.shizuku_request_permission), Toast.LENGTH_SHORT ).show() - ShizukuManager.requestPermission() + permissionGrantController.requestShizukuPermission() return } viewModelScope.launch(ioDispatcher) { - val result = ShizukuManager.executeGrantViaShizuku(context, packageName) - Logger.logShizukuGrantResult(context, result, packageName) + val result = permissionGrantController.grantViaShizuku(context, packageName) withContext(mainDispatcher) { when (result) { - is ShizukuManager.GrantResult.Success -> { + SetupGrantResult.Success -> { completeSetup(source = "shizuku") } - is ShizukuManager.GrantResult.ServiceNotRunning -> { + SetupGrantResult.ServiceNotRunning -> { Toast.makeText( context, context.getString(R.string.shizuku_not_running), @@ -568,7 +527,7 @@ class SetupViewModel( openShizukuAppIfInstalled() } - is ShizukuManager.GrantResult.NotAuthorized -> { + SetupGrantResult.NotAuthorized -> { Toast.makeText( context, context.getString(R.string.shizuku_not_running), @@ -576,7 +535,7 @@ class SetupViewModel( ).show() } - is ShizukuManager.GrantResult.ShellCommandFailed -> { + is SetupGrantResult.Failed -> { Toast.makeText( context, context.getString(R.string.shizuku_grant_shell_failed), @@ -584,7 +543,7 @@ class SetupViewModel( ).show() } - is ShizukuManager.GrantResult.Unexpected -> { + is SetupGrantResult.Unexpected -> { Toast.makeText( context, context.getString(R.string.shizuku_grant_unexpected), @@ -598,8 +557,8 @@ class SetupViewModel( private fun attemptSilentRootGrant() { viewModelScope.launch(ioDispatcher) { - val result = tryGrantViaRoot() - if (result is RootGrantResult.Success) { + val result = permissionGrantController.grantViaRoot(application.packageName) + if (result is SetupGrantResult.Success) { withContext(mainDispatcher) { completeSetup(source = "root_silent") } @@ -612,10 +571,10 @@ class SetupViewModel( Toast.makeText(context, R.string.setup_root_request, Toast.LENGTH_SHORT).show() viewModelScope.launch(ioDispatcher) { - val result = tryGrantViaRoot() + val result = permissionGrantController.grantViaRoot(application.packageName) withContext(mainDispatcher) { when (result) { - RootGrantResult.Success -> { + SetupGrantResult.Success -> { completeSetup(source = "root") Toast.makeText( context, @@ -624,8 +583,11 @@ class SetupViewModel( ).show() } - is RootGrantResult.Failure -> { - Log.w(TAG, "Root setup commands failed: ${result.reason}") + is SetupGrantResult.Failed, + is SetupGrantResult.Unexpected, + SetupGrantResult.ServiceNotRunning, + SetupGrantResult.NotAuthorized -> { + Log.w(TAG, "Root setup commands failed: $result") Toast.makeText( context, R.string.setup_root_grant_failed, @@ -642,64 +604,6 @@ class SetupViewModel( } } - private sealed interface RootGrantResult { - data object Success : RootGrantResult - data class Failure(val reason: String) : RootGrantResult - } - - private fun tryGrantViaRoot(): RootGrantResult { - val packageName = application.packageName - val commands = ShizukuManager.buildAllGrantCommands(packageName) - - for ((index, command) in commands.withIndex()) { - val result = executeSingleRootCommand(command) - when (result) { - is RootCommandResult.Success -> Unit - is RootCommandResult.Failure -> { - return RootGrantResult.Failure( - "command_${index + 1}_exit_${result.exitCode}: ${result.command}" - ) - } - is RootCommandResult.Unexpected -> { - return RootGrantResult.Failure( - "command_${index + 1}_${result.throwableName}: ${result.command}" - ) - } - } - } - - return RootGrantResult.Success - } - - private sealed interface RootCommandResult { - data object Success : RootCommandResult - data class Failure(val command: String, val exitCode: Int) : RootCommandResult - data class Unexpected(val command: String, val throwableName: String) : RootCommandResult - } - - private fun executeSingleRootCommand(command: String): RootCommandResult { - return try { - val process = Runtime.getRuntime().exec("su") - java.io.DataOutputStream(process.outputStream).use { os -> - os.writeBytes("$command\n") - os.writeBytes("exit\n") - os.flush() - } - - val exitCode = process.waitFor() - if (exitCode == 0) { - RootCommandResult.Success - } else { - RootCommandResult.Failure(command = command, exitCode = exitCode) - } - } catch (e: Exception) { - RootCommandResult.Unexpected( - command = command, - throwableName = e.javaClass.simpleName - ) - } - } - private fun openShizukuAppIfInstalled() { val context = application.applicationContext val pm = context.packageManager @@ -720,9 +624,8 @@ class SetupViewModel( } override fun onCleared() { - registeredShizukuListener?.let { Shizuku.removeRequestPermissionResultListener(it) } + registeredShizukuListener?.unregister() stopEnvironmentMonitoring() - super.onCleared() } } diff --git a/app/src/main/kotlin/dev/lexip/hecate/ui/setup/components/FaqCards.kt b/app/src/main/kotlin/dev/lexip/hecate/ui/setup/components/FaqCards.kt index 2d2a0477..fe2e4caa 100644 --- a/app/src/main/kotlin/dev/lexip/hecate/ui/setup/components/FaqCards.kt +++ b/app/src/main/kotlin/dev/lexip/hecate/ui/setup/components/FaqCards.kt @@ -27,12 +27,12 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ExpandMore import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -48,6 +48,9 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import dev.lexip.hecate.R @@ -65,6 +68,9 @@ internal fun WhyOtherDeviceCard() { var expanded by remember { mutableStateOf(false) } val haptic = LocalHapticFeedback.current val uriHandler = LocalUriHandler.current + val expansionStateDescription = stringResource( + id = if (expanded) R.string.state_expanded else R.string.state_collapsed + ) Card( modifier = Modifier.fillMaxWidth(), @@ -79,7 +85,13 @@ internal fun WhyOtherDeviceCard() { verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .clickable { expanded = !expanded } + .semantics { + stateDescription = expansionStateDescription + } + .clickable( + role = Role.Button, + onClick = { expanded = !expanded } + ) ) { Text( text = stringResource(id = R.string.setup_why_other_device_title), @@ -92,13 +104,14 @@ internal fun WhyOtherDeviceCard() { targetValue = if (expanded) 180f else 0f, animationSpec = tween(durationMillis = 300) ) - IconButton(onClick = { expanded = !expanded }) { - Icon( - imageVector = Icons.Outlined.ExpandMore, - contentDescription = null, - modifier = Modifier.rotate(rotation) - ) - } + Icon( + imageVector = Icons.Outlined.ExpandMore, + contentDescription = null, + modifier = Modifier + .size(48.dp) + .padding(12.dp) + .rotate(rotation) + ) } AnimatedVisibility( @@ -141,6 +154,9 @@ internal fun IsThisSafeCard() { var expanded by remember { mutableStateOf(false) } val haptic = LocalHapticFeedback.current val uriHandler = LocalUriHandler.current + val expansionStateDescription = stringResource( + id = if (expanded) R.string.state_expanded else R.string.state_collapsed + ) Card( modifier = Modifier.fillMaxWidth(), @@ -155,7 +171,13 @@ internal fun IsThisSafeCard() { verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .clickable { expanded = !expanded } + .semantics { + stateDescription = expansionStateDescription + } + .clickable( + role = Role.Button, + onClick = { expanded = !expanded } + ) ) { Text( text = stringResource(id = R.string.setup_is_this_safe_title), @@ -168,13 +190,14 @@ internal fun IsThisSafeCard() { targetValue = if (expanded) 180f else 0f, animationSpec = tween(durationMillis = 300) ) - IconButton(onClick = { expanded = !expanded }) { - Icon( - imageVector = Icons.Outlined.ExpandMore, - contentDescription = null, - modifier = Modifier.rotate(rotation) - ) - } + Icon( + imageVector = Icons.Outlined.ExpandMore, + contentDescription = null, + modifier = Modifier + .size(48.dp) + .padding(12.dp) + .rotate(rotation) + ) } AnimatedVisibility( diff --git a/app/src/main/kotlin/dev/lexip/hecate/ui/setup/components/ForExpertsSection.kt b/app/src/main/kotlin/dev/lexip/hecate/ui/setup/components/ForExpertsSection.kt index 4f7719f6..0056e963 100644 --- a/app/src/main/kotlin/dev/lexip/hecate/ui/setup/components/ForExpertsSection.kt +++ b/app/src/main/kotlin/dev/lexip/hecate/ui/setup/components/ForExpertsSection.kt @@ -28,12 +28,12 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ExpandMore import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text @@ -48,6 +48,9 @@ import androidx.compose.ui.draw.rotate import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import dev.lexip.hecate.R @@ -62,6 +65,9 @@ internal fun ForExpertsSectionCard( ) { val haptic = LocalHapticFeedback.current var expanded by remember { mutableStateOf(false) } + val expansionStateDescription = stringResource( + id = if (expanded) R.string.state_expanded else R.string.state_collapsed + ) val toggleExpanded = { expanded = !expanded @@ -84,7 +90,13 @@ internal fun ForExpertsSectionCard( Row( modifier = Modifier .fillMaxWidth() - .clickable(onClick = toggleExpanded), + .semantics { + stateDescription = expansionStateDescription + } + .clickable( + role = Role.Button, + onClick = toggleExpanded + ), verticalAlignment = Alignment.CenterVertically ) { Text( @@ -98,13 +110,14 @@ internal fun ForExpertsSectionCard( targetValue = if (expanded) 180f else 0f, animationSpec = tween(durationMillis = 300) ) - IconButton(onClick = toggleExpanded) { - Icon( - imageVector = Icons.Outlined.ExpandMore, - contentDescription = null, - modifier = Modifier.rotate(rotation) - ) - } + Icon( + imageVector = Icons.Outlined.ExpandMore, + contentDescription = null, + modifier = Modifier + .size(48.dp) + .padding(12.dp) + .rotate(rotation) + ) } AnimatedVisibility( diff --git a/app/src/main/kotlin/dev/lexip/hecate/util/InstallMetadataProvider.kt b/app/src/main/kotlin/dev/lexip/hecate/util/InstallMetadataProvider.kt new file mode 100644 index 00000000..9ed28c16 --- /dev/null +++ b/app/src/main/kotlin/dev/lexip/hecate/util/InstallMetadataProvider.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2025-2026 xLexip + * + * Licensed under the GNU General Public License, Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Please see the License for specific terms regarding permissions and limitations. + */ + +package dev.lexip.hecate.util + +import android.content.Context + +interface InstallMetadataProvider { + fun isInstalledFromPlayStore(): Boolean + fun daysSinceFirstInstall(): Long +} + +class AndroidInstallMetadataProvider( + private val context: Context +) : InstallMetadataProvider { + override fun isInstalledFromPlayStore(): Boolean = + InstallSourceChecker.fromPlayStore(context) + + override fun daysSinceFirstInstall(): Long = + InstallSourceChecker.getDaysSinceFirstInstall(context) +} diff --git a/app/src/main/kotlin/dev/lexip/hecate/util/LightSensorManager.kt b/app/src/main/kotlin/dev/lexip/hecate/util/LightSensorManager.kt index 7d634d01..8bd09833 100644 --- a/app/src/main/kotlin/dev/lexip/hecate/util/LightSensorManager.kt +++ b/app/src/main/kotlin/dev/lexip/hecate/util/LightSensorManager.kt @@ -21,16 +21,16 @@ import android.util.Log private const val TAG = "LightSensorManager" -class LightSensorManager(context: Context) : SensorEventListener { +class LightSensorManager(context: Context) : SensorEventListener, SensorReader { private val sensorManager: SensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager private val lightSensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_LIGHT) private lateinit var callback: (Float) -> Unit - fun startListening( + override fun startListening( callback: (Float) -> Unit, - sensorDelay: Int = SensorManager.SENSOR_DELAY_FASTEST + sensorDelay: Int ) { this.callback = callback lightSensor?.let { @@ -38,7 +38,7 @@ class LightSensorManager(context: Context) : SensorEventListener { } } - fun stopListening() { + override fun stopListening() { sensorManager.unregisterListener(this) } @@ -52,4 +52,4 @@ class LightSensorManager(context: Context) : SensorEventListener { // No-op: accuracy changes are not relevant for current light sensor usage } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/dev/lexip/hecate/util/ProximitySensorManager.kt b/app/src/main/kotlin/dev/lexip/hecate/util/ProximitySensorManager.kt index 363824a3..231ab5d5 100644 --- a/app/src/main/kotlin/dev/lexip/hecate/util/ProximitySensorManager.kt +++ b/app/src/main/kotlin/dev/lexip/hecate/util/ProximitySensorManager.kt @@ -21,19 +21,19 @@ import android.util.Log private const val TAG = "ProximitySensorManager" -class ProximitySensorManager(context: Context) : SensorEventListener { +class ProximitySensorManager(context: Context) : SensorEventListener, ProximitySensorReader { private val sensorManager: SensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager private val proximitySensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY) private lateinit var callback: (Float) -> Unit - val hasProximitySensor: Boolean + override val hasProximitySensor: Boolean get() = proximitySensor != null - fun startListening( + override fun startListening( callback: (Float) -> Unit, - sensorDelay: Int = SensorManager.SENSOR_DELAY_FASTEST + sensorDelay: Int ) { if (!hasProximitySensor) { Log.w( @@ -50,7 +50,7 @@ class ProximitySensorManager(context: Context) : SensorEventListener { } } - fun stopListening() { + override fun stopListening() { sensorManager.unregisterListener(this) } @@ -64,4 +64,4 @@ class ProximitySensorManager(context: Context) : SensorEventListener { // No-op: accuracy changes are not relevant for current proximity sensor usage } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/dev/lexip/hecate/util/SensorReader.kt b/app/src/main/kotlin/dev/lexip/hecate/util/SensorReader.kt new file mode 100644 index 00000000..337cf258 --- /dev/null +++ b/app/src/main/kotlin/dev/lexip/hecate/util/SensorReader.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2026 xLexip + * + * Licensed under the GNU General Public License, Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Please see the License for specific terms regarding permissions and limitations. + */ + +package dev.lexip.hecate.util + +import android.hardware.SensorManager + +interface SensorReader { + fun startListening( + callback: (Float) -> Unit, + sensorDelay: Int = SensorManager.SENSOR_DELAY_FASTEST + ) + + fun stopListening() +} + +interface ProximitySensorReader : SensorReader { + val hasProximitySensor: Boolean +} diff --git a/app/src/main/kotlin/dev/lexip/hecate/util/ThemeController.kt b/app/src/main/kotlin/dev/lexip/hecate/util/ThemeController.kt new file mode 100644 index 00000000..38c18b63 --- /dev/null +++ b/app/src/main/kotlin/dev/lexip/hecate/util/ThemeController.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2026 xLexip + * + * Licensed under the GNU General Public License, Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Please see the License for specific terms regarding permissions and limitations. + */ + +package dev.lexip.hecate.util + +/** Boundary around the privileged system-theme implementation. */ +fun interface ThemeController { + fun setDarkTheme(enabled: Boolean) +} + +class DarkThemeController( + private val handler: DarkThemeHandler +) : ThemeController { + override fun setDarkTheme(enabled: Boolean) { + handler.setDarkTheme(enabled) + } +} + +class AdaptiveAppearanceController( + private val handler: AdaptiveAppearanceHandler +) : ThemeController { + override fun setDarkTheme(enabled: Boolean) { + handler.applyAppearance(enabled) + } +} diff --git a/app/src/main/kotlin/dev/lexip/hecate/util/ThemeDecisionPolicy.kt b/app/src/main/kotlin/dev/lexip/hecate/util/ThemeDecisionPolicy.kt new file mode 100644 index 00000000..1994a47a --- /dev/null +++ b/app/src/main/kotlin/dev/lexip/hecate/util/ThemeDecisionPolicy.kt @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2026 xLexip + * + * Licensed under the GNU General Public License, Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Please see the License for specific terms regarding permissions and limitations. + */ + +package dev.lexip.hecate.util + +fun interface MinuteProvider { + fun currentMinutes(): Int +} + +val SystemMinuteProvider = MinuteProvider(NightWindowPolicy::currentMinutes) + +object ThemeDecisionPolicy { + fun shouldUseDarkTheme( + lightValue: Float, + thresholdLux: Float, + stayDarkAtNightEnabled: Boolean, + nightStartMinutes: Int, + nightEndMinutes: Int, + nowMinutes: Int + ): Boolean { + if ( + stayDarkAtNightEnabled && + NightWindowPolicy.isInNightWindow( + nowMinutes = nowMinutes, + startMinutes = nightStartMinutes, + endMinutes = nightEndMinutes + ) + ) { + return true + } + + return lightValue < thresholdLux + } +} diff --git a/app/src/main/kotlin/dev/lexip/hecate/util/WallpaperHandler.kt b/app/src/main/kotlin/dev/lexip/hecate/util/WallpaperHandler.kt index af25464a..882da538 100644 --- a/app/src/main/kotlin/dev/lexip/hecate/util/WallpaperHandler.kt +++ b/app/src/main/kotlin/dev/lexip/hecate/util/WallpaperHandler.kt @@ -14,33 +14,74 @@ package dev.lexip.hecate.util import android.app.WallpaperManager import android.content.Context +import android.content.Intent import android.net.Uri import android.util.Log +import androidx.core.net.toUri +import java.io.InputStream import java.lang.ref.WeakReference import dev.lexip.hecate.logging.Logger private const val TAG = "WallpaperHandler" +internal interface WallpaperPlatform { + fun isLiveWallpaperActive(): Boolean + fun takePersistableReadPermission(uri: Uri) + fun applyWallpaperForTheme( + isDark: Boolean, + dayUriStr: String?, + nightUriStr: String? + ): Boolean +} + /** * Handler for theme-synchronized wallpaper swapping. */ -class WallpaperHandler(context: Context) { - private val contextRef = WeakReference(context.applicationContext) +internal class WallpaperHandler internal constructor( + context: Context, + private val liveWallpaperActive: () -> Boolean, + private val persistReadPermission: (Uri) -> Unit, + private val openInputStream: (Uri) -> InputStream?, + private val setStream: (InputStream, Int) -> Unit +) : WallpaperPlatform { + constructor(context: Context) : this( + context = context, + liveWallpaperActive = { + WallpaperManager.getInstance(context.applicationContext).wallpaperInfo != null + }, + persistReadPermission = { uri -> + context.applicationContext.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + }, + openInputStream = context.applicationContext.contentResolver::openInputStream, + setStream = { stream, flags -> + WallpaperManager.getInstance(context.applicationContext).setStream( + stream, + null, + true, + flags + ) + } + ) - private val wallpaperManager: WallpaperManager? - get() = contextRef.get()?.let { WallpaperManager.getInstance(it) } + private val contextRef = WeakReference(context.applicationContext) /** * Checks whether a live wallpaper is currently active. */ - fun isLiveWallpaperActive(): Boolean { - val wm = wallpaperManager ?: return false - val isLive = wm.wallpaperInfo != null + override fun isLiveWallpaperActive(): Boolean { + val isLive = liveWallpaperActive() Log.d(TAG, "Live wallpaper active: $isLive") return isLive } + override fun takePersistableReadPermission(uri: Uri) { + persistReadPermission(uri) + } + /** * Applies the corresponding theme wallpaper based on dark theme status. * @param isDark True if target theme is dark, false for light. @@ -48,9 +89,12 @@ class WallpaperHandler(context: Context) { * @param nightUriStr Content URI string for night wallpaper. * @return true if wallpaper was successfully applied, false otherwise. */ - fun applyWallpaperForTheme(isDark: Boolean, dayUriStr: String?, nightUriStr: String?): Boolean { + override fun applyWallpaperForTheme( + isDark: Boolean, + dayUriStr: String?, + nightUriStr: String? + ): Boolean { val context = contextRef.get() ?: return false - val wm = wallpaperManager ?: return false val targetUriStr = if (isDark) nightUriStr else dayUriStr if (targetUriStr.isNullOrEmpty()) { @@ -59,15 +103,13 @@ class WallpaperHandler(context: Context) { } val succeeded: Boolean = try { - val uri = Uri.parse(targetUriStr) - val stream = context.contentResolver.openInputStream(uri) + val uri = targetUriStr.toUri() + val stream = openInputStream(uri) if (stream != null) { stream.use { s -> // Future addition: allow separate home screen and lock screen wallpaper configuration - wm.setStream( + setStream( s, - null, - true, WallpaperManager.FLAG_SYSTEM or WallpaperManager.FLAG_LOCK ) } diff --git a/app/src/main/kotlin/dev/lexip/hecate/util/shizuku/GrantCommandBuilder.kt b/app/src/main/kotlin/dev/lexip/hecate/util/shizuku/GrantCommandBuilder.kt new file mode 100644 index 00000000..ee23d220 --- /dev/null +++ b/app/src/main/kotlin/dev/lexip/hecate/util/shizuku/GrantCommandBuilder.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2026 xLexip + * + * Licensed under the GNU General Public License, Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Please see the License for specific terms regarding permissions and limitations. + */ + +package dev.lexip.hecate.util.shizuku + +object GrantCommandBuilder { + fun grantWriteSecureSettings(packageName: String): String = + "pm grant $packageName android.permission.WRITE_SECURE_SETTINGS" + + fun all(packageName: String): List = + listOf( + grantWriteSecureSettings(packageName), + "cmd appops set $packageName RUN_ANY_IN_BACKGROUND allow", + "dumpsys deviceidle whitelist +$packageName" + ) + + fun adbGrantWriteSecureSettings(packageName: String): String = + "adb shell ${grantWriteSecureSettings(packageName)}" +} diff --git a/app/src/main/kotlin/dev/lexip/hecate/util/shizuku/ShizukuManager.kt b/app/src/main/kotlin/dev/lexip/hecate/util/shizuku/ShizukuManager.kt index cc209c6a..ae2ee96e 100644 --- a/app/src/main/kotlin/dev/lexip/hecate/util/shizuku/ShizukuManager.kt +++ b/app/src/main/kotlin/dev/lexip/hecate/util/shizuku/ShizukuManager.kt @@ -93,20 +93,10 @@ object ShizukuManager { } fun buildGrantWriteSecureSettingsCommand(packageName: String): String = - "pm grant $packageName android.permission.WRITE_SECURE_SETTINGS" - - private fun buildAllowRunAnyInBackgroundCommand(packageName: String): String = - "cmd appops set $packageName RUN_ANY_IN_BACKGROUND allow" - - private fun buildDeviceIdleWhitelistCommand(packageName: String): String = - "dumpsys deviceidle whitelist +$packageName" + GrantCommandBuilder.grantWriteSecureSettings(packageName) fun buildAllGrantCommands(packageName: String): List = - listOf( - buildGrantWriteSecureSettingsCommand(packageName), - buildAllowRunAnyInBackgroundCommand(packageName), - buildDeviceIdleWhitelistCommand(packageName) - ) + GrantCommandBuilder.all(packageName) fun executeGrantViaShizuku(context: Context, packageName: String): GrantResult { if (Shizuku.isPreV11()) return GrantResult.ServiceNotRunning diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index b7e0a66e..df1b277d 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -11,6 +11,8 @@ %1$s verwenden Erweiterte Einstellungen Einklappen + Eingeklappt + Ausgeklappt Überspringen @@ -104,7 +106,6 @@ Berechtigung anfragen… Shizuku konnte den Command nicht ausführen. Unerwarteter Fehler mit Shizuku. - Hintergrundbild-Synchronisierung Beta Hintergrundbild beim Wechsel zwischen hellem und dunklem Modus austauschen. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index dfa379e6..0cf18ecb 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -12,6 +12,8 @@ Usar %1$s Ajustes avanzados Contraer + Contraído + Expandido Alterna entre el modo claro y oscuro según la luz ambiental, optimizando el confort visual y la batería. @@ -104,7 +106,6 @@ Solicitando permiso… Shizuku no pudo ejecutar el comando. Error inesperado al usar Shizuku. - Sincronización del fondo de pantalla Beta Cambia el fondo de pantalla al alternar entre los modos claro y oscuro. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 127cd12e..634abe3e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,6 +16,8 @@ Use %1$s Advanced Settings Collapse + Collapsed + Expanded Switches between light and dark mode based on ambient light, optimizing eye comfort and battery life. @@ -108,7 +110,6 @@ Requesting permission… Shizuku could not run the command. Unexpected error while using Shizuku. - Wallpaper Sync Beta Swap wallpaper when switching between light and dark mode. diff --git a/app/src/test/kotlin/dev/lexip/hecate/Fakes.kt b/app/src/test/kotlin/dev/lexip/hecate/Fakes.kt new file mode 100644 index 00000000..7370d71e --- /dev/null +++ b/app/src/test/kotlin/dev/lexip/hecate/Fakes.kt @@ -0,0 +1,232 @@ +/* + * Copyright (C) 2026 xLexip + * + * Licensed under the GNU General Public License, Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Please see the License for specific terms regarding permissions and limitations. + */ + +package dev.lexip.hecate + +import android.net.Uri +import dev.lexip.hecate.data.AdaptiveThreshold +import dev.lexip.hecate.data.UserPreferences +import dev.lexip.hecate.data.UserPreferencesDataSource +import dev.lexip.hecate.services.AdaptiveThemeServiceController +import dev.lexip.hecate.ui.setup.SetupEnvironmentProvider +import dev.lexip.hecate.ui.setup.SetupEnvironmentSnapshot +import dev.lexip.hecate.ui.setup.SetupGrantResult +import dev.lexip.hecate.ui.setup.SetupPermissionGrantController +import dev.lexip.hecate.ui.setup.SetupPermissionListenerRegistration +import dev.lexip.hecate.util.InstallMetadataProvider +import dev.lexip.hecate.util.ProximitySensorReader +import dev.lexip.hecate.util.SensorReader +import dev.lexip.hecate.util.WallpaperPlatform +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +class FakeUserPreferencesDataSource( + initial: UserPreferences = UserPreferences( + adaptiveThemeEnabled = false, + adaptiveThemeThresholdLux = AdaptiveThreshold.DAYLIGHT.lux + ) +) : UserPreferencesDataSource { + private val state = MutableStateFlow(initial) + override val userPreferencesFlow = state.asStateFlow() + + var thresholdDefaultCalls = 0 + var nightDefaultCalls = 0 + var setupCompletedWrites = 0 + var rejectNightWindow = false + + val current: UserPreferences + get() = state.value + + fun emit(value: UserPreferences) { + state.value = value + } + + override suspend fun fetchInitialPreferences(): UserPreferences = current + + override suspend fun ensureAdaptiveThemeThresholdDefault(default: Float) { + thresholdDefaultCalls++ + } + + override suspend fun ensureNightDefaults(defaultStartMinutes: Int, defaultEndMinutes: Int) { + nightDefaultCalls++ + } + + override suspend fun updateAdaptiveThemeEnabled(enabled: Boolean) { + state.value = current.copy(adaptiveThemeEnabled = enabled) + } + + override suspend fun updateAdaptiveThemeThresholdLux(lux: Float) { + state.value = current.copy( + adaptiveThemeThresholdLux = lux, + customAdaptiveThemeThresholdLux = null + ) + } + + override suspend fun updateCustomAdaptiveThemeThresholdLux(lux: Float) { + state.value = current.copy( + adaptiveThemeThresholdLux = lux, + customAdaptiveThemeThresholdLux = lux + ) + } + + override suspend fun updateSetupCompleted(completed: Boolean) { + setupCompletedWrites++ + state.value = current.copy(hasSetupCompleted = completed) + } + + override suspend fun updateStayDarkAtNightEnabled(enabled: Boolean) { + state.value = current.copy(stayDarkAtNightEnabled = enabled) + } + + override suspend fun updateNightWindow(startMinutes: Int, endMinutes: Int): Boolean { + if (rejectNightWindow) return false + state.value = current.copy( + nightStartMinutes = startMinutes, + nightEndMinutes = endMinutes + ) + return true + } + + override suspend fun updateWallpaperSyncEnabled(enabled: Boolean) { + state.value = current.copy(wallpaperSyncEnabled = enabled) + } + + override suspend fun updateDayWallpaperUri(uri: String?) { + state.value = current.copy(dayWallpaperUri = uri) + } + + override suspend fun updateNightWallpaperUri(uri: String?) { + state.value = current.copy(nightWallpaperUri = uri) + } +} + +open class FakeSensorReader : SensorReader { + private var callback: ((Float) -> Unit)? = null + var startCalls = 0 + var stopCalls = 0 + + override fun startListening(callback: (Float) -> Unit, sensorDelay: Int) { + startCalls++ + this.callback = callback + } + + override fun stopListening() { + stopCalls++ + callback = null + } + + fun emit(value: Float) { + callback?.invoke(value) + } +} + +class FakeProximitySensorReader( + override val hasProximitySensor: Boolean = true +) : FakeSensorReader(), ProximitySensorReader + +class FakeAdaptiveThemeServiceController : AdaptiveThemeServiceController { + var startCalls = 0 + var stopCalls = 0 + var lastEnableMonitoring = false + var startFailure: Exception? = null + + override fun start(enableMonitoring: Boolean) { + startCalls++ + lastEnableMonitoring = enableMonitoring + startFailure?.let { throw it } + } + + override fun stop() { + stopCalls++ + } +} + +class FakeInstallMetadataProvider( + var fromPlayStore: Boolean = false, + var installedDaysAgo: Long = 0 +) : InstallMetadataProvider { + override fun isInstalledFromPlayStore(): Boolean = fromPlayStore + override fun daysSinceFirstInstall(): Long = installedDaysAgo +} + +class FakeWallpaperPlatform : WallpaperPlatform { + var liveWallpaperActive = false + var permissionFailure: Exception? = null + val persistedUris = mutableListOf() + + override fun isLiveWallpaperActive(): Boolean = liveWallpaperActive + + override fun takePersistableReadPermission(uri: Uri) { + permissionFailure?.let { throw it } + persistedUris += uri + } + + override fun applyWallpaperForTheme( + isDark: Boolean, + dayUriStr: String?, + nightUriStr: String? + ): Boolean = true +} + +class FakeSetupEnvironmentProvider( + var current: SetupEnvironmentSnapshot = SetupEnvironmentSnapshot( + isShizukuInstalled = false, + isDeveloperOptionsEnabled = false, + isUsbDebuggingEnabled = false, + hasWriteSecureSettings = false, + isUsbConnected = false + ) +) : SetupEnvironmentProvider { + override fun snapshot(): SetupEnvironmentSnapshot = current +} + +class FakeSetupPermissionGrantController : SetupPermissionGrantController { + var binderReady = false + var shizukuPermission = false + var permissionRequestCalls = 0 + var rootResult: SetupGrantResult = SetupGrantResult.Failed("root unavailable") + var shizukuResult: SetupGrantResult = SetupGrantResult.Failed("shizuku unavailable") + var rootGrantCalls = 0 + var shizukuGrantCalls = 0 + var listener: ((Int, Boolean) -> Unit)? = null + + override fun registerShizukuPermissionListener( + listener: (requestCode: Int, granted: Boolean) -> Unit + ): SetupPermissionListenerRegistration { + this.listener = listener + return SetupPermissionListenerRegistration { + this.listener = null + } + } + + override fun isShizukuBinderReady(): Boolean = binderReady + + override fun hasShizukuPermission(context: android.content.Context): Boolean = + shizukuPermission + + override fun requestShizukuPermission() { + permissionRequestCalls++ + } + + override suspend fun grantViaShizuku( + context: android.content.Context, + packageName: String + ): SetupGrantResult { + shizukuGrantCalls++ + return shizukuResult + } + + override suspend fun grantViaRoot(packageName: String): SetupGrantResult { + rootGrantCalls++ + return rootResult + } +} diff --git a/app/src/test/kotlin/dev/lexip/hecate/MainDispatcherRule.kt b/app/src/test/kotlin/dev/lexip/hecate/MainDispatcherRule.kt new file mode 100644 index 00000000..48b6d4e2 --- /dev/null +++ b/app/src/test/kotlin/dev/lexip/hecate/MainDispatcherRule.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2026 xLexip + * + * Licensed under the GNU General Public License, Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Please see the License for specific terms regarding permissions and limitations. + */ + +package dev.lexip.hecate + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@OptIn(ExperimentalCoroutinesApi::class) +class MainDispatcherRule( + val dispatcher: TestDispatcher = StandardTestDispatcher() +) : TestWatcher() { + override fun starting(description: Description) { + Dispatchers.setMain(dispatcher) + } + + override fun finished(description: Description) { + Dispatchers.resetMain() + } +} diff --git a/app/src/test/kotlin/dev/lexip/hecate/broadcasts/ScreenOnCoordinatorTest.kt b/app/src/test/kotlin/dev/lexip/hecate/broadcasts/ScreenOnCoordinatorTest.kt new file mode 100644 index 00000000..2947181d --- /dev/null +++ b/app/src/test/kotlin/dev/lexip/hecate/broadcasts/ScreenOnCoordinatorTest.kt @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2026 xLexip + * + * Licensed under the GNU General Public License, Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Please see the License for specific terms regarding permissions and limitations. + */ + +package dev.lexip.hecate.broadcasts + +import dev.lexip.hecate.util.ProximitySensorReader +import dev.lexip.hecate.util.SensorReader +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ScreenOnCoordinatorTest { + @Test + fun noProximitySensorReadsLightAndAppliesThemeOnce() { + val proximity = FakeProximitySensor(hasProximitySensor = false) + val light = FakeSensor() + var requestedTheme: Boolean? = null + val coordinator = coordinator(proximity, light) { requestedTheme = it } + + coordinator.onScreenOn() + light.emit(25f) + light.emit(10_000f) + + assertEquals(1, light.stopCount) + assertEquals(true, requestedTheme) + } + + @Test + fun coveredDeviceStopsAfterProximityReading() { + val proximity = FakeProximitySensor() + val light = FakeSensor() + var requestedTheme: Boolean? = null + val coordinator = coordinator(proximity, light) { requestedTheme = it } + + coordinator.onScreenOn() + proximity.emit(COVERED_DISTANCE_CENTIMETERS - 1f) + + assertEquals(1, proximity.stopCount) + assertEquals(0, light.startCount) + assertNull(requestedTheme) + } + + @Test + fun uncoveredDeviceReadsLightAfterProximity() { + val proximity = FakeProximitySensor() + val light = FakeSensor() + var requestedTheme: Boolean? = null + val coordinator = coordinator(proximity, light) { requestedTheme = it } + + coordinator.onScreenOn() + proximity.emit(COVERED_DISTANCE_CENTIMETERS) + proximity.emit(COVERED_DISTANCE_CENTIMETERS) + light.emit(100f) + + assertEquals(1, proximity.stopCount) + assertEquals(1, light.startCount) + assertEquals(1, light.stopCount) + assertEquals(false, requestedTheme) + } + + private fun coordinator( + proximity: FakeProximitySensor, + light: FakeSensor, + onThemeRequested: (Boolean) -> Unit + ) = ScreenOnCoordinator( + proximitySensor = proximity, + lightSensor = light, + themeController = onThemeRequested, + minuteProvider = { 12 * 60 }, + adaptiveThemeThresholdLux = 100f, + stayDarkAtNightEnabled = false, + nightStartMinutes = 21 * 60, + nightEndMinutes = 6 * 60 + ) +} + +private open class FakeSensor : SensorReader { + private var callback: ((Float) -> Unit)? = null + var startCount = 0 + var stopCount = 0 + + override fun startListening(callback: (Float) -> Unit, sensorDelay: Int) { + startCount += 1 + this.callback = callback + } + + override fun stopListening() { + stopCount += 1 + } + + fun emit(value: Float) { + callback?.invoke(value) + } +} + +private class FakeProximitySensor( + override val hasProximitySensor: Boolean = true +) : FakeSensor(), ProximitySensorReader diff --git a/app/src/test/kotlin/dev/lexip/hecate/broadcasts/ScreenOnReceiverTest.kt b/app/src/test/kotlin/dev/lexip/hecate/broadcasts/ScreenOnReceiverTest.kt new file mode 100644 index 00000000..276328c2 --- /dev/null +++ b/app/src/test/kotlin/dev/lexip/hecate/broadcasts/ScreenOnReceiverTest.kt @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2026 xLexip + * + * Licensed under the GNU General Public License, Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Please see the License for specific terms regarding permissions and limitations. + */ + +package dev.lexip.hecate.broadcasts + +import android.content.Intent +import androidx.test.core.app.ApplicationProvider +import dev.lexip.hecate.FakeProximitySensorReader +import dev.lexip.hecate.FakeSensorReader +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36, 37]) +class ScreenOnReceiverTest { + + @Test + fun screenOnBroadcastReadsLightAndAppliesAppearance() { + val proximity = FakeProximitySensorReader(hasProximitySensor = false) + val light = FakeSensorReader() + var requestedTheme: Boolean? = null + val receiver = ScreenOnReceiver( + proximitySensorManager = proximity, + lightSensorManager = light, + themeController = { requestedTheme = it }, + adaptiveThemeThresholdLux = 100f, + stayDarkAtNightEnabled = false, + nightStartMinutes = 21 * 60, + nightEndMinutes = 6 * 60, + minuteProvider = { 12 * 60 } + ) + + receiver.onReceive( + ApplicationProvider.getApplicationContext(), + Intent(Intent.ACTION_SCREEN_ON) + ) + light.emit(25f) + + assertEquals(1, light.startCalls) + assertEquals(1, light.stopCalls) + assertEquals(true, requestedTheme) + } + + @Test + fun unrelatedBroadcastIsIgnored() { + val proximity = FakeProximitySensorReader() + val light = FakeSensorReader() + var themeCalls = 0 + val receiver = ScreenOnReceiver( + proximitySensorManager = proximity, + lightSensorManager = light, + themeController = { themeCalls++ }, + adaptiveThemeThresholdLux = 100f, + stayDarkAtNightEnabled = false, + nightStartMinutes = 21 * 60, + nightEndMinutes = 6 * 60, + minuteProvider = { 12 * 60 } + ) + + receiver.onReceive( + ApplicationProvider.getApplicationContext(), + Intent(Intent.ACTION_BATTERY_CHANGED) + ) + + assertEquals(0, proximity.startCalls) + assertEquals(0, light.startCalls) + assertEquals(0, themeCalls) + } +} diff --git a/app/src/test/kotlin/dev/lexip/hecate/data/AdaptiveThresholdTest.kt b/app/src/test/kotlin/dev/lexip/hecate/data/AdaptiveThresholdTest.kt new file mode 100644 index 00000000..bac7fd51 --- /dev/null +++ b/app/src/test/kotlin/dev/lexip/hecate/data/AdaptiveThresholdTest.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2026 xLexip + * + * Licensed under the GNU General Public License, Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Please see the License for specific terms regarding permissions and limitations. + */ + +package dev.lexip.hecate.data + +import org.junit.Assert.assertEquals +import org.junit.Test + +class AdaptiveThresholdTest { + @Test + fun fromIndexClampsOutsideRange() { + assertEquals(AdaptiveThreshold.DARK, AdaptiveThreshold.fromIndex(-1)) + assertEquals(AdaptiveThreshold.SUNLIGHT, AdaptiveThreshold.fromIndex(Int.MAX_VALUE)) + } + + @Test + fun fromLuxReturnsExactAndNearestThresholds() { + assertEquals(AdaptiveThreshold.BRIGHT, AdaptiveThreshold.fromLux(100f)) + assertEquals(AdaptiveThreshold.SOFT, AdaptiveThreshold.fromLux(40f)) + assertEquals(AdaptiveThreshold.BRIGHT, AdaptiveThreshold.fromLux(60f)) + } + + @Test + fun fromLuxUsesFirstThresholdWhenDistanceIsEqual() { + assertEquals(AdaptiveThreshold.DARK, AdaptiveThreshold.fromLux(0.5f)) + } +} diff --git a/app/src/test/kotlin/dev/lexip/hecate/data/UserPreferencesRepositoryTest.kt b/app/src/test/kotlin/dev/lexip/hecate/data/UserPreferencesRepositoryTest.kt new file mode 100644 index 00000000..4d874211 --- /dev/null +++ b/app/src/test/kotlin/dev/lexip/hecate/data/UserPreferencesRepositoryTest.kt @@ -0,0 +1,175 @@ +/* + * Copyright (C) 2026 xLexip + * + * Licensed under the GNU General Public License, Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Please see the License for specific terms regarding permissions and limitations. + */ + +package dev.lexip.hecate.data + +import androidx.datastore.core.DataStore +import androidx.datastore.core.IOException +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.emptyPreferences +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File +import java.nio.file.Files + +private const val NIGHT_WALLPAPER_URI = "content://wallpaper/night" + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36, 37]) +@OptIn(ExperimentalCoroutinesApi::class) +class UserPreferencesRepositoryTest { + private lateinit var directory: File + private lateinit var scope: TestScope + private lateinit var repository: UserPreferencesRepository + + @Before + fun setUp() { + directory = Files.createTempDirectory("adaptive-theme-prefs").toFile() + scope = TestScope(UnconfinedTestDispatcher()) + val dataStore = PreferenceDataStoreFactory.create( + scope = scope, + produceFile = { File(directory, "preferences.preferences_pb") } + ) + repository = UserPreferencesRepository(dataStore) + } + + @After + fun tearDown() { + scope.cancel() + directory.deleteRecursively() + } + + @Test + fun emptyStoreExposesApplicationDefaults() = runTest { + val preferences = repository.userPreferencesFlow.first() + + assertFalse(preferences.adaptiveThemeEnabled) + assertEquals(AdaptiveThreshold.DAYLIGHT.lux, preferences.adaptiveThemeThresholdLux) + assertNull(preferences.customAdaptiveThemeThresholdLux) + assertEquals(21 * 60, preferences.nightStartMinutes) + assertEquals(6 * 60, preferences.nightEndMinutes) + assertFalse(preferences.wallpaperSyncEnabled) + assertNull(preferences.dayWallpaperUri) + assertNull(preferences.nightWallpaperUri) + } + + @Test + fun ensureDefaultsDoesNotOverwriteExistingValues() = runTest { + repository.updateAdaptiveThemeThresholdLux(100f) + repository.updateNightWindow(20 * 60, 7 * 60) + + repository.ensureAdaptiveThemeThresholdDefault() + repository.ensureNightDefaults() + + val preferences = repository.userPreferencesFlow.first() + assertEquals(100f, preferences.adaptiveThemeThresholdLux) + assertEquals(20 * 60, preferences.nightStartMinutes) + assertEquals(7 * 60, preferences.nightEndMinutes) + } + + @Test + fun presetThresholdClearsCustomThreshold() = runTest { + repository.updateCustomAdaptiveThemeThresholdLux(42f) + repository.updateAdaptiveThemeThresholdLux(100f) + + val preferences = repository.userPreferencesFlow.first() + assertEquals(100f, preferences.adaptiveThemeThresholdLux) + assertNull(preferences.customAdaptiveThemeThresholdLux) + } + + @Test + fun invalidNightWindowIsRejectedWithoutChangingStoredValues() = runTest { + val updated = repository.updateNightWindow(60, 60) + + assertFalse(updated) + val preferences = repository.userPreferencesFlow.first() + assertEquals(21 * 60, preferences.nightStartMinutes) + assertEquals(6 * 60, preferences.nightEndMinutes) + } + + @Test + fun updatesArePersistedTogether() = runTest { + repository.updateAdaptiveThemeEnabled(true) + repository.updateSetupCompleted(true) + repository.updateStayDarkAtNightEnabled(true) + assertTrue(repository.updateNightWindow(22 * 60, 5 * 60)) + + val preferences = repository.fetchInitialPreferences() + assertTrue(preferences.adaptiveThemeEnabled) + assertTrue(preferences.hasSetupCompleted) + assertTrue(preferences.stayDarkAtNightEnabled) + assertEquals(22 * 60, preferences.nightStartMinutes) + assertEquals(5 * 60, preferences.nightEndMinutes) + } + + @Test + fun wallpaperSettingsArePersistedReplacedAndClearedIndependently() = runTest { + repository.updateWallpaperSyncEnabled(true) + repository.updateDayWallpaperUri("content://wallpaper/day-1") + repository.updateNightWallpaperUri(NIGHT_WALLPAPER_URI) + repository.updateDayWallpaperUri("content://wallpaper/day-2") + + var preferences = repository.fetchInitialPreferences() + assertTrue(preferences.wallpaperSyncEnabled) + assertEquals("content://wallpaper/day-2", preferences.dayWallpaperUri) + assertEquals(NIGHT_WALLPAPER_URI, preferences.nightWallpaperUri) + + repository.updateDayWallpaperUri(null) + preferences = repository.fetchInitialPreferences() + assertNull(preferences.dayWallpaperUri) + assertEquals(NIGHT_WALLPAPER_URI, preferences.nightWallpaperUri) + + repository.updateNightWallpaperUri(null) + preferences = repository.fetchInitialPreferences() + assertNull(preferences.dayWallpaperUri) + assertNull(preferences.nightWallpaperUri) + } + + @Test + fun readIOExceptionFallsBackToDefaults() = runTest { + val failingDataStore = object : DataStore { + override val data: Flow = flow { + throw IOException("simulated read failure") + } + + override suspend fun updateData( + transform: suspend (t: Preferences) -> Preferences + ): Preferences = transform(emptyPreferences()) + } + + val preferences = UserPreferencesRepository(failingDataStore) + .userPreferencesFlow + .first() + + assertFalse(preferences.adaptiveThemeEnabled) + assertEquals(AdaptiveThreshold.DAYLIGHT.lux, preferences.adaptiveThemeThresholdLux) + assertEquals(21 * 60, preferences.nightStartMinutes) + assertEquals(6 * 60, preferences.nightEndMinutes) + } +} diff --git a/app/src/test/kotlin/dev/lexip/hecate/services/MonitoringPreferencesCoordinatorTest.kt b/app/src/test/kotlin/dev/lexip/hecate/services/MonitoringPreferencesCoordinatorTest.kt new file mode 100644 index 00000000..6bcb8e74 --- /dev/null +++ b/app/src/test/kotlin/dev/lexip/hecate/services/MonitoringPreferencesCoordinatorTest.kt @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2026 xLexip + * + * Licensed under the GNU General Public License, Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + */ + +package dev.lexip.hecate.services + +import dev.lexip.hecate.broadcasts.ScreenOnReceiverSettings +import dev.lexip.hecate.data.UserPreferences +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class MonitoringPreferencesCoordinatorTest { + @Test + fun appliesReceiverAndWallpaperPreferencesTogether() { + val wallpaperConfigurations = mutableListOf() + val coordinator = MonitoringPreferencesCoordinator { enabled, dayUri, nightUri -> + wallpaperConfigurations += WallpaperConfiguration(enabled, dayUri, nightUri) + } + val receiver = FakeScreenOnReceiverSettings() + val preferences = preferences( + thresholdLux = 420f, + stayDarkAtNight = true, + nightStartMinutes = 22 * 60, + nightEndMinutes = 5 * 60, + wallpaperSyncEnabled = true, + dayUri = "content://wallpaper/day", + nightUri = "content://wallpaper/night" + ) + + coordinator.apply(preferences, receiver) + + assertEquals(420f, receiver.adaptiveThemeThresholdLux) + assertTrue(receiver.stayDarkAtNightEnabled) + assertEquals(22 * 60, receiver.nightStartMinutes) + assertEquals(5 * 60, receiver.nightEndMinutes) + assertEquals( + listOf( + WallpaperConfiguration( + true, + "content://wallpaper/day", + "content://wallpaper/night" + ) + ), + wallpaperConfigurations + ) + } + + @Test + fun appliesWallpaperConfigurationWithoutARegisteredReceiver() { + var configuration: WallpaperConfiguration? = null + val coordinator = MonitoringPreferencesCoordinator { enabled, dayUri, nightUri -> + configuration = WallpaperConfiguration(enabled, dayUri, nightUri) + } + + coordinator.apply( + preferences( + wallpaperSyncEnabled = false, + dayUri = null, + nightUri = null + ), + receiver = null + ) + + assertEquals(WallpaperConfiguration(false, null, null), configuration) + } + + private fun preferences( + thresholdLux: Float = 1_000f, + stayDarkAtNight: Boolean = false, + nightStartMinutes: Int = 21 * 60, + nightEndMinutes: Int = 6 * 60, + wallpaperSyncEnabled: Boolean = false, + dayUri: String? = null, + nightUri: String? = null + ) = UserPreferences( + adaptiveThemeEnabled = true, + adaptiveThemeThresholdLux = thresholdLux, + stayDarkAtNightEnabled = stayDarkAtNight, + nightStartMinutes = nightStartMinutes, + nightEndMinutes = nightEndMinutes, + wallpaperSyncEnabled = wallpaperSyncEnabled, + dayWallpaperUri = dayUri, + nightWallpaperUri = nightUri + ) +} + +private data class WallpaperConfiguration( + val enabled: Boolean, + val dayUri: String?, + val nightUri: String? +) + +private class FakeScreenOnReceiverSettings : ScreenOnReceiverSettings { + override var adaptiveThemeThresholdLux: Float = 0f + override var stayDarkAtNightEnabled: Boolean = false + override var nightStartMinutes: Int = 0 + override var nightEndMinutes: Int = 0 +} diff --git a/app/src/test/kotlin/dev/lexip/hecate/services/QuickSettingsToggleCoordinatorTest.kt b/app/src/test/kotlin/dev/lexip/hecate/services/QuickSettingsToggleCoordinatorTest.kt new file mode 100644 index 00000000..15b1718a --- /dev/null +++ b/app/src/test/kotlin/dev/lexip/hecate/services/QuickSettingsToggleCoordinatorTest.kt @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2026 xLexip + * + * Licensed under the GNU General Public License, Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Please see the License for specific terms regarding permissions and limitations. + */ + +package dev.lexip.hecate.services + +import dev.lexip.hecate.FakeAdaptiveThemeServiceController +import dev.lexip.hecate.FakeUserPreferencesDataSource +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class QuickSettingsToggleCoordinatorTest { + + @Test + fun stateIsUnavailableWithoutPermission() { + assertEquals( + QuickSettingsState.UNAVAILABLE, + QuickSettingsStatePolicy.from( + hasPermission = false, + adaptiveThemeEnabled = true + ) + ) + assertEquals( + QuickSettingsState.INACTIVE, + QuickSettingsStatePolicy.from( + hasPermission = true, + adaptiveThemeEnabled = false + ) + ) + assertEquals( + QuickSettingsState.ACTIVE, + QuickSettingsStatePolicy.from( + hasPermission = true, + adaptiveThemeEnabled = true + ) + ) + } + + @Test + fun enableStartsMonitoringAndPersistsPreference() = runTest { + val preferences = FakeUserPreferencesDataSource() + val service = FakeAdaptiveThemeServiceController() + val coordinator = QuickSettingsToggleCoordinator(preferences, service) + + val result = coordinator.toggle(currentlyEnabled = false) + + assertEquals(QuickSettingsToggleResult.Enabled, result) + assertEquals(1, service.startCalls) + assertTrue(service.lastEnableMonitoring) + assertEquals(1, preferences.thresholdDefaultCalls) + assertTrue(preferences.current.adaptiveThemeEnabled) + } + + @Test + fun disableStopsServiceAndPersistsPreference() = runTest { + val preferences = FakeUserPreferencesDataSource().apply { + updateAdaptiveThemeEnabled(true) + } + val service = FakeAdaptiveThemeServiceController() + val coordinator = QuickSettingsToggleCoordinator(preferences, service) + + val result = coordinator.toggle(currentlyEnabled = true) + + assertEquals(QuickSettingsToggleResult.Disabled, result) + assertEquals(1, service.stopCalls) + assertFalse(preferences.current.adaptiveThemeEnabled) + } + + @Test + fun serviceStartFailureRollsBackWithoutPersistingEnabledState() = runTest { + val preferences = FakeUserPreferencesDataSource() + val service = FakeAdaptiveThemeServiceController().apply { + startFailure = IllegalStateException("service unavailable") + } + val coordinator = QuickSettingsToggleCoordinator(preferences, service) + + val result = coordinator.toggle(currentlyEnabled = false) + + assertTrue(result is QuickSettingsToggleResult.StartFailed) + assertFalse(preferences.current.adaptiveThemeEnabled) + assertEquals(0, preferences.thresholdDefaultCalls) + } +} diff --git a/app/src/test/kotlin/dev/lexip/hecate/ui/MainViewModelTest.kt b/app/src/test/kotlin/dev/lexip/hecate/ui/MainViewModelTest.kt new file mode 100644 index 00000000..cb27ca64 --- /dev/null +++ b/app/src/test/kotlin/dev/lexip/hecate/ui/MainViewModelTest.kt @@ -0,0 +1,320 @@ +/* + * Copyright (C) 2026 xLexip + * + * Licensed under the GNU General Public License, Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Please see the License for specific terms regarding permissions and limitations. + */ + +package dev.lexip.hecate.ui + +import android.net.Uri +import androidx.test.core.app.ApplicationProvider +import dev.lexip.hecate.Application +import dev.lexip.hecate.FakeAdaptiveThemeServiceController +import dev.lexip.hecate.FakeInstallMetadataProvider +import dev.lexip.hecate.FakeProximitySensorReader +import dev.lexip.hecate.FakeSensorReader +import dev.lexip.hecate.FakeUserPreferencesDataSource +import dev.lexip.hecate.FakeWallpaperPlatform +import dev.lexip.hecate.MainDispatcherRule +import dev.lexip.hecate.data.AdaptiveThreshold +import dev.lexip.hecate.data.UserPreferences +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +private const val DAY_WALLPAPER_URI = "content://wallpaper/day" +private const val NIGHT_WALLPAPER_URI = "content://wallpaper/night" + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36, 37], application = Application::class) +class MainViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private lateinit var application: Application + private lateinit var preferences: FakeUserPreferencesDataSource + private lateinit var lightSensor: FakeSensorReader + private lateinit var proximitySensor: FakeProximitySensorReader + private lateinit var serviceController: FakeAdaptiveThemeServiceController + private lateinit var installMetadata: FakeInstallMetadataProvider + private lateinit var wallpaperPlatform: FakeWallpaperPlatform + + @Before + fun setUp() { + application = ApplicationProvider.getApplicationContext() + preferences = FakeUserPreferencesDataSource() + lightSensor = FakeSensorReader() + proximitySensor = FakeProximitySensorReader() + serviceController = FakeAdaptiveThemeServiceController() + installMetadata = FakeInstallMetadataProvider() + wallpaperPlatform = FakeWallpaperPlatform() + } + + @Test + fun mapsPreferencesAndInstallMetadataToUiState() = runTest(mainDispatcherRule.dispatcher) { + installMetadata.fromPlayStore = true + val viewModel = createViewModel() + + preferences.emit( + UserPreferences( + adaptiveThemeEnabled = false, + adaptiveThemeThresholdLux = 42f, + customAdaptiveThemeThresholdLux = 42f, + hasSetupCompleted = true, + stayDarkAtNightEnabled = true, + nightStartMinutes = 20 * 60, + nightEndMinutes = 7 * 60, + wallpaperSyncEnabled = true, + dayWallpaperUri = DAY_WALLPAPER_URI, + nightWallpaperUri = NIGHT_WALLPAPER_URI + ) + ) + advanceUntilIdle() + + assertEquals(42f, viewModel.uiState.value.adaptiveThemeThresholdLux) + assertEquals(42f, viewModel.uiState.value.customAdaptiveThemeThresholdLux) + assertTrue(viewModel.uiState.value.hasSetupCompleted) + assertTrue(viewModel.uiState.value.isInstalledFromPlayStore) + assertTrue(viewModel.uiState.value.stayDarkAtNightEnabled) + assertEquals(20 * 60, viewModel.uiState.value.nightStartMinutes) + assertEquals(7 * 60, viewModel.uiState.value.nightEndMinutes) + assertTrue(viewModel.uiState.value.wallpaperSyncEnabled) + assertEquals(DAY_WALLPAPER_URI, viewModel.uiState.value.dayWallpaperUri) + assertEquals(NIGHT_WALLPAPER_URI, viewModel.uiState.value.nightWallpaperUri) + } + + @Test + fun enablingWithoutPermissionNavigatesToSetupWithoutChangingPreference() = + runTest(mainDispatcherRule.dispatcher) { + val viewModel = createViewModel() + val events = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiEvents.toList(events) + } + advanceUntilIdle() + + val toggled = viewModel.onServiceToggleRequested( + checked = true, + hasPermission = false + ) + advanceUntilIdle() + + assertFalse(toggled) + assertFalse(preferences.current.adaptiveThemeEnabled) + assertEquals(listOf(NavigateToSetup), events) + assertEquals(0, serviceController.startCalls) + } + + @Test + fun serviceToggleWritesPreferenceAndControlsService() = + runTest(mainDispatcherRule.dispatcher) { + val viewModel = createViewModel() + advanceUntilIdle() + + assertTrue(viewModel.onServiceToggleRequested(true, hasPermission = true)) + advanceUntilIdle() + assertTrue(preferences.current.adaptiveThemeEnabled) + assertEquals(1, preferences.thresholdDefaultCalls) + assertEquals(1, serviceController.startCalls) + + assertTrue(viewModel.onServiceToggleRequested(false, hasPermission = true)) + advanceUntilIdle() + assertFalse(preferences.current.adaptiveThemeEnabled) + assertEquals(1, serviceController.stopCalls) + } + + @Test + fun proximityWarningUsesVirtualTimeAndCancelsWhenUncovered() = + runTest(mainDispatcherRule.dispatcher) { + preferences.emit(preferences.current.copy(adaptiveThemeEnabled = true)) + val viewModel = createViewModel() + runCurrent() + + proximitySensor.emit(0f) + advanceTimeBy(999) + assertFalse(viewModel.uiState.value.isDeviceCovered) + + advanceTimeBy(1) + runCurrent() + assertTrue(viewModel.uiState.value.isDeviceCovered) + + proximitySensor.emit(10f) + runCurrent() + assertFalse(viewModel.uiState.value.isDeviceCovered) + viewModel.stopSensors() + } + + @Test + fun thresholdCustomAndNightSettingsAreForwarded() = + runTest(mainDispatcherRule.dispatcher) { + val viewModel = createViewModel() + advanceUntilIdle() + + viewModel.updateAdaptiveThemeThresholdByIndex(AdaptiveThreshold.BRIGHT.ordinal) + advanceUntilIdle() + assertEquals(AdaptiveThreshold.BRIGHT.lux, preferences.current.adaptiveThemeThresholdLux) + + viewModel.setCustomAdaptiveThemeThreshold(321f) + advanceUntilIdle() + assertEquals(321f, preferences.current.customAdaptiveThemeThresholdLux) + + viewModel.updateStayDarkAtNightEnabled(true) + advanceUntilIdle() + assertEquals(1, preferences.nightDefaultCalls) + assertTrue(preferences.current.stayDarkAtNightEnabled) + + viewModel.updateNightWindow(22 * 60, 5 * 60) + advanceUntilIdle() + assertEquals(22 * 60, preferences.current.nightStartMinutes) + assertEquals(5 * 60, preferences.current.nightEndMinutes) + } + + @Test + fun equalOrRepositoryRejectedNightWindowInvokesRejectionCallback() = + runTest(mainDispatcherRule.dispatcher) { + val viewModel = createViewModel() + advanceUntilIdle() + var rejectionCalls = 0 + + viewModel.updateNightWindow(60, 60) { rejectionCalls++ } + preferences.rejectNightWindow = true + viewModel.updateNightWindow(22 * 60, 5 * 60) { rejectionCalls++ } + advanceUntilIdle() + + assertEquals(2, rejectionCalls) + } + + @Test + fun reviewRequestIsEmittedOnlyOncePerViewModelSession() = + runTest(mainDispatcherRule.dispatcher) { + preferences.emit(preferences.current.copy(adaptiveThemeEnabled = true)) + installMetadata.installedDaysAgo = 3 + val viewModel = createViewModel() + val events = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiEvents.toList(events) + } + advanceUntilIdle() + + viewModel.checkReviewPrompt() + viewModel.checkReviewPrompt() + advanceUntilIdle() + + assertEquals(listOf(RequestInAppReview), events) + viewModel.stopSensors() + } + + @Test + fun wallpaperPicksPersistPermissionAndUris() = + runTest(mainDispatcherRule.dispatcher) { + val viewModel = createViewModel() + val dayUri = Uri.parse(DAY_WALLPAPER_URI) + val nightUri = Uri.parse(NIGHT_WALLPAPER_URI) + advanceUntilIdle() + + viewModel.onDayWallpaperPicked(dayUri) + viewModel.onNightWallpaperPicked(nightUri) + advanceUntilIdle() + + assertEquals(listOf(dayUri, nightUri), wallpaperPlatform.persistedUris) + assertEquals(dayUri.toString(), preferences.current.dayWallpaperUri) + assertEquals(nightUri.toString(), preferences.current.nightWallpaperUri) + } + + @Test + fun wallpaperPickIsStoredWhenPersistablePermissionFails() = + runTest(mainDispatcherRule.dispatcher) { + wallpaperPlatform.permissionFailure = SecurityException("not persistable") + val viewModel = createViewModel() + val uri = Uri.parse(DAY_WALLPAPER_URI) + advanceUntilIdle() + + viewModel.onDayWallpaperPicked(uri) + advanceUntilIdle() + + assertTrue(wallpaperPlatform.persistedUris.isEmpty()) + assertEquals(uri.toString(), preferences.current.dayWallpaperUri) + } + + @Test + fun wallpaperSyncEnablesImmediatelyWithoutLiveWallpaperAndDisables() = + runTest(mainDispatcherRule.dispatcher) { + val viewModel = createViewModel() + advanceUntilIdle() + + viewModel.onWallpaperSyncToggleRequested(true) + advanceUntilIdle() + assertTrue(preferences.current.wallpaperSyncEnabled) + assertFalse(viewModel.uiState.value.showLiveWallpaperWarningDialog) + + viewModel.onWallpaperSyncToggleRequested(false) + advanceUntilIdle() + assertFalse(preferences.current.wallpaperSyncEnabled) + } + + @Test + fun liveWallpaperRequiresConfirmationBeforeEnablingSync() = + runTest(mainDispatcherRule.dispatcher) { + wallpaperPlatform.liveWallpaperActive = true + val viewModel = createViewModel() + advanceUntilIdle() + + viewModel.onWallpaperSyncToggleRequested(true) + assertTrue(viewModel.uiState.value.showLiveWallpaperWarningDialog) + assertFalse(preferences.current.wallpaperSyncEnabled) + + viewModel.confirmEnableWithLiveWallpaper() + advanceUntilIdle() + assertFalse(viewModel.uiState.value.showLiveWallpaperWarningDialog) + assertTrue(preferences.current.wallpaperSyncEnabled) + } + + @Test + fun liveWallpaperWarningCanBeDismissedWithoutEnablingSync() = + runTest(mainDispatcherRule.dispatcher) { + wallpaperPlatform.liveWallpaperActive = true + val viewModel = createViewModel() + advanceUntilIdle() + + viewModel.onWallpaperSyncToggleRequested(true) + viewModel.dismissLiveWallpaperWarningDialog() + advanceUntilIdle() + + assertFalse(viewModel.uiState.value.showLiveWallpaperWarningDialog) + assertFalse(preferences.current.wallpaperSyncEnabled) + } + + private fun createViewModel(): MainViewModel = MainViewModel( + application = application, + userPreferencesRepository = preferences, + lightSensorManager = lightSensor, + proximitySensorManager = proximitySensor, + serviceController = serviceController, + installMetadataProvider = installMetadata, + wallpaperPlatform = wallpaperPlatform, + ioDispatcher = mainDispatcherRule.dispatcher, + mainDispatcher = mainDispatcherRule.dispatcher + ) +} diff --git a/app/src/test/kotlin/dev/lexip/hecate/ui/setup/SetupViewModelTest.kt b/app/src/test/kotlin/dev/lexip/hecate/ui/setup/SetupViewModelTest.kt new file mode 100644 index 00000000..1d2a2ea1 --- /dev/null +++ b/app/src/test/kotlin/dev/lexip/hecate/ui/setup/SetupViewModelTest.kt @@ -0,0 +1,220 @@ +/* + * Copyright (C) 2026 xLexip + * + * Licensed under the GNU General Public License, Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Please see the License for specific terms regarding permissions and limitations. + */ + +package dev.lexip.hecate.ui.setup + +import androidx.test.core.app.ApplicationProvider +import dev.lexip.hecate.Application +import dev.lexip.hecate.FakeAdaptiveThemeServiceController +import dev.lexip.hecate.FakeSetupEnvironmentProvider +import dev.lexip.hecate.FakeSetupPermissionGrantController +import dev.lexip.hecate.FakeUserPreferencesDataSource +import dev.lexip.hecate.MainDispatcherRule +import dev.lexip.hecate.ui.navigation.NavigationEvent +import dev.lexip.hecate.ui.navigation.NavigationManager +import dev.lexip.hecate.ui.navigation.SetupRoute +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36, 37], application = Application::class) +class SetupViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private lateinit var application: Application + private lateinit var preferences: FakeUserPreferencesDataSource + private lateinit var navigation: NavigationManager + private lateinit var environment: FakeSetupEnvironmentProvider + private lateinit var permissionController: FakeSetupPermissionGrantController + private lateinit var serviceController: FakeAdaptiveThemeServiceController + + @Before + fun setUp() { + application = ApplicationProvider.getApplicationContext() + preferences = FakeUserPreferencesDataSource() + navigation = NavigationManager() + environment = FakeSetupEnvironmentProvider() + permissionController = FakeSetupPermissionGrantController() + serviceController = FakeAdaptiveThemeServiceController() + } + + @Test + fun initialStepSkipsDeveloperModeWhenItsRequirementsAreAlreadyMet() = + runTest(mainDispatcherRule.dispatcher) { + environment.current = environment.current.copy( + isDeveloperOptionsEnabled = true, + isUsbDebuggingEnabled = true + ) + + val viewModel = createViewModel() + runCurrent() + + assertEquals(SetupRoute.ConnectUsb, viewModel.uiState.value.currentStep) + assertTrue(viewModel.uiState.value.isStep1Complete) + assertTrue(viewModel.uiState.value.pendingAdbCommand.contains(application.packageName)) + viewModel.exitSetup() + } + + @Test + fun completedStepCountsDownAndAutomaticallyAdvances() = + runTest(mainDispatcherRule.dispatcher) { + val viewModel = createViewModel() + runCurrent() + + environment.current = environment.current.copy( + isDeveloperOptionsEnabled = true, + isUsbDebuggingEnabled = true + ) + advanceTimeBy(1_000) + runCurrent() + assertTrue(viewModel.uiState.value.isStep1Complete) + assertEquals(2, viewModel.uiState.value.autoAdvanceCountdown) + + advanceTimeBy(2_000) + runCurrent() + assertEquals(SetupRoute.ConnectUsb, viewModel.uiState.value.currentStep) + assertEquals(0, viewModel.uiState.value.autoAdvanceCountdown) + viewModel.exitSetup() + } + + @Test + fun nextAndBackUpdateStateAndEmitNavigation() = + runTest(mainDispatcherRule.dispatcher) { + val viewModel = createViewModel() + val events = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + navigation.navigationEvents.toList(events) + } + runCurrent() + + viewModel.navigateToNextStep() + viewModel.navigateBack() + runCurrent() + + assertEquals(SetupRoute.DeveloperMode, viewModel.uiState.value.currentStep) + assertEquals( + listOf( + NavigationEvent.ToSetupStep(SetupRoute.ConnectUsb), + NavigationEvent.Back + ), + events + ) + viewModel.exitSetup() + } + + @Test + fun completionIsIdempotentAndStartsServiceOnce() = + runTest(mainDispatcherRule.dispatcher) { + val viewModel = createViewModel() + runCurrent() + + viewModel.completeSetup(source = "test") + viewModel.completeSetup(source = "duplicate") + advanceUntilIdle() + + assertTrue(viewModel.uiState.value.isSetupCompleted) + assertTrue(preferences.current.hasSetupCompleted) + assertTrue(preferences.current.adaptiveThemeEnabled) + assertEquals(1, preferences.setupCompletedWrites) + assertEquals(1, preferences.thresholdDefaultCalls) + assertEquals(1, serviceController.startCalls) + } + + @Test + fun silentRootSuccessCompletesSetupThroughInjectedController() = + runTest(mainDispatcherRule.dispatcher) { + permissionController.rootResult = SetupGrantResult.Success + + val viewModel = createViewModel() + advanceUntilIdle() + + assertTrue(viewModel.uiState.value.isSetupCompleted) + assertEquals(1, permissionController.rootGrantCalls) + assertEquals(1, serviceController.startCalls) + } + + @Test + fun shizukuSuccessCompletesSetupWithoutRealBinder() = + runTest(mainDispatcherRule.dispatcher) { + permissionController.binderReady = true + permissionController.shizukuPermission = true + permissionController.shizukuResult = SetupGrantResult.Success + val viewModel = createViewModel() + runCurrent() + + viewModel.onGrantViaShizukuRequested() + advanceUntilIdle() + + assertTrue(viewModel.uiState.value.isSetupCompleted) + assertEquals(1, permissionController.shizukuGrantCalls) + } + + @Test + fun missingShizukuAuthorizationRequestsPermissionWithoutExecutingGrant() = + runTest(mainDispatcherRule.dispatcher) { + permissionController.binderReady = true + permissionController.shizukuPermission = false + val viewModel = createViewModel() + runCurrent() + + viewModel.onGrantViaShizukuRequested() + runCurrent() + + assertEquals(1, permissionController.permissionRequestCalls) + assertEquals(0, permissionController.shizukuGrantCalls) + assertFalse(viewModel.uiState.value.isSetupCompleted) + viewModel.exitSetup() + } + + @Test + fun rejectedPermissionCheckDoesNotCompleteSetup() = + runTest(mainDispatcherRule.dispatcher) { + val viewModel = createViewModel() + runCurrent() + + viewModel.checkPermissionAndComplete() + runCurrent() + + assertFalse(viewModel.uiState.value.isSetupCompleted) + assertEquals(0, serviceController.startCalls) + viewModel.exitSetup() + } + + private fun createViewModel(): SetupViewModel = SetupViewModel( + application = application, + userPreferencesRepository = preferences, + navigationManager = navigation, + environmentProvider = environment, + permissionGrantController = permissionController, + serviceController = serviceController, + ioDispatcher = mainDispatcherRule.dispatcher, + mainDispatcher = mainDispatcherRule.dispatcher + ) +} diff --git a/app/src/test/kotlin/dev/lexip/hecate/util/AdaptiveAppearanceHandlerTest.kt b/app/src/test/kotlin/dev/lexip/hecate/util/AdaptiveAppearanceHandlerTest.kt index 202a39f4..785ffd2a 100644 --- a/app/src/test/kotlin/dev/lexip/hecate/util/AdaptiveAppearanceHandlerTest.kt +++ b/app/src/test/kotlin/dev/lexip/hecate/util/AdaptiveAppearanceHandlerTest.kt @@ -119,6 +119,62 @@ class AdaptiveAppearanceHandlerTest { assertFalse(wallpaperApplied) } + @Test + fun `applies day wallpaper for a light theme transition`() { + var appliedTheme: Boolean? = null + val lightHandler = AdaptiveAppearanceHandler( + setDarkTheme = { DarkThemeChangeResult(succeeded = true, changed = true) }, + applyWallpaperForTheme = { isDark, _, _ -> + appliedTheme = isDark + true + } + ) + lightHandler.configureWallpaperSync( + enabled = true, + dayWallpaperUri = "content://wallpaper/day", + nightWallpaperUri = "content://wallpaper/night" + ) + + lightHandler.applyAppearance(useDarkTheme = false) + + assertEquals(false, appliedTheme) + } + + @Test + fun `uses the latest wallpaper configuration`() { + var appliedUris: Pair? = null + val handler = AdaptiveAppearanceHandler( + setDarkTheme = { DarkThemeChangeResult(succeeded = true, changed = true) }, + applyWallpaperForTheme = { _, dayUri, nightUri -> + appliedUris = dayUri to nightUri + true + } + ) + handler.configureWallpaperSync(true, "content://old/day", "content://old/night") + handler.configureWallpaperSync(true, "content://new/day", "content://new/night") + + handler.applyAppearance(useDarkTheme = true) + + assertEquals( + "content://new/day" to "content://new/night", + appliedUris + ) + } + + @Test + fun `wallpaper failure does not change successful theme result`() { + val handler = AdaptiveAppearanceHandler( + setDarkTheme = { DarkThemeChangeResult(succeeded = true, changed = true) }, + applyWallpaperForTheme = { _, _, _ -> false } + ) + handler.configureWallpaperSync(true, "content://day", "content://night") + + val result = handler.applyAppearance(useDarkTheme = true) + + assertTrue(result.succeeded) + assertTrue(result.changed) + } + private fun createHandler( themeResult: DarkThemeChangeResult, onWallpaperApplied: () -> Unit diff --git a/app/src/test/kotlin/dev/lexip/hecate/util/NightWindowPolicyTest.kt b/app/src/test/kotlin/dev/lexip/hecate/util/NightWindowPolicyTest.kt new file mode 100644 index 00000000..93630a5c --- /dev/null +++ b/app/src/test/kotlin/dev/lexip/hecate/util/NightWindowPolicyTest.kt @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2026 xLexip + * + * Licensed under the GNU General Public License, Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Please see the License for specific terms regarding permissions and limitations. + */ + +package dev.lexip.hecate.util + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class NightWindowPolicyTest { + @Test + fun sameDayWindowIncludesStartAndExcludesEnd() { + assertTrue(NightWindowPolicy.isInNightWindow(60, 60, 120)) + assertTrue(NightWindowPolicy.isInNightWindow(119, 60, 120)) + assertFalse(NightWindowPolicy.isInNightWindow(120, 60, 120)) + } + + @Test + fun overnightWindowWrapsAcrossMidnight() { + assertTrue(NightWindowPolicy.isInNightWindow(23 * 60, 21 * 60, 6 * 60)) + assertTrue(NightWindowPolicy.isInNightWindow(0, 21 * 60, 6 * 60)) + assertFalse(NightWindowPolicy.isInNightWindow(12 * 60, 21 * 60, 6 * 60)) + } + + @Test + fun equalOrInvalidMinutesAreNeverInWindow() { + assertFalse(NightWindowPolicy.isInNightWindow(60, 60, 60)) + assertFalse(NightWindowPolicy.isInNightWindow(-1, 60, 120)) + assertFalse(NightWindowPolicy.isInNightWindow(60, -1, 120)) + assertFalse(NightWindowPolicy.isInNightWindow(60, 60, 1440)) + } +} diff --git a/app/src/test/kotlin/dev/lexip/hecate/util/ThemeDecisionPolicyTest.kt b/app/src/test/kotlin/dev/lexip/hecate/util/ThemeDecisionPolicyTest.kt new file mode 100644 index 00000000..fb8c7684 --- /dev/null +++ b/app/src/test/kotlin/dev/lexip/hecate/util/ThemeDecisionPolicyTest.kt @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2026 xLexip + * + * Licensed under the GNU General Public License, Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Please see the License for specific terms regarding permissions and limitations. + */ + +package dev.lexip.hecate.util + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ThemeDecisionPolicyTest { + @Test + fun lightBelowThresholdUsesDarkTheme() { + assertTrue(decide(light = 99f, threshold = 100f)) + } + + @Test + fun lightAtOrAboveThresholdUsesLightTheme() { + assertFalse(decide(light = 100f, threshold = 100f)) + assertFalse(decide(light = 101f, threshold = 100f)) + } + + @Test + fun activeNightWindowForcesDarkTheme() { + assertTrue( + ThemeDecisionPolicy.shouldUseDarkTheme( + lightValue = 10_000f, + thresholdLux = 100f, + stayDarkAtNightEnabled = true, + nightStartMinutes = 21 * 60, + nightEndMinutes = 6 * 60, + nowMinutes = 23 * 60 + ) + ) + } + + private fun decide(light: Float, threshold: Float) = + ThemeDecisionPolicy.shouldUseDarkTheme( + lightValue = light, + thresholdLux = threshold, + stayDarkAtNightEnabled = false, + nightStartMinutes = 21 * 60, + nightEndMinutes = 6 * 60, + nowMinutes = 12 * 60 + ) +} diff --git a/app/src/test/kotlin/dev/lexip/hecate/util/WallpaperHandlerTest.kt b/app/src/test/kotlin/dev/lexip/hecate/util/WallpaperHandlerTest.kt new file mode 100644 index 00000000..fbc3049a --- /dev/null +++ b/app/src/test/kotlin/dev/lexip/hecate/util/WallpaperHandlerTest.kt @@ -0,0 +1,155 @@ +/* + * Copyright (C) 2026 xLexip + * + * Licensed under the GNU General Public License, Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + */ + +package dev.lexip.hecate.util + +import android.app.WallpaperManager +import android.content.Context +import android.net.Uri +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.ByteArrayInputStream + +private const val DAY_WALLPAPER_URI = "content://wallpaper/day" +private const val NIGHT_WALLPAPER_URI = "content://wallpaper/night" +private const val SHORT_DAY_URI = "content://day" +private const val SHORT_NIGHT_URI = "content://night" + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36, 37]) +class WallpaperHandlerTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + @Test + fun selectsDayAndNightUrisForTheirMatchingThemes() { + val openedUris = mutableListOf() + val handler = handler( + openInputStream = { uri -> + openedUris += uri.toString() + ByteArrayInputStream(byteArrayOf(1)) + } + ) + + assertTrue( + handler.applyWallpaperForTheme( + isDark = false, + dayUriStr = DAY_WALLPAPER_URI, + nightUriStr = NIGHT_WALLPAPER_URI + ) + ) + assertTrue( + handler.applyWallpaperForTheme( + isDark = true, + dayUriStr = DAY_WALLPAPER_URI, + nightUriStr = NIGHT_WALLPAPER_URI + ) + ) + + assertEquals( + listOf(DAY_WALLPAPER_URI, NIGHT_WALLPAPER_URI), + openedUris + ) + } + + @Test + fun missingTargetUriSkipsPlatformIo() { + var openCalls = 0 + val handler = handler( + openInputStream = { + openCalls++ + ByteArrayInputStream(byteArrayOf(1)) + } + ) + + assertFalse(handler.applyWallpaperForTheme(false, null, NIGHT_WALLPAPER_URI)) + assertFalse(handler.applyWallpaperForTheme(true, DAY_WALLPAPER_URI, "")) + assertEquals(0, openCalls) + } + + @Test + fun nullStreamAndPlatformExceptionsReturnFailure() { + val nullStreamHandler = handler(openInputStream = { null }) + val openFailureHandler = handler( + openInputStream = { throw SecurityException("denied") } + ) + val setFailureHandler = handler( + setStream = { _, _ -> throw IllegalStateException("set failed") } + ) + + assertFalse(nullStreamHandler.applyWallpaperForTheme(false, SHORT_DAY_URI, SHORT_NIGHT_URI)) + assertFalse(openFailureHandler.applyWallpaperForTheme(false, SHORT_DAY_URI, SHORT_NIGHT_URI)) + assertFalse(setFailureHandler.applyWallpaperForTheme(false, SHORT_DAY_URI, SHORT_NIGHT_URI)) + } + + @Test + fun successfulApplicationUsesSystemAndLockFlagsAndClosesStream() { + val stream = CloseTrackingInputStream() + var appliedFlags = 0 + val handler = handler( + openInputStream = { stream }, + setStream = { _, flags -> appliedFlags = flags } + ) + + val succeeded = handler.applyWallpaperForTheme( + isDark = false, + dayUriStr = DAY_WALLPAPER_URI, + nightUriStr = NIGHT_WALLPAPER_URI + ) + + assertTrue(succeeded) + assertEquals( + WallpaperManager.FLAG_SYSTEM or WallpaperManager.FLAG_LOCK, + appliedFlags + ) + assertTrue(stream.closed) + } + + @Test + fun delegatesLiveWallpaperAndPersistablePermissionChecks() { + val persisted = mutableListOf() + val handler = handler( + liveWallpaperActive = { true }, + persistReadPermission = { persisted += it } + ) + val uri = Uri.parse(DAY_WALLPAPER_URI) + + assertTrue(handler.isLiveWallpaperActive()) + handler.takePersistableReadPermission(uri) + + assertEquals(listOf(uri), persisted) + } + + private fun handler( + liveWallpaperActive: () -> Boolean = { false }, + persistReadPermission: (Uri) -> Unit = {}, + openInputStream: (Uri) -> ByteArrayInputStream? = { + ByteArrayInputStream(byteArrayOf(1)) + }, + setStream: (java.io.InputStream, Int) -> Unit = { _, _ -> } + ) = WallpaperHandler( + context = context, + liveWallpaperActive = liveWallpaperActive, + persistReadPermission = persistReadPermission, + openInputStream = openInputStream, + setStream = setStream + ) +} + +private class CloseTrackingInputStream : ByteArrayInputStream(byteArrayOf(1)) { + var closed = false + + override fun close() { + closed = true + super.close() + } +} diff --git a/app/src/test/kotlin/dev/lexip/hecate/util/shizuku/GrantCommandBuilderTest.kt b/app/src/test/kotlin/dev/lexip/hecate/util/shizuku/GrantCommandBuilderTest.kt new file mode 100644 index 00000000..fb12e7a6 --- /dev/null +++ b/app/src/test/kotlin/dev/lexip/hecate/util/shizuku/GrantCommandBuilderTest.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2026 xLexip + * + * Licensed under the GNU General Public License, Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Please see the License for specific terms regarding permissions and limitations. + */ + +package dev.lexip.hecate.util.shizuku + +import org.junit.Assert.assertEquals +import org.junit.Test + +class GrantCommandBuilderTest { + @Test + fun allBuildsCommandsInRequiredOrder() { + assertEquals( + listOf( + "pm grant dev.example android.permission.WRITE_SECURE_SETTINGS", + "cmd appops set dev.example RUN_ANY_IN_BACKGROUND allow", + "dumpsys deviceidle whitelist +dev.example" + ), + GrantCommandBuilder.all("dev.example") + ) + } + + @Test + fun adbCommandPrefixesShellInvocation() { + assertEquals( + "adb shell pm grant dev.example android.permission.WRITE_SECURE_SETTINGS", + GrantCommandBuilder.adbGrantWriteSecureSettings("dev.example") + ) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 82f5cc72..4d59e398 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,6 +16,7 @@ googleServices = "4.5.0" junit = "4.13.2" junitVersion = "1.3.0" kotlin = "2.4.10" +coroutines = "1.9.0" kotlinxSerializationJson = "1.11.0" lifecycleRuntimeKtx = "2.11.0" lifecycleViewmodelCompose = "2.11.0" @@ -24,7 +25,9 @@ material = "1.14.0" navigationCompose = "2.9.8" preference = "1.2.1" review = "2.0.2" +robolectric = "4.17-beta-2" shizuku = "13.1.5" +testCore = "1.7.0" [libraries] androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } @@ -45,7 +48,8 @@ androidx-preference = { group = "androidx.preference", name = "preference", vers androidx-ui = { group = "androidx.compose.ui", name = "ui" } androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } -androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest", version = "1.11.4" } +androidx-ui-test-junit4-accessibility = { group = "androidx.compose.ui", name = "ui-test-junit4-accessibility" } +androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } app-update-ktx = { module = "com.google.android.play:app-update-ktx", version.ref = "appUpdateKtx" } @@ -53,7 +57,10 @@ review = { group = "com.google.android.play", name = "review", version.ref = "re review-ktx = { group = "com.google.android.play", name = "review-ktx", version.ref = "review" } firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" } junit = { group = "junit", name = "junit", version.ref = "junit" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } +robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } +androidx-test-core-ktx = { group = "androidx.test", name = "core-ktx", version.ref = "testCore" } firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics", version.ref = "firebaseAnalytics" } firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics", version.ref = "firebaseCrashlytics" } shizuku-api = { group = "dev.rikka.shizuku", name = "api", version.ref = "shizuku" }