init: android project scaffold (Phase 28.1)

- Kotlin DSL build with version catalog (AGP 8.7, Kotlin 2.1, Compose BOM 2025.01)
- app module: Compose + Material 3, dark theme, network security config
- DualNetworkTestScreen — Phase 28.1 gate screen (buttons wired in 28.1.2)
- YarisApp, MainActivity, YarisOBDTheme
- ProGuard rules for Retrofit/OkHttp/Kotlinx Serialization/Coil/MapLibre/Room
- cleartext allowed for 10.42.0.1 + yaris.local
- gradle wrapper properties (needs Android Studio to generate wrapper jar)
This commit is contained in:
Mathieu Moran
2026-08-12 00:26:54 -04:00
parent 69109762a8
commit f1c83a2ee7
17 changed files with 629 additions and 4 deletions
+12
View File
@@ -0,0 +1,12 @@
# Android project ignores
*.iml
.gradle/
build/
local.properties
.idea/
*.apk
*.aab
*.jks
*.keystore
!gradle/wrapper/gradle-wrapper.jar
!gradle/wrapper/gradle-wrapper.properties
+114
View File
@@ -0,0 +1,114 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
}
android {
namespace = "com.yarisobd.app"
compileSdk = 35
defaultConfig {
applicationId = "com.yarisobd.app"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
debug {
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
}
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
buildConfig = true
}
}
dependencies {
// Compose BOM
val composeBom = platform(libs.compose.bom)
implementation(composeBom)
androidTestImplementation(composeBom)
// Compose UI
implementation(libs.compose.ui)
implementation(libs.compose.ui.graphics)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3)
implementation(libs.compose.material.icons)
debugImplementation(libs.compose.ui.tooling)
// Navigation
implementation(libs.navigation.compose)
// Activity + Compose integration
implementation(libs.activity.compose)
// Core
implementation(libs.core.ktx)
// Lifecycle
implementation(libs.lifecycle.runtime.compose)
implementation(libs.lifecycle.viewmodel.compose)
// Network — will be used in 28.1.2
// implementation(libs.okhttp)
// implementation(libs.okhttp.logging)
// implementation(libs.retrofit)
// implementation(libs.retrofit.kotlinx.serialization)
// implementation(libs.kotlinx.serialization.json)
// Coroutines
implementation(libs.kotlinx.coroutines.android)
// Local storage — will be used in 28.3
// implementation(libs.room.runtime)
// implementation(libs.room.ktx)
// ksp(libs.room.compiler)
// Background work — will be used in 28.3
// implementation(libs.workmanager)
// Images — will be used in 28.2
// implementation(libs.coil.compose)
// implementation(libs.coil.okhttp)
// Maps — will be used in 28.4
// implementation(libs.maplibre)
// Security — will be used in 28.1.3
// implementation(libs.security.crypto)
// Testing
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
androidTestImplementation(composeBom)
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
}
+45
View File
@@ -0,0 +1,45 @@
# Add project specific ProGuard rules here.
# Keep rules for libraries used:
# Retrofit
-keepattributes Signature, InnerClasses, EnclosingMethod
-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations
-keepclassmembers,allowshrinking,allowobfuscation interface * {
@retrofit2.http.* <methods>;
}
-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
-dontwarn javax.annotation.**
-dontwarn kotlin.Unit
-dontwarn retrofit2.KotlinExtensions
-dontwarn retrofit2.KotlinExtensions$*
# OkHttp
-dontwarn okhttp3.**
-dontwarn okio.**
# Kotlinx Serialization
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
-keepclassmembers class kotlinx.serialization.json.** {
*** Companion;
}
-keepclasseswithmembers class kotlinx.serialization.json.** {
kotlinx.serialization.KSerializer serializer(...);
}
-keep,includedescriptorclasses class com.yarisobd.app.**$$serializer { *; }
-keepclassmembers class com.yarisobd.app.** {
*** Companion;
}
-keepclasseswithmembers class com.yarisobd.app.** {
kotlinx.serialization.KSerializer serializer(...);
}
# Coil
-dontwarn coil.**
# MapLibre
-keep class org.maplibre.** { *; }
# Room
-keep class * extends androidx.room.RoomDatabase
-dontwarn androidx.room.paging.**
+41
View File
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Internet: Pi hotspot API calls + remote server uploads -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Foreground service for uploads (28.3) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- Notification permission for upload progress (Android 13+) -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Access external storage for downloaded dashcam files -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<application
android:name=".YarisApp"
android:allowBackup="false"
android:fullBackupOnly="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.YarisOBD"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.YarisOBD">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,128 @@
package com.yarisobd.app
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Phase 28.1 gate screen — proves dual-network binding works on the real device
* before building anything else.
*
* Two buttons:
* "Test Pi" → calls 10.42.0.1/api/gps/status over WiFi
* "Test Server" → calls yaris.ceaf.dev/health over cellular
*
* Both must succeed simultaneously while the phone is associated with the
* Pi's no-internet hotspot ("yaris 911").
*/
@Composable
fun DualNetworkTestScreen() {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "yaris-obd",
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Phase 28.1 — Network Binding Proof",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(32.dp))
// Pi WiFi test
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "Pi Hotspot (WiFi)",
style = MaterialTheme.typography.titleMedium
)
Text(
text = "Target: 10.42.0.1/api/gps/status",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = { /* TODO: 28.1.2 — WiFi-bound OkHttp call */ },
modifier = Modifier.fillMaxWidth()
) {
Text("Test Pi")
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// Server cellular test
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "Remote Server (Cellular)",
style = MaterialTheme.typography.titleMedium
)
Text(
text = "Target: yaris.ceaf.dev/health",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = { /* TODO: 28.1.2 — Cellular-bound OkHttp call */ },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary
)
) {
Text("Test Server")
}
}
}
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Connect phone to \"yaris 911\" hotspot first.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@@ -0,0 +1,31 @@
package com.yarisobd.app
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.yarisobd.app.ui.theme.YarisOBDTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
YarisOBDTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
// Placeholder — replaced by nav host in 28.1.2
DualNetworkTestScreen()
}
}
}
}
}
@@ -0,0 +1,16 @@
package com.yarisobd.app
import android.app.Application
class YarisApp : Application() {
override fun onCreate() {
super.onCreate()
instance = this
}
companion object {
lateinit var instance: YarisApp
private set
}
}
@@ -0,0 +1,83 @@
package com.yarisobd.app.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
// Brand colors — dark, car-friendly. Inspired by the Pi web UI's dark sidebar.
private val YarisGreen = Color(0xFF4CAF50)
private val YarisGreenDark = Color(0xFF388E3C)
private val YarisBackground = Color(0xFF121212)
private val YarisSurface = Color(0xFF1E1E1E)
private val YarisSurfaceVariant = Color(0xFF2C2C2C)
private val DarkColorScheme = darkColorScheme(
primary = YarisGreen,
onPrimary = Color.Black,
primaryContainer = YarisGreenDark,
secondary = Color(0xFF81C784),
background = YarisBackground,
surface = YarisSurface,
surfaceContainerHigh = YarisSurfaceVariant,
onBackground = Color(0xFFE0E0E0),
onSurface = Color(0xFFE0E0E0),
onSurfaceVariant = Color(0xFFAAAAAA),
error = Color(0xFFEF5350),
)
// Light scheme exists for completeness but the app defaults to dark
private val LightColorScheme = lightColorScheme(
primary = YarisGreenDark,
onPrimary = Color.White,
primaryContainer = Color(0xFFC8E6C9),
secondary = Color(0xFF43A047),
background = Color(0xFFFAFAFA),
surface = Color.White,
surfaceContainerHigh = Color(0xFFF5F5F5),
onBackground = Color(0xFF212121),
onSurface = Color(0xFF212121),
onSurfaceVariant = Color(0xFF757575),
error = Color(0xFFD32F2F),
)
@Composable
fun YarisOBDTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = false, // Off by default — use our brand colors
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.background.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
}
}
MaterialTheme(
colorScheme = colorScheme,
content = content
)
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">yaris-obd</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.YarisOBD" parent="android:Theme.Material.NoActionBar">
<item name="android:statusBarColor">@android:color/black</item>
<item name="android:navigationBarColor">@android:color/black</item>
</style>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Allow cleartext to Pi hotspot (10.42.0.1) — local-only, no internet -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">10.42.0.1</domain>
<domain includeSubdomains="false">yaris.local</domain>
</domain-config>
</network-security-config>
+7
View File
@@ -0,0 +1,7 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.ksp) apply false
}
+20
View File
@@ -0,0 +1,20 @@
# Project-wide Gradle settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.caching=true
# AndroidX package structure
android.useAndroidX=true
# Kotlin code style
kotlin.code.style=official
# Compose compiler (managed by Kotlin plugin in AGP 8.x+)
# No longer need composeVersion here — managed automatically
# Disable build features we don't use
android.defaults.buildfeatures.buildconfig=true
android.defaults.buildfeatures.aidl=false
android.defaults.buildfeatures.renderscript=false
android.defaults.buildfeatures.resvalues=false
android.defaults.buildfeatures.shaders=false
+78
View File
@@ -0,0 +1,78 @@
[versions]
agp = "8.7.3"
kotlin = "2.1.0"
compose-bom = "2025.01.00"
compose-compiler = "1.5.15" # Kotlin 2.1.x
okhttp = "4.12.0"
retrofit = "2.11.0"
kotlinx-serialization = "1.7.3"
kotlinx-coroutines = "1.9.0"
room = "2.6.1"
workmanager = "2.10.0"
coil = "3.0.4"
maplibre = "11.7.0"
security-crypto = "1.1.0-alpha06"
lifecycle = "2.8.7"
activity-compose = "1.9.3"
core-ktx = "1.15.0"
navigation-compose = "2.8.5"
[libraries]
# Compose BOM
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
compose-ui = { group = "androidx.compose.ui", name = "ui" }
compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" }
# Navigation
navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation-compose" }
# Activity + Compose
activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" }
# Core
core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" }
# Lifecycle
lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
# Network
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" }
# Serialization
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
# Coroutines
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }
# Local storage
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
# Background work
workmanager = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workmanager" }
# Images
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
coil-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
# Maps
maplibre = { group = "org.maplibre.gl", name = "android-sdk", version.ref = "maplibre" }
# Security
security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "security-crypto" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version = "2.1.0-1.0.29" }
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+4 -4
View File
@@ -69,13 +69,13 @@ android/
## Sub-phases ## Sub-phases
### 28.1 — Scaffold + networking proof-of-concept ⬜ NOT STARTED ### 28.1 — Scaffold + networking proof-of-concept 🚧 IN PROGRESS
**Gate: dual-network binding must work on the real Pixel before anything else is built.** **Gate: dual-network binding must work on the real Pixel before anything else is built.**
#### 28.1.1 — Project creation #### 28.1.1 — Project creation
- [ ] Create Android project in `android/` via Android Studio (Empty Activity + Compose) - [x] Create Android project in `android/` via Android Studio (Empty Activity + Compose)
- [ ] Kotlin DSL build files with version catalog - [x] Kotlin DSL build files with version catalog
- [ ] `.gitignore` for Android (`*.iml`, `.gradle/`, `build/`, `local.properties`, `.idea/`) - [x] `.gitignore` for Android (`*.iml`, `.gradle/`, `build/`, `local.properties`, `.idea/`)
- [ ] Verify project builds and runs on emulator (blank screen) - [ ] Verify project builds and runs on emulator (blank screen)
- [ ] Commit: `init: android project scaffold` - [ ] Commit: `init: android project scaffold`
+24
View File
@@ -0,0 +1,24 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
rootProject.name = "yaris-obd"
include(":app")