Skip to main content

Build Configuration & CI/CD

How the Visla GPS Android app is built, tested, linted, and published.

Gradle Setup​

The project uses AGP 9.0 with built-in Kotlin support (no separate org.jetbrains.kotlin.android plugin), Kotlin 2.3.10, Java 21, and Gradle 9.3.1.

Project-Level Build File​

build.gradle.kts
// Use higher Kotlin version than AGP's built-in (2.2.10)
buildscript {
dependencies {
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.3.10")
classpath("com.google.devtools.ksp:symbol-processing-gradle-plugin:2.3.6")
}
}

plugins {
id("com.android.application") version "9.0.1" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.3.10" apply false
id("com.google.gms.google-services") version "4.4.2" apply false
id("com.google.dagger.hilt.android") version "2.59.1" apply false
id("com.google.devtools.ksp") version "2.3.6" apply false
id("com.github.triplet.play") version "4.0.0" apply false
id("org.jlleitschuh.gradle.ktlint") version "12.1.2" apply false
id("io.gitlab.arturbosch.detekt") version "1.23.8" apply false
}

AGP 9.0 bundles Kotlin 2.2.10 by default. The buildscript block overrides this to Kotlin 2.3.10 via a higher-version classpath dependency. The android.builtInKotlin=true flag in gradle.properties enables this mode.

Gradle Properties​

gradle.properties
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
android.builtInKotlin=true
org.gradle.configuration-cache=true

Settings​

settings.gradle.kts
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
maven {
url = uri("https://api.mapbox.com/downloads/v2/releases/maven")
credentials.username = "mapbox"
credentials.password = providers.environmentVariable("MAPBOX_DOWNLOADS_TOKEN")
.orElse(providers.gradleProperty("MAPBOX_DOWNLOADS_TOKEN"))
.getOrElse("")
authentication {
create<BasicAuthentication>("basic")
}
}
}
}

The Mapbox SDK requires an authenticated Maven repository. The download token is read from the MAPBOX_DOWNLOADS_TOKEN environment variable first, then gradle.properties.

SDK Versions​

PropertyValueNotes
compileSdk36Android 16
targetSdk36Android 16
minSdk26Android 8.0 Oreo (~94.8% coverage)
Java21Source, target, and JVM toolchain

Core library desugaring is enabled to use Java 8+ APIs (like java.time) on older devices:

compileOptions {
isCoreLibraryDesugaringEnabled = true
}

Build Types​

Debug​

  • Uses the default Android debug keystore
  • USE_FAKE_BILLING = true β€” bypasses Google Play Billing for testing
  • Environment URLs can be overridden via local.properties
  • No minification

Release​

  • Signed with visla_android_keystore (passwords from local.properties)
  • USE_FAKE_BILLING = false β€” real Google Play Billing
  • Production URLs are hardcoded (ignores local.properties)
  • Minification is disabled (isMinifyEnabled = false)
  • ProGuard rules are minimal β€” only Firebase annotation keeprules
app/build.gradle.kts (signing configs)
signingConfigs {
getByName("debug") {
storeFile = file("${System.getProperty("user.home")}/.android/debug.keystore")
storePassword = "android"
keyAlias = "androiddebugkey"
keyPassword = "android"
}
create("release") {
storeFile = rootProject.file("visla_android_keystore")
storePassword = keystoreProperties.getProperty("storePassword") ?: ""
keyAlias = "key0"
keyPassword = keystoreProperties.getProperty("keyPassword") ?: ""
}
}

Build Config Fields​

Both build types expose these BuildConfig fields:

FieldDebugRelease
USE_FAKE_BILLINGtruefalse
API_BASE_URLfrom local.properties or prodhttps://api.vislagps.com
WS_BASE_URLfrom local.properties or prodwss://api.vislagps.com
WEB_APP_URLfrom local.properties or prodhttps://app.vislagps.com
WEBSITE_URLfrom local.properties or prodhttps://www.visla.it
MAPBOX_ACCESS_TOKENfrom local.propertiesfrom local.properties

API Key Management​

All secrets are stored in local.properties (git-ignored) and injected at build time. A local.properties.example template is provided.

