chore(scaffold): M0 — Gradle + Compose + Hilt skeleton with CI
Some checks failed
PR Checks / android-build (pull_request) Failing after 2m42s

Stand up the Android-app repo per docs/android/PLAN.md M0: a buildable
Kotlin + Jetpack Compose (Material 3) single-activity skeleton wired for
Hilt, ready for the M1-M4 functional pass.

- Gradle 8.7 wrapper (chmod +x, and re-chmod'd in CI); AGP 8.6.1 /
  Kotlin 2.0.20, JDK 17, minSdk 29, target 35, applicationId com.runicgateway.app.
- Version catalog (gradle/libs.versions.toml) pins the full planned stack
  (Compose, Hilt, Retrofit/OkHttp + kotlinx.serialization, DataStore,
  security-crypto, Coil, Navigation) so later milestones reference by alias.
- RunicGatewayApp (@HiltAndroidApp) + MainActivity (Compose) + ui/theme/*;
  externalized strings; adaptive launcher icon; backup rules exclude the
  token store / DataStore (no session material off-device).
- CI: .gitea/workflows/pr-checks.yml gates PRs with lint + test + assembleDebug.
  Installs JDK 17 via apt (the runner can't resolve api.adoptium.net, so
  actions/setup-java is avoided) and the SDK via sdkmanager; chmods gradlew
  in-step because this runner's checkout drops the git executable bit.

Verified locally against the Android Studio SDK (platform 35 + build-tools
35.0.0): `./gradlew lint test assembleDebug` passes and produces a debug APK.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-19 13:35:25 -05:00
parent 95c4c38bd3
commit b2ee0c4394
29 changed files with 1086 additions and 0 deletions

107
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,107 @@
// SPDX-License-Identifier: GPL-3.0-or-later
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)
alias(libs.plugins.hilt)
}
android {
namespace = "com.runicgateway.app"
compileSdk = 35
defaultConfig {
// Target application id per docs/android/PLAN.md §13 (pending runicgateway.app domain).
applicationId = "com.runicgateway.app"
minSdk = 29
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
// Signing/minification are wired at M6 (release hardening). Debug is auto-signed.
isMinifyEnabled = false
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
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
// Core / lifecycle / activity
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)
// Compose (BOM-managed versions)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.navigation.compose)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
// DI
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.androidx.hilt.navigation.compose)
// Networking (wired for M1+; declared now so the stack resolves)
implementation(libs.retrofit)
implementation(platform(libs.okhttp.bom))
implementation(libs.okhttp)
implementation(libs.okhttp.logging.interceptor)
implementation(libs.okhttp.sse)
implementation(libs.kotlinx.serialization.json)
implementation(libs.retrofit.kotlinx.serialization.converter)
// Storage
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.security.crypto)
// Images
implementation(libs.coil.compose)
// Unit tests
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
// Instrumented tests
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
}

3
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,3 @@
# Runic Gateway Android app ProGuard/R8 rules.
# Minification is disabled until M6 (release hardening); real keep rules for
# kotlinx.serialization DTOs and Retrofit models are added there.

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The app is purely an HTTPS API client of a shard's website backend. -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".RunicGatewayApp"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.RunicGateway">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.RunicGateway">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,64 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.runicgateway.app.ui.theme.RunicGatewayTheme
import dagger.hilt.android.AndroidEntryPoint
/**
* Single-activity host. Navigation-Compose and the first-run base-URL flow (§3)
* land in M1; this M0 skeleton only proves the Compose + Hilt + theme wiring.
*/
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
RunicGatewayTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Placeholder(modifier = Modifier.padding(innerPadding))
}
}
}
}
}
@Composable
private fun Placeholder(modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = stringResource(id = R.string.app_scaffold_ready),
style = MaterialTheme.typography.titleLarge,
)
}
}
@Preview(showBackground = true)
@Composable
private fun PlaceholderPreview() {
RunicGatewayTheme {
Placeholder()
}
}

