import org.gradle.api.tasks.testing.Test plugins { alias(libs.plugins.android.application) alias(libs.plugins.compose.compiler) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.room) alias(libs.plugins.ksp) } android { namespace = "com.m57.hermescontrol" sourceSets.getByName("androidTest").assets.srcDir("$projectDir/schemas") lint { disable += "MissingTranslation" } compileSdk = 37 buildToolsVersion = "37.0.0" defaultConfig { applicationId = "online.turkservis.aiturk.mobil" minSdk = 26 targetSdk = 37 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" // Version overrides passed from CI via -PversionName / -PversionCode // Falls back to defaults for local development. versionCode = (project.findProperty("versionCode") as? String)?.toIntOrNull() ?: 1 versionName = (project.findProperty("versionName") as? String) ?: "1.0-dev" // Use a fixed prefix: git --short varies with the checkout's object graph // (shallow GitHub Actions vs. full F-Droid clone), breaking reproducible APKs. val gitSha = providers.exec { commandLine("git", "rev-parse", "HEAD") }.standardOutput.asText.get().trim().take(7) buildConfigField("String", "GIT_SHA", "\"$gitSha\"") } signingConfigs { getByName("debug") { // Stable debug keystore committed to the repo so debug APKs keep // the same signature across machines and CI runs. CI runners use // an ephemeral default debug keystore otherwise, which makes every // CI debug APK uninstallable over a previously installed build. storeFile = file("debug.keystore") storePassword = "android" keyAlias = "androiddebugkey" keyPassword = "android" } create("release") { val isReleaseBuild = gradle.startParameter.taskNames.any { name -> name.contains("Release", ignoreCase = true) || name == "build" } if (isReleaseBuild) { val storePath = System.getenv("KEYSTORE_PATH") val storePass = System.getenv("KEYSTORE_PASSWORD") val alias = System.getenv("KEY_ALIAS") val keyPass = System.getenv("KEY_PASSWORD") if (storePath != null && storePass != null && alias != null && keyPass != null) { storeFile = file(storePath) storePassword = storePass keyAlias = alias keyPassword = keyPass } else { // Env vars missing — signing deferred to release workflow. // Don't hard-require(): that would break compileReleaseKotlin // in CI where keystore secrets aren't set. logger.warn("Release signing config missing env vars — signing deferred to release workflow") storeFile = file("dummy.keystore") storePassword = "dummy" keyAlias = "dummy" keyPassword = "dummy" } } else { // Dummy values for evaluation configuration during non-release builds storeFile = file("dummy.keystore") storePassword = "dummy" keyAlias = "dummy" keyPassword = "dummy" } } } buildTypes { debug { applicationIdSuffix = ".dev" versionNameSuffix = "-dev" buildConfigField("boolean", "ALLOW_CLEARTEXT", "true") manifestPlaceholders["usesCleartextTraffic"] = "true" } release { isMinifyEnabled = true isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", ) signingConfig = signingConfigs["release"] buildConfigField("boolean", "ALLOW_CLEARTEXT", "true") manifestPlaceholders["usesCleartextTraffic"] = "true" } } compileOptions { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } buildFeatures { compose = true aidl = false buildConfig = true shaders = false } dependenciesInfo { includeInApk = false } packaging { resources { pickFirsts += "META-INF/AL2.0" pickFirsts += "META-INF/LGPL2.1" pickFirsts += "META-INF/LICENSE" pickFirsts += "META-INF/LICENSE.md" pickFirsts += "META-INF/LICENSE-notice.md" pickFirsts += "META-INF/NOTICE" pickFirsts += "META-INF/NOTICE.md" } } } // Overwrite version-control-info with empty content for reproducible builds // F-Droid CI uses detached HEAD (branches: []) while GitHub CI uses main // (branches: ["main"]) — this field in the generated textproto causes a // byte-level APK difference. Emptying the file makes both CI environments // produce identical artifacts. tasks.matching { it.name.endsWith("VersionControlInfo") }.configureEach { doLast { outputs.files.filter { it.exists() }.forEach { it.writeText("") } } } kotlin { jvmToolchain(21) } room3 { schemaDirectory("$projectDir/schemas") } dependencies { val composeBom = platform(libs.androidx.compose.bom) implementation(composeBom) androidTestImplementation(composeBom) // Core Android dependencies implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) implementation(libs.androidx.browser) // Arch Components implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.viewmodel.compose) // Compose implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material.icons.extended) // Tooling debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.leakcanary.android) // Instrumented tests androidTestImplementation(libs.androidx.compose.ui.test.junit4) debugImplementation(libs.androidx.compose.ui.test.manifest) // Networking implementation(libs.okhttp) implementation(libs.okhttp.logging) implementation(libs.retrofit) implementation(libs.retrofit.converter.kotlinx.serialization) implementation(libs.kotlinx.serialization.json) // Native Compose LaTeX rendering implementation(libs.latex.base) implementation(libs.latex.parser) implementation(libs.latex.renderer) // Image loading (Coil 3) implementation(libs.coil.compose) implementation(libs.coil.gif) implementation(libs.coil.network.okhttp) // Media3 (ExoPlayer) implementation(libs.media3.exoplayer) implementation(libs.media3.ui) implementation(libs.media3.datasource.okhttp) // AITURK IDE pairing: QR scanner (ZXing, no Play Services) implementation(libs.zxing.android.embedded) // Encrypted storage implementation(libs.androidx.security.crypto) implementation(libs.androidx.datastore) implementation("androidx.startup:startup-runtime:1.1.1") // Local database (Room 3) — message persistence implementation(libs.androidx.room.runtime) implementation(libs.sqlcipher) ksp(libs.androidx.room.compiler) // Local tests: jUnit, coroutines, Android runner testImplementation(libs.junit) testImplementation(libs.junit.jupiter.api) testRuntimeOnly(libs.junit.jupiter.engine) testRuntimeOnly(libs.junit.vintage.engine) testRuntimeOnly(libs.junit.platform.launcher) testImplementation(libs.okhttp.mockwebserver) testImplementation(libs.okhttp.tls) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.mockk) testImplementation(libs.turbine) // Instrumented tests: jUnit rules and runners androidTestImplementation(libs.androidx.test.core) androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.androidx.test.espresso.core) androidTestImplementation(libs.mockk.android) // Navigation implementation(libs.androidx.navigation3.ui) implementation(libs.androidx.navigation3.runtime) implementation(libs.androidx.lifecycle.viewmodel.navigation3) } tasks.register("ktlintCheck") { group = "verification" description = "Runs ktlint check." workingDir = rootProject.projectDir commandLine("sh", "-c", "ktlint \"app/src/**/*.kt\"") } tasks.register("ktlintFormat") { group = "formatting" description = "Runs ktlint format." workingDir = rootProject.projectDir commandLine("sh", "-c", "ktlint -F \"app/src/**/*.kt\"") } // ── Hardcoded Color Guard (issue #622) ──────────────────────────────────── // // Fails the build if a hardcoded Color literal (Color(0x...) or named // Color.White/Black/Red/Green/Gray/etc.) is introduced outside the theme // module, *Preview.kt files, and _test sources. Pairing/Auth screens are // exempt via the per-path overlay list below. // // Acceptable replacements (see docs/color-hardcoded-audit.md): // - MaterialTheme.colorScheme. // - LocalHermesStatusColors.current. // - Color.Transparent / Color.Unspecified (intentional) // Resolve paths at configuration time (config-cache compatible). val colorGuardSrcDir = layout.projectDirectory .dir("src/main/java/com/m57/hermescontrol") val colorGuardExemptions = listOf( "/theme/", "PairingScreen.kt", "AuthLoginScreen.kt", "Preview.kt", ) val colorGuardHexPattern = Regex("""Color\(\s*0x[0-9A-Fa-f]{6,8}\s*\)""") val colorGuardNamedPattern = Regex("""Color\.(White|Black|Red|Green|Gray|LightGray|DarkGray|Yellow|Blue|Cyan|Magenta)\b""") tasks.register("checkColorLiterals") { group = "verification" description = "Fails if hardcoded Color(...) literals appear outside theme/, *Preview.kt, and _test." val srcDir = colorGuardSrcDir val exemptions = colorGuardExemptions val hexPattern = colorGuardHexPattern val namedPattern = colorGuardNamedPattern doLast { val offenders = mutableListOf>() srcDir.asFile.walkTopDown().filter { it.isFile && it.extension == "kt" }.forEach { file -> if (exemptions.any { file.absolutePath.contains(it) }) return@forEach file.useLines { lines -> lines.forEachIndexed { idx, raw -> val line = raw.trim() if (line.startsWith("import ")) return@forEachIndexed if (line.startsWith("//") || line.startsWith("*")) return@forEachIndexed if (hexPattern.containsMatchIn(line) || namedPattern.containsMatchIn(line)) { offenders.add(file.absolutePath to (idx + 1)) } } } } if (offenders.isNotEmpty()) { val report = offenders.joinToString("\n") { (path, line) -> " - $path:$line" } throw GradleException( "Hardcoded Color literals found outside theme/ + *Preview.kt + _test:\n$report\n\n" + "Replace with MaterialTheme.colorScheme., " + "LocalHermesStatusColors.current., or " + "Color.Transparent / Color.Unspecified (intentional).\n" + "See docs/color-hardcoded-audit.md.", ) } logger.lifecycle("checkColorLiterals: no hardcoded Color literals found. ✅") } } tasks.named("check") { dependsOn("checkColorLiterals") } val includeBenchmarks = providers.gradleProperty("includeBenchmarks").isPresent tasks.withType().configureEach { useJUnitPlatform() maxParallelForks = 2 // Source-level expectations use decimal dots; the developer's Windows // display language must not change test output (the APK locale is unaffected). systemProperty("user.language", "en") systemProperty("user.country", "US") // Assertion-free timing probes are opt-in: ./gradlew testDebugUnitTest -PincludeBenchmarks if (!includeBenchmarks) { filter { excludeTestsMatching("*Benchmark") } } }