diff --git a/.github/workflows/android_ci.yml b/.github/workflows/android_ci.yml index 3e073b75..f5fa05a2 100644 --- a/.github/workflows/android_ci.yml +++ b/.github/workflows/android_ci.yml @@ -83,7 +83,7 @@ jobs: # Run Unit Test and Generate Coverage - name: Run unit tests and generate coverage - run: ./gradlew generateTestCoverageReport + run: ./gradlew generateTestCoverageReport --no-configuration-cache # Upload Coverage to Codecov - name: Upload coverage to Codecov diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..ce2efe61 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,20 @@ +# Repository Guidelines + +## Project Structure & Module Organization +Orbit is a multi-module Android app. The entry point lives in `app/`, while shared layers live in `domain/` and `data/`. Reusable UI, networking, analytics, alarms, and configuration code sit in `core/*` (for example `core:designsystem`, `core:network`, `core:analytics`). Feature-specific flows ship from `feature/*` (e.g., `feature:fortune`, `feature:mission`). Common build scripts are centralized under `build-logic/` and dependency catalogues live in `gradle/`. + +## Build, Test & Development Commands +- `./gradlew assembleDebug` – Build the debug APK with all modules. +- `./gradlew :app:installDebug` – Install the latest debug build on a connected device or emulator. +- `./gradlew testDebugUnitTest` – Execute JVM unit tests across modules. +- `./gradlew connectedDebugAndroidTest` – Run instrumentation/UI tests on a device. +- `./gradlew ktlintCheck` / `ktlintFormat` – Verify or auto-format Kotlin style. + +## Coding Style & Naming Conventions +Kotlin source uses 4-space indentation, trailing commas where helpful, and idiomatic coroutines/Flow patterns. Follow Jetpack Compose best practices: hoist state, keep composables small, and preview via `@Preview` where possible. Classes, objects, and functions use UpperCamelCase or lowerCamelCase; resource IDs and Gradle task names use snake_case. Keep packages aligned with features (`feature.fortune`, `core.network`). ktlint enforces formatting; run it before opening a PR. + +## Testing Guidelines +Unit tests rely on JUnit4, MockK, and coroutine-testing; place them under `src/test`. UI and integration tests use Espresso, Compose testing, or Robolectric in `src/androidTest`. Prefer test method names that read like sentences (e.g., `shouldReturnDefaultMission_whenAlarmCreated`). For new features, cover at least happy-path and primary failure cases, and update any existing baselines. + +## Commit & Pull Request Guidelines +Commit messages follow the `"[TYPE/#issue] Summary"` convention observed in the log (e.g., `[BUGFIX/#256] Resolve ghost mission state`). Types commonly include FEAT, BUGFIX, CHORE, and HOTFIX. For pull requests, include a concise summary, linked issue reference, screenshots or screen recordings for UI-facing changes, and a checklist of tests you ran. Request reviews from module owners and ensure CI builds, tests, and ktlint checks are green before merging. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 570e3265..b14587e9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -4,6 +4,8 @@ plugins { alias(libs.plugins.google.service) alias(libs.plugins.firebase.app.distribution) alias(libs.plugins.firebase.crashlytics) + alias(libs.plugins.android.application) + alias(libs.plugins.baselineprofile) } android { @@ -26,6 +28,8 @@ android { release { signingConfig = signingConfigs.getByName("debug") + isMinifyEnabled = true + isShrinkResources = true } } } @@ -50,12 +54,17 @@ dependencies { implementation(projects.feature.mission) implementation(projects.feature.setting) implementation(projects.feature.webview) - implementation(platform(libs.firebase.bom)) + implementation(libs.compose.material) - implementation(libs.firebase.analytics) - implementation(libs.firebase.crashlytics) - implementation(libs.play.services.ads) implementation(libs.kotlin.reflect) implementation(libs.hilt.worker) implementation(libs.androidx.work.runtime) + + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.analytics) + implementation(libs.firebase.crashlytics) + implementation(libs.play.services.ads) + + implementation(libs.androidx.profileinstaller) + baselineProfile(projects.baselineprofile) } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 481bb434..26c0c211 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,21 +1,7 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html +-keepattributes SourceFile,LineNumberTable -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} +-keep class com.google.android.gms.** { *; } +-keep class com.google.firebase.** { *; } -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +-keepattributes KotlinMetadata +-keepattributes *Annotation* diff --git a/baselineprofile/.gitignore b/baselineprofile/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/baselineprofile/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/baselineprofile/build.gradle.kts b/baselineprofile/build.gradle.kts new file mode 100644 index 00000000..0c477837 --- /dev/null +++ b/baselineprofile/build.gradle.kts @@ -0,0 +1,66 @@ +import com.android.build.api.dsl.ManagedVirtualDevice + +plugins { + alias(libs.plugins.android.test) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.baselineprofile) +} + +android { + namespace = "com.dongchyeon.baselineprofile" + compileSdk = 36 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = "11" + } + + defaultConfig { + minSdk = 28 + targetSdk = 36 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunnerArguments["androidx.benchmark.suppressErrors"] = "EMULATOR" + } + + targetProjectPath = ":app" + + // This code creates the gradle managed device used to generate baseline profiles. + // To use GMD please invoke generation through the command line: + // ./gradlew :app:generateBaselineProfile + testOptions.managedDevices.devices { + create("pixel6Api34") { + device = "Pixel 6" + apiLevel = 34 + systemImageSource = "google" + } + } +} + +// This is the configuration block for the Baseline Profile plugin. +// You can specify to run the generators on a managed devices or connected devices. +baselineProfile { + managedDevices += "pixel6Api34" + useConnectedDevices = false +} + +dependencies { + implementation(libs.androidx.test.ext.junit) + implementation(libs.androidx.espresso.core) + implementation(libs.androidx.uiautomator) + implementation(libs.androidx.benchmark.macro.junit4) +} + +androidComponents { + onVariants { v -> + val artifactsLoader = v.artifacts.getBuiltArtifactsLoader() + v.instrumentationRunnerArguments.put( + "targetAppId", + v.testedApks.map { artifactsLoader.load(it)?.applicationId!! }, + ) + } +} diff --git a/baselineprofile/src/main/AndroidManifest.xml b/baselineprofile/src/main/AndroidManifest.xml new file mode 100644 index 00000000..227314ee --- /dev/null +++ b/baselineprofile/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/baselineprofile/src/main/java/com/dongchyeon/baselineprofile/BaselineProfileGenerator.kt b/baselineprofile/src/main/java/com/dongchyeon/baselineprofile/BaselineProfileGenerator.kt new file mode 100644 index 00000000..6620c965 --- /dev/null +++ b/baselineprofile/src/main/java/com/dongchyeon/baselineprofile/BaselineProfileGenerator.kt @@ -0,0 +1,68 @@ +package com.dongchyeon.baselineprofile + +import androidx.benchmark.macro.junit4.BaselineProfileRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * This test class generates a basic startup baseline profile for the target package. + * + * We recommend you start with this but add important user flows to the profile to improve their performance. + * Refer to the [baseline profile documentation](https://d.android.com/topic/performance/baselineprofiles) + * for more information. + * + * You can run the generator with the "Generate Baseline Profile" run configuration in Android Studio or + * the equivalent `generateBaselineProfile` gradle task: + * ``` + * ./gradlew :app:generateReleaseBaselineProfile + * ``` + * The run configuration runs the Gradle task and applies filtering to run only the generators. + * + * Check [documentation](https://d.android.com/topic/performance/benchmarking/macrobenchmark-instrumentation-args) + * for more information about available instrumentation arguments. + * + * After you run the generator, you can verify the improvements running the [StartupBenchmarks] benchmark. + * + * When using this class to generate a baseline profile, only API 33+ or rooted API 28+ are supported. + * + * The minimum required version of androidx.benchmark to generate a baseline profile is 1.2.0. + **/ +@RunWith(AndroidJUnit4::class) +@LargeTest +class BaselineProfileGenerator { + + @get:Rule + val rule = BaselineProfileRule() + + @Test + fun generate() { + // The application id for the running build variant is read from the instrumentation arguments. + rule.collect( + packageName = InstrumentationRegistry.getArguments().getString("targetAppId") + ?: throw Exception("targetAppId not passed as instrumentation runner arg"), + + // See: https://d.android.com/topic/performance/baselineprofiles/dex-layout-optimizations + includeInStartupProfile = true, + ) { + // This block defines the app's critical user journey. Here we are interested in + // optimizing for app startup. But you can also navigate and scroll through your most important UI. + + // Start default activity for your app + pressHome() + startActivityAndWait() + + // TODO Write more interactions to optimize advanced journeys of your app. + // For example: + // 1. Wait until the content is asynchronously loaded + // 2. Scroll the feed content + // 3. Navigate to detail screen + + // Check UiAutomator documentation for more information how to interact with the app. + // https://d.android.com/training/testing/other-components/ui-automator + } + } +} diff --git a/baselineprofile/src/main/java/com/dongchyeon/baselineprofile/StartupBenchmarks.kt b/baselineprofile/src/main/java/com/dongchyeon/baselineprofile/StartupBenchmarks.kt new file mode 100644 index 00000000..9f88e97a --- /dev/null +++ b/baselineprofile/src/main/java/com/dongchyeon/baselineprofile/StartupBenchmarks.kt @@ -0,0 +1,76 @@ +package com.dongchyeon.baselineprofile + +import androidx.benchmark.macro.BaselineProfileMode +import androidx.benchmark.macro.CompilationMode +import androidx.benchmark.macro.StartupMode +import androidx.benchmark.macro.StartupTimingMetric +import androidx.benchmark.macro.junit4.MacrobenchmarkRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * This test class benchmarks the speed of app startup. + * Run this benchmark to verify how effective a Baseline Profile is. + * It does this by comparing [CompilationMode.None], which represents the app with no Baseline + * Profiles optimizations, and [CompilationMode.Partial], which uses Baseline Profiles. + * + * Run this benchmark to see startup measurements and captured system traces for verifying + * the effectiveness of your Baseline Profiles. You can run it directly from Android + * Studio as an instrumentation test, or run all benchmarks for a variant, for example benchmarkRelease, + * with this Gradle task: + * ``` + * ./gradlew :baselineprofile:connectedBenchmarkReleaseAndroidTest + * ``` + * + * You should run the benchmarks on a physical device, not an Android emulator, because the + * emulator doesn't represent real world performance and shares system resources with its host. + * + * For more information, see the [Macrobenchmark documentation](https://d.android.com/macrobenchmark#create-macrobenchmark) + * and the [instrumentation arguments documentation](https://d.android.com/topic/performance/benchmarking/macrobenchmark-instrumentation-args). + **/ +@RunWith(AndroidJUnit4::class) +@LargeTest +class StartupBenchmarks { + + @get:Rule + val rule = MacrobenchmarkRule() + + @Test + fun startupCompilationNone() = + benchmark(CompilationMode.None()) + + @Test + fun startupCompilationBaselineProfiles() = + benchmark(CompilationMode.Partial(BaselineProfileMode.Require)) + + private fun benchmark(compilationMode: CompilationMode) { + // The application id for the running build variant is read from the instrumentation arguments. + rule.measureRepeated( + packageName = InstrumentationRegistry.getArguments().getString("targetAppId") + ?: throw Exception("targetAppId not passed as instrumentation runner arg"), + metrics = listOf(StartupTimingMetric()), + compilationMode = compilationMode, + startupMode = StartupMode.COLD, + iterations = 10, + setupBlock = { + pressHome() + }, + measureBlock = { + startActivityAndWait() + + // TODO Add interactions to wait for when your app is fully drawn. + // The app is fully drawn when Activity.reportFullyDrawn is called. + // For Jetpack Compose, you can use ReportDrawn, ReportDrawnWhen and ReportDrawnAfter + // from the AndroidX Activity library. + + // Check the UiAutomator documentation for more information on how to + // interact with the app. + // https://d.android.com/training/testing/other-components/ui-automator + }, + ) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 01648063..66b0eb56 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -14,6 +14,8 @@ plugins { alias(libs.plugins.firebase.app.distribution) apply false alias(libs.plugins.firebase.crashlytics) apply false alias(libs.plugins.stability.analyzer) apply false + alias(libs.plugins.android.test) apply false + alias(libs.plugins.baselineprofile) apply false } apply { diff --git a/core/remoteconfig/src/main/java/com/yapp/remoteconfig/di/RemoteConfigModule.kt b/core/remoteconfig/src/main/java/com/yapp/remoteconfig/di/RemoteConfigModule.kt index 0b2928b1..cba09e6f 100644 --- a/core/remoteconfig/src/main/java/com/yapp/remoteconfig/di/RemoteConfigModule.kt +++ b/core/remoteconfig/src/main/java/com/yapp/remoteconfig/di/RemoteConfigModule.kt @@ -1,9 +1,9 @@ package com.yapp.remoteconfig.di -import com.google.firebase.ktx.Firebase +import com.google.firebase.Firebase import com.google.firebase.remoteconfig.FirebaseRemoteConfig -import com.google.firebase.remoteconfig.ktx.remoteConfig -import com.google.firebase.remoteconfig.ktx.remoteConfigSettings +import com.google.firebase.remoteconfig.remoteConfig +import com.google.firebase.remoteconfig.remoteConfigSettings import com.yapp.remoteconfig.FirebaseRemoteConfigManager import dagger.Module import dagger.Provides diff --git a/gradle.properties b/gradle.properties index e0d20494..a14e036d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,7 +6,7 @@ # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. For more details, visit # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects @@ -21,4 +21,5 @@ kotlin.code.style=official # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true -android.experimental.androidTest.useUnifiedTestPlatform=false +org.gradle.configuration-cache=true +org.gradle.parallel=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c2dd2ace..3aa13360 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,7 +30,7 @@ androidx-datastore = "1.1.1" androidx-room = "2.7.2" androidx-work = "2.10.3" -androidx-lifecycle = "2.8.7" +androidx-lifecycle = "2.9.4" annotation = "1.9.1" @@ -54,9 +54,9 @@ coil = "2.7.0" # Google Libraries Versions google-service = "4.4.2" playServicesAd = "24.2.0" -firebase-bom = "33.1.1" -firebase-app-distribution = "5.1.0" -firebase-crashlytics = "3.0.3" +firebase-bom = "34.4.0" +firebase-app-distribution = "5.2.0" +firebase-crashlytics = "3.0.6" ## Test junit4 = "4.13.2" @@ -78,6 +78,10 @@ accompanist = "0.37.0" materialAndroid = "1.7.5" amplitude = "1.20.3" stability-analyzer = "0.5.0" +uiautomator = "2.3.0" +benchmarkMacroJunit4 = "1.4.1" +baselineprofile = "1.4.1" +profileinstaller = "1.4.1" [libraries] @@ -148,9 +152,9 @@ okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", # Google Libraries firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebase-bom" } -firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics-ktx" } -firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics-ktx" } -firebase-config = { group = "com.google.firebase", name = "firebase-config-ktx" } +firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics" } +firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics" } +firebase-config = { group = "com.google.firebase", name = "firebase-config" } ## Logging timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" } @@ -178,6 +182,9 @@ amplitude-analytics = { group = "com.amplitude", name = "analytics-android", ver play-services-ads = { group = "com.google.android.gms", name = "play-services-ads", version.ref = "playServicesAd" } kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } +androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" } +androidx-benchmark-macro-junit4 = { group = "androidx.benchmark", name = "benchmark-macro-junit4", version.ref = "benchmarkMacroJunit4" } +androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "profileinstaller" } [plugins] ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } @@ -195,3 +202,4 @@ google-service = { id = "com.google.gms.google-services", version.ref = "google- firebase-app-distribution = { id = "com.google.firebase.appdistribution", version.ref = "firebase-app-distribution" } firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebase-crashlytics" } stability-analyzer = { id = "com.github.skydoves.compose.stability.analyzer", version.ref = "stability-analyzer" } +baselineprofile = { id = "androidx.baselineprofile", version.ref = "baselineprofile" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 984c9fc9..3fa1cea7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -4,13 +4,7 @@ pluginManagement { name = "build-logic" } repositories { - google { - content { - includeGroupByRegex("com\\.android.*") - includeGroupByRegex("com\\.google.*") - includeGroupByRegex("androidx.*") - } - } + google() mavenCentral() gradlePluginPortal() } @@ -47,3 +41,4 @@ include(":feature:webview") include(":core:analytics") include(":core:remoteconfig") include(":core:database") +include(":baselineprofile")