local.properties.example
sdk.dir=/path/to/your/Android/Sdk

# Keystore passwords for release builds
storePassword=your_store_password
keyPassword=your_key_password

# API Keys (required for app to function)
GOOGLE_MAPS_API_KEY=your_google_maps_api_key
MAPBOX_ACCESS_TOKEN=your_mapbox_access_token
FACEBOOK_APP_ID=your_facebook_app_id
FACEBOOK_CLIENT_TOKEN=your_facebook_client_token

# Environment URLs (optional, defaults to production)
# API_BASE_URL=http://10.0.2.2:8080
# WS_BASE_URL=ws://10.0.2.2:8080
# WEB_APP_URL=http://localhost:3000
# WEBSITE_URL=https://www.visla.it

Keys are loaded early in app/build.gradle.kts:

val keystoreProperties = Properties()
val localPropertiesFile = rootProject.file("local.properties")
if (localPropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(localPropertiesFile))
}

val googleMapsApiKey = keystoreProperties.getProperty("GOOGLE_MAPS_API_KEY") ?: ""

Keys are injected via three mechanisms:

MechanismUsed For
manifestPlaceholdersGoogle Maps, Facebook App ID in AndroidManifest
buildConfigFieldMapbox token, URLs, billing flag
resValueFacebook login protocol scheme string resource

Makefile Targets​

The Makefile provides a developer CLI for common tasks. All device-targeting commands accept VIA=wifi|usb|emu and DEVICE=ip:port.

Emulator​

TargetCommandDescription
emulatorsemulator -list-avdsList available AVDs
emuemulator -avd $(EMU)Start emulator (EMU=name)
stop-emuadb -e emu killStop running emulator

Build & Deploy​

TargetCommandDescription
build./gradlew assembleDebugBuild debug APK
release./gradlew bundleReleaseBuild release AAB
installbuild + adb install -rBuild and install debug APK
launchadb shell am start -n ...Launch app on device
uninstalladb uninstall com.visla.vislagpsUninstall app from device
devbuild + install + adb reverse + launchFull dev workflow with port forwarding
devicesadb devices -lList connected devices
connectadb connect $(DEVICE)Connect to device over WiFi

The dev target sets up adb reverse to forward BACKEND_PORT (default 8080) from device to host.

Linting​

TargetCommandDescription
lintktlint + detekt + lint-androidRun all linters
ktlint./gradlew ktlintCheckRun ktlint code style checker
ktlint-fix./gradlew ktlintFormatRun ktlint with auto-fix
detekt./gradlew detektRun detekt static analysis
detekt-baseline./gradlew detektBaselineGenerate detekt baseline
lint-android./gradlew lintDebugRun Android lint
lint-baseline./gradlew lintDebug -Dlint.baselines.continue=trueGenerate lint baseline

Testing​

TargetCommandDescription
test-unit./gradlew testDebugUnitTestRun all unit tests
test-unit test=X./gradlew testDebugUnitTest --tests "*X*"Run specific test file
test-ui./gradlew connectedDebugAndroidTestRun UI tests on device
test-ui-single./gradlew connectedAndroidTest -P...Run single UI test class

Cleanup​

TargetCommandDescription
clean./gradlew cleanClean build artifacts
clean-adbadb kill-server + start-serverReset ADB server
clean-allclean + clean-adbClean everything

Debug​

TargetCommandDescription
logsadb logcat (filtered by PID)Show app logs filtered by visla|Exception|Error

Code Quality Tools​

Three linters run in sequence via make lint:

ktlint​

Kotlin code style enforcement using ktlint 1.5.0.

app/build.gradle.kts
ktlint {
version.set("1.5.0")
android.set(true)
outputToConsole.set(true)
ignoreFailures.set(false)
filter {
exclude("**/generated/**")
exclude("**/build/**")
}
}

Style rules are configured via .editorconfig:

.editorconfig (Kotlin section)
[*.{kt,kts}]
ktlint_code_style = android_studio

