import java.net.URI import java.nio.file.Files import java.nio.file.Path import org.gradle.api.tasks.compile.JavaCompile plugins { id("com.android.application") } val releaseBaseUrl = providers.gradleProperty("WNH_BASE_URL").orElse("") val debugBaseUrl = providers.gradleProperty("WNH_DEBUG_BASE_URL").orElse("") val trackingMinTimeMs = providers.gradleProperty("WNH_TRACKING_MIN_TIME_MS").orElse("0") val trackingHttpTimeoutMs = providers.gradleProperty("WNH_TRACKING_HTTP_TIMEOUT_MS").orElse("0") val instrumentationBuildType = providers.gradleProperty("WNH_TEST_BUILD_TYPE").orElse("debug") val androidVersionCode = providers.gradleProperty("WNH_ANDROID_VERSION_CODE").orElse("1") val androidVersionName = providers.gradleProperty("WNH_ANDROID_VERSION_NAME").orElse("0.1.0") val releaseSigningStoreFile = providers.environmentVariable("WNH_ANDROID_SIGNING_STORE_FILE").orNull val releaseSigningPasswordFile = providers.environmentVariable("WNH_ANDROID_SIGNING_PASSWORD_FILE").orNull val releaseSigningKeyAlias = providers.environmentVariable("WNH_ANDROID_SIGNING_KEY_ALIAS").orNull fun nonBlank(value: String?): String? = value?.trim()?.takeIf(String::isNotEmpty) val releaseSigningInputs = listOf( nonBlank(releaseSigningStoreFile), nonBlank(releaseSigningPasswordFile), nonBlank(releaseSigningKeyAlias) ) val releaseSigningConfigured = releaseSigningInputs.all { it != null } val releaseSigningPartiallyConfigured = releaseSigningInputs.any { it != null } fun readSigningPassword(): String { val passwordPath = nonBlank(releaseSigningPasswordFile) ?: throw GradleException("Android release signing password file is not configured") val path = Path.of(passwordPath) if (!Files.isRegularFile(path)) { throw GradleException("Android release signing password file does not exist") } val password = Files.readString(path).trimEnd('\r', '\n') if (password.isBlank() || password.contains('\n') || password.contains('\r')) { throw GradleException("Android release signing password file is invalid") } return password } fun manifestOrigin(value: String): URI? = runCatching { URI(value) } .getOrNull() ?.takeIf { uri -> (uri.scheme == "http" || uri.scheme == "https") && !uri.host.isNullOrBlank() } fun isOriginPath(uri: URI): Boolean = uri.path.isNullOrEmpty() || uri.path == "/" val debugManifestOrigin = manifestOrigin(debugBaseUrl.get()) val releaseManifestOrigin = manifestOrigin(releaseBaseUrl.get()) android { namespace = "org.whoneedhelp.mobile" compileSdk = 37 buildToolsVersion = "37.0.0" testBuildType = instrumentationBuildType.get() defaultConfig { applicationId = "org.whoneedhelp.mobile" minSdk = 24 targetSdk = 37 versionCode = androidVersionCode.get().toIntOrNull()?.takeIf { it > 0 } ?: throw GradleException("WNH_ANDROID_VERSION_CODE must be a positive integer") versionName = androidVersionName.get().trim().takeIf(String::isNotEmpty) ?: throw GradleException("WNH_ANDROID_VERSION_NAME must not be empty") buildConfigField("long", "TRACKING_MIN_TIME_MS", "${trackingMinTimeMs.get()}L") buildConfigField( "long", "TRACKING_HTTP_TIMEOUT_MS", "${trackingHttpTimeoutMs.get()}L" ) testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } signingConfigs { if (releaseSigningConfigured) { create("release") { val storePath = Path.of(nonBlank(releaseSigningStoreFile)!!) if (!Files.isRegularFile(storePath)) { throw GradleException("Android release signing keystore does not exist") } storeFile = storePath.toFile() storePassword = readSigningPassword() keyAlias = nonBlank(releaseSigningKeyAlias) keyPassword = storePassword } } } buildTypes { debug { applicationIdSuffix = ".debug" versionNameSuffix = "-debug" buildConfigField( "String", "BASE_URL", "\"${debugBaseUrl.get().replace("\\", "\\\\").replace("\"", "\\\"")}\"" ) manifestPlaceholders["usesCleartextTraffic"] = "true" manifestPlaceholders["deepLinkScheme"] = debugManifestOrigin?.scheme ?: "https" manifestPlaceholders["deepLinkHost"] = debugManifestOrigin?.host ?: "invalid.whoneedhelp.local" } create("staging") { initWith(getByName("debug")) applicationIdSuffix = ".staging" versionNameSuffix = "-staging" buildConfigField( "String", "BASE_URL", "\"${releaseBaseUrl.get().replace("\\", "\\\\").replace("\"", "\\\"")}\"" ) manifestPlaceholders["usesCleartextTraffic"] = "false" manifestPlaceholders["deepLinkScheme"] = releaseManifestOrigin?.scheme ?: "https" manifestPlaceholders["deepLinkHost"] = releaseManifestOrigin?.host ?: "invalid.whoneedhelp.local" matchingFallbacks += listOf("debug") } release { isMinifyEnabled = true isShrinkResources = true if (releaseSigningConfigured) { signingConfig = signingConfigs.getByName("release") } buildConfigField( "String", "BASE_URL", "\"${releaseBaseUrl.get().replace("\\", "\\\\").replace("\"", "\\\"")}\"" ) manifestPlaceholders["usesCleartextTraffic"] = "false" manifestPlaceholders["deepLinkScheme"] = releaseManifestOrigin?.scheme ?: "https" manifestPlaceholders["deepLinkHost"] = releaseManifestOrigin?.host ?: "invalid.whoneedhelp.local" proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) } } buildFeatures { buildConfig = true } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } testOptions { unitTests.isIncludeAndroidResources = false } } tasks.matching { it.name == "preReleaseBuild" || it.name == "preStagingBuild" }.configureEach { doFirst { if (name == "preReleaseBuild" && !releaseSigningConfigured) { val detail = if (releaseSigningPartiallyConfigured) { "Release signing is only partially configured" } else { "Release signing is not configured" } throw GradleException( "$detail; set WNH_ANDROID_SIGNING_STORE_FILE, " + "WNH_ANDROID_SIGNING_PASSWORD_FILE, and " + "WNH_ANDROID_SIGNING_KEY_ALIAS" ) } val value = releaseBaseUrl.orNull.orEmpty() val uri = runCatching { URI(value) }.getOrNull() if ( uri == null || uri.scheme != "https" || uri.host.isNullOrBlank() || uri.userInfo != null || !isOriginPath(uri) || uri.query != null || uri.fragment != null ) { throw GradleException( "Staging and release builds require " + "-PWNH_BASE_URL=https://your-real-deployment.example" ) } validateTrackingConfiguration() } } tasks.matching { it.name == "preDebugBuild" }.configureEach { doFirst { val value = debugBaseUrl.orNull.orEmpty() val uri = runCatching { URI(value) }.getOrNull() if ( uri == null || (uri.scheme != "http" && uri.scheme != "https") || uri.host.isNullOrBlank() || uri.userInfo != null || !isOriginPath(uri) || uri.query != null || uri.fragment != null ) { throw GradleException( "Debug builds require -PWNH_DEBUG_BASE_URL=http(s)://your-development-host" ) } validateTrackingConfiguration() } } fun validateTrackingConfiguration() { val minTime = trackingMinTimeMs.orNull?.toLongOrNull() val httpTimeout = trackingHttpTimeoutMs.orNull?.toLongOrNull() if (minTime == null || minTime <= 0) { throw GradleException( "Builds require -PWNH_TRACKING_MIN_TIME_MS=POSITIVE_MILLISECONDS" ) } if (httpTimeout == null || httpTimeout <= 0 || httpTimeout > Int.MAX_VALUE) { throw GradleException( "Builds require -PWNH_TRACKING_HTTP_TIMEOUT_MS=POSITIVE_INT_MILLISECONDS" ) } } tasks.withType().configureEach { options.compilerArgs.add("-Xlint:deprecation") } dependencies { implementation("androidx.activity:activity:1.13.0") implementation("androidx.webkit:webkit:1.16.0") testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test:core:1.7.0") androidTestImplementation("androidx.test:runner:1.7.0") androidTestImplementation("androidx.test:rules:1.7.0") androidTestImplementation("androidx.test.ext:junit:1.3.0") androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0") androidTestImplementation("androidx.test.espresso:espresso-web:3.7.0") androidTestImplementation("androidx.test.uiautomator:uiautomator:2.4.0") }