From 3710fd41c1fce23a69cd69f9bc9d213cf9d2137e Mon Sep 17 00:00:00 2001 From: Gurupreet Date: Sat, 12 Sep 2026 15:39:13 +0200 Subject: [PATCH 1/4] ci: stabilize the PR gate and fix post-merge issues CI - gradle.properties: 6g daemon heap, workers.max=2, kotlin daemon 2g. The `build` job was dying with OutOfMemoryError in lintAnalyzeDebug and D8 mergeExtDexRelease before any test task ran (#205, #206, #223 all red). - android.yml: PR gate is now assembleDebug + testDebugUnitTest + verifyRoborazziDebug with gradle/actions caching and cancel-in-progress; lint runs in its own job (:app:lintDebug). Debug APK is still uploaded. Roborazzi diffs and test reports are uploaded on failure. - Remove build.yml: duplicated the APK job and could never fail (continue-on-error: true). - Add codeql.yml and dependency-review.yml from #218, with the CodeQL build run with --no-build-cache so the tracer sees real compilations (with the build cache warm every compileKotlin task was FROM-CACHE and CodeQL reported no Java/Kotlin code). Code - moviesapp: TMDB API key was hardcoded in MovieApi.kt. It now comes from BuildConfig.TMDB_API_KEY, read from local.properties (tmdbApiKey) or the TMDB_API_KEY env var. The old key has been public in git history and must be rotated on TMDB. - moviesapp: HTTP body logging only in debug builds. - moviesapp: Coil 3 exposes painter.state as a StateFlow; the `is Success` check in MovieDetailContent never matched, so the poster never expanded. Collect the state and react in a LaunchedEffect. - moviesapp: SimilarMoviesSection fired a network request on every recomposition; now once per movie id via LaunchedEffect. - pinlock: BiometricActivity is no longer exported (no intent filter). - README: JDK 17 / Ladybug, version catalog, TMDB key setup. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/android.yml | 81 ++++++++++++++++++- .github/workflows/build.yml | 49 ----------- .github/workflows/codeql.yml | 60 ++++++++++++++ .github/workflows/dependency-review.yml | 21 +++++ README.md | 14 ++-- .../details/components/MovieDetailContent.kt | 13 ++- .../components/SimilarMoviesSection.kt | 4 +- demos/moviesapp/data/build.gradle.kts | 21 ++++- .../moviesapp/data/api/MovieApi.kt | 10 ++- gradle.properties | 15 ++-- .../pinlock/src/main/AndroidManifest.xml | 2 +- 11 files changed, 216 insertions(+), 74 deletions(-) delete mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/dependency-review.yml diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 23ac9e2d..f9813b0d 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -5,24 +5,97 @@ on: branches: [ master ] pull_request: branches: [ master ] + workflow_dispatch: permissions: contents: read +# A new push to the same PR/branch cancels the previous run. +concurrency: + group: android-ci-${{ github.ref }} + cancel-in-progress: true + jobs: + # Compiles every module, runs JVM unit tests and Roborazzi screenshot + # verification, and publishes the debug APK. This is the required PR check. build: + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: temurin + + # Pinned to a commit SHA (same pin as wrapper-validation.yml). + - name: Set up Gradle + uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + with: + cache-read-only: ${{ github.ref != 'refs/heads/master' }} + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + - name: Assemble, unit tests, screenshot tests + run: ./gradlew assembleDebug testDebugUnitTest verifyRoborazziDebug --stacktrace + + - name: Upload debug APK + uses: actions/upload-artifact@v4 + with: + name: ComposeCookBook + path: app/build/outputs/apk/debug/*.apk + + - name: Upload Roborazzi diffs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: roborazzi-diffs + path: '**/build/outputs/roborazzi/' + if-no-files-found: ignore + + - name: Upload unit test reports + if: failure() + uses: actions/upload-artifact@v4 + with: + name: unit-test-reports + path: '**/build/reports/tests/' + if-no-files-found: ignore + + # Lint runs separately so its memory footprint cannot take the build job down + # with it. Release-variant lint (lintVitalAnalyzeRelease) is deliberately not + # part of the PR gate. + lint: runs-on: ubuntu-latest + timeout-minutes: 45 steps: - uses: actions/checkout@v4 - - name: set up JDK 17 + + - name: Set up JDK 17 uses: actions/setup-java@v4 with: java-version: 17 distribution: temurin - + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + with: + cache-read-only: true + - name: Grant execute permission for gradlew run: chmod +x gradlew - - name: Build with Gradle - run: ./gradlew build + + - name: Lint (app) + run: ./gradlew :app:lintDebug --stacktrace + + - name: Upload lint report + if: always() + uses: actions/upload-artifact@v4 + with: + name: lint-report + path: app/build/reports/lint-results-debug.html + if-no-files-found: ignore diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index ed1b6dd2..00000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Build APK - -on: - - push: - branches: [ "master" ] - pull_request: - branches: [ "master" ] - - workflow_dispatch: - -permissions: - contents: read - -jobs: - - build_job: - name: Building the APK - runs-on: ubuntu-latest - continue-on-error: true - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Restore Cache - uses: actions/cache@v4 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }} - restore-keys: | - ${{ runner.os }}-gradle- - - name: Change wrapper permissions - run: chmod +x ./gradlew - - - - name: Touch local properties - run: touch local.properties - - name: Add Api Key - run: echo "apiKey=\"\"" >> local.properties - - - name: Assemble Debug - run: ./gradlew assembleDebug - - - name: Upload APK - uses: actions/upload-artifact@v4 - with: - name: ComposeCookBook - path: app/build/outputs/apk/debug/**.apk diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..a62c319e --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,60 @@ +name: CodeQL + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + schedule: + # Weekly scan so new CodeQL queries run even without pushes + - cron: '26 7 * * 1' + workflow_dispatch: + +permissions: + contents: read + +jobs: + analyze: + name: Analyze (java-kotlin) + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read + security-events: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: temurin + + # Dependency cache only. The Gradle *build* cache is disabled for the + # analysis build below: CodeQL's tracer has to observe real compilations, + # and a warm build cache makes every compileKotlin task FROM-CACHE, which + # ends in "CodeQL could not process any code written in Java/Kotlin". + - name: Set up Gradle + uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + with: + cache-read-only: true + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: java-kotlin + build-mode: manual + + # Kotlin extraction requires a real compilation; assembleDebug is the + # cheapest target that compiles every module. + - name: Build for analysis + run: | + chmod +x gradlew + ./gradlew assembleDebug --no-build-cache --no-configuration-cache + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: '/language:java-kotlin' diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 00000000..0603c165 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,21 @@ +name: Dependency review + +# Flags PRs that introduce dependencies with known vulnerabilities. +# Requires "Dependency graph" to be enabled under Settings > Code security. +on: + pull_request: + branches: [ master ] + +permissions: + contents: read + +jobs: + dependency-review: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Dependency review + uses: actions/dependency-review-action@v4 + with: + fail-on-severity: high diff --git a/README.md b/README.md index 34dc1924..c16ecd05 100644 --- a/README.md +++ b/README.md @@ -82,14 +82,16 @@ Adding card details | CaseCade Menu :-------------------------:| :-------------------------: ![](https://user-images.githubusercontent.com/8813304/112016144-1e21ef00-8b35-11eb-8c33-362c8cbe6c0d.gif) | ![](https://media.giphy.com/media/WoFe2OZ7kbW2KBkzFN/giphy.gif) -### Kotlin DSL Templates -You can find them in *buildSrc* -- build.bradle -- dependency -- configurations +### Build setup +- Dependencies and versions live in the Gradle version catalog: `gradle/libs.versions.toml` +- Shared module configuration (convention plugins, SDK levels) lives in *buildSrc* ## How to get started -Please get **Android Studio Bumblebee latest Canary** [from here](https://developer.android.com/studio/preview/) and use **JDK 11** to build this project. +Use **Android Studio Ladybug or newer** and **JDK 17** (the project targets AGP 8.7 / Kotlin 2.1). + +The MoviesApp demo needs a [TMDB API key](https://www.themoviedb.org/settings/api). Add it to your +(git-ignored) `local.properties` as `tmdbApiKey=YOUR_KEY`, or export `TMDB_API_KEY`. Without it the +demo builds fine but the movie lists stay empty. ## Features & Where to start - __Widgets:__ Widgets Screen show case all the available components to build UI. diff --git a/demos/moviesapp/app/src/main/java/com/guru/composecookbook/moviesapp/ui/details/components/MovieDetailContent.kt b/demos/moviesapp/app/src/main/java/com/guru/composecookbook/moviesapp/ui/details/components/MovieDetailContent.kt index c7982651..3ca3cf6c 100644 --- a/demos/moviesapp/app/src/main/java/com/guru/composecookbook/moviesapp/ui/details/components/MovieDetailContent.kt +++ b/demos/moviesapp/app/src/main/java/com/guru/composecookbook/moviesapp/ui/details/components/MovieDetailContent.kt @@ -20,6 +20,9 @@ import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.LibraryAdd import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -67,16 +70,18 @@ fun MovieDetailContent(movie: Movie, imageId: Int) { item { val painter = rememberAsyncImagePainter(model = "https://image.tmdb.org/t/p/w500/${movie.poster_path}") + // Coil 3 exposes the painter state as a StateFlow; collect it and react outside + // of composition instead of comparing the flow object itself. + val painterState by painter.state.collectAsState() + LaunchedEffect(painterState) { + expand.value = painterState is AsyncImagePainter.State.Success + } Image( painter = painter, contentScale = ContentScale.Crop, contentDescription = null, modifier = Modifier.height(600.dp).fillMaxWidth(), ) - when (painter.state) { - is AsyncImagePainter.State.Success -> expand.value = true - else -> expand.value = false - } } item { Column(modifier = Modifier.background(MaterialTheme.colors.onSurface)) { diff --git a/demos/moviesapp/app/src/main/java/com/guru/composecookbook/moviesapp/ui/details/components/SimilarMoviesSection.kt b/demos/moviesapp/app/src/main/java/com/guru/composecookbook/moviesapp/ui/details/components/SimilarMoviesSection.kt index 7da79a33..b9d73d11 100644 --- a/demos/moviesapp/app/src/main/java/com/guru/composecookbook/moviesapp/ui/details/components/SimilarMoviesSection.kt +++ b/demos/moviesapp/app/src/main/java/com/guru/composecookbook/moviesapp/ui/details/components/SimilarMoviesSection.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Modifier @@ -22,7 +23,8 @@ import com.guru.composecookbook.theme.typography @Composable fun SimilarMoviesSection(currentMovie: Movie?, viewModel: MovieDetailViewModel) { - viewModel.getSimilarMovies(currentMovie?.id.toString()) + // Fetch once per movie, not on every recomposition. + LaunchedEffect(currentMovie?.id) { viewModel.getSimilarMovies(currentMovie?.id.toString()) } val similarMovies by viewModel.similarMoviesLiveData.observeAsState() similarMovies?.let { movies -> Text(text = "Similar Movies", style = typography.h5, modifier = Modifier.padding(8.dp)) diff --git a/demos/moviesapp/data/build.gradle.kts b/demos/moviesapp/data/build.gradle.kts index 2a084a48..ec8dd211 100644 --- a/demos/moviesapp/data/build.gradle.kts +++ b/demos/moviesapp/data/build.gradle.kts @@ -1,9 +1,28 @@ +import java.util.Properties + plugins { /** See [common-kotlin-module-configs-script-plugin.gradle.kts] file */ id("common-kotlin-module-configs-script-plugin") } -android { namespace = "com.guru.composecookbook.moviesapp.data" } +// TMDB API key. Put `tmdbApiKey=` in local.properties (git-ignored) or +// export TMDB_API_KEY. Never commit the key; get one at https://www.themoviedb.org/settings/api +val tmdbApiKey: String = run { + val props = Properties() + val localProperties = rootProject.file("local.properties") + if (localProperties.exists()) localProperties.inputStream().use { props.load(it) } + props.getProperty("tmdbApiKey")?.trim('"')?.takeIf { it.isNotBlank() } + ?: System.getenv("TMDB_API_KEY") + ?: "" +} + +android { + namespace = "com.guru.composecookbook.moviesapp.data" + + buildFeatures { buildConfig = true } + + defaultConfig { buildConfigField("String", "TMDB_API_KEY", "\"$tmdbApiKey\"") } +} dependencies { implementation(libs.bundles.core.android) diff --git a/demos/moviesapp/data/src/main/java/com/guru/composecookbook/moviesapp/data/api/MovieApi.kt b/demos/moviesapp/data/src/main/java/com/guru/composecookbook/moviesapp/data/api/MovieApi.kt index c8eaee9d..4a882300 100644 --- a/demos/moviesapp/data/src/main/java/com/guru/composecookbook/moviesapp/data/api/MovieApi.kt +++ b/demos/moviesapp/data/src/main/java/com/guru/composecookbook/moviesapp/data/api/MovieApi.kt @@ -1,5 +1,6 @@ package com.guru.composecookbook.moviesapp.data.api +import com.guru.composecookbook.moviesapp.data.BuildConfig import com.guru.composecookbook.moviesapp.data.api.models.GenreApiResponse import com.guru.composecookbook.moviesapp.data.api.models.MovieListResponse import com.guru.composecookbook.moviesapp.data.db.models.Movie @@ -45,7 +46,7 @@ interface MovieApi { .request() .url .newBuilder() - .addQueryParameter("api_key", "852eb333fbdf1f20f7da454df993da34") + .addQueryParameter("api_key", BuildConfig.TMDB_API_KEY) .build() val request = chain.request().newBuilder().url(url).build() @@ -55,7 +56,12 @@ interface MovieApi { val okHttpClient = OkHttpClient.Builder() .addInterceptor(requestInterceptor) - .addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)) + .apply { + // Body logging leaks the API key and response payloads; debug builds only. + if (BuildConfig.DEBUG) { + addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)) + } + } .build() return Retrofit.Builder() diff --git a/gradle.properties b/gradle.properties index ca3125ba..74be85fa 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,9 +4,15 @@ # any settings specified in this file. # For more details on how to configure your build environment visit # http://www.gradle.org/docs/current/userguide/build_environment.html -# Specifies the JVM arguments used for the daemon process. -# The setting is particularly useful for tweaking memory settings. -#org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 + +# JVM arguments for the Gradle daemon. ~31 Android modules run lint and D8 in +# this heap; 4g was not enough on the 16 GB GitHub runner (OutOfMemoryError in +# lintAnalyzeDebug and mergeExtDexRelease). +org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 +# Cap parallel workers so lint/dex workers do not all compete for the heap at once. +org.gradle.workers.max=2 +kotlin.daemon.jvmargs=-Xmx2g + # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects @@ -16,8 +22,5 @@ org.gradle.caching=true # Android operating system, and which are packaged with your app"s APK # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true -# Automatically convert third-party libraries to use AndroidX -android.enableJetifier=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official -org.gradle.jvmargs=-Xmx4096M -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 diff --git a/templates/pinlock/src/main/AndroidManifest.xml b/templates/pinlock/src/main/AndroidManifest.xml index 0a467df3..56ac4c63 100644 --- a/templates/pinlock/src/main/AndroidManifest.xml +++ b/templates/pinlock/src/main/AndroidManifest.xml @@ -4,7 +4,7 @@ From e1220ff86fa16d6c99fe139b74738878073ea705 Mon Sep 17 00:00:00 2001 From: Gurupreet Date: Sat, 12 Sep 2026 15:40:59 +0200 Subject: [PATCH 2/4] ci: scan gradle deps from the repo root only Dependabot's first two gradle runs on master failed with "Error processing androidx.paging:paging-compose (RuntimeError) No files changed!". With directories ["/", "/**"] each module directory is updated on its own, but module build files only reference catalog aliases; the version is in gradle/libs.versions.toml at the root. Co-Authored-By: Claude Fable 5.1 --- .github/dependabot.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 85e4d294..49963cff 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,13 +1,14 @@ # Dependabot: security updates + routine version bumps. -# The gradle ecosystem uses `directories` (glob) so every module's build file -# is watched — root, buildSrc, each app/data/theme/component/template/demo subproject. +# The gradle ecosystem is rooted at "/" only: versions live in +# gradle/libs.versions.toml and Dependabot follows settings.gradle.kts to every +# subproject from there. Scanning each module directory separately ("/**") made +# the updater fail with "No files changed" because the module build files only +# reference catalog aliases. # The github-actions ecosystem watches workflows; directories key does not apply there. version: 2 updates: - package-ecosystem: gradle - directories: - - "/" - - "/**" + directory: / schedule: interval: weekly open-pull-requests-limit: 5 From 7c28128f7f5fd201ebccb97702df0d6937f0528e Mon Sep 17 00:00:00 2001 From: Gurupreet Date: Sat, 12 Sep 2026 16:18:19 +0200 Subject: [PATCH 3/4] ci: skip dependency review while Dependency graph is disabled actions/dependency-review-action hard-fails with "Dependency review is not supported on this repository" when the graph is off. Probe the SBOM endpoint first and skip with a workflow warning instead, so the check is green until the setting is enabled and starts reviewing automatically afterwards. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/dependency-review.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 0603c165..36fa43c9 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -2,6 +2,7 @@ name: Dependency review # Flags PRs that introduce dependencies with known vulnerabilities. # Requires "Dependency graph" to be enabled under Settings > Code security. +# While it is disabled the job skips with a warning instead of failing. on: pull_request: branches: [ master ] @@ -15,7 +16,21 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 + + - name: Check that Dependency graph is enabled + id: graph + env: + GH_TOKEN: ${{ github.token }} + run: | + if gh api "repos/${GITHUB_REPOSITORY}/dependency-graph/sbom" --silent >/dev/null 2>&1; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + else + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "::warning title=Dependency review skipped::Dependency graph is disabled for this repository. Enable it under Settings > Code security and analysis to activate dependency review." + fi + - name: Dependency review + if: steps.graph.outputs.enabled == 'true' uses: actions/dependency-review-action@v4 with: fail-on-severity: high From 7717932447d0ec45f0895f4d7fbf83f7bf377348 Mon Sep 17 00:00:00 2001 From: Gurupreet Date: Mon, 14 Sep 2026 16:54:11 +0200 Subject: [PATCH 4/4] ci: restore the "Building the APK" required check as a thin job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Branch protection on master requires a status check named literally "Building the APK" — the job name in the old build.yml this PR deleted. Deleting that workflow left the required check with nothing to ever report, permanently blocking merges. Add a lightweight job under that same name that depends on the real build job instead of editing the protected-branch rule. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/android.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index f9813b0d..182e72b3 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -99,3 +99,15 @@ jobs: name: lint-report path: app/build/reports/lint-results-debug.html if-no-files-found: ignore + + # Branch protection on master requires a status check literally named + # "Building the APK" — that was the job name in the old build.yml, which + # this PR removed as a duplicate of the `build` job above. Keeping this + # thin job (rather than editing the protected-branch setting) makes the + # existing rule keep working without re-running assembleDebug a second time. + building-the-apk: + name: Building the APK + needs: build + runs-on: ubuntu-latest + steps: + - run: echo "Covered by the build job above, which assembles and uploads the debug APK."