View File

@@ -0,0 +1,15 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
* Runic Gateway — native Android client of a shard's website API.
*/
package com.runicgateway.app
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
/**
* Application entry point. Annotated for Hilt so the DI graph is available
* to activities, view models, and (from M1) repositories / API services.
*/
@HiltAndroidApp
class RunicGatewayApp : Application()

View File

@@ -0,0 +1,16 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.theme
import androidx.compose.ui.graphics.Color
// Placeholder palette for the M0 skeleton. The M5 design pass replaces this and
// derives Material 3 colors from each shard's per-install branding (PLAN.md §3, §5).
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650A4)
val PurpleGrey40 = Color(0xFF625B71)
val Pink40 = Color(0xFF7D5260)

View File

@@ -0,0 +1,53 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.theme
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.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80,
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40,
)
/**
* App theme for the M0 skeleton. Dynamic color (Android 12+) is used when
* available; otherwise a static placeholder scheme. The M5 design pass wires
* the color scheme to per-shard branding (PLAN.md §3, §5).
*/
@Composable
fun RunicGatewayTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
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
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content,
)
}

View File

@@ -0,0 +1,21 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Default Material 3 type scale for the skeleton; refined in the M5 design pass.
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp,
),
)

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<!-- Placeholder launcher glyph: a stylized gateway arch. Replaced in the M5 design pass. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#D0BCFF"
android:pathData="M38,74 L38,50 A16,16 0 0,1 70,50 L70,74 L62,74 L62,50 A8,8 0 0,0 46,50 L46,74 Z" />
<path
android:fillColor="#EFB8C8"
android:pathData="M52,34 L56,34 L56,40 L52,40 Z M50,40 L58,40 L58,44 L50,44 Z" />
</vector>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<resources>
<color name="ic_launcher_background">#1B1033</color>
</resources>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<!--
All user-facing strings are externalized from day one (PLAN.md §2): English is
the only bundled locale, but the structure invites community translations.
No hardcoded UI strings in Kotlin.
-->
<resources>
<!-- Fixed launcher name, baked at build even though in-app branding is per-shard (PLAN.md §13). -->
<string name="app_name">Runic Gateway</string>
<string name="app_scaffold_ready">Runic Gateway — scaffold ready</string>
</resources>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<!--
Base XML theme used by the manifest before Compose takes over. The live UI
theme is defined in Kotlin (ui/theme/Theme.kt); this only sets the window
background / status-bar behavior for launch.
-->
<resources>
<style name="Theme.RunicGateway" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<!--
Auto Backup rules (API 29 uses this). Exclude the encrypted token store and
DataStore prefs so session material is never carried off-device in a backup.
-->
<full-backup-content>
<exclude domain="sharedpref" path="." />
<exclude domain="file" path="datastore/" />
</full-backup-content>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<!--
Android 12+ backup / device-transfer rules. Same posture as backup_rules.xml:
never transfer session tokens or DataStore prefs off-device.
-->
<data-extraction-rules>
<cloud-backup>
<exclude domain="sharedpref" path="." />
<exclude domain="file" path="datastore/" />
</cloud-backup>
<device-transfer>
<exclude domain="sharedpref" path="." />
<exclude domain="file" path="datastore/" />
</device-transfer>
</data-extraction-rules>

View File

@@ -0,0 +1,27 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Placeholder JVM unit test so the `test` CI gate has something to run in M0.
* Real repository / view-model tests arrive with the functional pass (M1+).
*
* Assertions must be variant-agnostic: the `test` task runs both the debug and
* release unit-test variants, so nothing here may depend on `BuildConfig.DEBUG`.
*/
class ScaffoldSanityTest {
@Test
fun applicationIdMatchesNamespace() {
assertEquals("com.runicgateway.app", BuildConfig.APPLICATION_ID)
}
@Test
fun versionNameIsSet() {
assertTrue(BuildConfig.VERSION_NAME.isNotBlank())
}
}