diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index bbb96637..4ad7a4e9 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -1,9 +1,15 @@ -# Android CI — JVM unit tests + static repo scans + secret scan. +# Android CI — JVM unit tests + Android Lint + R8 release-build gate + static +# repo scans + secret scan. # -# Secrets are intentionally ABSENT here: the debug build compiles with an empty -# APP_CHECK_DEBUG_TOKEN and a placeholder RC_API_KEY (see app/build.gradle.kts -# `secret()` resolution). CI-built APKs therefore cannot pass App Check once -# enforcement is on — expected; CI only runs JVM tests. +# Real Firebase/RC secrets are intentionally ABSENT: the debug build compiles with +# an empty APP_CHECK_DEBUG_TOKEN and a placeholder RC_API_KEY (see +# app/build.gradle.kts `secret()` resolution). CI-built APKs therefore cannot pass +# App Check once enforcement is on — expected. +# +# The `release-build` job DOES exercise the RELEASE variant (minify+shrink+sign) +# using a THROWAWAY keystore + a dummy RC_API_KEY, purely to satisfy the release +# guards; that AAB is never distributed. A green release-build proves the R8 +# toolchain, not that reflectively-loaded classes survive shrinking at runtime. # # app/google-services.json is gitignored (live Firebase config), but the # google-services Gradle plugin hard-fails without one, so CI copies the dummy @@ -45,6 +51,85 @@ jobs: name: unit-test-reports path: app/build/reports/tests/ + android-lint: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - uses: gradle/actions/setup-gradle@v4 + + - name: Provide stub google-services.json (real one is gitignored) + run: | + if [ ! -f app/google-services.json ]; then + cp .github/ci/google-services.json app/google-services.json + fi + + # Fails on error-severity issues (NewApi, correctness). This gate caught two + # LocalDate.ofInstant NewApi crashes (API 34 call, minSdk 26) on 2026-07-11 + # that emulator QA missed because the fixture emulators run API 34+. + - name: Android Lint (debug) + run: ./gradlew :app:lintDebug + + - name: Upload lint report on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: lint-report + path: app/build/reports/lint-results-debug.html + + release-build: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - uses: gradle/actions/setup-gradle@v4 + + - name: Provide stub google-services.json (real one is gitignored) + run: | + if [ ! -f app/google-services.json ]; then + cp .github/ci/google-services.json app/google-services.json + fi + + # Throwaway upload key — only exists to satisfy the release signing guard. + # This AAB is never distributed. + - name: Generate throwaway CI signing keystore + run: | + keytool -genkeypair -v \ + -keystore "$RUNNER_TEMP/ci-release.jks" \ + -alias ci -keyalg RSA -keysize 2048 -validity 30 \ + -storepass ci_ci_ci -keypass ci_ci_ci \ + -dname "CN=CI, O=CI, C=US" + + - name: Build minified release bundle (R8 gate) + env: + RC_API_KEY: ci_dummy_rc_key_not_real + RELEASE_STORE_FILE: ${{ runner.temp }}/ci-release.jks + RELEASE_STORE_PASSWORD: ci_ci_ci + RELEASE_KEY_ALIAS: ci + RELEASE_KEY_PASSWORD: ci_ci_ci + # Skip the Crashlytics mapping upload — it needs real Firebase creds the + # stub google-services.json does not have. + run: ./gradlew :app:bundleRelease -x uploadCrashlyticsMappingFileRelease + + - name: Upload R8 mapping on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: r8-mapping + path: app/build/outputs/mapping/release/ + repo-scans: runs-on: ubuntu-latest timeout-minutes: 10 diff --git a/app/src/main/java/app/closer/ui/messages/components/ChatComponents.kt b/app/src/main/java/app/closer/ui/messages/components/ChatComponents.kt index 946925de..4a2bd223 100644 --- a/app/src/main/java/app/closer/ui/messages/components/ChatComponents.kt +++ b/app/src/main/java/app/closer/ui/messages/components/ChatComponents.kt @@ -288,6 +288,9 @@ private fun rememberMediaImageLoader(): ImageLoader { } } +// False positive: the producer DOES assign `value` unconditionally below. The +// ProduceStateDoesNotAssignValue check misfires when the RHS is a suspend call. +@Suppress("ProduceStateDoesNotAssignValue") @Composable private fun EncryptedChatImage( mediaUrl: String, diff --git a/app/src/main/java/app/closer/ui/outcomes/YourProgressViewModel.kt b/app/src/main/java/app/closer/ui/outcomes/YourProgressViewModel.kt index 017af227..7df35a46 100644 --- a/app/src/main/java/app/closer/ui/outcomes/YourProgressViewModel.kt +++ b/app/src/main/java/app/closer/ui/outcomes/YourProgressViewModel.kt @@ -125,7 +125,8 @@ class YourProgressViewModel @Inject constructor( } private fun dueFollowUpDay(createdAt: Long, outcomes: List): OutcomeDay? { - val paired = LocalDate.ofInstant(Instant.ofEpochMilli(createdAt), ZoneId.systemDefault()) + // LocalDate.ofInstant is API 34+; atZone().toLocalDate() is API 26 (our minSdk) — same result. + val paired = Instant.ofEpochMilli(createdAt).atZone(ZoneId.systemDefault()).toLocalDate() val today = LocalDate.now() val ageDays = java.time.temporal.ChronoUnit.DAYS.between(paired, today) diff --git a/app/src/main/java/app/closer/ui/questions/components/QuestionDiscussionThread.kt b/app/src/main/java/app/closer/ui/questions/components/QuestionDiscussionThread.kt index ff76d1a7..9588f3bf 100644 --- a/app/src/main/java/app/closer/ui/questions/components/QuestionDiscussionThread.kt +++ b/app/src/main/java/app/closer/ui/questions/components/QuestionDiscussionThread.kt @@ -186,6 +186,9 @@ private fun DiscussionMessageBubble( } /** Downloads the encrypted image bytes, decrypts them on-device, and renders the photo. */ +// False positive: the producer DOES assign `value` unconditionally below. The +// ProduceStateDoesNotAssignValue check misfires when the RHS is a suspend call. +@Suppress("ProduceStateDoesNotAssignValue") @Composable private fun EncryptedChatImage( mediaUrl: String, diff --git a/app/src/main/java/app/closer/ui/settings/SettingsViewModel.kt b/app/src/main/java/app/closer/ui/settings/SettingsViewModel.kt index 083ba353..85d02961 100644 --- a/app/src/main/java/app/closer/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/app/closer/ui/settings/SettingsViewModel.kt @@ -220,7 +220,8 @@ class SettingsViewModel @Inject constructor( fun consumeOutcomeError() = _uiState.update { it.copy(outcomeError = null) } private fun dueFollowUpDay(createdAt: Long, outcomes: List): OutcomeDay? { - val paired = LocalDate.ofInstant(Instant.ofEpochMilli(createdAt), ZoneId.systemDefault()) + // LocalDate.ofInstant is API 34+; atZone().toLocalDate() is API 26 (our minSdk) — same result. + val paired = Instant.ofEpochMilli(createdAt).atZone(ZoneId.systemDefault()).toLocalDate() val today = LocalDate.now() val ageDays = java.time.temporal.ChronoUnit.DAYS.between(paired, today) val due = listOf(