# Disabled rules
ktlint_standard_filename = disabled
ktlint_standard_backing-property-naming = disabled
ktlint_standard_parameter-list-spacing = disabled
ktlint_standard_class-signature = disabled
ktlint_standard_function-signature = disabled
ktlint_standard_trailing-comma-on-call-site = disabled
ktlint_standard_trailing-comma-on-declaration-site = disabled
ktlint_standard_function-naming = disabled # Composable uppercase names
ktlint_standard_property-naming = disabled # Constants with capitals

detekt​

Kotlin static analysis using detekt 1.23.8 with a custom config at config/detekt/detekt.yml.

app/build.gradle.kts
detekt {
buildUponDefaultConfig = true
allRules = false
config.setFrom(files("$rootDir/config/detekt/detekt.yml"))
baseline = file("$rootDir/config/detekt/baseline.xml")
parallel = true
}

Key detekt configuration choices:

Rule AreaNotable Settings
ComplexityCognitiveComplexMethod threshold 15, @Composable ignored
ComplexityLongMethod threshold 60 lines, @Composable ignored
ComplexityLongParameterList 8 for functions, 10 for constructors
ComplexityLargeClass threshold 600 lines
StyleMaxLineLength 120 chars
StyleMagicNumber active but @Composable/@Preview ignored
StyleReturnCount max 3 (guard clauses excluded)
CoroutinesGlobalCoroutineUsage, InjectDispatcher, SleepInsteadOfDelay active
NamingFunctionNaming ignores @Composable (uppercase names OK)
Forbiddenprintln/print forbidden β€” use Logger
ForbiddenFIXME: and STOPSHIP: comments forbidden

Android Lint​

app/build.gradle.kts
lint {
abortOnError = true
warningsAsErrors = false
checkReleaseBuilds = true
htmlReport = true
xmlReport = true

disable += setOf(
"InvalidFragmentVersionForActivityResult",
"ObsoleteLintCustomCheck",
"MissingTranslation"
)
enable += setOf("Interoperability", "UnusedResources")
}

Additional per-issue suppressions are configured in app/lint.xml with documented rationale for each:

  • StaticFieldLeak β€” suppressed for Application-context singletons
  • Aligned16KB β€” third-party library limitation (Mapbox native libs)
  • PluralsCandidate β€” false positives for ratio strings like %1$d/%2$d
  • FrequentlyChangingValue β€” intentional for MapBottomSheet drag animation

Play Store Publishing​

Automated publishing uses Gradle Play Publisher 4.0.0.

app/build.gradle.kts
play {
val serviceAccountFile = rootProject.file("visla-android-publisher.json")
if (serviceAccountFile.exists()) {
serviceAccountCredentials.set(serviceAccountFile)
}
track.set("internal")
releaseStatus.set(com.github.triplet.gradle.androidpublisher.ReleaseStatus.COMPLETED)
defaultToAppBundles.set(true)
resolutionStrategy.set(com.github.triplet.gradle.androidpublisher.ResolutionStrategy.AUTO)
}
SettingValueEffect
trackinternalPublishes to internal testing track
releaseStatusCOMPLETEDAuto-completes (not draft)
defaultToAppBundlestrueUploads AAB instead of APK
resolutionStrategyAUTOAuto-increments versionCode on conflict

The publishReleaseBundle Gradle task builds the release AAB and uploads it to Google Play in one step.

CI/CD Pipeline​

A single GitHub Actions workflow at .github/workflows/release.yml runs on every push to main.

Jobs​

lint and test run in parallel:

lint:
steps:
- Set up JDK 21 (Temurin, with Gradle cache)
- Decode google-services.json from secrets
- Create local.properties from secrets
- ./gradlew ktlintCheck detekt lintDebug --parallel --build-cache

test:
steps:
- Set up JDK 21 (Temurin, with Gradle cache)
- Decode google-services.json from secrets
- Create local.properties from secrets
- ./gradlew testDebugUnitTest --parallel --build-cache

release runs after both pass:

release:
needs: [lint, test]
steps:
- Set up JDK 21 (Temurin, with Gradle cache)
- Decode google-services.json, keystore, service account from secrets
- Create local.properties with all keys + keystore passwords
- ./gradlew publishReleaseBundle --parallel --build-cache

Required GitHub Secrets​

