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β
// 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β
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β
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β
| Property | Value | Notes |
|---|---|---|
compileSdk | 36 | Android 16 |
targetSdk | 36 | Android 16 |
minSdk | 26 | Android 8.0 Oreo (~94.8% coverage) |
| Java | 21 | Source, 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 fromlocal.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
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:
| Field | Debug | Release |
|---|---|---|
USE_FAKE_BILLING | true | false |
API_BASE_URL | from local.properties or prod | https://api.vislagps.com |
WS_BASE_URL | from local.properties or prod | wss://api.vislagps.com |
WEB_APP_URL | from local.properties or prod | https://app.vislagps.com |
WEBSITE_URL | from local.properties or prod | https://www.visla.it |
MAPBOX_ACCESS_TOKEN | from local.properties | from 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.
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:
| Mechanism | Used For |
|---|---|
manifestPlaceholders | Google Maps, Facebook App ID in AndroidManifest |
buildConfigField | Mapbox token, URLs, billing flag |
resValue | Facebook 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β
| Target | Command | Description |
|---|---|---|
emulators | emulator -list-avds | List available AVDs |
emu | emulator -avd $(EMU) | Start emulator (EMU=name) |
stop-emu | adb -e emu kill | Stop running emulator |
Build & Deployβ
| Target | Command | Description |
|---|---|---|
build | ./gradlew assembleDebug | Build debug APK |
release | ./gradlew bundleRelease | Build release AAB |
install | build + adb install -r | Build and install debug APK |
launch | adb shell am start -n ... | Launch app on device |
uninstall | adb uninstall com.visla.vislagps | Uninstall app from device |
dev | build + install + adb reverse + launch | Full dev workflow with port forwarding |
devices | adb devices -l | List connected devices |
connect | adb 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β
| Target | Command | Description |
|---|---|---|
lint | ktlint + detekt + lint-android | Run all linters |
ktlint | ./gradlew ktlintCheck | Run ktlint code style checker |
ktlint-fix | ./gradlew ktlintFormat | Run ktlint with auto-fix |
detekt | ./gradlew detekt | Run detekt static analysis |
detekt-baseline | ./gradlew detektBaseline | Generate detekt baseline |
lint-android | ./gradlew lintDebug | Run Android lint |
lint-baseline | ./gradlew lintDebug -Dlint.baselines.continue=true | Generate lint baseline |
Testingβ
| Target | Command | Description |
|---|---|---|
test-unit | ./gradlew testDebugUnitTest | Run all unit tests |
test-unit test=X | ./gradlew testDebugUnitTest --tests "*X*" | Run specific test file |
test-ui | ./gradlew connectedDebugAndroidTest | Run UI tests on device |
test-ui-single | ./gradlew connectedAndroidTest -P... | Run single UI test class |
Cleanupβ
| Target | Command | Description |
|---|---|---|
clean | ./gradlew clean | Clean build artifacts |
clean-adb | adb kill-server + start-server | Reset ADB server |
clean-all | clean + clean-adb | Clean everything |
Debugβ
| Target | Command | Description |
|---|---|---|
logs | adb 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.
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:
[*.{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.
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 Area | Notable Settings |
|---|---|
| Complexity | CognitiveComplexMethod threshold 15, @Composable ignored |
| Complexity | LongMethod threshold 60 lines, @Composable ignored |
| Complexity | LongParameterList 8 for functions, 10 for constructors |
| Complexity | LargeClass threshold 600 lines |
| Style | MaxLineLength 120 chars |
| Style | MagicNumber active but @Composable/@Preview ignored |
| Style | ReturnCount max 3 (guard clauses excluded) |
| Coroutines | GlobalCoroutineUsage, InjectDispatcher, SleepInsteadOfDelay active |
| Naming | FunctionNaming ignores @Composable (uppercase names OK) |
| Forbidden | println/print forbidden β use Logger |
| Forbidden | FIXME: and STOPSHIP: comments forbidden |
Android Lintβ
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
MapBottomSheetdrag animation
Play Store Publishingβ
Automated publishing uses Gradle Play Publisher 4.0.0.
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)
}
| Setting | Value | Effect |
|---|---|---|
track | internal | Publishes to internal testing track |
releaseStatus | COMPLETED | Auto-completes (not draft) |
defaultToAppBundles | true | Uploads AAB instead of APK |
resolutionStrategy | AUTO | Auto-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β
| Secret | Purpose |
|---|---|
GOOGLE_SERVICES_JSON | Base64-encoded google-services.json |
GOOGLE_MAPS_API_KEY | Google Maps API key |
MAPBOX_ACCESS_TOKEN | Mapbox access token |
FACEBOOK_APP_ID | Facebook Login app ID |
FACEBOOK_CLIENT_TOKEN | Facebook Login client token |
KEYSTORE_FILE | Base64-encoded release keystore |
KEYSTORE_PASSWORD | Keystore store password |
KEY_PASSWORD | Key alias password |
PLAY_STORE_SERVICE_ACCOUNT_JSON | Base64-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:
| Category | Libraries |
|---|---|
| UI | Compose BOM, Material 3, Navigation Compose |
| Maps | Google Maps Compose, Mapbox Maps SDK 11.x |
| Networking | OkHttp 5.x, Retrofit 3.x |
| DI | Hilt 2.59.x with KSP |
| Auth | Google Sign-In (Credential Manager), Facebook Login |
| Firebase | Messaging, Analytics (via BOM 34.x) |
| Billing | Google Play Billing 8.x |
| Storage | DataStore, EncryptedSharedPreferences, Tink |
| Camera/QR | CameraX 1.5.x, ML Kit Barcode, ZXing |
| Widgets | Glance 1.x, WorkManager |
| Testing | JUnit 4, Mockito, MockK, Turbine, Compose UI Test |
Build Performanceβ
| Optimization | Config |
|---|---|
| Configuration cache | org.gradle.configuration-cache=true |
| Gradle build cache | --build-cache flag in CI |
| Parallel execution | --parallel flag in CI |
| JVM heap | -Xmx2048m |
| Non-transitive R classes | android.nonTransitiveRClass=true |
| Parallel unit tests | maxParallelForks = availableProcessors() |
| Gradle dependency caching | actions/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.