diff --git a/.github/scripts/run-benchmark-with-retry.sh b/.github/scripts/run-benchmark-with-retry.sh new file mode 100644 index 00000000..6556ccd3 --- /dev/null +++ b/.github/scripts/run-benchmark-with-retry.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash + +set -uo pipefail + +readonly package_prefix="com.ytapps.composetemplate" +readonly max_attempts=3 + +adb wait-for-device + +for attempt in $(seq 1 "$max_attempts"); do + echo "Benchmark instrumentation attempt $attempt/$max_attempts" + if ./gradlew :benchmark:connectedBenchmarkAndroidTest; then + exit 0 + fi + + if (( attempt == max_attempts )); then + break + fi + + while IFS= read -r package_name; do + if [[ -n "$package_name" ]]; then + adb uninstall "$package_name" || true + fi + done < <( + adb shell pm list packages 2>/dev/null | + tr -d '\r' | + sed 's/^package://' | + grep "^${package_prefix}" || true + ) + + adb kill-server || true + adb start-server + adb wait-for-device + sleep 20 +done + +echo "Benchmark instrumentation failed after $max_attempts attempts." +exit 1 diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml new file mode 100644 index 00000000..d8f35332 --- /dev/null +++ b/.github/workflows/benchmark.yml @@ -0,0 +1,36 @@ +name: Benchmark + +on: + workflow_dispatch: + schedule: + - cron: '0 3 * * 1' + +permissions: + contents: read + +concurrency: + group: benchmark-${{ github.ref }} + cancel-in-progress: true + +jobs: + benchmark: + name: API 35 Benchmark Instrumentation + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-java@v5 + with: + java-version: 17 + distribution: temurin + - uses: gradle/actions/setup-gradle@v5 + - name: Create local.properties and secrets.properties + run: bash core/secrets/ci/setup.sh + - name: Run benchmark instrumentation + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 35 + arch: x86_64 + profile: pixel_6 + disable-animations: true + script: bash .github/scripts/run-benchmark-with-retry.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3f76194..776ec184 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,381 +6,131 @@ on: push: branches: [main] +permissions: + contents: read + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: lint: - name: Lint (ktlint + detekt) + name: Lint (ktlint + detekt + docs) runs-on: ubuntu-latest + timeout-minutes: 15 steps: - - uses: actions/checkout@v4 - - name: Set up JDK 17 - uses: actions/setup-java@v4 + - uses: actions/checkout@v5 + - uses: actions/setup-java@v5 with: java-version: 17 distribution: temurin - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v4 + - uses: gradle/actions/setup-gradle@v5 - name: Create local.properties and secrets.properties + run: bash core/secrets/ci/setup.sh + - name: Run Kotlin quality gates + run: ./gradlew ktlintCheck detekt + - name: Set up Python + if: ${{ hashFiles('mkdocs.yml') != '' }} + uses: actions/setup-python@v5 + with: + python-version: '3.13' + - name: Validate documentation + if: ${{ hashFiles('mkdocs.yml') != '' }} run: | - echo "STORE_FILE=debug.keystore" > local.properties - cat > secrets.properties <<'EOF' - API_KEY_DEBUG="debug_secret_value" - API_KEY_RELEASE="release_secret_value" - BASE_URL_DEBUG="https://example.com/" - BASE_URL_RELEASE="https://example.com/" - KEY_ALIAS="androiddebugkey" - KEY_PASSWORD="android" - STORE_PASSWORD="android" - XOR_MASK="ci_mask_value_with_24_chars_minimum" - EXPECTED_SIGNATURE_HASH="53017D6F6B5A861B3096D18257D5C91F0588287BC587F12C35767D362EF0A3A8" - NATIVE_RUNTIME_CHECKS_ENABLED=true - CERTIFICATE_PINNING_ENABLED=false - CERTIFICATE_PINS="" - MIN_VERSION=1 - EOF - - name: Run ktlint - run: ./gradlew ktlintCheck - - name: Run detekt - run: ./gradlew detekt + python -m pip install mkdocs-material + mkdocs build --strict test: - name: Unit Tests + name: Unit Tests + Coverage runs-on: ubuntu-latest + timeout-minutes: 20 steps: - - uses: actions/checkout@v4 - - name: Set up JDK 17 - uses: actions/setup-java@v4 + - uses: actions/checkout@v5 + - uses: actions/setup-java@v5 with: java-version: 17 distribution: temurin - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v4 + - uses: gradle/actions/setup-gradle@v5 - name: Create local.properties and secrets.properties - run: | - echo "STORE_FILE=debug.keystore" > local.properties - cat > secrets.properties <<'EOF' - API_KEY_DEBUG="debug_secret_value" - API_KEY_RELEASE="release_secret_value" - BASE_URL_DEBUG="https://example.com/" - BASE_URL_RELEASE="https://example.com/" - KEY_ALIAS="androiddebugkey" - KEY_PASSWORD="android" - STORE_PASSWORD="android" - XOR_MASK="ci_mask_value_with_24_chars_minimum" - EXPECTED_SIGNATURE_HASH="53017D6F6B5A861B3096D18257D5C91F0588287BC587F12C35767D362EF0A3A8" - NATIVE_RUNTIME_CHECKS_ENABLED=true - CERTIFICATE_PINNING_ENABLED=false - CERTIFICATE_PINS="" - MIN_VERSION=1 - EOF - - name: Run unit tests - run: ./gradlew testDebugUnitTest + run: bash core/secrets/ci/setup.sh + - name: Run app tests and coverage + run: ./gradlew testDebugUnitTest jacocoDebugReport + - name: Test build logic + run: ./gradlew -p build-logic :convention:test + - name: Upload coverage reports + uses: actions/upload-artifact@v4 + with: + name: jacoco-debug-reports + path: '**/build/reports/jacoco/jacocoDebugReport/**' + if-no-files-found: warn build: name: Assemble Debug + Release runs-on: ubuntu-latest + timeout-minutes: 20 steps: - - uses: actions/checkout@v4 - - name: Set up JDK 17 - uses: actions/setup-java@v4 + - uses: actions/checkout@v5 + - uses: actions/setup-java@v5 with: java-version: 17 distribution: temurin - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v4 + - uses: gradle/actions/setup-gradle@v5 - name: Create local.properties and secrets.properties - run: | - echo "STORE_FILE=debug.keystore" > local.properties - cat > secrets.properties <<'EOF' - API_KEY_DEBUG="debug_secret_value" - API_KEY_RELEASE="release_secret_value" - BASE_URL_DEBUG="https://example.com/" - BASE_URL_RELEASE="https://example.com/" - KEY_ALIAS="androiddebugkey" - KEY_PASSWORD="android" - STORE_PASSWORD="android" - XOR_MASK="ci_mask_value_with_24_chars_minimum" - EXPECTED_SIGNATURE_HASH="53017D6F6B5A861B3096D18257D5C91F0588287BC587F12C35767D362EF0A3A8" - NATIVE_RUNTIME_CHECKS_ENABLED=true - CERTIFICATE_PINNING_ENABLED=false - CERTIFICATE_PINS="" - MIN_VERSION=1 - EOF - - name: Assemble debug and release - run: ./gradlew assembleDebug :app:assembleRelease + run: bash core/secrets/ci/setup.sh + - run: ./gradlew assembleDebug :app:assembleRelease --stacktrace plug-out: name: Plug-out (optional modules removed) runs-on: ubuntu-latest + timeout-minutes: 15 steps: - - uses: actions/checkout@v4 - - name: Set up JDK 17 - uses: actions/setup-java@v4 + - uses: actions/checkout@v5 + - uses: actions/setup-java@v5 with: java-version: 17 distribution: temurin - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v4 + - uses: gradle/actions/setup-gradle@v5 - name: Create local.properties and secrets.properties - run: | - echo "STORE_FILE=debug.keystore" > local.properties - cat > secrets.properties <<'EOF' - API_KEY_DEBUG="debug_secret_value" - API_KEY_RELEASE="release_secret_value" - BASE_URL_DEBUG="https://example.com/" - BASE_URL_RELEASE="https://example.com/" - KEY_ALIAS="androiddebugkey" - KEY_PASSWORD="android" - STORE_PASSWORD="android" - XOR_MASK="ci_mask_value_with_24_chars_minimum" - EXPECTED_SIGNATURE_HASH="53017D6F6B5A861B3096D18257D5C91F0588287BC587F12C35767D362EF0A3A8" - NATIVE_RUNTIME_CHECKS_ENABLED=true - CERTIFICATE_PINNING_ENABLED=false - CERTIFICATE_PINS="" - MIN_VERSION=1 - EOF - - name: Delete optional modules - run: | - set -eu - for module in \ - core/security \ - core/analytics \ - core/database \ - core/network \ - core/secrets \ - feature/auth \ - benchmark \ - baselineprofile - do - echo "Removing $module" - rm -rf "$module" - done - - name: Verify removed modules are not referenced - run: | - set -eu - if grep -rn \ - --include="*.kt" \ - -E "core\.security|core\.analytics|core\.database|core\.network|core\.secrets|feature\.auth" \ - app core feature - then - echo "Removed module is still referenced from Kotlin sources." - exit 1 - fi - - name: Assemble debug without optional modules + run: bash core/secrets/ci/setup.sh + - name: Remove optional modules and build run: | set -euo pipefail - ./gradlew :app:assembleDebug | tee plug-out-build.log - if ! grep -q "Baseline profiles are disabled" plug-out-build.log; then - echo "Baseline profile wiring is not conditional." + rm -rf core/security core/analytics core/database core/network core/secrets feature/auth benchmark baselineprofile + if grep -rn --include='*.kt' -E 'core\.security|core\.analytics|core\.database|core\.network|core\.secrets|feature\.auth' app core feature; then + echo 'Removed module is still referenced.' exit 1 fi + ./gradlew :app:assembleDebug | tee plug-out-build.log + grep -q 'Baseline profiles are disabled' plug-out-build.log template-smoke: - name: Template Smoke (${{ matrix.strategy }}) + name: Template Smoke (remote) runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - strategy: remote - package_name: com.example.remote - app_name: RemoteApp - expect_network: true - expect_database: false - expect_secrets: true - - strategy: offline-first - package_name: com.example.offlinefirst - app_name: OfflineFirstApp - expect_network: true - expect_database: true - expect_secrets: true - - strategy: local - package_name: com.example.local - app_name: LocalApp - expect_network: false - expect_database: true - expect_secrets: false - - strategy: minimal - package_name: com.example.minimal - app_name: MinimalApp - expect_network: false - expect_database: false - expect_secrets: false + timeout-minutes: 15 steps: - - uses: actions/checkout@v4 - - name: Set up JDK 17 - uses: actions/setup-java@v4 + - uses: actions/checkout@v5 + - uses: actions/setup-java@v5 with: java-version: 17 distribution: temurin - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v4 - - name: Create template CI configuration - run: | - echo "STORE_FILE=debug.keystore" > local.properties - cat > secrets.properties <<'EOF' - API_KEY_DEBUG="debug_secret_value" - API_KEY_RELEASE="release_secret_value" - BASE_URL_DEBUG="https://example.com/" - BASE_URL_RELEASE="https://example.com/" - KEY_ALIAS="androiddebugkey" - KEY_PASSWORD="android" - STORE_PASSWORD="android" - XOR_MASK="ci_mask_value_with_24_chars_minimum" - EXPECTED_SIGNATURE_HASH="53017D6F6B5A861B3096D18257D5C91F0588287BC587F12C35767D362EF0A3A8" - NATIVE_RUNTIME_CHECKS_ENABLED=true - CERTIFICATE_PINNING_ENABLED=false - CERTIFICATE_PINS="" - MIN_VERSION=1 - EOF - - name: Verify invalid data strategy is rejected - if: matrix.strategy == 'remote' - run: | - set -euo pipefail - if ./gradlew create-new-app \ - -Pargs='com.example.invalid,InvalidStrategyApp' \ - -PdataStrategy=invalid \ - -q \ - --console=plain - then - echo "Invalid data strategy unexpectedly succeeded." - exit 1 - fi - test ! -e ../InvalidStrategyApp - - name: Verify scaffold task - run: ./gradlew help --task scaffoldFeature - - name: Generate regular feature scaffold - run: ./gradlew scaffoldFeature -PfeatureName=ci_feature - - name: Compile regular feature scaffold - run: ./gradlew :feature:ci_feature:presentation:compileDebugKotlin - - name: Generate Room feature scaffold - run: | - ./gradlew scaffoldFeature \ - -PfeatureName=ci_database_feature \ - -PwithDatabase=true - - name: Compile Room feature scaffold - run: | - ./gradlew \ - :feature:ci_database_feature:data:compileDebugKotlin \ - :feature:ci_database_feature:presentation:compileDebugKotlin - - name: Remove smoke-test feature scaffolds - run: rm -rf feature/ci_feature feature/ci_database_feature - - name: Generate ${{ matrix.strategy }} application - run: | - ./gradlew create-new-app \ - -Pargs='${{ matrix.package_name }},${{ matrix.app_name }}' \ - -PdataStrategy='${{ matrix.strategy }}' \ - -q \ - --console=plain - - name: Verify generated project metadata - shell: bash + - uses: gradle/actions/setup-gradle@v5 + - name: Create local.properties and secrets.properties + run: bash core/secrets/ci/setup.sh + - name: Generate remote application + run: ./gradlew create-new-app -Pargs='com.example.remote,RemoteApp' -PdataStrategy=remote -q --console=plain + - name: Configure generated application run: | set -euo pipefail - app_dir="../${{ matrix.app_name }}" + app_dir=../RemoteApp test -d "$app_dir" test ! -d "$app_dir/.git" test ! -f "$app_dir/local.properties" - if [ "${{ matrix.expect_secrets }}" = "true" ]; then - test -d "$app_dir/core/secrets" - test -f "$app_dir/secrets.properties.example" - echo "STORE_FILE=debug.keystore" > "$app_dir/local.properties" - keytool -genkeypair \ - -keystore "$app_dir/app/debug.keystore" \ - -storepass android \ - -alias androiddebugkey \ - -keypass android \ - -dname "CN=Android Debug,O=Android,C=US" \ - -keyalg RSA \ - -keysize 2048 \ - -validity 10000 - cat > "$app_dir/secrets.properties" <<'EOF' - API_KEY_DEBUG="debug_secret_value" - API_KEY_RELEASE="release_secret_value" - BASE_URL_DEBUG="https://example.com/" - BASE_URL_RELEASE="https://example.com/" - KEY_ALIAS="androiddebugkey" - KEY_PASSWORD="android" - STORE_PASSWORD="android" - XOR_MASK="ci_mask_value_with_24_chars_minimum" - EXPECTED_SIGNATURE_HASH="53017D6F6B5A861B3096D18257D5C91F0588287BC587F12C35767D362EF0A3A8" - NATIVE_RUNTIME_CHECKS_ENABLED=true - CERTIFICATE_PINNING_ENABLED=false - CERTIFICATE_PINS="" - MIN_VERSION=1 - EOF - else - test ! -d "$app_dir/core/secrets" - test ! -d "$app_dir/core/security" - test ! -f "$app_dir/secrets.properties" - test ! -f "$app_dir/secrets.properties.example" - fi - - name: Verify network projection - shell: bash - run: | - set -euo pipefail - app_dir="../${{ matrix.app_name }}" - if [ "${{ matrix.expect_network }}" = "true" ]; then - test -d "$app_dir/core/network" - test -d "$app_dir/feature/auth" - grep -R "LoginRoute" "$app_dir/feature/splash" "$app_dir/feature/onboarding" - else - test ! -d "$app_dir/core/network" - test ! -d "$app_dir/feature/auth" - ! grep -R "LoginRoute" "$app_dir/feature" --exclude-dir=build - ! grep -R \ - -E "core:network|core\.network|feature:auth|feature\.auth|libs\.retrofit|libs\.okhttp|NetworkMonitor|AppNoInternetBanner" \ - "$app_dir" \ - --exclude-dir=.gradle \ - --exclude-dir=build - fi - - name: Verify database projection - shell: bash - run: | - set -euo pipefail - app_dir="../${{ matrix.app_name }}" - if [ "${{ matrix.expect_database }}" = "true" ]; then - test -d "$app_dir/core/database" - else - test ! -d "$app_dir/core/database" - ! grep -R \ - -E "core:database|core\.database|androidx.room|androidx-room|AndroidRoomConventionPlugin|withDatabase" \ - "$app_dir" \ - --exclude-dir=.gradle \ - --exclude-dir=build - fi - - name: Build generated ${{ matrix.strategy }} application - working-directory: ../${{ matrix.app_name }} - run: ./gradlew assembleDebug :app:assembleRelease - - name: Generate network-backed application without secrets - if: matrix.strategy == 'remote' - run: | - ./gradlew create-new-app \ - -Pargs='com.example.remotesecretfree,RemoteSecretFreeApp' \ - -PdataStrategy=remote \ - -PwithSecrets=false \ - -q \ - --console=plain - - name: Verify network-backed secret-free projection - if: matrix.strategy == 'remote' - run: | - set -euo pipefail - app_dir="../RemoteSecretFreeApp" test -d "$app_dir/core/network" + test -d "$app_dir/core/secrets" test -d "$app_dir/feature/auth" - test ! -d "$app_dir/core/secrets" - test ! -d "$app_dir/core/security" - test ! -f "$app_dir/secrets.properties" - test ! -f "$app_dir/secrets.properties.example" - test ! -f "$app_dir/local.properties" - ! grep -R \ - -E "core:secrets|core\.secrets|core:security|core\.security|SecretManager|validateSecrets|scanApkForSecrets|hardeningReport|secrets\.properties|NATIVE_RUNTIME_CHECKS_ENABLED|EXPECTED_SIGNATURE_HASH|XOR_MASK|ndk =" \ - "$app_dir" \ - --exclude-dir=.gradle \ - --exclude-dir=build - - name: Build network-backed secret-free application - if: matrix.strategy == 'remote' - working-directory: ../RemoteSecretFreeApp - run: ./gradlew assembleDebug :app:assembleRelease + test ! -d "$app_dir/core/database" + (cd "$app_dir" && bash core/secrets/ci/setup.sh) + keytool -genkeypair -keystore "$app_dir/app/debug.keystore" -storepass android -alias androiddebugkey -keypass android -dname 'CN=Android Debug,O=Android,C=US' -keyalg RSA -keysize 2048 -validity 10000 + - name: Build generated application + working-directory: ../RemoteApp + run: ./gradlew assembleDebug --stacktrace diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b4052ceb..6b950fd0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -107,6 +107,10 @@ dependencies { implementation(libs.androidx.material3.adaptive.navigation3) implementation(libs.kotlinx.serialization.core) - // Baseline profile wiring, including the profileinstaller runtime dependency, is contributed - // by composetemplate.perf and only when :baselineprofile exists. + // Required in every tested app variant so Macrobenchmark's partial compilation broadcast can + // reach ProfileInstallReceiver in the target APK. + implementation(libs.androidx.profileinstaller) + + // Baseline profile plugin wiring is contributed by composetemplate.perf only when the + // :baselineprofile generator project exists. } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 66c2baee..91645223 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -8,18 +8,9 @@ -keepattributes SourceFile,LineNumberTable -renamesourcefileattribute SourceFile -# ----------------------------------------------------------- -# Gson: Keep all network model / DTO classes -# R8 full-mode with proguard-android-optimize.txt can strip -# synthetic constructors that Gson relies on via Unsafe -# ----------------------------------------------------------- --keep class com.ytapps.composetemplate.feature.auth.data.model.** { *; } - # ----------------------------------------------------------- # Kotlinx Serialization: Keep generated serializer classes -# Navigation3 deserializes routes at runtime across module -# boundaries. The compiler plugin adds @Keep to serializers -# but multi-module builds need explicit rules as a safety net. +# Navigation3 and Retrofit deserialize typed models and routes. # ----------------------------------------------------------- -keep,includedescriptorclasses class com.ytapps.composetemplate.**$$serializer { *; } -keepclassmembers class com.ytapps.composetemplate.** { @@ -29,7 +20,5 @@ # ----------------------------------------------------------- # Retrofit: Keep service interface method signatures -# Consumer rules already cover @retrofit2.http.* annotations; -# explicit rule prevents shrinking when interfaces are internal # ----------------------------------------------------------- -keep,allowobfuscation,allowshrinking interface com.ytapps.composetemplate.feature.auth.data.remote.AuthService { *; } diff --git a/app/src/main/java/com/ytapps/composetemplate/MainActivity.kt b/app/src/main/java/com/ytapps/composetemplate/MainActivity.kt index a8d7d548..06a73586 100644 --- a/app/src/main/java/com/ytapps/composetemplate/MainActivity.kt +++ b/app/src/main/java/com/ytapps/composetemplate/MainActivity.kt @@ -6,6 +6,7 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.ytapps.composetemplate.core.common.IThemeManager import com.ytapps.composetemplate.core.navigation.INavigationManager import com.ytapps.composetemplate.core.navigation.NavigationObserver import com.ytapps.composetemplate.core.navigation.ScreenRegistry @@ -22,14 +23,23 @@ class MainActivity : ComponentActivity() { @Inject lateinit var screenRegistry: ScreenRegistry + @Inject + lateinit var themeManager: IThemeManager + @Inject lateinit var navigationObservers: Set<@JvmSuppressWildcards NavigationObserver> override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + savedInstanceState + ?.getStringArrayList(NAVIGATION_BACK_STACK_KEY) + ?.let(screenRegistry::restoreBackStack) + ?.takeIf { it.isNotEmpty() } + ?.let(navigationManager::restoreBackStack) + enableEdgeToEdge() setContent { - val isDarkMode by navigationManager.isDarkModeFlow.collectAsStateWithLifecycle() + val isDarkMode by themeManager.isDarkModeFlow.collectAsStateWithLifecycle() ComposeTemplateTheme(darkTheme = isDarkMode) { AppNavigation( @@ -40,4 +50,16 @@ class MainActivity : ComponentActivity() { } } } + + override fun onSaveInstanceState(outState: Bundle) { + outState.putStringArrayList( + NAVIGATION_BACK_STACK_KEY, + ArrayList(navigationManager.backStack.value.map { it.route }), + ) + super.onSaveInstanceState(outState) + } + + private companion object { + const val NAVIGATION_BACK_STACK_KEY = "navigation_back_stack" + } } diff --git a/benchmark/src/main/java/com/ytapps/composetemplate/benchmark/StartupBenchmark.kt b/benchmark/src/main/java/com/ytapps/composetemplate/benchmark/StartupBenchmark.kt index 556c1cdf..3e1088c9 100644 --- a/benchmark/src/main/java/com/ytapps/composetemplate/benchmark/StartupBenchmark.kt +++ b/benchmark/src/main/java/com/ytapps/composetemplate/benchmark/StartupBenchmark.kt @@ -20,7 +20,7 @@ class StartupBenchmark { packageName = "com.ytapps.composetemplate", metrics = listOf(StartupTimingMetric()), compilationMode = CompilationMode.Partial( - baselineProfileMode = BaselineProfileMode.Require, + baselineProfileMode = BaselineProfileMode.UseIfAvailable, ), iterations = 5, startupMode = StartupMode.COLD diff --git a/build-logic/README.md b/build-logic/README.md index d31b1ed1..35041511 100644 --- a/build-logic/README.md +++ b/build-logic/README.md @@ -1,58 +1,21 @@ # Build Logic -`build-logic` is an included Gradle build that owns ComposeTemplate's generation, architecture, quality, optional-infrastructure, and verification conventions. - -## Registered convention plugins (21) - -| Area | Plugin IDs | -| --- | --- | -| Application/library | `composetemplate.android.application`, `android.application.compose`, `android.library`, `android.library.compose` | -| Android capabilities | `android.hilt`, `android.room`, `android.library.native` | -| Feature layers | `feature.domain`, `feature.data`, `feature.navigation`, `feature.presentation` | -| Quality/boundaries | `test`, `static.analysis`, `app.boundary`, `module.boundary` | -| Generation | `create.new.app`, `data.strategy.projection`, `scaffold.feature` | -| Operations | `validate.secrets`, `baseline.profile.generator`, `perf` | - -The exact registrations live in `convention/build.gradle.kts`. +`build-logic` is the included Gradle build for generation, architecture, quality, optional infrastructure, and verification conventions. ## Generation -### `composetemplate.create.new.app` - -Copies the source template into a sibling project, rebrands package/application names, excludes local/template-only files, writes consumer documentation, and validates that template-only residue is absent. - -### `composetemplate.data.strategy.projection` - -Decorates `create-new-app` with strict `remote`, `offline-first`, `local`, and `minimal` projection. It selects network/auth and Room infrastructure, projects the correct Login/Home navigation flow, applies effective secrets selection, removes unselected catalog/build/source/documentation residue, and deletes generator-only code from the consumer. - -### `composetemplate.scaffold.feature` - -Creates the fixed `data`, `domain`, `navigation`, and `presentation` feature vertical. In database-backed consumers, `-PwithDatabase=true` adds starter Room entity/DAO files. Filesystem module discovery means scaffolding does not edit settings or app build files. - -## Architecture enforcement - -- `app.boundary` prevents `:app` source imports from optional modules. -- `module.boundary` registers source-import and literal project-dependency checks for library modules. -- Dynamic project paths remain supported for filesystem discovery. -- Per-module exceptions are explicit in that module's `moduleBoundary` block. - -## Optional infrastructure +- `create.new.app` copies and rebrands the template. +- `data.strategy.projection` applies strict `remote`, `offline-first`, `local`, or `minimal` topology. Required rewrites must match exactly once; unit tests cover successful, missing, and duplicate matches. +- `scaffold.feature` creates the fixed data/domain/navigation/presentation vertical. Generated screen providers include stable-route restoration; `-PwithDatabase=true` adds Room starters when available. -- `feature.data` wires only infrastructure selected in the generated consumer. -- `perf` enables baseline-profile wiring only when `:baselineprofile` exists. -- `android.room` owns Room/KSP configuration. -- `android.library.native` owns CMake/NDK configuration. -- `validate.secrets` provides `validateSecrets`, `scanApkForSecrets`, and `hardeningReport`. +Network-free projection removes Retrofit/OkHttp/auth and the kotlinx.serialization Retrofit converter. Database-free projection removes Room and database scaffold options. `offline-first` retains both infrastructure families but intentionally does not generate synchronization policy. -## Quality and shared configuration +## Quality -Android, Compose, Hilt, testing, static analysis, SDK values, and dependencies are centralized in convention plugins and `gradle/libs.versions.toml`. Current baseline: minSdk 26, targetSdk 36, compileSdk 37, Kotlin 2.0.21, AGP 9.2.1, and KSP 2.0.21-1.0.28. +The test convention registers `jacocoDebugReport` with XML and HTML output. CI runs Android unit tests, build-logic tests, strict docs validation, coverage artifact upload, strategy smoke builds, and benchmark instrumentation. -Compose compiler metrics and reports are controlled by: +## Boundaries -```properties -composetemplate.composeCompilerMetricsEnabled=true -composetemplate.composeCompilerReportsEnabled=true -``` +App/module boundary plugins protect optional-module removability. Dynamic filesystem discovery remains supported, while literal project dependencies and forbidden imports are checked. -See [`../wiki/01-module-topology.md`](../wiki/01-module-topology.md), [`../wiki/05-generator-and-scaffolding.md`](../wiki/05-generator-and-scaffolding.md), and [`../wiki/06-quality-tests-ci.md`](../wiki/06-quality-tests-ci.md). +See the [module topology](../wiki/01-module-topology.md), [generator guide](../wiki/05-generator-and-scaffolding.md), [quality guide](../wiki/06-quality-tests-ci.md), and [risk register](../wiki/07-risks-and-gaps.md). diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index 611da900..ef53cfb8 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -11,6 +11,8 @@ dependencies { compileOnly(libs.compose.compiler.gradle.plugin) compileOnly(libs.detekt.gradle.plugin) compileOnly(libs.ktlint.gradle.plugin) + + testImplementation(kotlin("test")) } gradlePlugin { diff --git a/build-logic/convention/src/main/kotlin/com/ytapps/composetemplate/convention/CreateNewAppPlugin.kt b/build-logic/convention/src/main/kotlin/com/ytapps/composetemplate/convention/CreateNewAppPlugin.kt index 34849a56..9a0e3411 100644 --- a/build-logic/convention/src/main/kotlin/com/ytapps/composetemplate/convention/CreateNewAppPlugin.kt +++ b/build-logic/convention/src/main/kotlin/com/ytapps/composetemplate/convention/CreateNewAppPlugin.kt @@ -73,6 +73,7 @@ class CreateNewAppPlugin : Plugin { "mkdocs.yml", "CONTRIBUTING.md", ".github/workflows/pages.yml", + ".github/workflows/diagnostic.yml", ) } @@ -191,6 +192,8 @@ class CreateNewAppPlugin : Plugin { } removeTemplateOnlyWorkflowJobs(targetDir) + removeWorkflowStep(targetDir, "Set up Python") + removeWorkflowStep(targetDir, "Validate documentation") writeConsumerReadme(targetDir, appName, withSecrets) writeConsumerBuildLogicReadme(targetDir) validateGeneratedProject(targetDir, withSecrets) diff --git a/build-logic/convention/src/main/kotlin/com/ytapps/composetemplate/convention/DataStrategyProjector.kt b/build-logic/convention/src/main/kotlin/com/ytapps/composetemplate/convention/DataStrategyProjector.kt index b118d7aa..8473cfa8 100644 --- a/build-logic/convention/src/main/kotlin/com/ytapps/composetemplate/convention/DataStrategyProjector.kt +++ b/build-logic/convention/src/main/kotlin/com/ytapps/composetemplate/convention/DataStrategyProjector.kt @@ -49,22 +49,22 @@ internal object DataStrategyProjector { private fun applyAuthFlow(targetDir: File) { targetDir.findRequired("feature/splash/domain", "SplashDestination.kt") - .replaceRequired( + .replaceRequiredExactly( " data object Onboarding : SplashDestination\n\n data object Home : SplashDestination", " data object Onboarding : SplashDestination\n\n data object Login : SplashDestination\n\n data object Home : SplashDestination", ) targetDir.findRequired("feature/splash/domain", "ISplashRepository.kt") - .replaceRequired( + .replaceRequiredExactly( "interface ISplashRepository {\n suspend fun isOnboardingCompleted(): Boolean", "interface ISplashRepository {\n suspend fun hasUser(): Boolean\n\n suspend fun isOnboardingCompleted(): Boolean", ) targetDir.findRequired("feature/splash/data", "SplashRepository.kt") - .replaceRequired( + .replaceRequiredExactly( " ) : ISplashRepository {\n override suspend fun isOnboardingCompleted(): Boolean", " ) : ISplashRepository {\n override suspend fun hasUser(): Boolean = preferencesManager.hasUser()\n\n override suspend fun isOnboardingCompleted(): Boolean", ) targetDir.findRequired("feature/splash/domain", "GetStartDestinationUseCase.kt") - .replaceRequired( + .replaceRequiredExactly( """ suspend operator fun invoke(): SplashDestination = if (splashRepository.isOnboardingCompleted()) { SplashDestination.Home @@ -82,44 +82,50 @@ internal object DataStrategyProjector { ) File(targetDir, "feature/splash/presentation/build.gradle.kts") - .replaceRequired( + .replaceRequiredExactly( " implementation(project(\":feature:home:navigation\"))\n", " implementation(project(\":feature:home:navigation\"))\n implementation(project(\":feature:auth:navigation\"))\n", ) targetDir.findRequired("feature/splash/presentation", "SplashViewModel.kt") .addAuthImportBeforeHomeImport() - .replaceRequired( + .replaceRequiredExactly( " SplashDestination.Home -> HomeRoute\n", " SplashDestination.Home -> HomeRoute\n SplashDestination.Login -> LoginRoute\n", ) targetDir.findRequired("feature/splash/domain", "GetStartDestinationUseCaseTest.kt") - .replaceRequired( + .replaceRequiredExactly( " coEvery { splashRepository.isOnboardingCompleted() } returns true\n\n val result", " coEvery { splashRepository.isOnboardingCompleted() } returns true\n coEvery { splashRepository.hasUser() } returns true\n\n val result", ) File(targetDir, "feature/onboarding/presentation/build.gradle.kts") - .replaceRequired(":feature:home:navigation", ":feature:auth:navigation") + .replaceRequiredExactly(":feature:home:navigation", ":feature:auth:navigation") targetDir.findRequired("feature/onboarding/presentation", "OnboardingRoute.kt") - .replaceRequired("feature.home.navigation.HomeRoute", "feature.auth.navigation.LoginRoute") - .replaceRequired("NavigateToHome", "NavigateToLogin") - .replaceRequired("route = HomeRoute", "route = LoginRoute") + .replaceRequiredExactly("feature.home.navigation.HomeRoute", "feature.auth.navigation.LoginRoute") + .replaceRequiredExactly("NavigateToHome", "NavigateToLogin") + .replaceRequiredExactly("route = HomeRoute", "route = LoginRoute") targetDir.findRequired("feature/onboarding/presentation", "OnboardingViewModel.kt") - .replaceRequired("NavigateToHome", "NavigateToLogin") + .replaceRequiredExactly( + "sendEvent(OnboardingEvent.NavigateToHome)", + "sendEvent(OnboardingEvent.NavigateToLogin)", + ).replaceRequiredExactly( + "object NavigateToHome : OnboardingEvent()", + "object NavigateToLogin : OnboardingEvent()", + ) File(targetDir, "feature/profile/presentation/build.gradle.kts") - .replaceRequired(":feature:home:navigation", ":feature:auth:navigation") + .replaceRequiredExactly(":feature:home:navigation", ":feature:auth:navigation") targetDir.findRequired("feature/profile/presentation", "ProfileEvent.kt") - .replaceRequired("NavigateToHome", "NavigateToLogin") + .replaceRequiredExactly("NavigateToHome", "NavigateToLogin") targetDir.findRequired("feature/profile/presentation", "ProfileRoute.kt") - .replaceRequired("feature.home.navigation.HomeRoute", "feature.auth.navigation.LoginRoute") - .replaceRequired("NavigateToHome", "NavigateToLogin") - .replaceRequired("navigateToTop(HomeRoute)", "navigateToTop(LoginRoute)") + .replaceRequiredExactly("feature.home.navigation.HomeRoute", "feature.auth.navigation.LoginRoute") + .replaceRequiredExactly("NavigateToHome", "NavigateToLogin") + .replaceRequiredExactly("navigateToTop(HomeRoute)", "navigateToTop(LoginRoute)") targetDir.findRequired("feature/profile/presentation", "ProfileViewModel.kt") - .replaceRequired("NavigateToHome", "NavigateToLogin") + .replaceRequiredExactly("NavigateToHome", "NavigateToLogin") targetDir.findRequired("feature/profile/presentation", "ProfileViewModelTest.kt") - .replaceRequired("navigate to home", "navigate to login") - .replaceRequired("NavigateToHome", "NavigateToLogin") + .replaceRequiredExactly("navigate to home", "navigate to login") + .replaceRequiredExactly("NavigateToHome", "NavigateToLogin") } private fun removeNetworkCapability(targetDir: File) { @@ -128,7 +134,7 @@ internal object DataStrategyProjector { val value = line.trimStart() value == "# Network" || value.startsWith("retrofit =") || - value.startsWith("converter-gson =") || + value.startsWith("converter-kotlinx-serialization =") || value.startsWith("okhttp =") || value.startsWith("logging-interceptor =") || value.startsWith("coil-network-okhttp =") @@ -139,11 +145,6 @@ internal object DataStrategyProjector { val proguard = File(targetDir, "app/proguard-rules.pro") if (proguard.exists()) { var content = proguard.readText() - val gsonStart = content.indexOf("# Gson: Keep all network model / DTO classes") - val serializationStart = content.indexOf("# Kotlinx Serialization:") - if (gsonStart >= 0 && serializationStart > gsonStart) { - content = content.removeRange(content.lastIndexOf("# ---", gsonStart), serializationStart) - } val retrofitStart = content.indexOf("# Retrofit: Keep service interface method signatures") if (retrofitStart >= 0) { content = content.substring(0, content.lastIndexOf("# ---", retrofitStart)).trimEnd() + "\n" @@ -227,7 +228,7 @@ internal object DataStrategyProjector { .mapNotNull(rootProject::findProject)""" } targetDir.findRequired("build-logic/convention", "FeatureDataConventionPlugin.kt") - .replaceRequired( + .replaceRequiredExactly( """ val optionalInfrastructure = listOf(":core:network", ":core:database") .mapNotNull(rootProject::findProject)""", @@ -332,6 +333,8 @@ All retained dependencies and versions are centralized in `gradle/libs.versions. private fun removeProjectionInfrastructure(targetDir: File) { targetDir.findByName("DataStrategyProjectionPlugin.kt")?.delete() targetDir.findByName("DataStrategyProjector.kt")?.delete() + targetDir.findByName("DataStrategyProjectorTest.kt")?.delete() + targetDir.findByName("DataStrategyTest.kt")?.delete() removePluginRegistration( File(targetDir, "build-logic/convention/build.gradle.kts"), "dataStrategyProjection", @@ -348,7 +351,7 @@ All retained dependencies and versions are centralized in `gradle/libs.versions. val pathViolations = forbiddenPaths.filter { File(targetDir, it).exists() } val forbiddenTokens = buildList { if (!strategy.usesNetwork) { - addAll(listOf("core:network", "core.network", "feature:auth", "feature.auth", "libs.retrofit", "libs.okhttp", "libs.converter.gson", "libs.logging.interceptor", "libs.coil.network.okhttp", "AppNoInternetBanner", "NetworkMonitor")) + addAll(listOf("core:network", "core.network", "feature:auth", "feature.auth", "libs.retrofit", "libs.okhttp", "libs.converter.kotlinx.serialization", "libs.logging.interceptor", "libs.coil.network.okhttp", "AppNoInternetBanner", "NetworkMonitor")) } if (!strategy.usesDatabase) { addAll(listOf("core:database", "core.database", "androidx-room", "androidx.room", "AndroidRoomConventionPlugin", ".android.room", "withDatabase")) @@ -377,7 +380,7 @@ All retained dependencies and versions are centralized in `gradle/libs.versions. val homeImport = content.lineSequence().firstOrNull { it.endsWith("feature.home.navigation.HomeRoute") } ?: throw GradleException("Could not find HomeRoute import in $path") val authImport = homeImport.replace("feature.home.navigation.HomeRoute", "feature.auth.navigation.LoginRoute") - return replaceRequired(homeImport, "$authImport\n$homeImport") + return replaceRequiredExactly(homeImport, "$authImport\n$homeImport") } private fun File.findRequired(modulePath: String, fileName: String): File = @@ -387,9 +390,12 @@ All retained dependencies and versions are centralized in `gradle/libs.versions. private fun File.findByName(fileName: String): File? = walkTopDown().firstOrNull { it.isFile && it.name == fileName } - private fun File.replaceRequired(oldValue: String, newValue: String): File { + internal fun File.replaceRequiredExactly(oldValue: String, newValue: String): File { val content = readText() - if (!content.contains(oldValue)) throw GradleException("Expected text not found in $path") + val matchCount = content.windowedSequence(oldValue.length).count { it == oldValue } + if (matchCount != 1) { + throw GradleException("Expected exactly one match in $path, but found $matchCount") + } writeText(content.replace(oldValue, newValue)) return this } diff --git a/build-logic/convention/src/main/kotlin/com/ytapps/composetemplate/convention/ScaffoldFeaturePlugin.kt b/build-logic/convention/src/main/kotlin/com/ytapps/composetemplate/convention/ScaffoldFeaturePlugin.kt index 876f2b18..29b53ce4 100644 --- a/build-logic/convention/src/main/kotlin/com/ytapps/composetemplate/convention/ScaffoldFeaturePlugin.kt +++ b/build-logic/convention/src/main/kotlin/com/ytapps/composetemplate/convention/ScaffoldFeaturePlugin.kt @@ -346,6 +346,9 @@ import javax.inject.Inject class ${routeName}ScreenProvider @Inject constructor() : IScreenProvider { + override fun restoreRoute(route: String): INavigationItem? = + if (route == ${routeName}Route.route) ${routeName}Route else null + @Composable override fun provideScreen( route: INavigationItem, diff --git a/build-logic/convention/src/main/kotlin/com/ytapps/composetemplate/convention/TestConventionPlugin.kt b/build-logic/convention/src/main/kotlin/com/ytapps/composetemplate/convention/TestConventionPlugin.kt index 1c859d5c..865c3b56 100644 --- a/build-logic/convention/src/main/kotlin/com/ytapps/composetemplate/convention/TestConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/com/ytapps/composetemplate/convention/TestConventionPlugin.kt @@ -3,10 +3,14 @@ package com.ytapps.composetemplate.convention import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.dependencies +import org.gradle.kotlin.dsl.register +import org.gradle.testing.jacoco.tasks.JacocoReport class TestConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { + pluginManager.apply("jacoco") + dependencies { add("testImplementation", libs.findLibrary("junit").get()) add("testImplementation", libs.findLibrary("truth").get()) @@ -16,6 +20,43 @@ class TestConventionPlugin : Plugin { add("androidTestImplementation", libs.findLibrary("androidx-junit").get()) add("androidTestImplementation", libs.findLibrary("androidx-espresso-core").get()) } + + tasks.register("jacocoDebugReport") { + dependsOn("testDebugUnitTest") + executionData( + fileTree(layout.buildDirectory) { + include("jacoco/testDebugUnitTest.exec") + include("outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec") + }, + ) + sourceDirectories.setFrom(files("src/main/java", "src/main/kotlin")) + classDirectories.setFrom( + fileTree(layout.buildDirectory.dir("tmp/kotlin-classes/debug")) { + exclude(JACOCO_EXCLUSIONS) + }, + fileTree(layout.buildDirectory.dir("intermediates/javac/debug/classes")) { + exclude(JACOCO_EXCLUSIONS) + }, + ) + reports { + xml.required.set(true) + html.required.set(true) + } + onlyIf { executionData.files.any { it.exists() } } + } } } + + private companion object { + val JACOCO_EXCLUSIONS = + listOf( + "**/R.class", + "**/R$*.class", + "**/BuildConfig.*", + "**/*_Factory.*", + "**/*_HiltModules*.*", + "**/*Hilt*.*", + "**/*ComposableSingletons*.*", + ) + } } diff --git a/build-logic/convention/src/test/kotlin/com/ytapps/composetemplate/convention/DataStrategyProjectorTest.kt b/build-logic/convention/src/test/kotlin/com/ytapps/composetemplate/convention/DataStrategyProjectorTest.kt new file mode 100644 index 00000000..601c2d1b --- /dev/null +++ b/build-logic/convention/src/test/kotlin/com/ytapps/composetemplate/convention/DataStrategyProjectorTest.kt @@ -0,0 +1,42 @@ +package com.ytapps.composetemplate.convention + +import org.gradle.api.GradleException +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class DataStrategyProjectorTest { + @Test + fun `required replacement updates exactly one occurrence`() { + val file = Files.createTempFile("projector", ".txt").toFile().apply { writeText("before target after") } + + with(DataStrategyProjector) { + file.replaceRequiredExactly("target", "updated") + } + + assertEquals("before updated after", file.readText()) + } + + @Test + fun `required replacement rejects missing content`() { + val file = Files.createTempFile("projector", ".txt").toFile().apply { writeText("before after") } + + assertFailsWith { + with(DataStrategyProjector) { + file.replaceRequiredExactly("target", "updated") + } + } + } + + @Test + fun `required replacement rejects duplicate content`() { + val file = Files.createTempFile("projector", ".txt").toFile().apply { writeText("target and target") } + + assertFailsWith { + with(DataStrategyProjector) { + file.replaceRequiredExactly("target", "updated") + } + } + } +} diff --git a/build-logic/convention/src/test/kotlin/com/ytapps/composetemplate/convention/DataStrategyTest.kt b/build-logic/convention/src/test/kotlin/com/ytapps/composetemplate/convention/DataStrategyTest.kt new file mode 100644 index 00000000..6c6803c8 --- /dev/null +++ b/build-logic/convention/src/test/kotlin/com/ytapps/composetemplate/convention/DataStrategyTest.kt @@ -0,0 +1,27 @@ +package com.ytapps.composetemplate.convention + +import org.gradle.api.GradleException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class DataStrategyTest { + @Test + fun `default strategy is remote`() { + assertEquals(DataStrategy.REMOTE, DataStrategy.parse(null)) + } + + @Test + fun `all documented strategy values parse strictly`() { + DataStrategy.entries.forEach { strategy -> + assertEquals(strategy, DataStrategy.parse(strategy.cliValue)) + } + } + + @Test + fun `unknown strategy is rejected`() { + assertFailsWith { + DataStrategy.parse("hybrid") + } + } +} diff --git a/core/common/src/main/java/com/ytapps/composetemplate/core/common/IThemeManager.kt b/core/common/src/main/java/com/ytapps/composetemplate/core/common/IThemeManager.kt new file mode 100644 index 00000000..8f16ac02 --- /dev/null +++ b/core/common/src/main/java/com/ytapps/composetemplate/core/common/IThemeManager.kt @@ -0,0 +1,8 @@ +package com.ytapps.composetemplate.core.common + +import kotlinx.coroutines.flow.StateFlow + +/** App-level theme state independent from navigation. */ +interface IThemeManager { + val isDarkModeFlow: StateFlow +} diff --git a/core/data/src/main/java/com/ytapps/composetemplate/core/data/PreferencesManager.kt b/core/data/src/main/java/com/ytapps/composetemplate/core/data/PreferencesManager.kt index 5aa4c954..88e2d2fa 100644 --- a/core/data/src/main/java/com/ytapps/composetemplate/core/data/PreferencesManager.kt +++ b/core/data/src/main/java/com/ytapps/composetemplate/core/data/PreferencesManager.kt @@ -7,6 +7,7 @@ import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore +import com.ytapps.composetemplate.core.common.IThemeManager import com.ytapps.composetemplate.core.common.IoDispatcher import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher @@ -19,7 +20,6 @@ import kotlinx.coroutines.flow.stateIn import javax.inject.Inject import javax.inject.Singleton -// Extension to create DataStore instance private val Context.dataStore: DataStore by preferencesDataStore( name = "user_preferences", ) @@ -31,11 +31,11 @@ class PreferencesManager constructor( @ApplicationContext private val appContext: Context, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, - ) : IPreferencesManager { + ) : IPreferencesManager, + IThemeManager { private val scope = CoroutineScope(SupervisorJob() + ioDispatcher) private val dataStore = appContext.dataStore - // Cached StateFlows for synchronous access private val cachedAccessToken: StateFlow = dataStore.data .map { preferences -> preferences[Keys.ACCESS_TOKEN] } @@ -71,7 +71,6 @@ class PreferencesManager .map { preferences -> preferences[Keys.IS_ONBOARDING_COMPLETED] ?: false } .stateIn(scope, SharingStarted.Eagerly, false) - // Synchronous getters (use cached StateFlow values) override fun getAccessToken(): String? = cachedAccessToken.value override fun getRefreshToken(): String? = cachedRefreshToken.value @@ -82,47 +81,32 @@ class PreferencesManager override fun hasUser(): Boolean = !cachedAccessToken.value.isNullOrBlank() - // Async setters (DataStore operations) override suspend fun setAccessToken(accessToken: String) { - dataStore.edit { preferences -> - preferences[Keys.ACCESS_TOKEN] = accessToken - } + dataStore.edit { preferences -> preferences[Keys.ACCESS_TOKEN] = accessToken } } override suspend fun setRefreshToken(refreshToken: String) { - dataStore.edit { preferences -> - preferences[Keys.REFRESH_TOKEN] = refreshToken - } + dataStore.edit { preferences -> preferences[Keys.REFRESH_TOKEN] = refreshToken } } override suspend fun setTokenType(tokenType: String) { - dataStore.edit { preferences -> - preferences[Keys.TOKEN_TYPE] = tokenType - } + dataStore.edit { preferences -> preferences[Keys.TOKEN_TYPE] = tokenType } } override suspend fun setUUID(uuid: String) { - dataStore.edit { preferences -> - preferences[Keys.UUID] = uuid - } + dataStore.edit { preferences -> preferences[Keys.UUID] = uuid } } override suspend fun setDarkMode(isEnabled: Boolean) { - dataStore.edit { preferences -> - preferences[Keys.IS_DARK_MODE] = isEnabled - } + dataStore.edit { preferences -> preferences[Keys.IS_DARK_MODE] = isEnabled } } override suspend fun setLanguageCode(languageCode: String) { - dataStore.edit { preferences -> - preferences[Keys.LANGUAGE_CODE] = languageCode - } + dataStore.edit { preferences -> preferences[Keys.LANGUAGE_CODE] = languageCode } } override suspend fun setOnboardingCompleted(isCompleted: Boolean) { - dataStore.edit { preferences -> - preferences[Keys.IS_ONBOARDING_COMPLETED] = isCompleted - } + dataStore.edit { preferences -> preferences[Keys.IS_ONBOARDING_COMPLETED] = isCompleted } } override suspend fun clearAuth() { @@ -135,12 +119,9 @@ class PreferencesManager } override suspend fun clear() { - dataStore.edit { preferences -> - preferences.clear() - } + dataStore.edit { preferences -> preferences.clear() } } - // Flow-based reactive access (delegates to cached StateFlows) override val accessTokenFlow: StateFlow get() = cachedAccessToken override val refreshTokenFlow: StateFlow get() = cachedRefreshToken diff --git a/core/data/src/main/java/com/ytapps/composetemplate/core/data/di/DataModule.kt b/core/data/src/main/java/com/ytapps/composetemplate/core/data/di/DataModule.kt index ac4fed9f..9da743ac 100644 --- a/core/data/src/main/java/com/ytapps/composetemplate/core/data/di/DataModule.kt +++ b/core/data/src/main/java/com/ytapps/composetemplate/core/data/di/DataModule.kt @@ -1,5 +1,6 @@ package com.ytapps.composetemplate.core.data.di +import com.ytapps.composetemplate.core.common.IThemeManager import com.ytapps.composetemplate.core.data.IPreferencesManager import com.ytapps.composetemplate.core.data.PreferencesManager import dagger.Binds @@ -14,4 +15,8 @@ internal abstract class DataModule { @Binds @Singleton abstract fun bindPreferencesManager(preferencesManager: PreferencesManager): IPreferencesManager + + @Binds + @Singleton + abstract fun bindThemeManager(preferencesManager: PreferencesManager): IThemeManager } diff --git a/core/navigation/build.gradle.kts b/core/navigation/build.gradle.kts index 9587dc7a..fe3eb450 100644 --- a/core/navigation/build.gradle.kts +++ b/core/navigation/build.gradle.kts @@ -11,7 +11,6 @@ android { dependencies { implementation(project(":core:common")) - implementation(project(":core:data")) api(libs.androidx.navigation3.runtime) implementation(libs.androidx.navigation3.ui) implementation(libs.androidx.lifecycle.viewmodel.navigation3) diff --git a/core/navigation/src/main/java/com/ytapps/composetemplate/core/navigation/INavigationManager.kt b/core/navigation/src/main/java/com/ytapps/composetemplate/core/navigation/INavigationManager.kt index 70f78f64..c6ec9119 100644 --- a/core/navigation/src/main/java/com/ytapps/composetemplate/core/navigation/INavigationManager.kt +++ b/core/navigation/src/main/java/com/ytapps/composetemplate/core/navigation/INavigationManager.kt @@ -7,7 +7,7 @@ interface INavigationManager { val startDestination: INavigationItem val bottomBarItems: List - val isDarkModeFlow: StateFlow + fun restoreBackStack(routes: List) fun showBottomBar(route: INavigationItem): Boolean diff --git a/core/navigation/src/main/java/com/ytapps/composetemplate/core/navigation/IScreenProvider.kt b/core/navigation/src/main/java/com/ytapps/composetemplate/core/navigation/IScreenProvider.kt index 4b30d08e..e63ce7be 100644 --- a/core/navigation/src/main/java/com/ytapps/composetemplate/core/navigation/IScreenProvider.kt +++ b/core/navigation/src/main/java/com/ytapps/composetemplate/core/navigation/IScreenProvider.kt @@ -3,15 +3,13 @@ package com.ytapps.composetemplate.core.navigation import androidx.compose.runtime.Composable /** - * Interface for providing screens for navigation routes. - * Each feature's presentation module should implement this interface - * to provide screens for its routes. + * Provides rendering and restoration for routes owned by one feature. */ interface IScreenProvider { - /** - * Provides the screen composable for the given route. - * Returns false if this provider doesn't handle the given route. - */ + /** Recreates a typed route from its stable route string after process recreation. */ + fun restoreRoute(route: String): INavigationItem? = null + + /** Returns false when this provider does not handle the route. */ @Composable fun provideScreen( route: INavigationItem, diff --git a/core/navigation/src/main/java/com/ytapps/composetemplate/core/navigation/NavigationManager.kt b/core/navigation/src/main/java/com/ytapps/composetemplate/core/navigation/NavigationManager.kt index d3daa1ec..a59634d8 100644 --- a/core/navigation/src/main/java/com/ytapps/composetemplate/core/navigation/NavigationManager.kt +++ b/core/navigation/src/main/java/com/ytapps/composetemplate/core/navigation/NavigationManager.kt @@ -12,16 +12,23 @@ internal class NavigationManager constructor( override val startDestination: INavigationItem, private val bottomBarItemsRaw: Map, - private val preferencesManager: com.ytapps.composetemplate.core.data.IPreferencesManager, ) : INavigationManager { private val _backStack = MutableStateFlow(listOf(startDestination)) override val backStack = _backStack.asStateFlow() - override val isDarkModeFlow = preferencesManager.isDarkModeFlow - override val bottomBarItems: List = bottomBarItemsRaw.entries.sortedBy { it.key }.map { it.value } + override fun restoreBackStack(routes: List) { + if (routes.isEmpty()) return + _backStack.value = + if (routes.first().route == startDestination.route) { + routes + } else { + listOf(startDestination) + routes + } + } + override fun showBottomBar(route: INavigationItem): Boolean = route in bottomBarItems override fun selectTab(selected: IBottomBarItem) { @@ -50,9 +57,7 @@ internal class NavigationManager } override fun navigate(route: INavigationItem) { - _backStack.update { stack -> - stack + route - } + _backStack.update { stack -> stack + route } } override fun navigateOver( diff --git a/core/navigation/src/main/java/com/ytapps/composetemplate/core/navigation/ScreenRegistry.kt b/core/navigation/src/main/java/com/ytapps/composetemplate/core/navigation/ScreenRegistry.kt index 8ec2e757..301e0115 100644 --- a/core/navigation/src/main/java/com/ytapps/composetemplate/core/navigation/ScreenRegistry.kt +++ b/core/navigation/src/main/java/com/ytapps/composetemplate/core/navigation/ScreenRegistry.kt @@ -1,11 +1,6 @@ package com.ytapps.composetemplate.core.navigation -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier import javax.inject.Inject import javax.inject.Singleton @@ -15,22 +10,23 @@ class ScreenRegistry constructor( private val screenProviders: Set<@JvmSuppressWildcards IScreenProvider>, ) { + fun restoreBackStack(routes: List): List = + routes.mapNotNull { savedRoute -> + val matches = screenProviders.mapNotNull { it.restoreRoute(savedRoute) } + check(matches.size <= 1) { + "Multiple screen providers restored route '$savedRoute'." + } + matches.singleOrNull() + } + @Composable fun ScreenProvider( route: INavigationItem, navigationManager: INavigationManager, ) { for (provider in screenProviders) { - if (provider.provideScreen(route, navigationManager)) { - return - } - } - - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Text(text = "Screen not found: ${route.route}") + if (provider.provideScreen(route, navigationManager)) return } + error("No IScreenProvider registered for route '${route.route}'.") } } diff --git a/core/navigation/src/test/java/com/ytapps/composetemplate/core/navigation/NavigationManagerTest.kt b/core/navigation/src/test/java/com/ytapps/composetemplate/core/navigation/NavigationManagerTest.kt index 29ac4bb4..bb4d6266 100644 --- a/core/navigation/src/test/java/com/ytapps/composetemplate/core/navigation/NavigationManagerTest.kt +++ b/core/navigation/src/test/java/com/ytapps/composetemplate/core/navigation/NavigationManagerTest.kt @@ -1,25 +1,16 @@ package com.ytapps.composetemplate.core.navigation import com.google.common.truth.Truth.assertThat -import com.ytapps.composetemplate.core.data.IPreferencesManager -import io.mockk.every -import io.mockk.mockk -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test internal class NavigationManagerTest { - private lateinit var preferencesManager: IPreferencesManager private lateinit var navigationManager: NavigationManager @Before fun setUp() { - preferencesManager = - mockk { - every { isDarkModeFlow } returns MutableStateFlow(false) - } navigationManager = NavigationManager( startDestination = TestRoute.Home, @@ -28,22 +19,41 @@ internal class NavigationManagerTest { "1" to TestRoute.Home, "2" to TestRoute.Search, ), - preferencesManager = preferencesManager, ) } @Test fun `given initial state then back stack contains only start destination`() { - val stack = navigationManager.backStack.value - assertThat(stack).containsExactly(TestRoute.Home) + assertThat(navigationManager.backStack.value).containsExactly(TestRoute.Home) + } + + @Test + fun `given saved routes then restoreBackStack preserves history`() { + navigationManager.restoreBackStack( + listOf(TestRoute.Home, TestRoute.Detail, TestRoute.Profile), + ) + + assertThat(navigationManager.backStack.value) + .containsExactly(TestRoute.Home, TestRoute.Detail, TestRoute.Profile) + .inOrder() + } + + @Test + fun `given saved routes without root then restoreBackStack prepends root`() { + navigationManager.restoreBackStack(listOf(TestRoute.Detail)) + + assertThat(navigationManager.backStack.value) + .containsExactly(TestRoute.Home, TestRoute.Detail) + .inOrder() } @Test fun `given single item when navigate then route is added to stack`() { navigationManager.navigate(TestRoute.Detail) - val stack = navigationManager.backStack.value - assertThat(stack).containsExactly(TestRoute.Home, TestRoute.Detail) + assertThat(navigationManager.backStack.value) + .containsExactly(TestRoute.Home, TestRoute.Detail) + .inOrder() } @Test @@ -54,136 +64,104 @@ internal class NavigationManagerTest { val navigated = navigationManager.navigateBack() assertThat(navigated).isTrue() - val stack = navigationManager.backStack.value - assertThat(stack).containsExactly(TestRoute.Home, TestRoute.Detail) + assertThat(navigationManager.backStack.value) + .containsExactly(TestRoute.Home, TestRoute.Detail) + .inOrder() } @Test - fun `given single item when navigateBack then returns false and stack stays same`() { - val navigated = navigationManager.navigateBack() - - assertThat(navigated).isFalse() - val stack = navigationManager.backStack.value - assertThat(stack).containsExactly(TestRoute.Home) + fun `given single item when navigateBack then returns false`() { + assertThat(navigationManager.navigateBack()).isFalse() + assertThat(navigationManager.backStack.value).containsExactly(TestRoute.Home) } @Test - fun `given tab selected when already in stack then truncates to that tab`() { + fun `given tab already in stack then selection truncates to tab`() { navigationManager.navigate(TestRoute.Detail) navigationManager.selectTab(TestRoute.Home) - val stack = navigationManager.backStack.value - assertThat(stack).containsExactly(TestRoute.Home) + assertThat(navigationManager.backStack.value).containsExactly(TestRoute.Home) } @Test - fun `given tab selected when not in stack then appends to stack`() { + fun `given new tab then selection appends tab`() { navigationManager.selectTab(TestRoute.Search) - val stack = navigationManager.backStack.value - assertThat(stack).containsExactly(TestRoute.Home, TestRoute.Search) + assertThat(navigationManager.backStack.value) + .containsExactly(TestRoute.Home, TestRoute.Search) + .inOrder() } @Test - fun `given route when navigateOver existing route then replaces from that point`() { + fun `given existing target then navigateOver replaces from target`() { navigationManager.navigate(TestRoute.Detail) navigationManager.navigate(TestRoute.Profile) - navigationManager.navigateOver(route = TestRoute.Search, over = TestRoute.Detail) + navigationManager.navigateOver(TestRoute.Search, TestRoute.Detail) - val stack = navigationManager.backStack.value - assertThat(stack).containsExactly(TestRoute.Home, TestRoute.Search) + assertThat(navigationManager.backStack.value) + .containsExactly(TestRoute.Home, TestRoute.Search) + .inOrder() } @Test - fun `given route when navigateOver non-existing route then appends`() { - navigationManager.navigateOver(route = TestRoute.Search, over = TestRoute.Detail) - - val stack = navigationManager.backStack.value - assertThat(stack).containsExactly(TestRoute.Home, TestRoute.Search) - } - - @Test - fun `given route when navigateToTop then replaces from start destination`() { + fun `given route then navigateToTop resets above root`() { navigationManager.navigate(TestRoute.Detail) - navigationManager.navigate(TestRoute.Profile) - navigationManager.navigateToTop(TestRoute.Search) - val stack = navigationManager.backStack.value - assertThat(stack).containsExactly(TestRoute.Home, TestRoute.Search) + assertThat(navigationManager.backStack.value) + .containsExactly(TestRoute.Home, TestRoute.Search) + .inOrder() } @Test - fun `given bottom bar route then showBottomBar returns true`() { - val result = navigationManager.showBottomBar(TestRoute.Home) - - assertThat(result).isTrue() + fun `bottom bar visibility uses registered items`() { + assertThat(navigationManager.showBottomBar(TestRoute.Home)).isTrue() + assertThat(navigationManager.showBottomBar(TestRoute.Detail)).isFalse() } @Test - fun `given non-bottom bar route then showBottomBar returns false`() { - val result = navigationManager.showBottomBar(TestRoute.Detail) - - assertThat(result).isFalse() - } - - @Test - fun `given bottomBarItems then returns sorted by key`() { - val items = navigationManager.bottomBarItems - - assertThat(items).hasSize(2) - assertThat(items[0]).isEqualTo(TestRoute.Home) - assertThat(items[1]).isEqualTo(TestRoute.Search) + fun `bottom bar items are sorted by key`() { + assertThat(navigationManager.bottomBarItems) + .containsExactly(TestRoute.Home, TestRoute.Search) + .inOrder() } @Test - fun `given multiple items when navigateBackToRoot then resets to single item`() { + fun `given nested stack then navigateBackToRoot resets stack`() { navigationManager.navigate(TestRoute.Detail) - navigationManager.navigate(TestRoute.Profile) - - val navigated = navigationManager.navigateBackToRoot() - - assertThat(navigated).isTrue() - val stack = navigationManager.backStack.value - assertThat(stack).containsExactly(TestRoute.Home) - } - - @Test - fun `given single item when navigateBackToRoot then returns false and stack stays same`() { - val navigated = navigationManager.navigateBackToRoot() - assertThat(navigated).isFalse() - val stack = navigationManager.backStack.value - assertThat(stack).containsExactly(TestRoute.Home) + assertThat(navigationManager.navigateBackToRoot()).isTrue() + assertThat(navigationManager.backStack.value).containsExactly(TestRoute.Home) } @Test - fun `given backStack is StateFlow then emits updates`() = + fun `backStack state flow emits updates`() = runTest { navigationManager.navigate(TestRoute.Detail) - val stack = navigationManager.backStack.first() - assertThat(stack).containsExactly(TestRoute.Home, TestRoute.Detail) + assertThat(navigationManager.backStack.first()) + .containsExactly(TestRoute.Home, TestRoute.Detail) + .inOrder() } private sealed interface TestRoute : INavigationItem { data object Home : TestRoute, IBottomBarItem { - override val route: String get() = "home" + override val route = "home" override val icon: @androidx.compose.runtime.Composable () -> Unit = {} } data object Search : TestRoute, IBottomBarItem { - override val route: String get() = "search" + override val route = "search" override val icon: @androidx.compose.runtime.Composable () -> Unit = {} } data object Detail : TestRoute { - override val route: String get() = "detail" + override val route = "detail" } data object Profile : TestRoute { - override val route: String get() = "profile" + override val route = "profile" } } } diff --git a/core/navigation/src/test/java/com/ytapps/composetemplate/core/navigation/ScreenRegistryTest.kt b/core/navigation/src/test/java/com/ytapps/composetemplate/core/navigation/ScreenRegistryTest.kt new file mode 100644 index 00000000..708207b4 --- /dev/null +++ b/core/navigation/src/test/java/com/ytapps/composetemplate/core/navigation/ScreenRegistryTest.kt @@ -0,0 +1,45 @@ +package com.ytapps.composetemplate.core.navigation + +import androidx.compose.runtime.Composable +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +internal class ScreenRegistryTest { + @Test + fun `restoreBackStack recreates known routes and ignores removed routes`() { + val registry = ScreenRegistry(setOf(TestProvider())) + + val restored = registry.restoreBackStack(listOf("home", "removed", "detail/42")) + + assertThat(restored) + .containsExactly(TestRoute.Home, TestRoute.Detail("42")) + .inOrder() + } + + private class TestProvider : IScreenProvider { + override fun restoreRoute(route: String): INavigationItem? = + when { + route == TestRoute.Home.route -> TestRoute.Home + route.startsWith("detail/") -> TestRoute.Detail(route.removePrefix("detail/")) + else -> null + } + + @Composable + override fun provideScreen( + route: INavigationItem, + navigationManager: INavigationManager, + ): Boolean = false + } + + private sealed interface TestRoute : INavigationItem { + data object Home : TestRoute { + override val route = "home" + } + + data class Detail( + val id: String, + ) : TestRoute { + override val route = "detail/$id" + } + } +} diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index 2c0e62d9..69dc7d68 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -13,7 +13,8 @@ dependencies { implementation(project(":core:data")) implementation(libs.androidx.core) implementation(libs.retrofit) - implementation(libs.converter.gson) + implementation(libs.converter.kotlinx.serialization) + implementation(libs.kotlinx.serialization.json) implementation(libs.okhttp) implementation(libs.logging.interceptor) implementation(libs.timber) diff --git a/core/network/src/main/java/com/ytapps/composetemplate/core/network/di/NetworkModule.kt b/core/network/src/main/java/com/ytapps/composetemplate/core/network/di/NetworkModule.kt index 925d86ff..e32a721d 100644 --- a/core/network/src/main/java/com/ytapps/composetemplate/core/network/di/NetworkModule.kt +++ b/core/network/src/main/java/com/ytapps/composetemplate/core/network/di/NetworkModule.kt @@ -9,12 +9,14 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import kotlinx.serialization.json.Json import okhttp3.CertificatePinner import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit -import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.converter.kotlinx.serialization.asConverterFactory import javax.inject.Singleton @Module @@ -23,15 +25,21 @@ internal object NetworkModule { @Provides @Singleton fun provideNetworkConfig(providers: Set<@JvmSuppressWildcards NetworkConfigProvider>): NetworkConfigProvider { - if (providers.isEmpty()) { - return DefaultNetworkConfigProvider - } + if (providers.isEmpty()) return DefaultNetworkConfigProvider check(providers.size == 1) { "Only one NetworkConfigProvider may be contributed, found ${providers.size}." } return providers.first() } + @Provides + @Singleton + fun provideJson(): Json = + Json { + ignoreUnknownKeys = true + explicitNulls = false + } + @Provides @Singleton fun provideOkHttpClient( @@ -63,23 +71,21 @@ internal object NetworkModule { fun provideRetrofit( okHttpClient: OkHttpClient, networkConfig: NetworkConfigProvider, + json: Json, ): Retrofit = Retrofit .Builder() .baseUrl(networkConfig.baseUrl) .client(okHttpClient) - .addConverterFactory(GsonConverterFactory.create()) + .addConverterFactory(json.asConverterFactory(JSON_MEDIA_TYPE)) .build() private fun OkHttpClient.Builder.applyCertificatePinning(networkConfig: NetworkConfigProvider): OkHttpClient.Builder { - if (BuildConfig.DEBUG || !networkConfig.certificatePinningEnabled) { - return this - } + if (BuildConfig.DEBUG || !networkConfig.certificatePinningEnabled) return this require(networkConfig.certificatePins.size >= MIN_CERTIFICATE_PIN_COUNT) { "Release certificate pinning requires primary and backup SHA-256 pins." } - val host = networkConfig.baseUrl.toHttpUrl().host val certificatePinnerBuilder = CertificatePinner.Builder() networkConfig.certificatePins.forEach { pin -> @@ -89,5 +95,6 @@ internal object NetworkModule { return this } + private val JSON_MEDIA_TYPE = "application/json".toMediaType() private const val MIN_CERTIFICATE_PIN_COUNT = 2 } diff --git a/core/network/src/test/java/com/ytapps/composetemplate/core/network/BaseRepositoryTest.kt b/core/network/src/test/java/com/ytapps/composetemplate/core/network/BaseRepositoryTest.kt index 3150c1da..0660dcf3 100644 --- a/core/network/src/test/java/com/ytapps/composetemplate/core/network/BaseRepositoryTest.kt +++ b/core/network/src/test/java/com/ytapps/composetemplate/core/network/BaseRepositoryTest.kt @@ -2,6 +2,7 @@ package com.ytapps.composetemplate.core.network import com.google.common.truth.Truth.assertThat import com.ytapps.composetemplate.core.common.Result +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.test.runTest import okhttp3.MediaType.Companion.toMediaType import okhttp3.ResponseBody.Companion.toResponseBody @@ -14,18 +15,16 @@ class BaseRepositoryTest { private val repository = object : BaseRepository() {} @Test - fun `given successful response with body then return Success with data`() = + fun `successful response returns Success`() = runTest { - val response = Response.success("test_data") - - val result = repository.safeCall { response } + val result = repository.safeCall { Response.success("test_data") } assertThat(result).isInstanceOf(Result.Success::class.java) assertThat((result as Result.Success).data).isEqualTo("test_data") } @Test - fun `given successful response with null body then return Error`() = + fun `successful response with null body returns Error`() = runTest { val response: Response = Response.success(null) @@ -36,106 +35,55 @@ class BaseRepositoryTest { } @Test - fun `given 401 response then return Unauthorized error`() = - runTest { - val response = - Response.error( - 401, - "{}".toResponseBody("application/json".toMediaType()), - ) - - val result = repository.safeCall { response } - - assertThat(result).isInstanceOf(Result.Error::class.java) - assertThat((result as Result.Error).message).isEqualTo("Unauthorized access") - } - - @Test - fun `given 403 response then return Forbidden error`() = - runTest { - val response = - Response.error( - 403, - "{}".toResponseBody("application/json".toMediaType()), - ) - - val result = repository.safeCall { response } - - assertThat(result).isInstanceOf(Result.Error::class.java) - assertThat((result as Result.Error).message).isEqualTo("Forbidden access") - } - - @Test - fun `given 404 response then return Resource not found error`() = + fun `known HTTP status codes are mapped`() = runTest { - val response = - Response.error( - 404, - "{}".toResponseBody("application/json".toMediaType()), - ) - - val result = repository.safeCall { response } - - assertThat(result).isInstanceOf(Result.Error::class.java) - assertThat((result as Result.Error).message).isEqualTo("Resource not found") - } - - @Test - fun `given 500 response then return Server error`() = - runTest { - val response = - Response.error( - 500, - "{}".toResponseBody("application/json".toMediaType()), - ) - - val result = repository.safeCall { response } - - assertThat(result).isInstanceOf(Result.Error::class.java) - assertThat((result as Result.Error).message).isEqualTo("Server error occurred") + assertErrorMessage(401, "Unauthorized access") + assertErrorMessage(403, "Forbidden access") + assertErrorMessage(404, "Resource not found") + assertErrorMessage(500, "Server error occurred") } @Test - fun `given IOException when calling API then return Error`() = + fun `IOException returns Error`() = runTest { - val result = - repository.safeCall { - throw IOException("Network error") - } + val result = repository.safeCall { throw IOException("Network error") } assertThat(result).isInstanceOf(Result.Error::class.java) assertThat((result as Result.Error).message).isEqualTo("Network error") } @Test - fun `given HttpException when calling API then return Error`() = + fun `HttpException returns Error`() = runTest { - val errorResponse = - Response.error( - 400, - "bad request".toResponseBody("application/json".toMediaType()), - ) + val response = errorResponse(400, "bad request") - val result = - repository.safeCall { - throw HttpException(errorResponse) - } + val result = repository.safeCall { throw HttpException(response) } assertThat(result).isInstanceOf(Result.Error::class.java) } - @Test - fun `given custom error body then return error with body message`() = + @Test(expected = CancellationException::class) + fun `cancellation is never converted to a domain error`() = runTest { - val response = - Response.error( - 422, - """{"error":"Invalid input"}""".toResponseBody("application/json".toMediaType()), - ) - - val result = repository.safeCall { response } - - assertThat(result).isInstanceOf(Result.Error::class.java) - assertThat((result as Result.Error).message).contains("Invalid input") + repository.safeCall { throw CancellationException("cancelled") } } + + private suspend fun assertErrorMessage( + code: Int, + expected: String, + ) { + val result = repository.safeCall { errorResponse(code, "{}") } + + assertThat(result).isInstanceOf(Result.Error::class.java) + assertThat((result as Result.Error).message).isEqualTo(expected) + } + + private fun errorResponse( + code: Int, + body: String, + ): Response = + Response.error( + code, + body.toResponseBody("application/json".toMediaType()), + ) } diff --git a/core/secrets/ci/setup.sh b/core/secrets/ci/setup.sh new file mode 100644 index 00000000..ecd3e39d --- /dev/null +++ b/core/secrets/ci/setup.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "STORE_FILE=debug.keystore" > local.properties +cat > secrets.properties <<'EOF' +API_KEY_DEBUG="debug_secret_value" +API_KEY_RELEASE="release_secret_value" +BASE_URL_DEBUG="https://example.com/" +BASE_URL_RELEASE="https://example.com/" +KEY_ALIAS="androiddebugkey" +KEY_PASSWORD="android" +STORE_PASSWORD="android" +XOR_MASK="ci_mask_value_with_24_chars_minimum" +EXPECTED_SIGNATURE_HASH="53017D6F6B5A861B3096D18257D5C91F0588287BC587F12C35767D362EF0A3A8" +NATIVE_RUNTIME_CHECKS_ENABLED=true +CERTIFICATE_PINNING_ENABLED=false +CERTIFICATE_PINS="" +MIN_VERSION=1 +EOF diff --git a/feature/auth/data/build.gradle.kts b/feature/auth/data/build.gradle.kts index 3a016326..7d758965 100644 --- a/feature/auth/data/build.gradle.kts +++ b/feature/auth/data/build.gradle.kts @@ -1,5 +1,6 @@ plugins { id("composetemplate.feature.data") + alias(libs.plugins.kotlin.serialization) } android { @@ -9,7 +10,7 @@ android { dependencies { implementation(project(":feature:auth:domain")) implementation(libs.retrofit) - implementation(libs.converter.gson) + implementation(libs.kotlinx.serialization.json) testImplementation(libs.okhttp) } diff --git a/feature/auth/data/src/main/java/com/ytapps/composetemplate/feature/auth/data/model/AuthRequestModel.kt b/feature/auth/data/src/main/java/com/ytapps/composetemplate/feature/auth/data/model/AuthRequestModel.kt index 92296529..1e322f9f 100644 --- a/feature/auth/data/src/main/java/com/ytapps/composetemplate/feature/auth/data/model/AuthRequestModel.kt +++ b/feature/auth/data/src/main/java/com/ytapps/composetemplate/feature/auth/data/model/AuthRequestModel.kt @@ -1,10 +1,12 @@ package com.ytapps.composetemplate.feature.auth.data.model -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable internal data class AuthRequestModel( - @SerializedName("email") + @SerialName("email") val email: String, - @SerializedName("password") + @SerialName("password") val password: String, ) diff --git a/feature/auth/data/src/main/java/com/ytapps/composetemplate/feature/auth/data/model/AuthResponseModel.kt b/feature/auth/data/src/main/java/com/ytapps/composetemplate/feature/auth/data/model/AuthResponseModel.kt index 8fefb914..042806fd 100644 --- a/feature/auth/data/src/main/java/com/ytapps/composetemplate/feature/auth/data/model/AuthResponseModel.kt +++ b/feature/auth/data/src/main/java/com/ytapps/composetemplate/feature/auth/data/model/AuthResponseModel.kt @@ -1,14 +1,16 @@ package com.ytapps.composetemplate.feature.auth.data.model -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable internal data class AuthResponseModel( - @SerializedName("accessToken") + @SerialName("accessToken") val accessToken: String, - @SerializedName("refreshToken") + @SerialName("refreshToken") val refreshToken: String, - @SerializedName("expiresIn") + @SerialName("expiresIn") val expiresIn: String, - @SerializedName("tokenType") + @SerialName("tokenType") val tokenType: String, ) diff --git a/feature/auth/data/src/main/java/com/ytapps/composetemplate/feature/auth/data/model/RefreshTokenRequestModel.kt b/feature/auth/data/src/main/java/com/ytapps/composetemplate/feature/auth/data/model/RefreshTokenRequestModel.kt index 1674ba73..72d681d0 100644 --- a/feature/auth/data/src/main/java/com/ytapps/composetemplate/feature/auth/data/model/RefreshTokenRequestModel.kt +++ b/feature/auth/data/src/main/java/com/ytapps/composetemplate/feature/auth/data/model/RefreshTokenRequestModel.kt @@ -1,8 +1,10 @@ package com.ytapps.composetemplate.feature.auth.data.model -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable internal data class RefreshTokenRequestModel( - @SerializedName("refreshToken") + @SerialName("refreshToken") val refreshToken: String, ) diff --git a/feature/auth/presentation/src/main/java/com/ytapps/composetemplate/feature/auth/presentation/AuthScreenProvider.kt b/feature/auth/presentation/src/main/java/com/ytapps/composetemplate/feature/auth/presentation/AuthScreenProvider.kt index 59368174..a0b6893e 100644 --- a/feature/auth/presentation/src/main/java/com/ytapps/composetemplate/feature/auth/presentation/AuthScreenProvider.kt +++ b/feature/auth/presentation/src/main/java/com/ytapps/composetemplate/feature/auth/presentation/AuthScreenProvider.kt @@ -7,13 +7,11 @@ import com.ytapps.composetemplate.core.navigation.IScreenProvider import com.ytapps.composetemplate.feature.auth.navigation.LoginRoute import javax.inject.Inject -/** - * Screen provider for Auth feature module. - * Provides screens for all Auth routes (LoginRoute, etc.). - */ class AuthScreenProvider @Inject constructor() : IScreenProvider { + override fun restoreRoute(route: String): INavigationItem? = LoginRoute.takeIf { it.route == route } + @Composable override fun provideScreen( route: INavigationItem, diff --git a/feature/detail/presentation/src/main/java/com/ytapps/composetemplate/feature/detail/presentation/DetailScreenProvider.kt b/feature/detail/presentation/src/main/java/com/ytapps/composetemplate/feature/detail/presentation/DetailScreenProvider.kt index 9acdb9a2..5ae26553 100644 --- a/feature/detail/presentation/src/main/java/com/ytapps/composetemplate/feature/detail/presentation/DetailScreenProvider.kt +++ b/feature/detail/presentation/src/main/java/com/ytapps/composetemplate/feature/detail/presentation/DetailScreenProvider.kt @@ -7,13 +7,16 @@ import com.ytapps.composetemplate.core.navigation.IScreenProvider import com.ytapps.composetemplate.feature.detail.navigation.DetailRoute import javax.inject.Inject -/** - * Screen provider for Detail feature. - * Provides screens for DetailRoute. - */ class DetailScreenProvider @Inject constructor() : IScreenProvider { + override fun restoreRoute(route: String): INavigationItem? = + route + .takeIf { it.startsWith(ROUTE_PREFIX) } + ?.removePrefix(ROUTE_PREFIX) + ?.takeIf { it.isNotBlank() } + ?.let(::DetailRoute) + @Composable override fun provideScreen( route: INavigationItem, @@ -27,4 +30,8 @@ class DetailScreenProvider else -> false } + + private companion object { + const val ROUTE_PREFIX = "route_detail/" + } } diff --git a/feature/home/presentation/src/main/java/com/ytapps/composetemplate/feature/home/presentation/HomeScreenProvider.kt b/feature/home/presentation/src/main/java/com/ytapps/composetemplate/feature/home/presentation/HomeScreenProvider.kt index e90353e0..a39ab9d9 100644 --- a/feature/home/presentation/src/main/java/com/ytapps/composetemplate/feature/home/presentation/HomeScreenProvider.kt +++ b/feature/home/presentation/src/main/java/com/ytapps/composetemplate/feature/home/presentation/HomeScreenProvider.kt @@ -7,13 +7,11 @@ import com.ytapps.composetemplate.core.navigation.IScreenProvider import com.ytapps.composetemplate.feature.home.navigation.HomeRoute import javax.inject.Inject -/** - * Screen provider for Home feature. - * Provides screens for HomeRoute. - */ class HomeScreenProvider @Inject constructor() : IScreenProvider { + override fun restoreRoute(route: String): INavigationItem? = HomeRoute.takeIf { it.route == route } + @Composable override fun provideScreen( route: INavigationItem, diff --git a/feature/list/presentation/src/main/java/com/ytapps/composetemplate/feature/list/presentation/ListScreenProvider.kt b/feature/list/presentation/src/main/java/com/ytapps/composetemplate/feature/list/presentation/ListScreenProvider.kt index 7d32c6ff..a518526e 100644 --- a/feature/list/presentation/src/main/java/com/ytapps/composetemplate/feature/list/presentation/ListScreenProvider.kt +++ b/feature/list/presentation/src/main/java/com/ytapps/composetemplate/feature/list/presentation/ListScreenProvider.kt @@ -7,13 +7,11 @@ import com.ytapps.composetemplate.core.navigation.IScreenProvider import com.ytapps.composetemplate.feature.list.navigation.ListRoute import javax.inject.Inject -/** - * Screen provider for List feature. - * Provides screens for ListRoute. - */ class ListScreenProvider @Inject constructor() : IScreenProvider { + override fun restoreRoute(route: String): INavigationItem? = ListRoute.takeIf { it.route == route } + @Composable override fun provideScreen( route: INavigationItem, diff --git a/feature/onboarding/presentation/src/main/java/com/ytapps/composetemplate/feature/onboarding/presentation/OnboardingScreenProvider.kt b/feature/onboarding/presentation/src/main/java/com/ytapps/composetemplate/feature/onboarding/presentation/OnboardingScreenProvider.kt index a6138b32..edff0e09 100644 --- a/feature/onboarding/presentation/src/main/java/com/ytapps/composetemplate/feature/onboarding/presentation/OnboardingScreenProvider.kt +++ b/feature/onboarding/presentation/src/main/java/com/ytapps/composetemplate/feature/onboarding/presentation/OnboardingScreenProvider.kt @@ -10,6 +10,8 @@ import javax.inject.Inject class OnboardingScreenProvider @Inject constructor() : IScreenProvider { + override fun restoreRoute(route: String): INavigationItem? = OnboardingRoute.takeIf { it.route == route } + @Composable override fun provideScreen( route: INavigationItem, @@ -20,6 +22,7 @@ class OnboardingScreenProvider OnboardingScreen(navigationManager) true } + else -> false } } diff --git a/feature/profile/presentation/src/main/java/com/ytapps/composetemplate/feature/profile/presentation/ProfileScreenProvider.kt b/feature/profile/presentation/src/main/java/com/ytapps/composetemplate/feature/profile/presentation/ProfileScreenProvider.kt index 2e0153e9..8c157bd7 100644 --- a/feature/profile/presentation/src/main/java/com/ytapps/composetemplate/feature/profile/presentation/ProfileScreenProvider.kt +++ b/feature/profile/presentation/src/main/java/com/ytapps/composetemplate/feature/profile/presentation/ProfileScreenProvider.kt @@ -7,13 +7,11 @@ import com.ytapps.composetemplate.core.navigation.IScreenProvider import com.ytapps.composetemplate.feature.profile.navigation.ProfileRoute import javax.inject.Inject -/** - * Screen provider for Profile feature. - * Provides screens for ProfileRoute. - */ class ProfileScreenProvider @Inject constructor() : IScreenProvider { + override fun restoreRoute(route: String): INavigationItem? = ProfileRoute.takeIf { it.route == route } + @Composable override fun provideScreen( route: INavigationItem, diff --git a/feature/search/presentation/src/main/java/com/ytapps/composetemplate/feature/search/presentation/SearchScreenProvider.kt b/feature/search/presentation/src/main/java/com/ytapps/composetemplate/feature/search/presentation/SearchScreenProvider.kt index 04b5a601..c7e713f7 100644 --- a/feature/search/presentation/src/main/java/com/ytapps/composetemplate/feature/search/presentation/SearchScreenProvider.kt +++ b/feature/search/presentation/src/main/java/com/ytapps/composetemplate/feature/search/presentation/SearchScreenProvider.kt @@ -7,13 +7,11 @@ import com.ytapps.composetemplate.core.navigation.IScreenProvider import com.ytapps.composetemplate.feature.search.navigation.SearchRoute import javax.inject.Inject -/** - * Screen provider for Search feature. - * Provides screens for SearchRoute. - */ class SearchScreenProvider @Inject constructor() : IScreenProvider { + override fun restoreRoute(route: String): INavigationItem? = SearchRoute.takeIf { it.route == route } + @Composable override fun provideScreen( route: INavigationItem, diff --git a/feature/splash/presentation/src/main/java/com/ytapps/composetemplate/feature/splash/presentation/SplashScreenProvider.kt b/feature/splash/presentation/src/main/java/com/ytapps/composetemplate/feature/splash/presentation/SplashScreenProvider.kt index 2cdfe1ea..c681ceac 100644 --- a/feature/splash/presentation/src/main/java/com/ytapps/composetemplate/feature/splash/presentation/SplashScreenProvider.kt +++ b/feature/splash/presentation/src/main/java/com/ytapps/composetemplate/feature/splash/presentation/SplashScreenProvider.kt @@ -7,13 +7,11 @@ import com.ytapps.composetemplate.core.navigation.IScreenProvider import com.ytapps.composetemplate.feature.splash.navigation.SplashRoute import javax.inject.Inject -/** - * Screen provider for Splash feature. - * Provides screens for SplashRoute. - */ class SplashScreenProvider @Inject constructor() : IScreenProvider { + override fun restoreRoute(route: String): INavigationItem? = SplashRoute.takeIf { it.route == route } + @Composable override fun provideScreen( route: INavigationItem, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ce42f73c..fdb8fa0f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,7 +20,6 @@ androidx-material-icons-core = "1.7.8" # Network retrofit = "2.12.0" -converter-gson = "2.12.0" okhttp = "4.12.0" logging-interceptor = "4.12.0" @@ -66,8 +65,6 @@ google-play-app-update = "2.1.0" # Image Loading coil = "3.4.0" - - [libraries] # AndroidX Core androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } @@ -101,7 +98,7 @@ androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = # Network retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } -converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "converter-gson" } +converter-kotlinx-serialization = { module = "com.squareup.retrofit2:converter-kotlinx-serialization", version.ref = "retrofit" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "logging-interceptor" } @@ -113,6 +110,7 @@ androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-com # Kotlin kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-test" } kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization-core" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-core" } # Logging timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } diff --git a/wiki/01-module-topology.md b/wiki/01-module-topology.md index 79ba5a7b..5d58bbb5 100644 --- a/wiki/01-module-topology.md +++ b/wiki/01-module-topology.md @@ -1,80 +1,30 @@ # 01 - Module Topology and Build System -## Composite build and discovery +## Topology -`settings.gradle.kts` includes `build-logic` through `pluginManagement`, centralizes repositories with `RepositoriesMode.FAIL_ON_PROJECT_REPOS`, and uses the Foojay resolver for JDK provisioning. +`settings.gradle.kts` discovers directories containing `build.gradle.kts`; `:app` similarly aggregates discovered modules. The current tree contains the app, core libraries, four-layer feature verticals, benchmark tooling, and the included `build-logic` build. -The settings script walks the repository and includes each directory that directly contains `build.gradle.kts`, excluding `build`, `build-logic`, `buildSrc`, `gradle`, and `src`. There is no hand-maintained `include(...)` inventory. +Convention plugins enforce Android defaults, Compose, Hilt, testing, static analysis, module boundaries, generation, optional infrastructure, and performance wiring. `core:navigation` has no dependency on `core:data`; theme state is exposed through `IThemeManager`, not the navigation contract. -## Module inventory +## Projection -| Group | Count | Notes | -| --- | ---: | --- | -| `:app` | 1 | Composition root and discovered dependency aggregator | -| `:core:*` | 12 | analytics, common, config, data, database, google-play, navigation, network, permission, secrets, security, ui | -| `:feature:*:*` | 32 | 8 features × 4 layers | -| `:benchmark`, `:baselineprofile` | 2 | Macrobenchmark and Baseline Profile tooling | +`data.strategy.projection` supports `remote`, `offline-first`, `local`, and `minimal`. Required source rewrites demand exactly one match, and network-free outputs remove Retrofit, OkHttp, the kotlinx.serialization Retrofit converter, auth modules, and related residue. Database-free outputs remove Room and database scaffolding. -Total: **47 Gradle modules**. The 12 core and 32 feature submodules are **44 Android libraries**. Counts describe the current tree; discovery makes them an outcome rather than a fixed include list. +`offline-first` selects network/auth plus Room infrastructure. It deliberately does not generate a synchronization engine. -## Convention plugins (21) +## Serialization -Registered IDs are grouped below: - -- **Android base**: `android.application`, `android.application.compose`, `android.library`, `android.library.compose`, `android.library.native`, `android.hilt`, `android.room` -- **Feature layers**: `feature.domain`, `feature.data`, `feature.navigation`, `feature.presentation` -- **Generation**: `create.new.app`, `data.strategy.projection`, `scaffold.feature` -- **Quality/operations**: `test`, `static.analysis`, `validate.secrets`, `baseline.profile.generator`, `app.boundary`, `module.boundary`, `perf` - -`ProjectExtensions` contains shared helpers but is not a registered plugin. - -## Boundary enforcement - -- `checkAppModuleBoundary` scans app imports. -- `checkModuleBoundary` scans library imports according to the module’s Gradle path. -- `checkProjectDependencyBoundary` scans literal `project(":…")` and `project(path = ":…")` dependencies. -- Dynamic paths remain allowed because `:app` intentionally wires discovered projects. - -These checks protect folder-level removability. Optional consumers receive implementations through multibindings rather than direct imports. - -## `app/build.gradle.kts` - -The application applies generation, projection, Android application, Compose, Hilt, test, performance, and serialization plugins, including `composetemplate.data.strategy.projection`. - -Other behavior: - -- namespace/application ID start as `com.ytapps.composetemplate` -- version code/name come from the catalog -- release minification and resource shrinking are enabled -- signing is secret-backed only when that subsystem is retained -- a benchmark build type derives from release -- core and feature project dependencies are derived from discovered projects - -## Strategy projection at the build level - -`data.strategy.projection` removes unselected module paths and related catalog entries, conventions, scaffolding, ProGuard text, navigation source, and secret/signing infrastructure. It keeps the four-layer feature shape unchanged. - -Database-backed outputs retain optional Room starter generation through `-PwithDatabase=true`. Database-free outputs contain no Room scaffolding flag, path, or identifier. +Navigation routes and Retrofit DTOs both use kotlinx.serialization. Retrofit is configured with `converter-kotlinx-serialization`; Gson is not part of the active network stack. ## Toolchain snapshot -| Area | Version | -| --- | --- | -| minSdk / targetSdk / compileSdk | 26 / 36 / 37 | -| NDK | 27.0.12077973 | -| Kotlin | 2.0.21 | -| AGP | 9.2.1 | -| KSP | 2.0.21-1.0.28 | -| Compose BOM | 2026.05.01 | -| Navigation3 | 1.1.2 | -| Hilt | 2.59.2 | -| Retrofit / OkHttp | 2.12.0 / 4.12.0 | -| Room | 2.8.4 | -| DataStore | 1.2.1 | -| Coil | 3.4.0 | - -The catalog currently includes Gson for Retrofit and kotlinx.serialization for routes; this split is documented in [03 - Network and Auth](03-network-and-auth.md). +- minSdk / targetSdk / compileSdk: 26 / 36 / 37 +- Kotlin: 2.0.21 +- AGP: 9.2.1 +- Navigation3: 1.1.2 +- Retrofit: 2.12.0 +- Room: 2.8.4 --- -[← Previous: 00 - Project Context](00-project-context.md) · [Index](README.md) · [Next: 02 - Navigation and UI State →](02-navigation-and-ui-state.md) +[← Previous](00-project-context.md) · [Index](README.md) · [Next →](02-navigation-and-ui-state.md) diff --git a/wiki/02-navigation-and-ui-state.md b/wiki/02-navigation-and-ui-state.md index 2cab2636..84021aeb 100644 --- a/wiki/02-navigation-and-ui-state.md +++ b/wiki/02-navigation-and-ui-state.md @@ -1,47 +1,26 @@ # 02 - Navigation and UI State -## `core:navigation` +## Navigation contract -`INavigationItem`, `IBottomBarItem`, `INavigationManager`, `IScreenProvider`, `NavigationManager`, `ScreenRegistry`, and `NavigationObserver` form the navigation surface. +`NavigationManager` owns the typed Navigation3 back stack. `MainActivity` persists stable route strings in `onSaveInstanceState` and restores them through `INavigationManager.restoreBackStack` and `IScreenProvider.restoreRoute`. Missing features are skipped, and the start destination is prepended when restored history does not contain it. -`NavigationManager` owns an in-memory `StateFlow` back stack and provides navigation, replacement, root, back, and tab operations. The stack is not persisted across process death. +Each retained feature provider restores its object route; detail restores its typed ID route. `scaffoldFeature` emits the same restoration hook for newly generated features. -`ScreenRegistry` receives `Set` through Hilt multibinding. The first provider that claims a typed route renders it; unresolved routes currently render a fallback rather than failing fast. +`ScreenRegistry` rejects multiple restorers for one route and throws when no provider can render a route. Unknown routes therefore fail fast instead of silently showing a fallback. -## Optional observers +## App shell and theme -`NavigationObserver` is a multibinding contract. `core:analytics` contributes the current implementation while the app shell depends only on the contract. Removing analytics therefore leaves a valid empty set. +`MainActivity` injects `IThemeManager` separately from `INavigationManager`. Navigation no longer owns preference or theme concerns. Optional navigation observers remain multibound, allowing analytics to be removed safely. -## App shell +## Start flow -`MainActivity` injects navigation contracts and renders `AppNavigation`. `AppNavigation`: +- `remote` / `offline-first`: incomplete onboarding → Onboarding; then stored user → Home, no user → Login. +- `local` / `minimal`: incomplete onboarding → Onboarding; then Home, with no auth or `LoginRoute` residue. -- renders Navigation3 `NavDisplay` -- notifies observers on route changes -- shows the bottom bar only for registered bottom-bar routes -- finishes the Activity when back navigation is unhandled +## Coverage -The app shell intentionally has no `NetworkMonitor` or global offline banner. Network-aware UI belongs in a feature that actually requires it, which keeps network-free generated consumers free of network concepts. - -## Generated start flow - -The generator projects one of two compile-time flows: - -- `remote` and `offline-first`: incomplete onboarding → Onboarding; then stored user → Home, no user → Login. Logout returns to Login. -- `local` and `minimal`: incomplete onboarding → Onboarding; then Home. The auth feature and `LoginRoute` references are absent. - -This is source projection at generation time, not runtime branching on internet availability. - -## UI state - -`BaseViewModel` exposes immutable state and sends one-shot events through a channel. Feature screens follow the Route/UI split and collect state with lifecycle awareness. - -The contract standardizes structure but does not replace product tests. Generated consumers should add ViewModel, navigation, restoration, and Compose UI coverage for their actual flows. - -## `core:ui` - -The design system includes buttons, fields, cards, dialogs, top bars, empty/error/loading states, search, image rendering, theme tokens, previews, and navigation-bar components. It does not contain a global no-internet banner. +Focused tests cover back-stack restoration, route reconstruction, duplicate restorers, unknown routes, and generated scaffold compilation. Product consumers should extend these with feature-specific ViewModel and Compose UI tests. --- -[← Previous: 01 - Module Topology and Build System](01-module-topology.md) · [Index](README.md) · [Next: 03 - Network and Auth Token Flow →](03-network-and-auth.md) +[← Previous](01-module-topology.md) · [Index](README.md) · [Next →](03-network-and-auth.md) diff --git a/wiki/03-network-and-auth.md b/wiki/03-network-and-auth.md index e762ca5c..f7b61dd4 100644 --- a/wiki/03-network-and-auth.md +++ b/wiki/03-network-and-auth.md @@ -1,55 +1,23 @@ # 03 - Network and Auth Token Flow -## Selection contract +## Scope -`core:network` and `feature:auth` are retained only for `remote` and `offline-first`. `local` and `minimal` remove their modules and associated catalog, ProGuard, convention, documentation, navigation, and identifier residue. +`core:network` and `feature:auth` are retained for `remote` and `offline-first`; `local` and `minimal` remove them and their catalog, build, ProGuard, navigation, and documentation residue. -`offline-first` selects both network/auth and Room infrastructure. The template does not generate synchronization, retry queues, conflict resolution, cache policy, or background reconciliation. +`offline-first` is an infrastructure topology: network/auth plus Room. Synchronization, retry queues, conflict resolution, cache policy, and background reconciliation remain product-specific and are intentionally not generated. -## `core:network` +## Network and serialization -The module contains Retrofit/OkHttp setup, `BaseRepository`, the auth interceptor, `TokenAuthenticator`, network configuration contracts, and unit tests. +Retrofit uses the kotlinx.serialization converter with a configured `Json` instance. Auth requests and responses are `@Serializable` and use `@SerialName` where wire names differ. Routes use the same serialization family, removing the former Gson/kotlinx split. -- HTTP logging is `BODY` in debug and `NONE` in release, with sensitive headers redacted. -- Configuration comes from a possibly empty Hilt set of `NetworkConfigProvider` implementations. -- A secret-free network consumer uses a non-routable placeholder base URL and disables pinning until product configuration is supplied. -- Multiple configuration providers are treated as a wiring error. -- The module does not own global connectivity UI. +`BaseRepository.safeCall` rethrows `CancellationException`; tests explicitly protect coroutine cancellation propagation. -## Authentication sample +## Authentication and refresh -`feature:auth` is a complete four-layer vertical. Its data layer provides Retrofit service wiring, repository logic, models, and an optional token refresher contribution. +Network-backed projects route completed onboarding to Home when a stored user exists and Login otherwise. `TokenAuthenticator` skips refresh calls, caps retries, serializes refresh, rechecks the newest token, and consumes optional `ITokenRefresher` contributions without making `core:network` depend on auth. -For network-backed generated projects: - -```text -Splash -├─ onboarding incomplete → Onboarding -└─ onboarding complete - ├─ stored user exists → Home - └─ no stored user → Login -``` - -For `local` and `minimal`, the generator removes the auth vertical and emits Onboarding/Home flow with no `LoginRoute` reference. - -## Token refresh - -`TokenAuthenticator` skips the refresh endpoint, caps retries, synchronizes refresh, double-checks the newest token, and consumes a lazy set of `ITokenRefresher` implementations. `core:network` never depends on the auth feature; when no refresher exists, a 401 is returned without retry. - -## Secrets dependency direction - -```text -core:network <- core:secrets -``` - -Secrets contribute network configuration and may be omitted from `remote` or `offline-first` with `-PwithSecrets=false`. Local/minimal remove secrets and security automatically. - -A network-backed consumer without secrets must replace the placeholder configuration with product-owned runtime/build configuration before contacting a real backend. - -## Serialization split - -Routes use kotlinx.serialization while Retrofit uses Gson. Network-backed outputs therefore retain two serialization stacks and their R8 configuration. +Secrets may be omitted from a network strategy with `-PwithSecrets=false`; such consumers receive placeholder network configuration that must be replaced before production use. --- -[← Previous: 02 - Navigation and UI State](02-navigation-and-ui-state.md) · [Index](README.md) · [Next: 04 - Secrets, Security and Hardening →](04-secrets-and-hardening.md) +[← Previous](02-navigation-and-ui-state.md) · [Index](README.md) · [Next →](04-secrets-and-hardening.md) diff --git a/wiki/06-quality-tests-ci.md b/wiki/06-quality-tests-ci.md index 22b3fee9..a2141bc8 100644 --- a/wiki/06-quality-tests-ci.md +++ b/wiki/06-quality-tests-ci.md @@ -1,83 +1,32 @@ # 06 - Quality, Tests and CI -## Local quality gates +## Local gates ```bash -./gradlew ktlintCheck detekt testDebugUnitTest assembleDebug :app:assembleRelease +./gradlew ktlintCheck detekt testDebugUnitTest jacocoDebugReport assembleDebug :app:assembleRelease +./gradlew -p build-logic :convention:test +mkdocs build --strict ``` -Application/library conventions attach import and literal project-dependency boundary checks to normal build/test tasks. +## Pull-request workflow -## Five job definitions, eight visible checks +CI has five job definitions and eight visible checks: lint/docs, unit tests/coverage, debug+release assembly, plug-out, and four template-smoke strategy rows. -`.github/workflows/ci.yml` defines five job types: +The workflow now: -1. Lint -2. Unit Tests -3. Assemble Debug + Release -4. Plug-out -5. Template Smoke matrix +- uses one `core/secrets/ci/setup.sh` bootstrap in every applicable job; +- runs strict MkDocs validation when `mkdocs.yml` exists; +- runs build-logic projector tests; +- produces and uploads Jacoco XML/HTML artifacts; +- runs feature scaffold checks only in the `remote` matrix row; +- verifies generated screen providers include restoration; +- executes the benchmark instrumentation suite on an API 35 emulator; +- generates, residue-checks, and builds all four data strategies; +- separately proves a secret-free network consumer; +- uses checkout/setup-java/setup-gradle v5 actions. -The four-row matrix expands the workflow to eight visible check runs: - -- Lint -- Unit Tests -- Assemble Debug + Release -- Plug-out -- Template Smoke (`remote`) -- Template Smoke (`offline-first`) -- Template Smoke (`local`) -- Template Smoke (`minimal`) - -## What CI proves - -- source-template lint, tests, debug assembly, and release assembly -- a plug-out rebuild after deleting the tested optional path set -- normal four-layer feature scaffolding and compilation -- Room-backed feature scaffolding and compilation -- rejection of an invalid `dataStrategy` -- four strategy-specific consumer generations -- strategy-specific path/text residue cleanup -- `LoginRoute` presence for network strategies and absence for network-free strategies -- debug and release assembly for every strategy -- `remote + withSecrets=false` generation, residue checks, and debug/release assembly - -## Exact plug-out set - -The plug-out job deletes: - -- `core/security` -- `core/analytics` -- `core/database` -- `core/network` -- `core/secrets` -- `feature/auth` -- `benchmark` -- `baselineprofile` - -This source-template test complements consumer generation: plug-out protects deletion-safe module boundaries, while the matrix validates the final projected consumer. - -## Matrix semantics - -| Strategy | Required retained infrastructure | -| --- | --- | -| `remote` | network/auth; no Room | -| `offline-first` | network/auth and Room | -| `local` | Room; no network/auth/secrets | -| `minimal` | no predefined network, auth, Room, secrets, or security | - -The matrix proves topology and buildability. For `offline-first`, it does not prove application synchronization semantics. - -## Known CI limitations - -- Secret bootstrap is duplicated across jobs. -- Scaffold checks repeat in every matrix instance. -- No instrumentation execution. -- No benchmark execution. -- No coverage/report artifact. -- MkDocs strict build/link validation is not currently a pull-request check. -- Workflow YAML remains sensitive to formatting and invisible-character mistakes. +Plug-out still removes security, analytics, database, network, secrets, auth, benchmark, and baseline-profile paths before rebuilding. The matrix proves topology and buildability; it does not claim generic offline synchronization semantics. --- -[← Previous: 05 - Generator and Scaffolding Tooling](05-generator-and-scaffolding.md) · [Index](README.md) · [Next: 07 - Risks, Gaps and Open Questions →](07-risks-and-gaps.md) +[← Previous](05-generator-and-scaffolding.md) · [Index](README.md) · [Next →](07-risks-and-gaps.md) diff --git a/wiki/07-risks-and-gaps.md b/wiki/07-risks-and-gaps.md index a786dc3a..2387b83b 100644 --- a/wiki/07-risks-and-gaps.md +++ b/wiki/07-risks-and-gaps.md @@ -1,71 +1,45 @@ # 07 - Risks, Gaps and Open Questions -## Current high-impact risks +## Resolved in the architecture hardening change -1. Navigation state is in-memory and is not restored after process death. -2. Unknown routes render a fallback instead of failing fast in debug. -3. Presentation, navigation, and Compose UI coverage is thinner than infrastructure/build coverage. -4. Retrofit uses Gson while routes use kotlinx.serialization. -5. Broad repository error handling must preserve coroutine cancellation correctly. -6. Theme state remains exposed through the navigation contract. -7. The data-strategy projector relies on exact source/file rewrites; source drift can make projection brittle despite residue checks. -8. `offline-first` names an infrastructure topology, not a generated synchronization architecture. -9. Secret bootstrap is duplicated across CI jobs, and scaffold checks repeat per matrix row. -10. Instrumentation, benchmark execution, coverage reporting, and strict MkDocs validation are absent from PR CI. -11. Branch protection and required checks are repository policy and must be verified/enforced separately. +- Navigation persists stable route strings and restores typed back stacks after Activity/process recreation. +- Unknown routes and ambiguous restorers fail fast. +- Navigation no longer exposes theme state or depends on `core:data`. +- Retrofit DTOs and routes use kotlinx.serialization; Gson was removed from the active stack. +- `BaseRepository.safeCall` has an explicit cancellation-propagation test. +- Existing and newly scaffolded screen providers restore routes. +- Data-strategy rewrites require exactly one source match and have build-logic tests for success, missing, and duplicate matches. +- Network-free projection removes the kotlinx.serialization Retrofit converter and validates its absence. +- CI secret setup is centralized, scaffold checks are deduplicated, MkDocs is strict, Jacoco reports are uploaded, and benchmark instrumentation executes on an emulator. -## Resolved structural risks +## Explicit scope boundaries -- Modules are discovered from the filesystem rather than mirrored in multiple build-file inventories. -- App and library boundaries cover imports and literal project dependencies. -- The plug-out job deletes eight optional paths before rebuilding. -- App-shell connectivity monitoring and the global offline banner were removed. -- Four strategy-specific consumer outputs are generated, residue-checked, and assembled. -- A network-backed secret-free consumer is explicitly proven. -- Invalid strategy values are rejected. +### Offline-first -## Load-bearing decisions +`offline-first` means network/auth and Room infrastructure are retained together. The template does not invent synchronization, conflict, retry, cache, or reconciliation policy; those decisions belong to the generated product. ### Fixed feature shape -Every retained feature has `data`, `domain`, `navigation`, and `presentation`. Data strategy changes project infrastructure, not feature-layer topology. +Every retained feature has `data`, `domain`, `navigation`, and `presentation`. Data strategy changes infrastructure, not feature-layer topology. ### Compile-time projection -Network-free consumers do not contain auth/network types. Valid source flows are emitted during generation rather than guarded by runtime capability checks. +Network-free consumers contain no auth/network types. The generator emits valid source rather than runtime capability branches. -### Module discovery +## Remaining risks -Settings and app aggregation derive from module folders. Removing a module does not require synchronized edits to a static include or dependency list. +1. Product-specific presentation and Compose UI behavior still needs product-specific tests beyond template smoke coverage. +2. Exact-count projection is safer and drift-detecting, but structured templates would reduce long-term coupling further. +3. Signing configuration remains coupled to the selected secret/hardening subsystem. +4. Branch protection is still not enabled on `main`; repository policy must require pull requests, green checks, an up-to-date branch, and optionally one approval. -### Optional integrations +## Next work -Multibindings are used where optional implementations have a real consumer, such as analytics observers and token refreshers. - -### Network and secrets scope - -`core:network` is transport infrastructure, not global connectivity UI. The secret provider configures that stack, so local/minimal always remove secrets/security; network strategies can retain or omit them. - -## Open questions - -- Should unknown routes throw in debug builds? -- Should navigation adopt saved-state/process restoration? -- Should Retrofit migrate to kotlinx.serialization? -- Should signing configuration be separated from secret/hardening selection? -- Should the projector move from exact text rewrites toward structured generation/templates? -- What synchronization policy should a real offline-first consumer use? -- Which instrumentation, benchmark, coverage, and docs checks should become required? - -## Recommended next work - -1. Add focused ViewModel/navigation tests for auth-aware and network-free start flows. -2. Add a strict MkDocs/link check to pull-request CI. -3. Reduce projector brittleness with structured templates or syntax-aware transformations. -4. Define and implement product-specific offline synchronization where needed. -5. Persist navigation state and tighten unknown-route behavior. -6. Consolidate CI bootstrap/scaffold work and add instrumentation/benchmark coverage. -7. Verify and enforce branch-protection policy with required green checks. +- Expand product-owned UI and end-to-end restoration coverage. +- Consider structured generation instead of source text projection. +- Decide whether signing and runtime hardening should be independently selectable. +- Enable branch protection/rulesets in repository settings. --- -[← Previous: 06 - Quality, Tests and CI](06-quality-tests-ci.md) · [Index](README.md) · [Next: 08 - Getting Started →](08-getting-started.md) +[← Previous](06-quality-tests-ci.md) · [Index](README.md) · [Next →](08-getting-started.md)