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 diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 23ac9e2d..182e72b3 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -5,24 +5,109 @@ 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 + + # 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." 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..36fa43c9 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,36 @@ +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 ] + +permissions: + contents: read + +jobs: + dependency-review: + runs-on: ubuntu-latest + 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 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 @@