SecretPurpose
GOOGLE_SERVICES_JSONBase64-encoded google-services.json
GOOGLE_MAPS_API_KEYGoogle Maps API key
MAPBOX_ACCESS_TOKENMapbox access token
FACEBOOK_APP_IDFacebook Login app ID
FACEBOOK_CLIENT_TOKENFacebook Login client token
KEYSTORE_FILEBase64-encoded release keystore
KEYSTORE_PASSWORDKeystore store password
KEY_PASSWORDKey alias password
PLAY_STORE_SERVICE_ACCOUNT_JSONBase64-encoded Play Store service account

Dependency Management​

Dependencies are declared directly in app/build.gradle.kts with explicit version strings (no version catalog). The project uses BOMs where available to align transitive versions:

// Compose BOM aligns all Compose library versions
implementation(platform("androidx.compose:compose-bom:2026.02.00"))
implementation("androidx.compose.ui:ui") // version from BOM

// Firebase BOM aligns all Firebase library versions
implementation(platform("com.google.firebase:firebase-bom:34.9.0"))
implementation("com.google.firebase:firebase-messaging") // version from BOM

Key dependency groups:

CategoryLibraries
UICompose BOM, Material 3, Navigation Compose
MapsGoogle Maps Compose, Mapbox Maps SDK 11.x
NetworkingOkHttp 5.x, Retrofit 3.x
DIHilt 2.59.x with KSP
AuthGoogle Sign-In (Credential Manager), Facebook Login
FirebaseMessaging, Analytics (via BOM 34.x)
BillingGoogle Play Billing 8.x
StorageDataStore, EncryptedSharedPreferences, Tink
Camera/QRCameraX 1.5.x, ML Kit Barcode, ZXing
WidgetsGlance 1.x, WorkManager
TestingJUnit 4, Mockito, MockK, Turbine, Compose UI Test

Build Performance​

OptimizationConfig
Configuration cacheorg.gradle.configuration-cache=true
Gradle build cache--build-cache flag in CI
Parallel execution--parallel flag in CI
JVM heap-Xmx2048m
Non-transitive R classesandroid.nonTransitiveRClass=true
Parallel unit testsmaxParallelForks = availableProcessors()
Gradle dependency cachingactions/setup-java with cache: 'gradle' in CI

Design Decisions​

AGP 9.0 built-in Kotlin with version override​

AGP 9.0 bundles Kotlin 2.2.10, but the project needs Kotlin 2.3.10. Rather than using the separate org.jetbrains.kotlin.android plugin (which AGP 9.0 deprecates), the project uses android.builtInKotlin=true and overrides the version via a buildscript classpath dependency. This avoids version conflicts while staying on the new AGP plugin model.

Secrets via local.properties instead of environment variables​

API keys are loaded from local.properties rather than environment variables. This keeps the developer workflow simple β€” one file to configure β€” while CI reconstructs the file from GitHub Secrets. The local.properties.example template documents all required keys.

Release URLs hardcoded, not from local.properties​

Debug builds read URLs from local.properties (allowing 10.0.2.2 for emulator development), but release builds hardcode production URLs. This prevents accidental release builds pointing at development servers.

R8/ProGuard disabled​

Minification is disabled (isMinifyEnabled = false). This simplifies debugging production crashes at the cost of larger APK size. The ProGuard rules file only contains Firebase annotation keeprules.

No version catalog​

Dependencies use inline version strings rather than a libs.versions.toml catalog. For a single-module project, this keeps versions visible where they're used. BOMs (Compose, Firebase) handle transitive version alignment.

detekt exemptions for @Composable​

Compose functions are declarative and naturally longer, more complex, and have more parameters than imperative code. The detekt config exempts @Composable-annotated functions from LongMethod, LongParameterList, CognitiveComplexMethod, CyclomaticComplexMethod, MagicNumber, and FunctionNaming rules.

Lint suppressions with rationale​

Every lint suppression in app/lint.xml includes a comment explaining why the issue is suppressed and under what conditions it should be re-evaluated. This prevents suppression drift.

Single CI workflow​

The pipeline is a single workflow with three jobs: lint, test, release. Lint and test run in parallel for speed; release only runs after both pass. Every push to main publishes to Google Play's internal track automatically.