on master help sign in
Add crush Android client with CI, release, and detekt infrastructure
commit6e7976c29bf7292bcc48f4d7eea66d4bad3a1d89 authoriko <mail@iko.soy> dateTue 7 Jul 2026, 20:20 (3 months ago)
48 files changed+3052
.envrc+1 .github/workflows/build.yml+72 .github/workflows/release.yml+96 .gitignore+35 README.md+114 app/build.gradle.kts+156 app/detekt-baseline.xml+14 app/proguard-rules.pro+13 app/src/main/AndroidManifest.xml+28 app/src/main/java/soy/iko/crush/CrushApp.kt+34 app/src/main/java/soy/iko/crush/MainActivity.kt+46 app/src/main/java/soy/iko/crush/data/CrushRepository.kt+231 app/src/main/java/soy/iko/crush/data/SettingsStore.kt+29 app/src/main/java/soy/iko/crush/net/CrushApiClient.kt+149 app/src/main/java/soy/iko/crush/net/CrushJson.kt+11 app/src/main/java/soy/iko/crush/net/EventStream.kt+97 app/src/main/java/soy/iko/crush/proto/Control.kt+7 app/src/main/java/soy/iko/crush/proto/Events.kt+80 app/src/main/java/soy/iko/crush/proto/Message.kt+94 app/src/main/java/soy/iko/crush/proto/Payload.kt+25 app/src/main/java/soy/iko/crush/proto/Session.kt+33 app/src/main/java/soy/iko/crush/proto/Workspace.kt+39 app/src/main/java/soy/iko/crush/ui/ChatViewModel.kt+154 app/src/main/java/soy/iko/crush/ui/MessageRenderer.kt+73 app/src/main/java/soy/iko/crush/ui/screens/ChatScreen.kt+331 app/src/main/java/soy/iko/crush/ui/screens/ConnectScreen.kt+112 app/src/main/java/soy/iko/crush/ui/theme/CrushTheme.kt+53 app/src/main/res/drawable/ic_launcher_background.xml+10 app/src/main/res/drawable/ic_launcher_foreground.xml+11 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml+5 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml+5 app/src/main/res/values/strings.xml+3 app/src/main/res/values/themes.xml+3 app/src/main/res/xml/network_security_config.xml+9 build.gradle.kts+5 config/detekt/detekt.yml+68 flake.lock+61 flake.nix+71 gradle.properties+9 gradle/libs.versions.toml+55 gradle/wrapper/gradle-wrapper.jar gradle/wrapper/gradle-wrapper.properties+7 gradlew+252 gradlew.bat+94 renovate.json+32 scripts/run-device.sh+106 scripts/run.sh+95 settings.gradle.kts+24
.envrcnew file+1
@@ -0,0 +1 @@
1+use flake
.github/workflows/build.ymlnew file+72
@@ -0,0 +1,72 @@
1+name: Build & Check
2+
3+on:
4+ push:
5+ branches: [master]
6+ pull_request:
7+
8+# Cancel an in-progress run when a newer commit is pushed to the same ref, so CI minutes
9+# aren't spent finishing a build that's already superseded.
10+concurrency:
11+ group: ${{ github.workflow }}-${{ github.ref }}
12+ cancel-in-progress: true
13+
14+jobs:
15+ build:
16+ runs-on: ubuntu-latest
17+
18+ steps:
19+ - uses: actions/checkout@v4
20+
21+ - uses: actions/setup-java@v4
22+ with:
23+ distribution: temurin
24+ java-version: 17
25+
26+ - uses: android-actions/setup-android@v3
27+
28+ - uses: gradle/actions/setup-gradle@v4
29+
30+ - name: Run unit tests
31+ run: ./gradlew testDebugUnitTest --no-daemon
32+
33+ - name: Static analysis (detekt)
34+ run: ./gradlew detekt --no-daemon
35+
36+ - name: Lint
37+ run: ./gradlew lintDebug --no-daemon
38+
39+ - name: Build debug APK
40+ run: ./gradlew assembleDebug --no-daemon
41+
42+ - name: Decode release keystore
43+ run: |
44+ mkdir -p keystore
45+ printf '%s' "$CRUSH_STORE_BASE64" | base64 -d > keystore/crush-release.jks
46+ env:
47+ CRUSH_STORE_BASE64: ${{ secrets.CRUSH_STORE_BASE64 }}
48+
49+ - name: Build release APK
50+ env:
51+ CRUSH_STORE_FILE: ${{ github.workspace }}/keystore/crush-release.jks
52+ CRUSH_STORE_PASSWORD: ${{ secrets.CRUSH_STORE_PASSWORD }}
53+ CRUSH_KEY_ALIAS: ${{ secrets.CRUSH_KEY_ALIAS }}
54+ CRUSH_KEY_PASSWORD: ${{ secrets.CRUSH_KEY_PASSWORD }}
55+ run: ./gradlew assembleRelease --no-daemon
56+
57+ - name: Upload debug APK
58+ uses: actions/upload-artifact@v4
59+ with:
60+ name: crush-debug.apk
61+ path: app/build/outputs/apk/debug/*.apk
62+ if-no-files-found: error
63+
64+ # The release output dir is `release/` when unsigned but `release-signed/` (or a
65+ # variant name) once a signing config is applied. Match both so CI keeps working
66+ # once the CRUSH_STORE_* secrets are wired up.
67+ - name: Upload release APK
68+ uses: actions/upload-artifact@v4
69+ with:
70+ name: crush-release.apk
71+ path: app/build/outputs/apk/**/*.apk
72+ if-no-files-found: error
.github/workflows/release.ymlnew file+96
@@ -0,0 +1,96 @@
1+name: Release
2+
3+# Build the production (release) APK and publish a GitHub Release for every
4+# commit that lands on master. The version -- and the git tag / release name --
5+# is derived from the commit's committer date in UTC, formatted as
6+# YYYY.MM.DD.HHMM (e.g. 2026.07.04.1430). It is passed to the Gradle build as
7+# `-PversionName=...` so it is stamped into the APK and surfaces in-app.
8+on:
9+ push:
10+ branches: [master]
11+
12+# A release run should never be aborted mid-flight by a newer push (a
13+# half-published release with a missing artifact is worse than a slightly stale
14+# one), so the group is per-ref but cancel-in-progress is left off.
15+concurrency:
16+ group: ${{ github.workflow }}-${{ github.ref }}
17+ cancel-in-progress: false
18+
19+jobs:
20+ release:
21+ runs-on: ubuntu-latest
22+ permissions:
23+ contents: write # required to push the tag and create the release
24+ id-token: write # required for OIDC so GitHub can sign the attestation
25+ attestations: write # required to publish the attestation record
26+ steps:
27+ - uses: actions/checkout@v4
28+ with:
29+ # Full history so the versionCode (git rev-list --count HEAD) is
30+ # accurate instead of collapsing to 1 on a shallow clone.
31+ fetch-depth: 0
32+
33+ - name: Compute version from commit date
34+ id: version
35+ run: |
36+ VERSION="$(TZ=UTC git log -1 --format=%cd --date=format-local:%Y.%m.%d.%H%M)"
37+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
38+ echo "Version: $VERSION"
39+
40+ - uses: actions/setup-java@v4
41+ with:
42+ distribution: temurin
43+ java-version: 17
44+
45+ - uses: android-actions/setup-android@v3
46+
47+ - uses: gradle/actions/setup-gradle@v4
48+
49+ # The keystore ships as base64 in a secret so it never touches the repo.
50+ # When the secret isn't configured the Gradle build falls back to an
51+ # unsigned APK (the same behavior as a local build), so this workflow is
52+ # green even before signing is wired up.
53+ - name: Decode signing keystore
54+ if: env.CRUSH_STORE_BASE64 != ''
55+ env:
56+ CRUSH_STORE_BASE64: ${{ secrets.CRUSH_STORE_BASE64 }}
57+ run: |
58+ STORE_FILE="$RUNNER_TEMP/release.keystore"
59+ printf '%s' "$CRUSH_STORE_BASE64" | base64 -d > "$STORE_FILE"
60+ echo "CRUSH_STORE_FILE=$STORE_FILE" >> "$GITHUB_ENV"
61+
62+ - name: Build release APK
63+ env:
64+ CRUSH_STORE_FILE: ${{ env.CRUSH_STORE_FILE }}
65+ CRUSH_STORE_PASSWORD: ${{ secrets.CRUSH_STORE_PASSWORD }}
66+ CRUSH_KEY_ALIAS: ${{ secrets.CRUSH_KEY_ALIAS }}
67+ CRUSH_KEY_PASSWORD: ${{ secrets.CRUSH_KEY_PASSWORD }}
68+ run: ./gradlew assembleRelease -PversionName="${{ steps.version.outputs.version }}" --no-daemon
69+
70+ # Rename to a stable, version-tagged filename so the release attachment is
71+ # recognizable regardless of the release/ vs release-signed/ output dir.
72+ - name: Stage APK
73+ run: |
74+ mkdir -p staging
75+ APK="$(find app/build/outputs/apk -name '*.apk' | head -n1)"
76+ cp "$APK" "staging/crush-${{ steps.version.outputs.version }}.apk"
77+
78+ # Generate a signed build-provenance attestation for the staged APK and
79+ # store it in GitHub's artifact attestation store, keyed by the file's
80+ # digest. Anyone can later verify the APK's origin/provenance with
81+ # `gh attestation verify` -- its repository & workflow. Run *after*
82+ # staging and *before* the release is published so the attested subject
83+ # is byte-identical to the artifact attached to the release.
84+ - name: Attest build provenance
85+ uses: actions/attest-build-provenance@v2
86+ with:
87+ subject-path: staging/*.apk
88+
89+ - name: Create release
90+ uses: softprops/action-gh-release@v2
91+ with:
92+ tag_name: ${{ steps.version.outputs.version }}
93+ name: ${{ steps.version.outputs.version }}
94+ make_latest: true
95+ generate_release_notes: true
96+ files: staging/*.apk
.gitignorenew file+35
@@ -0,0 +1,35 @@
1+# Gradle
2+.gradle/
3+build/
4+
5+# Kotlin
6+.kotlin/
7+
8+# Local configuration (signing keys, api keys, etc.)
9+local.properties
10+.buildenv.sh
11+*.keystore
12+*.jks
13+
14+# IntelliJ / Android Studio
15+*.iml
16+.idea/
17+captures/
18+.externalNativeBuild/
19+.cxx/
20+
21+# Eclipse
22+.classpath
23+.project
24+.settings/
25+
26+# OS
27+.DS_Store
28+Thumbs.db
29+
30+# Nix
31+result
32+.direnv/
33+
34+# Local SDK overlay (generated from the Nix SDK to fix platform dir naming)
35+.android-sdk/
README.mdnew file+114
@@ -0,0 +1,114 @@
1+# crush-android
2+
3+An Android client for the [crush](https://github.com/charmbracelet/crush) AI coding
4+assistant's server mode. Built with Kotlin and Jetpack Compose.
5+
6+## How it works
7+
8+Crush has a client/server architecture. Running `crush server --host 0.0.0.0:8080`
9+starts a daemon that exposes a REST API (v1) with real-time streaming via
10+Server-Sent Events (SSE). This app connects to that server over HTTP, lets you
11+pick a workspace (a directory on the server), and chat with the crush agent —
12+sending prompts, receiving streamed responses, and approving/denying tool
13+permission requests — all from your phone.
14+
15+```
16+┌─────────────┐ HTTP + SSE ┌──────────────┐
17+│ Android │ ──────────────────→ │ crush │
18+│ (this app) │ ←────────────────── │ server │
19+└─────────────┘ /v1/* REST + SSE └──────────────┘
20+```
21+
22+## Features
23+
24+- **Connect screen** — enter the server host and workspace path; connection
25+ settings are persisted via DataStore.
26+- **Chat UI** — message bubbles for user/assistant/tool messages, with
27+ reasoning, tool calls, and tool results rendered inline.
28+- **Session management** — create new sessions and switch between existing ones.
29+- **SSE event streaming** — real-time message updates, agent events, and
30+ run-completion via the `/v1/workspaces/{id}/events` endpoint.
31+- **Permission requests** — tool permission requests from the agent surface as
32+ dialogs; allow or deny with one tap.
33+- **Cancel runs** — stop a busy agent run mid-stream.
34+
35+## Building
36+
37+This project uses Nix for its dev shell (see `flake.nix`). The SDK platform
38+directory from Nix is named `android-37.0`, but AGP expects `android-37`, so a
39+small local SDK overlay is needed:
40+
41+```sh
42+# Enter the dev shell
43+nix develop
44+
45+# Create the corrected SDK overlay (one-time)
46+SDK_ROOT="$ANDROID_HOME"
47+LOCAL_SDK=".android-sdk"
48+mkdir -p "$LOCAL_SDK/platforms"
49+for d in build-tools cmdline-tools platform-tools tools licenses cmake; do
50+ ln -s "$SDK_ROOT/$d" "$LOCAL_SDK/$d"
51+done
52+cp -rL "$SDK_ROOT/platforms/android-37.0" "$LOCAL_SDK/platforms/android-37"
53+sed -i 's/AndroidVersion.ApiLevel=37\.0/AndroidVersion.ApiLevel=37/' \
54+ "$LOCAL_SDK/platforms/android-37/source.properties"
55+sed -i 's/<api-level>37\.0<\/api-level>/<api-level>37<\/api-level>/' \
56+ "$LOCAL_SDK/platforms/android-37/package.xml"
57+
58+# Point Gradle at the overlay
59+echo "sdk.dir=$LOCAL_SDK" > local.properties
60+
61+# Build
62+./gradlew assembleDebug
63+```
64+
65+The debug APK lands at `app/build/outputs/apk/debug/app-debug.apk`.
66+
67+## Using
68+
69+1. Start the crush server on a machine reachable from your phone:
70+ ```sh
71+ crush server --host 0.0.0.0:8080
72+ ```
73+2. Install the APK on your Android device.
74+3. Open the app, enter `http://<server-ip>:8080` and the workspace path
75+ (e.g. `/home/you/project`).
76+4. Tap **Connect**, then **+** to create a session, and start chatting.
77+
78+## Project structure
79+
80+```
81+app/src/main/java/soy/iko/crush/
82+├── CrushApp.kt # Application + ViewModel factory
83+├── MainActivity.kt # Single-activity Compose host
84+├── proto/ # Protocol model classes (match crush's Go structs)
85+│ ├── Workspace.kt
86+│ ├── Session.kt
87+│ ├── Message.kt # Message, ContentPart, PartWrapper (discriminated union)
88+│ ├── Events.kt # AgentEvent, RunComplete, PermissionRequest, etc.
89+│ ├── Payload.kt # SSE payload envelope (PayloadType discriminator)
90+│ └── Control.kt
91+├── net/
92+│ ├── CrushJson.kt # Shared kotlinx.serialization Json instance
93+│ ├── CrushApiClient.kt # OkHttp REST client for the v1 API
94+│ └── EventStream.kt # SSE parsing → Flow<Payload>
95+├── data/
96+│ ├── CrushRepository.kt # State aggregator: API + SSE → StateFlows
97+│ └── SettingsStore.kt # DataStore preferences (host, workspace)
98+└── ui/
99+ ├── ChatViewModel.kt # ViewModel exposing ChatUiState
100+ ├── MessageRenderer.kt # Flattens message parts → display text
101+ ├── theme/CrushTheme.kt # Material 3 color scheme
102+ └── screens/
103+ ├── ConnectScreen.kt # Server connection form
104+ └── ChatScreen.kt # Chat + session picker + permission dialogs
105+```
106+
107+## Protocol notes
108+
109+The crush server uses a discriminated-union pattern for message parts. Each part
110+is wrapped as `{"type": "text"|"tool_call"|"tool_result"|…, "data": {…}}`. The
111+Kotlin `ContentPart` data class has all possible fields as nullable, and
112+`PartWrapper` carries the type discriminator. SSE events are wrapped in a
113+`Payload` envelope: `{"type": "message"|"session"|"permission_request"|…,
114+"payload": {…}}`.
app/build.gradle.ktsnew file+156
@@ -0,0 +1,156 @@
1+plugins {
2+ alias(libs.plugins.android.application)
3+ alias(libs.plugins.kotlin.serialization)
4+ alias(libs.plugins.kotlin.compose)
5+ alias(libs.plugins.detekt)
6+}
7+
8+/**
9+ * Monotonically increasing versionCode: the git commit count on HEAD, so each release
10+ * ships a strictly higher code (never a "downgrade" block) without manual bookkeeping.
11+ * Falls back to 1 when git or the history isn't available (e.g. a plain source export).
12+ */
13+val gitCommitCount: Int = runCatching {
14+ providers.exec {
15+ commandLine("git", "rev-list", "--count", "HEAD")
16+ }.standardOutput.asText.get().trim().toInt()
17+}.getOrDefault(1)
18+
19+// Optional versionName override supplied via `-PversionName=...` (used by the
20+// release workflow to stamp the commit-date version into the APK). Falls back to
21+// the hard-coded version when absent so local builds are unaffected.
22+val versionNameOverride: String? = providers.gradleProperty("versionName").orNull
23+
24+android {
25+ namespace = "soy.iko.crush"
26+ compileSdk = 37
27+ buildToolsVersion = "37.0.0"
28+
29+ defaultConfig {
30+ applicationId = "soy.iko.crush"
31+ minSdk = 26
32+ targetSdk = 37
33+ // Monotonically increasing across builds: derived from the git commit count so
34+ // each release ships a strictly higher versionCode without manual bookkeeping
35+ // (and never triggers a "downgrade" install block). Falls back to 1 when the
36+ // .git history isn't available (e.g. a plain source export).
37+ versionCode = gitCommitCount
38+ versionName = versionNameOverride ?: "0.1.0"
39+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
40+ vectorDrawables { useSupportLibrary = true }
41+ }
42+
43+ // Release builds are signed when keystore credentials are supplied via env vars
44+ // (so CI can publish an installable APK). When the env vars are absent, the
45+ // release build falls back to unsigned — same behavior as before — so local
46+ // builds keep working without any keystore setup.
47+ val releaseSigning = providers.environmentVariable("CRUSH_STORE_FILE").orNull?.let { path ->
48+ signingConfigs.create("release") {
49+ storeFile = file(path)
50+ storePassword = providers.environmentVariable("CRUSH_STORE_PASSWORD").orNull
51+ keyAlias = providers.environmentVariable("CRUSH_KEY_ALIAS").orNull
52+ keyPassword = providers.environmentVariable("CRUSH_KEY_PASSWORD").orNull
53+ }
54+ }
55+
56+ buildTypes {
57+ debug {
58+ isMinifyEnabled = false
59+ }
60+ release {
61+ isMinifyEnabled = true
62+ isShrinkResources = true
63+ proguardFiles(
64+ getDefaultProguardFile("proguard-android-optimize.txt"),
65+ "proguard-rules.pro",
66+ )
67+ releaseSigning?.let { signingConfig = it }
68+ }
69+ }
70+
71+ compileOptions {
72+ sourceCompatibility = JavaVersion.VERSION_17
73+ targetCompatibility = JavaVersion.VERSION_17
74+ }
75+
76+ // Use the kotlin { compilerOptions {} } block (the kotlinOptions DSL is deprecated
77+ // in favor of the type-safe extension exposed by the Kotlin Gradle plugin).
78+ kotlin {
79+ compilerOptions {
80+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
81+ }
82+ }
83+
84+ buildFeatures {
85+ compose = true
86+ buildConfig = true
87+ }
88+
89+ lint {
90+ // Fail CI on real errors. Warnings are reported but don't break the build so
91+ // minor/style issues don't gate development.
92+ abortOnError = true
93+ // Dependency upgrades are tracked by Renovate, so don't lint for them here.
94+ disable += setOf(
95+ "GradleDependency",
96+ "AndroidGradlePluginVersion",
97+ )
98+ }
99+
100+ packaging {
101+ resources {
102+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
103+ }
104+ }
105+
106+ testOptions {
107+ unitTests {
108+ // Return default values for Android framework stubs (e.g. Log.d, Context.getString)
109+ // instead of throwing "not mocked" exceptions, so JVM unit tests that brush
110+ // against Android APIs don't need Robolectric.
111+ isReturnDefaultValues = true
112+ isIncludeAndroidResources = true
113+ }
114+ }
115+}
116+
117+dependencies {
118+ implementation(libs.androidx.core.ktx)
119+ implementation(libs.androidx.activity.compose)
120+ implementation(libs.androidx.lifecycle.viewmodel.compose)
121+ implementation(libs.androidx.lifecycle.runtime.compose)
122+ implementation(libs.androidx.navigation.compose)
123+
124+ implementation(platform(libs.compose.bom))
125+ implementation(libs.compose.ui)
126+ implementation(libs.compose.ui.graphics)
127+ implementation(libs.compose.ui.tooling.preview)
128+ implementation(libs.compose.material3)
129+ implementation(libs.compose.material.icons.extended)
130+ debugImplementation(libs.compose.ui.tooling)
131+
132+ implementation(libs.kotlinx.serialization.json)
133+ implementation(libs.kotlinx.coroutines.android)
134+
135+ implementation(libs.okhttp)
136+ implementation(libs.okhttp.sse)
137+ implementation(libs.okhttp.logging.interceptor)
138+
139+ implementation(libs.androidx.datastore.preferences)
140+
141+ testImplementation(libs.junit)
142+ testImplementation(libs.kotlinx.coroutines.test)
143+
144+ androidTestImplementation(libs.androidx.test.ext.junit)
145+ androidTestImplementation(libs.androidx.test.runner)
146+ androidTestImplementation(libs.androidx.test.core)
147+ androidTestImplementation(libs.kotlinx.coroutines.test)
148+}
149+
150+detekt {
151+ buildUponDefaultConfig = true
152+ // Carry forward the current baseline so CI only fails on *new* findings;
153+ // existing issues are grandfathered rather than blocking the migration.
154+ baseline = file("detekt-baseline.xml")
155+ config.setFrom("$rootDir/config/detekt/detekt.yml")
156+}
app/detekt-baseline.xmlnew file+14
@@ -0,0 +1,14 @@
1+<?xml version="1.0" ?>
2+<SmellBaseline>
3+ <ManuallySuppressedIssues/>
4+ <CurrentIssues>
5+ <ID>EnumNaming:Message.kt$MessageRole$assistant</ID>
6+ <ID>EnumNaming:Message.kt$MessageRole$system</ID>
7+ <ID>EnumNaming:Message.kt$MessageRole$tool</ID>
8+ <ID>EnumNaming:Message.kt$MessageRole$user</ID>
9+ <ID>MatchingDeclarationName:Control.kt$ServerControl</ID>
10+ <ID>TooManyFunctions:CrushApiClient.kt$CrushApiClient</ID>
11+ <ID>TooManyFunctions:CrushRepository.kt$CrushRepository</ID>
12+ <ID>UnusedParameter:ChatScreen.kt$onPick: (String?) -&gt; Unit</ID>
13+ </CurrentIssues>
14+</SmellBaseline>
app/proguard-rules.pronew file+13
@@ -0,0 +1,13 @@
1+# Keep kotlinx.serialization generated classes.
2+-keepattributes *Annotation*, InnerClasses
3+-dontnote kotlinx.serialization.**
4+
5+# Serializer
6+-keepclassmembers class **$$serializer { *; }
7+-keepclasseswithmembers class * {
8+ kotlinx.serialization.KSerializer serializer(...);
9+}
10+
11+# Crush model classes
12+-keep class soy.iko.crush.model.** { *; }
13+-keep class soy.iko.crush.proto.** { *; }
app/src/main/AndroidManifest.xmlnew file+28
@@ -0,0 +1,28 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
3+
4+ <uses-permission android:name="android.permission.INTERNET" />
5+ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
6+
7+ <application
8+ android:name=".CrushApp"
9+ android:allowBackup="true"
10+ android:icon="@mipmap/ic_launcher"
11+ android:label="@string/app_name"
12+ android:networkSecurityConfig="@xml/network_security_config"
13+ android:roundIcon="@mipmap/ic_launcher_round"
14+ android:supportsRtl="true"
15+ android:theme="@style/Theme.CrushAndroid">
16+ <activity
17+ android:name=".MainActivity"
18+ android:exported="true"
19+ android:theme="@style/Theme.CrushAndroid"
20+ android:windowSoftInputMode="adjustResize">
21+ <intent-filter>
22+ <action android:name="android.intent.action.MAIN" />
23+ <category android:name="android.intent.category.LAUNCHER" />
24+ </intent-filter>
25+ </activity>
26+ </application>
27+
28+</manifest>
app/src/main/java/soy/iko/crush/CrushApp.ktnew file+34
@@ -0,0 +1,34 @@
1+package soy.iko.crush
2+
3+import android.app.Application
4+import okhttp3.OkHttpClient
5+import soy.iko.crush.data.CrushRepository
6+import soy.iko.crush.data.SettingsStore
7+import soy.iko.crush.net.CrushApiClient
8+import soy.iko.crush.ui.ChatViewModel
9+import androidx.lifecycle.ViewModel
10+import androidx.lifecycle.ViewModelProvider
11+
12+class CrushApp : Application() {
13+
14+ lateinit var settings: SettingsStore
15+ private set
16+ lateinit var api: CrushApiClient
17+ private set
18+ lateinit var repo: CrushRepository
19+ private set
20+
21+ override fun onCreate() {
22+ super.onCreate()
23+ settings = SettingsStore(this)
24+ api = CrushApiClient("http://localhost:8080")
25+ repo = CrushRepository(api)
26+ }
27+
28+ @Suppress("UNCHECKED_CAST")
29+ val viewModelFactory: ViewModelProvider.Factory = object : ViewModelProvider.Factory {
30+ override fun <T : ViewModel> create(modelClass: Class<T>): T {
31+ return ChatViewModel(repo, settings) as T
32+ }
33+ }
34+}
app/src/main/java/soy/iko/crush/MainActivity.ktnew file+46
@@ -0,0 +1,46 @@
1+package soy.iko.crush
2+
3+import android.os.Bundle
4+import androidx.activity.ComponentActivity
5+import androidx.activity.compose.setContent
6+import androidx.activity.enableEdgeToEdge
7+import androidx.compose.runtime.collectAsState
8+import androidx.compose.runtime.getValue
9+import androidx.lifecycle.viewmodel.compose.viewModel
10+import soy.iko.crush.data.ConnectionState
11+import soy.iko.crush.data.isConnected
12+import soy.iko.crush.ui.ChatViewModel
13+import soy.iko.crush.ui.screens.ChatScreen
14+import soy.iko.crush.ui.screens.ConnectScreen
15+import soy.iko.crush.ui.theme.CrushTheme
16+
17+class MainActivity : ComponentActivity() {
18+ override fun onCreate(savedInstanceState: Bundle?) {
19+ super.onCreate(savedInstanceState)
20+ enableEdgeToEdge()
21+ val app = application as CrushApp
22+ setContent {
23+ CrushTheme {
24+ val vm: ChatViewModel = viewModel(factory = app.viewModelFactory)
25+ val state by vm.uiState.collectAsState()
26+ if (state.connection.isConnected) {
27+ ChatScreen(
28+ state = state,
29+ onSend = vm::sendPrompt,
30+ onCancel = vm::cancelRun,
31+ onNewSession = vm::newSession,
32+ onPickSession = vm::openSession,
33+ onGrantPermission = vm::grantPermission,
34+ )
35+ } else {
36+ ConnectScreen(
37+ defaultHost = state.host,
38+ defaultWorkspace = state.workspacePath,
39+ error = state.error,
40+ onConnect = vm::connect,
41+ )
42+ }
43+ }
44+ }
45+ }
46+}
app/src/main/java/soy/iko/crush/data/CrushRepository.ktnew file+231
@@ -0,0 +1,231 @@
1+package soy.iko.crush.data
2+
3+import kotlinx.coroutines.flow.Flow
4+import kotlinx.coroutines.flow.MutableSharedFlow
5+import kotlinx.coroutines.flow.MutableStateFlow
6+import kotlinx.coroutines.flow.SharedFlow
7+import kotlinx.coroutines.flow.StateFlow
8+import kotlinx.coroutines.flow.asSharedFlow
9+import kotlinx.coroutines.flow.asStateFlow
10+import kotlinx.serialization.json.JsonElement
11+import soy.iko.crush.net.CrushApiClient
12+import soy.iko.crush.net.CrushJson
13+import soy.iko.crush.net.eventStream
14+import soy.iko.crush.proto.AgentEvent
15+import soy.iko.crush.proto.AgentMessage
16+import soy.iko.crush.proto.EventEnvelope
17+import soy.iko.crush.proto.EventType
18+import soy.iko.crush.proto.Message
19+import soy.iko.crush.proto.Payload
20+import soy.iko.crush.proto.PayloadType
21+import soy.iko.crush.proto.PermissionNotification
22+import soy.iko.crush.proto.PermissionRequest
23+import soy.iko.crush.proto.RunComplete
24+import soy.iko.crush.proto.Session
25+import soy.iko.crush.proto.Workspace
26+import java.util.UUID
27+
28+/**
29+ * Aggregates the crush server API + SSE event stream into a simple
30+ * observable state holder. The repository owns the active workspace and
31+ * session, exposes the message log as a [StateFlow], and surfaces
32+ * permission requests and run-completion as a [SharedFlow].
33+ */
34+class CrushRepository(
35+ private val api: CrushApiClient,
36+) {
37+ private val _connection = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
38+ val connection: StateFlow<ConnectionState> = _connection.asStateFlow()
39+
40+ private val _workspaces = MutableStateFlow<List<Workspace>>(emptyList())
41+ val workspaces: StateFlow<List<Workspace>> = _workspaces.asStateFlow()
42+
43+ private val _sessions = MutableStateFlow<List<Session>>(emptyList())
44+ val sessions: StateFlow<List<Session>> = _sessions.asStateFlow()
45+
46+ private val _messages = MutableStateFlow<List<Message>>(emptyList())
47+ val messages: StateFlow<List<Message>> = _messages.asStateFlow()
48+
49+ private val _permissions = MutableStateFlow<List<PermissionRequest>>(emptyList())
50+ val permissions: StateFlow<List<PermissionRequest>> = _permissions.asStateFlow()
51+
52+ private val _events = MutableSharedFlow<CrushEvent>(extraBufferCapacity = 64)
53+ val events: SharedFlow<CrushEvent> = _events.asSharedFlow()
54+
55+ var workspaceId: String? = null
56+ private set
57+ var sessionId: String? = null
58+ private set
59+
60+ // ── connection / workspace ────────────────────────────────────────────
61+
62+ suspend fun connect(host: String): Boolean {
63+ api.baseUrl = host.trimEnd('/')
64+ if (!api.health()) {
65+ _connection.value = ConnectionState.Disconnected
66+ return false
67+ }
68+ _connection.value = ConnectionState.Connected(api.version())
69+ refreshWorkspaces()
70+ return true
71+ }
72+
73+ suspend fun refreshWorkspaces() {
74+ _workspaces.value = runCatching { api.listWorkspaces() }.getOrDefault(emptyList())
75+ }
76+
77+ suspend fun openWorkspace(path: String): Workspace? {
78+ val ws = runCatching { api.createWorkspace(path) }.getOrNull()
79+ ?: runCatching { api.getWorkspace(path) }.getOrNull()
80+ if (ws != null) {
81+ workspaceId = ws.id
82+ refreshSessions()
83+ }
84+ return ws
85+ }
86+
87+ suspend fun refreshSessions() {
88+ val wid = workspaceId ?: return
89+ _sessions.value = runCatching { api.listSessions(wid) }.getOrDefault(emptyList())
90+ }
91+
92+ suspend fun openSession(id: String) {
93+ sessionId = id
94+ val wid = workspaceId ?: return
95+ runCatching { api.setCurrentSession(wid, id) }
96+ _messages.value = runCatching { api.getMessages(wid, id) }.getOrDefault(emptyList())
97+ }
98+
99+ suspend fun newSession(): Session? {
100+ val wid = workspaceId ?: return null
101+ val s = runCatching { api.createSession(wid) }.getOrNull() ?: return null
102+ _sessions.value = _sessions.value + s
103+ openSession(s.id)
104+ return s
105+ }
106+
107+ suspend fun sendPrompt(text: String): Boolean {
108+ val wid = workspaceId ?: return false
109+ val sid = sessionId ?: return false
110+ val runId = UUID.randomUUID().toString()
111+ val msg = AgentMessage(sessionId = sid, runId = runId, prompt = text)
112+ return api.sendMessage(wid, msg)
113+ }
114+
115+ suspend fun grantPermission(req: PermissionRequest, allow: Boolean) {
116+ val wid = workspaceId ?: return
117+ val action = if (allow) "allow" else "deny"
118+ runCatching { api.grantPermission(wid, req, action) }
119+ _permissions.value = _permissions.value.filter { it.id != req.id }
120+ }
121+
122+ suspend fun cancelCurrentRun() {
123+ val wid = workspaceId ?: return
124+ val sid = sessionId ?: return
125+ runCatching { api.cancelRun(wid, sid) }
126+ }
127+
128+ // ── SSE event subscription ────────────────────────────────────────────
129+
130+ fun subscribeEvents(): Flow<Payload> {
131+ val wid = workspaceId ?: throw IllegalStateException("No workspace open")
132+ return api.eventStream(wid)
133+ }
134+
135+ /** Consume a [Payload] from the SSE stream, mutating internal state. */
136+ fun handlePayload(payload: Payload) {
137+ when (payload.type) {
138+ PayloadType.Message -> handleMessageEvent(payload.payload)
139+ PayloadType.Session -> handleSessionEvent(payload.payload)
140+ PayloadType.PermissionRequest -> handlePermissionEvent(payload.payload)
141+ PayloadType.PermissionNotification -> handlePermissionNotification(payload.payload)
142+ PayloadType.AgentEvent -> handleAgentEvent(payload.payload)
143+ PayloadType.RunComplete -> handleRunComplete(payload.payload)
144+ PayloadType.ConfigChanged,
145+ PayloadType.File,
146+ PayloadType.LspEvent,
147+ PayloadType.McpEvent,
148+ PayloadType.SkillsEvent -> {
149+ _events.tryEmit(CrushEvent.Info(payload.type.name, payload.payload.toString()))
150+ }
151+ }
152+ }
153+
154+ private fun handleMessageEvent(element: JsonElement) {
155+ val env = runCatching {
156+ CrushJson.decodeFromString(EventEnvelope.serializer(Message.serializer()), element.toString())
157+ }.getOrNull() ?: return
158+ val msg = env.payload
159+ val list = _messages.value.toMutableList()
160+ val idx = list.indexOfFirst { it.id == msg.id }
161+ if (idx >= 0) list[idx] = msg else list.add(msg)
162+ _messages.value = list
163+ _events.tryEmit(CrushEvent.MessageUpdated(msg, env.type))
164+ }
165+
166+ private fun handleSessionEvent(element: JsonElement) {
167+ val env = runCatching {
168+ CrushJson.decodeFromString(EventEnvelope.serializer(Session.serializer()), element.toString())
169+ }.getOrNull() ?: return
170+ val s = env.payload
171+ val list = _sessions.value.toMutableList()
172+ val idx = list.indexOfFirst { it.id == s.id }
173+ if (env.type == EventType.Deleted) {
174+ list.removeAll { it.id == s.id }
175+ } else if (idx >= 0) {
176+ list[idx] = s
177+ } else {
178+ list.add(s)
179+ }
180+ _sessions.value = list
181+ _events.tryEmit(CrushEvent.SessionUpdated(s, env.type))
182+ }
183+
184+ private fun handlePermissionEvent(element: JsonElement) {
185+ val req = runCatching {
186+ CrushJson.decodeFromString(PermissionRequest.serializer(), element.toString())
187+ }.getOrNull() ?: return
188+ val list = _permissions.value.toMutableList()
189+ if (list.none { it.id == req.id }) list.add(req)
190+ _permissions.value = list
191+ _events.tryEmit(CrushEvent.PermissionRequested(req))
192+ }
193+
194+ private fun handlePermissionNotification(element: JsonElement) {
195+ val note = runCatching {
196+ CrushJson.decodeFromString(PermissionNotification.serializer(), element.toString())
197+ }.getOrNull() ?: return
198+ _permissions.value = _permissions.value.filter { it.toolCallId != note.toolCallId }
199+ }
200+
201+ private fun handleAgentEvent(element: JsonElement) {
202+ val ev = runCatching {
203+ CrushJson.decodeFromString(AgentEvent.serializer(), element.toString())
204+ }.getOrNull() ?: return
205+ _events.tryEmit(CrushEvent.AgentEvent(ev))
206+ }
207+
208+ private fun handleRunComplete(element: JsonElement) {
209+ val rc = runCatching {
210+ CrushJson.decodeFromString(RunComplete.serializer(), element.toString())
211+ }.getOrNull() ?: return
212+ _events.tryEmit(CrushEvent.RunComplete(rc))
213+ }
214+}
215+
216+sealed class ConnectionState {
217+ data object Disconnected : ConnectionState()
218+ data class Connected(val version: soy.iko.crush.proto.VersionInfo) : ConnectionState()
219+}
220+
221+val ConnectionState.isConnected: Boolean
222+ get() = this is ConnectionState.Connected
223+
224+sealed class CrushEvent {
225+ data class MessageUpdated(val message: Message, val eventType: EventType) : CrushEvent()
226+ data class SessionUpdated(val session: Session, val eventType: EventType) : CrushEvent()
227+ data class PermissionRequested(val request: PermissionRequest) : CrushEvent()
228+ data class AgentEvent(val event: soy.iko.crush.proto.AgentEvent) : CrushEvent()
229+ data class RunComplete(val complete: soy.iko.crush.proto.RunComplete) : CrushEvent()
230+ data class Info(val kind: String, val raw: String) : CrushEvent()
231+}
app/src/main/java/soy/iko/crush/data/SettingsStore.ktnew file+29
@@ -0,0 +1,29 @@
1+package soy.iko.crush.data
2+
3+import android.content.Context
4+import androidx.datastore.preferences.core.Preferences
5+import androidx.datastore.preferences.core.edit
6+import androidx.datastore.preferences.core.stringPreferencesKey
7+import androidx.datastore.preferences.preferencesDataStore
8+import kotlinx.coroutines.flow.Flow
9+import kotlinx.coroutines.flow.map
10+
11+private val Context.dataStore by preferencesDataStore(name = "crush_settings")
12+
13+/** Persists the last-used server host and workspace path. */
14+class SettingsStore(private val context: Context) {
15+
16+ private val keyHost = stringPreferencesKey("server_host")
17+ private val keyWorkspace = stringPreferencesKey("workspace_path")
18+
19+ val host: Flow<String> = context.dataStore.data.map { it[keyHost] ?: "" }
20+ val workspace: Flow<String> = context.dataStore.data.map { it[keyWorkspace] ?: "" }
21+
22+ suspend fun setHost(value: String) {
23+ context.dataStore.edit { it[keyHost] = value }
24+ }
25+
26+ suspend fun setWorkspace(value: String) {
27+ context.dataStore.edit { it[keyWorkspace] = value }
28+ }
29+}
app/src/main/java/soy/iko/crush/net/CrushApiClient.ktnew file+149
@@ -0,0 +1,149 @@
1+package soy.iko.crush.net
2+
3+import okhttp3.MediaType.Companion.toMediaType
4+import okhttp3.OkHttpClient
5+import okhttp3.Request
6+import okhttp3.RequestBody.Companion.toRequestBody
7+import okhttp3.Response
8+import soy.iko.crush.proto.AgentMessage
9+import soy.iko.crush.proto.CurrentSession
10+import soy.iko.crush.proto.PermissionGrant
11+import soy.iko.crush.proto.PermissionGrantResponse
12+import soy.iko.crush.proto.PermissionRequest
13+import soy.iko.crush.proto.Session
14+import soy.iko.crush.proto.Workspace
15+import soy.iko.crush.proto.Message
16+import soy.iko.crush.proto.VersionInfo
17+import java.io.IOException
18+import java.util.UUID
19+import java.util.concurrent.TimeUnit
20+
21+/**
22+ * Low-level HTTP client for the crush `crush server` REST API (v1).
23+ *
24+ * The server is typically reached at `http://<host>:<port>` (TCP mode via
25+ * `crush server --host`). Every workspace-scoped request must include a
26+ * stable `client_id` query parameter so the server can track attached
27+ * clients for SSE lifecycle management.
28+ */
29+class CrushApiClient(
30+ var baseUrl: String,
31+ private val client: OkHttpClient = defaultClient(),
32+) {
33+ val clientId: String = UUID.randomUUID().toString()
34+
35+ fun health(): Boolean = runCatching {
36+ val req = Request.Builder().url("$baseUrl/v1/health").get().build()
37+ client.newCall(req).execute().use { it.isSuccessful }
38+ }.getOrDefault(false)
39+
40+ fun version(): VersionInfo = getJson("/v1/version")
41+
42+ fun listWorkspaces(): List<Workspace> = getJson("/v1/workspaces")
43+
44+ fun getWorkspace(id: String): Workspace = getJson("/v1/workspaces/$id")
45+
46+ fun createWorkspace(path: String, yolo: Boolean = false): Workspace {
47+ val body = CrushJson.encodeToString(Workspace.serializer(), Workspace(id = path, path = path, yolo = yolo))
48+ return postJsonReturning("/v1/workspaces", body, Workspace.serializer())
49+ }
50+
51+ fun listSessions(workspaceId: String): List<Session> =
52+ getJson("/v1/workspaces/$workspaceId/sessions?client_id=$clientId")
53+
54+ fun createSession(workspaceId: String, title: String = ""): Session {
55+ val titleEscaped = title.replace("\\", "\\\\").replace("\"", "\\\"")
56+ val body = """{"title":"$titleEscaped"}"""
57+ return postJsonReturning("/v1/workspaces/$workspaceId/sessions?client_id=$clientId", body, Session.serializer())
58+ }
59+
60+ fun getMessages(workspaceId: String, sessionId: String): List<Message> =
61+ getJson("/v1/workspaces/$workspaceId/sessions/$sessionId/messages?client_id=$clientId")
62+
63+ fun sendMessage(workspaceId: String, msg: AgentMessage): Boolean {
64+ val body = CrushJson.encodeToString(AgentMessage.serializer(), msg)
65+ val req = Request.Builder()
66+ .url("$baseUrl/v1/workspaces/$workspaceId/agent")
67+ .post(body.toRequestBody(JSON))
68+ .build()
69+ return client.newCall(req).execute().use { it.isSuccessful }
70+ }
71+
72+ fun setCurrentSession(workspaceId: String, sessionId: String) {
73+ val body = CrushJson.encodeToString(CurrentSession.serializer(), CurrentSession(sessionId))
74+ postVoid("/v1/workspaces/$workspaceId/current-session?client_id=$clientId", body)
75+ }
76+
77+ fun grantPermission(workspaceId: String, req: PermissionRequest, action: String): PermissionGrantResponse {
78+ val body = CrushJson.encodeToString(PermissionGrant.serializer(), PermissionGrant(req, action))
79+ return postJsonReturning("/v1/workspaces/$workspaceId/permissions/grant?client_id=$clientId", body, PermissionGrantResponse.serializer())
80+ }
81+
82+ fun cancelRun(workspaceId: String, sessionId: String): Boolean {
83+ val req = Request.Builder()
84+ .url("$baseUrl/v1/workspaces/$workspaceId/agent/sessions/$sessionId/cancel?client_id=$clientId")
85+ .post("{}".toRequestBody(JSON))
86+ .build()
87+ return client.newCall(req).execute().use { it.isSuccessful }
88+ }
89+
90+ /** Build the SSE request for a workspace event stream. */
91+ fun eventsRequest(workspaceId: String): Request =
92+ Request.Builder()
93+ .url("$baseUrl/v1/workspaces/$workspaceId/events?client_id=$clientId")
94+ .header("Accept", "text/event-stream")
95+ .header("Cache-Control", "no-cache")
96+ .get()
97+ .build()
98+
99+ fun client(): OkHttpClient = client
100+
101+ // ── helpers ───────────────────────────────────────────────────────────
102+
103+ private inline fun <reified T> getJson(path: String): T {
104+ val req = Request.Builder().url("$baseUrl$path").get().build()
105+ return client.newCall(req).execute().use { resp ->
106+ checkBody(resp)
107+ CrushJson.decodeFromString<T>(resp.body!!.string())
108+ }
109+ }
110+
111+ private inline fun <reified T> postJsonReturning(path: String, body: String, deserializer: kotlinx.serialization.KSerializer<T>): T {
112+ val req = Request.Builder().url("$baseUrl$path").post(body.toRequestBody(JSON)).build()
113+ return client.newCall(req).execute().use { resp ->
114+ checkBody(resp)
115+ val raw = resp.body?.string().orEmpty()
116+ if (raw.isBlank()) {
117+ @Suppress("UNCHECKED_CAST")
118+ (Unit as T)
119+ } else {
120+ CrushJson.decodeFromString(deserializer, raw)
121+ }
122+ }
123+ }
124+
125+ private fun postVoid(path: String, body: String) {
126+ val req = Request.Builder().url("$baseUrl$path").post(body.toRequestBody(JSON)).build()
127+ client.newCall(req).execute().use { resp ->
128+ if (!resp.isSuccessful) throw IOException("HTTP ${resp.code}: ${resp.body?.string().orEmpty()}")
129+ }
130+ }
131+
132+ private fun checkBody(resp: Response) {
133+ if (!resp.isSuccessful) {
134+ val body = resp.body?.string().orEmpty()
135+ throw IOException("HTTP ${resp.code}: $body")
136+ }
137+ }
138+
139+ companion object {
140+ private val JSON = "application/json; charset=utf-8".toMediaType()
141+
142+ fun defaultClient(): OkHttpClient = OkHttpClient.Builder()
143+ .connectTimeout(10, TimeUnit.SECONDS)
144+ .readTimeout(0, TimeUnit.SECONDS)
145+ .writeTimeout(10, TimeUnit.SECONDS)
146+ .retryOnConnectionFailure(true)
147+ .build()
148+ }
149+}
app/src/main/java/soy/iko/crush/net/CrushJson.ktnew file+11
@@ -0,0 +1,11 @@
1+package soy.iko.crush.net
2+
3+import kotlinx.serialization.json.Json
4+
5+/** Shared JSON instance configured for the crush server protocol. */
6+val CrushJson: Json = Json {
7+ ignoreUnknownKeys = true
8+ encodeDefaults = false
9+ explicitNulls = false
10+ classDiscriminator = "#type"
11+}
app/src/main/java/soy/iko/crush/net/EventStream.ktnew file+97
@@ -0,0 +1,97 @@
1+package soy.iko.crush.net
2+
3+import kotlinx.coroutines.flow.Flow
4+import kotlinx.coroutines.flow.callbackFlow
5+import kotlinx.coroutines.channels.awaitClose
6+import kotlinx.serialization.json.JsonElement
7+import kotlinx.serialization.json.JsonObject
8+import kotlinx.serialization.json.Json
9+import okhttp3.Call
10+import okhttp3.Callback
11+import okhttp3.Response
12+import okhttp3.ResponseBody
13+import soy.iko.crush.proto.Payload
14+import soy.iko.crush.proto.PayloadType
15+import java.io.BufferedReader
16+import java.io.IOException
17+import java.io.InputStreamReader
18+
19+/**
20+ * Decodes the SSE stream from `/v1/workspaces/{id}/events` into a cold
21+ * [Flow] of [Payload] objects.
22+ *
23+ * The crush server emits standard SSE frames:
24+ * ```
25+ * data: {"type":"message","payload":{"type":"updated","payload":{...}}}
26+ *
27+ * data: {"type":"run_complete","payload":{...}}
28+ * ```
29+ */
30+fun CrushApiClient.eventStream(workspaceId: String): Flow<Payload> = callbackFlow {
31+ val request = eventsRequest(workspaceId)
32+ val call = client().newCall(request)
33+
34+ call.enqueue(object : Callback {
35+ override fun onFailure(call: Call, e: IOException) {
36+ trySendBlockingOrClose(e)
37+ }
38+
39+ override fun onResponse(call: Call, response: Response) {
40+ if (!response.isSuccessful) {
41+ close(IOException("SSE HTTP ${response.code}"))
42+ return
43+ }
44+ val body: ResponseBody = response.body ?: run {
45+ close(IOException("SSE: empty body"))
46+ return
47+ }
48+ try {
49+ val reader = BufferedReader(InputStreamReader(body.byteStream()))
50+ val sb = StringBuilder()
51+ var line: String?
52+ while (reader.readLine().also { line = it } != null) {
53+ if (line.isNullOrEmpty()) {
54+ // blank line = frame boundary
55+ if (sb.isNotEmpty()) {
56+ val frame = sb.toString()
57+ sb.setLength(0)
58+ parseFrame(frame)?.let { trySendBlockingOrClose(it) }
59+ }
60+ } else if (line.startsWith("data:")) {
61+ if (sb.isNotEmpty()) sb.append('\n')
62+ sb.append(line.substring(5).trimStart())
63+ }
64+ }
65+ // stream ended (server closed)
66+ channel.close()
67+ } catch (e: Exception) {
68+ close(e)
69+ }
70+ }
71+ })
72+
73+ awaitClose { call.cancel() }
74+}
75+
76+private fun parseFrame(frame: String): Payload? {
77+ val trimmed = frame.trim()
78+ if (trimmed.isEmpty()) return null
79+ return runCatching {
80+ CrushJson.decodeFromString(Payload.serializer(), trimmed)
81+ }.getOrElse {
82+ // Skip keepalive comments or non-JSON frames.
83+ null
84+ }
85+}
86+
87+@Suppress("TooGenericExceptionThrown")
88+private fun <T> kotlinx.coroutines.channels.ProducerScope<T>.trySendBlockingOrClose(value: T) {
89+ val result = trySend(value)
90+ if (result.isFailure) {
91+ close(IOException("Channel closed"))
92+ }
93+}
94+
95+private fun <T> kotlinx.coroutines.channels.ProducerScope<T>.trySendBlockingOrClose(e: Throwable) {
96+ close(e)
97+}
app/src/main/java/soy/iko/crush/proto/Control.ktnew file+7
@@ -0,0 +1,7 @@
1+package soy.iko.crush.proto
2+
3+import kotlinx.serialization.SerialName
4+import kotlinx.serialization.Serializable
5+
6+@Serializable
7+data class ServerControl(val command: String)
app/src/main/java/soy/iko/crush/proto/Events.ktnew file+80
@@ -0,0 +1,80 @@
1+package soy.iko.crush.proto
2+
3+import kotlinx.serialization.SerialName
4+import kotlinx.serialization.Serializable
5+import kotlinx.serialization.json.JsonElement
6+
7+@Serializable
8+data class PermissionRequest(
9+ val id: String = "",
10+ @SerialName("session_id") val sessionId: String = "",
11+ @SerialName("tool_call_id") val toolCallId: String = "",
12+ @SerialName("tool_name") val toolName: String = "",
13+ val description: String = "",
14+ val action: String = "",
15+ val params: JsonElement? = null,
16+ val path: String = "",
17+)
18+
19+@Serializable
20+data class PermissionNotification(
21+ @SerialName("tool_call_id") val toolCallId: String = "",
22+ val granted: Boolean = false,
23+ val denied: Boolean = false,
24+)
25+
26+@Serializable
27+data class PermissionGrant(
28+ val permission: PermissionRequest,
29+ val action: String,
30+)
31+
32+@Serializable
33+data class PermissionGrantResponse(val resolved: Boolean = false)
34+
35+@Serializable
36+enum class AgentEventType {
37+ @SerialName("error") Error,
38+ @SerialName("response") Response,
39+ @SerialName("summarize") Summarize,
40+}
41+
42+@Serializable
43+data class AgentEvent(
44+ val type: AgentEventType,
45+ val message: Message? = null,
46+ val error: String? = null,
47+ @SerialName("run_id") val runId: String? = null,
48+ @SerialName("session_id") val sessionId: String? = null,
49+ @SerialName("session_title") val sessionTitle: String? = null,
50+ val progress: String? = null,
51+ val done: Boolean? = null,
52+)
53+
54+@Serializable
55+data class RunComplete(
56+ @SerialName("session_id") val sessionId: String = "",
57+ @SerialName("run_id") val runId: String? = null,
58+ @SerialName("message_id") val messageId: String = "",
59+ val text: String? = null,
60+ val error: String? = null,
61+ val cancelled: Boolean? = null,
62+)
63+
64+@Serializable
65+enum class EventType {
66+ @SerialName("created") Created,
67+ @SerialName("updated") Updated,
68+ @SerialName("deleted") Deleted,
69+}
70+
71+@Serializable
72+data class EventEnvelope<T>(
73+ val type: EventType,
74+ val payload: T,
75+)
76+
77+@Serializable
78+data class CurrentSession(
79+ @SerialName("session_id") val sessionId: String,
80+)
app/src/main/java/soy/iko/crush/proto/Message.ktnew file+94
@@ -0,0 +1,94 @@
1+package soy.iko.crush.proto
2+
3+import kotlinx.serialization.SerialName
4+import kotlinx.serialization.Serializable
5+
6+@Serializable
7+enum class MessageRole { assistant, user, system, tool }
8+
9+@Serializable
10+enum class FinishReason {
11+ @SerialName("end_turn") EndTurn,
12+ @SerialName("max_tokens") MaxTokens,
13+ @SerialName("tool_use") ToolUse,
14+ @SerialName("canceled") Canceled,
15+ @SerialName("error") Error,
16+ @SerialName("unknown") Unknown,
17+}
18+
19+@Serializable
20+enum class PartType {
21+ @SerialName("reasoning") Reasoning,
22+ @SerialName("text") Text,
23+ @SerialName("image_url") ImageUrl,
24+ @SerialName("binary") Binary,
25+ @SerialName("tool_call") ToolCall,
26+ @SerialName("tool_result") ToolResult,
27+ @SerialName("finish") Finish,
28+ @SerialName("shell_command") ShellCommand,
29+}
30+
31+@Serializable
32+data class PartWrapper(
33+ val type: PartType,
34+ val data: ContentPart,
35+)
36+
37+@Serializable
38+data class ContentPart(
39+ val text: String? = null,
40+ val thinking: String? = null,
41+ val signature: String? = null,
42+ @SerialName("thought_signature") val thoughtSignature: String? = null,
43+ @SerialName("tool_id") val toolId: String? = null,
44+ @SerialName("started_at") val startedAt: Long? = null,
45+ @SerialName("finished_at") val finishedAt: Long? = null,
46+ val url: String? = null,
47+ val detail: String? = null,
48+ val id: String? = null,
49+ val name: String? = null,
50+ val input: String? = null,
51+ @SerialName("provider_executed") val providerExecuted: Boolean? = null,
52+ val finished: Boolean? = null,
53+ @SerialName("tool_call_id") val toolCallId: String? = null,
54+ val content: String? = null,
55+ val data: String? = null,
56+ @SerialName("mime_type") val mimeType: String? = null,
57+ val metadata: String? = null,
58+ @SerialName("is_error") val isError: Boolean? = null,
59+ val reason: FinishReason? = null,
60+ val time: Long? = null,
61+ val message: String? = null,
62+ val details: String? = null,
63+ val command: String? = null,
64+ val output: String? = null,
65+ @SerialName("exit_code") val exitCode: Int? = null,
66+)
67+
68+@Serializable
69+data class Message(
70+ val id: String,
71+ val role: MessageRole,
72+ @SerialName("session_id") val sessionId: String,
73+ val parts: List<PartWrapper> = emptyList(),
74+ val model: String = "",
75+ val provider: String = "",
76+ @SerialName("created_at") val createdAt: Long = 0,
77+ @SerialName("updated_at") val updatedAt: Long = 0,
78+)
79+
80+@Serializable
81+data class AgentMessage(
82+ @SerialName("session_id") val sessionId: String,
83+ @SerialName("run_id") val runId: String? = null,
84+ val prompt: String,
85+ val attachments: List<Attachment> = emptyList(),
86+)
87+
88+@Serializable
89+data class Attachment(
90+ @SerialName("file_path") val filePath: String = "",
91+ @SerialName("file_name") val fileName: String = "",
92+ @SerialName("mime_type") val mimeType: String = "",
93+ val content: String = "",
94+)
app/src/main/java/soy/iko/crush/proto/Payload.ktnew file+25
@@ -0,0 +1,25 @@
1+package soy.iko.crush.proto
2+
3+import kotlinx.serialization.SerialName
4+import kotlinx.serialization.Serializable
5+
6+@Serializable
7+enum class PayloadType {
8+ @SerialName("lsp_event") LspEvent,
9+ @SerialName("mcp_event") McpEvent,
10+ @SerialName("permission_request") PermissionRequest,
11+ @SerialName("permission_notification") PermissionNotification,
12+ @SerialName("message") Message,
13+ @SerialName("session") Session,
14+ @SerialName("file") File,
15+ @SerialName("agent_event") AgentEvent,
16+ @SerialName("config_changed") ConfigChanged,
17+ @SerialName("skills_event") SkillsEvent,
18+ @SerialName("run_complete") RunComplete,
19+}
20+
21+@Serializable
22+data class Payload(
23+ val type: PayloadType,
24+ val payload: kotlinx.serialization.json.JsonElement,
25+)
app/src/main/java/soy/iko/crush/proto/Session.ktnew file+33
@@ -0,0 +1,33 @@
1+package soy.iko.crush.proto
2+
3+import kotlinx.serialization.SerialName
4+import kotlinx.serialization.Serializable
5+
6+@Serializable
7+data class Session(
8+ val id: String,
9+ @SerialName("parent_session_id") val parentSessionId: String = "",
10+ val title: String = "",
11+ @SerialName("message_count") val messageCount: Long = 0,
12+ @SerialName("prompt_tokens") val promptTokens: Long = 0,
13+ @SerialName("completion_tokens") val completionTokens: Long = 0,
14+ @SerialName("summary_message_id") val summaryMessageId: String = "",
15+ val cost: Double = 0.0,
16+ val todos: List<Todo> = emptyList(),
17+ @SerialName("created_at") val createdAt: Long = 0,
18+ @SerialName("updated_at") val updatedAt: Long = 0,
19+ @SerialName("is_busy") val isBusy: Boolean = false,
20+ @SerialName("attached_clients") val attachedClients: Int = 0,
21+)
22+
23+@Serializable
24+data class Todo(
25+ val content: String,
26+ val status: String,
27+ @SerialName("active_form") val activeForm: String = "",
28+)
29+
30+@Serializable
31+data class CreateSessionRequest(
32+ val title: String = "",
33+)
app/src/main/java/soy/iko/crush/proto/Workspace.ktnew file+39
@@ -0,0 +1,39 @@
1+package soy.iko.crush.proto
2+
3+import kotlinx.serialization.SerialName
4+import kotlinx.serialization.Serializable
5+import kotlinx.serialization.json.JsonElement
6+
7+@Serializable
8+data class Workspace(
9+ val id: String,
10+ val path: String,
11+ @SerialName("yolo") val yolo: Boolean = false,
12+ @SerialName("debug") val debug: Boolean = false,
13+ @SerialName("data_dir") val dataDir: String? = null,
14+ @SerialName("version") val version: String? = null,
15+ @SerialName("client_id") val clientId: String? = null,
16+ val config: JsonElement? = null,
17+ val env: List<String>? = null,
18+ val skills: List<SkillState>? = null,
19+)
20+
21+@Serializable
22+data class SkillState(
23+ val name: String,
24+ val path: String,
25+ val state: Int = 0,
26+ val error: String? = null,
27+)
28+
29+@Serializable
30+data class VersionInfo(
31+ val version: String,
32+ val commit: String = "",
33+ @SerialName("build_id") val buildId: String = "",
34+ @SerialName("go_version") val goVersion: String = "",
35+ val platform: String = "",
36+)
37+
38+@Serializable
39+data class ErrorBody(val message: String)
app/src/main/java/soy/iko/crush/ui/ChatViewModel.ktnew file+154
@@ -0,0 +1,154 @@
1+package soy.iko.crush.ui
2+
3+import androidx.lifecycle.ViewModel
4+import androidx.lifecycle.viewModelScope
5+import kotlinx.coroutines.CoroutineScope
6+import kotlinx.coroutines.Dispatchers
7+import kotlinx.coroutines.Job
8+import kotlinx.coroutines.flow.MutableStateFlow
9+import kotlinx.coroutines.flow.SharingStarted
10+import kotlinx.coroutines.flow.StateFlow
11+import kotlinx.coroutines.flow.asStateFlow
12+import kotlinx.coroutines.flow.combine
13+import kotlinx.coroutines.flow.stateIn
14+import kotlinx.coroutines.flow.Flow
15+import kotlinx.coroutines.launch
16+import soy.iko.crush.data.ConnectionState
17+import soy.iko.crush.data.CrushEvent
18+import soy.iko.crush.data.CrushRepository
19+import soy.iko.crush.data.SettingsStore
20+import soy.iko.crush.proto.Message
21+import soy.iko.crush.proto.PermissionRequest
22+
23+/** Immutable UI state. */
24+data class ChatUiState(
25+ val connection: ConnectionState = ConnectionState.Disconnected,
26+ val host: String = "",
27+ val workspacePath: String = "",
28+ val workspaceOpen: Boolean = false,
29+ val sessions: List<soy.iko.crush.proto.Session> = emptyList(),
30+ val currentSessionId: String? = null,
31+ val messages: List<Message> = emptyList(),
32+ val pendingPermissions: List<PermissionRequest> = emptyList(),
33+ val isBusy: Boolean = false,
34+ val error: String? = null,
35+)
36+
37+class ChatViewModel(
38+ private val repo: CrushRepository,
39+ private val settings: SettingsStore,
40+) : ViewModel() {
41+
42+ private val _error = MutableStateFlow<String?>(null)
43+ private val _busy = MutableStateFlow(false)
44+
45+ private var sseJob: Job? = null
46+
47+ @Suppress("UNCHECKED_CAST")
48+ val uiState: StateFlow<ChatUiState> =
49+ combine(
50+ repo.connection as Flow<Any>,
51+ settings.host as Flow<Any>,
52+ settings.workspace as Flow<Any>,
53+ repo.sessions as Flow<Any>,
54+ repo.messages as Flow<Any>,
55+ repo.permissions as Flow<Any>,
56+ _busy as Flow<Any>,
57+ _error as Flow<Any>,
58+ ) { values ->
59+ ChatUiState(
60+ connection = values[0] as ConnectionState,
61+ host = values[1] as String,
62+ workspacePath = values[2] as String,
63+ workspaceOpen = repo.workspaceId != null,
64+ sessions = values[3] as List<soy.iko.crush.proto.Session>,
65+ currentSessionId = repo.sessionId,
66+ messages = values[4] as List<Message>,
67+ pendingPermissions = values[5] as List<PermissionRequest>,
68+ isBusy = values[6] as Boolean,
69+ error = values[7] as String?,
70+ )
71+ }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ChatUiState())
72+
73+ // ── actions ───────────────────────────────────────────────────────────
74+
75+ fun connect(host: String, workspacePath: String) {
76+ viewModelScope.launch(Dispatchers.IO) {
77+ _error.value = null
78+ settings.setHost(host)
79+ settings.setWorkspace(workspacePath)
80+ val ok = repo.connect(host)
81+ if (!ok) {
82+ _error.value = "Could not reach crush server at $host"
83+ return@launch
84+ }
85+ val ws = repo.openWorkspace(workspacePath)
86+ if (ws == null) {
87+ _error.value = "Could not open workspace '$workspacePath'"
88+ return@launch
89+ }
90+ startEventStream()
91+ }
92+ }
93+
94+ fun newSession() {
95+ viewModelScope.launch(Dispatchers.IO) {
96+ _error.value = null
97+ val s = repo.newSession()
98+ if (s == null) _error.value = "Failed to create session"
99+ }
100+ }
101+
102+ fun openSession(id: String) {
103+ viewModelScope.launch(Dispatchers.IO) {
104+ _error.value = null
105+ repo.openSession(id)
106+ }
107+ }
108+
109+ fun sendPrompt(text: String) {
110+ if (text.isBlank()) return
111+ viewModelScope.launch(Dispatchers.IO) {
112+ _error.value = null
113+ _busy.value = true
114+ val ok = repo.sendPrompt(text)
115+ if (!ok) _error.value = "Failed to send message"
116+ }
117+ }
118+
119+ fun grantPermission(req: PermissionRequest, allow: Boolean) {
120+ viewModelScope.launch(Dispatchers.IO) {
121+ repo.grantPermission(req, allow)
122+ }
123+ }
124+
125+ fun cancelRun() {
126+ viewModelScope.launch(Dispatchers.IO) {
127+ repo.cancelCurrentRun()
128+ }
129+ }
130+
131+ fun clearError() { _error.value = null }
132+
133+ private fun startEventStream() {
134+ sseJob?.cancel()
135+ sseJob = viewModelScope.launch(Dispatchers.IO) {
136+ try {
137+ repo.subscribeEvents().collect { payload ->
138+ repo.handlePayload(payload)
139+ if (payload.type == soy.iko.crush.proto.PayloadType.RunComplete) {
140+ _busy.value = false
141+ }
142+ }
143+ } catch (e: Exception) {
144+ _error.value = "Stream lost: ${e.message}"
145+ _busy.value = false
146+ }
147+ }
148+ }
149+
150+ override fun onCleared() {
151+ super.onCleared()
152+ sseJob?.cancel()
153+ }
154+}
app/src/main/java/soy/iko/crush/ui/MessageRenderer.ktnew file+73
@@ -0,0 +1,73 @@
1+package soy.iko.crush.ui
2+
3+import soy.iko.crush.proto.ContentPart
4+import soy.iko.crush.proto.FinishReason
5+import soy.iko.crush.proto.Message
6+import soy.iko.crush.proto.MessageRole
7+import soy.iko.crush.proto.PartType
8+import soy.iko.crush.proto.PartWrapper
9+
10+/**
11+ * Render helpers that flatten the discriminated `parts` list of a [Message]
12+ * into simple text segments suitable for the chat UI.
13+ */
14+object MessageRenderer {
15+
16+ /** Concatenate all text segments from assistant/user messages. */
17+ fun renderText(message: Message): String {
18+ val sb = StringBuilder()
19+ for (wrapper in message.parts) {
20+ val text = renderPart(wrapper)
21+ if (text.isNotEmpty()) {
22+ if (sb.isNotEmpty()) sb.append('\n')
23+ sb.append(text)
24+ }
25+ }
26+ return sb.toString()
27+ }
28+
29+ fun renderPart(wrapper: PartWrapper): String = when (wrapper.type) {
30+ PartType.Text -> wrapper.data.text.orEmpty()
31+ PartType.Reasoning -> {
32+ val thinking = wrapper.data.thinking.orEmpty()
33+ if (thinking.isNotEmpty()) "💭 $thinking" else ""
34+ }
35+ PartType.ToolCall -> {
36+ val name = wrapper.data.name.orEmpty()
37+ val input = wrapper.data.input.orEmpty()
38+ val preview = if (input.length > 120) input.take(120) + "…" else input
39+ "🔧 $name($preview)"
40+ }
41+ PartType.ToolResult -> {
42+ val name = wrapper.data.name.orEmpty()
43+ val content = wrapper.data.content.orEmpty()
44+ val isError = wrapper.data.isError == true
45+ val icon = if (isError) "❌" else "✅"
46+ val preview = if (content.length > 200) content.take(200) + "…" else content
47+ "$icon $name: $preview"
48+ }
49+ PartType.Finish -> {
50+ val reason = wrapper.data.reason ?: FinishReason.Unknown
51+ "⟡ finished: ${reason.name}"
52+ }
53+ PartType.ShellCommand -> {
54+ val cmd = wrapper.data.command.orEmpty()
55+ val code = wrapper.data.exitCode ?: -1
56+ "⚙ \$ $cmd (exit $code)"
57+ }
58+ PartType.ImageUrl -> "[image: ${wrapper.data.url.orEmpty()}]"
59+ PartType.Binary -> "[binary: ${wrapper.data.mimeType.orEmpty()}]"
60+ }
61+
62+ /** True if the message has any visible text content. */
63+ fun hasVisibleContent(message: Message): Boolean =
64+ message.parts.any { it.type == PartType.Text && !it.data.text.isNullOrEmpty() }
65+
66+ /** A short label for the role. */
67+ fun roleLabel(role: MessageRole): String = when (role) {
68+ MessageRole.user -> "You"
69+ MessageRole.assistant -> "Crush"
70+ MessageRole.system -> "System"
71+ MessageRole.tool -> "Tool"
72+ }
73+}
app/src/main/java/soy/iko/crush/ui/screens/ChatScreen.ktnew file+331
@@ -0,0 +1,331 @@
1+package soy.iko.crush.ui.screens
2+
3+import androidx.compose.foundation.background
4+import androidx.compose.foundation.layout.Arrangement
5+import androidx.compose.foundation.layout.Box
6+import androidx.compose.foundation.layout.Column
7+import androidx.compose.foundation.layout.Row
8+import androidx.compose.foundation.layout.Spacer
9+import androidx.compose.foundation.layout.fillMaxSize
10+import androidx.compose.foundation.layout.fillMaxWidth
11+import androidx.compose.foundation.layout.height
12+import androidx.compose.foundation.layout.padding
13+import androidx.compose.foundation.layout.size
14+import androidx.compose.foundation.layout.widthIn
15+import androidx.compose.foundation.lazy.LazyColumn
16+import androidx.compose.foundation.lazy.items
17+import androidx.compose.foundation.lazy.rememberLazyListState
18+import androidx.compose.foundation.shape.RoundedCornerShape
19+import androidx.compose.material.icons.Icons
20+import androidx.compose.material.icons.automirrored.filled.Send
21+import androidx.compose.material.icons.filled.Add
22+import androidx.compose.material.icons.filled.Stop
23+import androidx.compose.material3.AlertDialog
24+import androidx.compose.material3.Button
25+import androidx.compose.material3.Card
26+import androidx.compose.material3.CardDefaults
27+import androidx.compose.material3.CircularProgressIndicator
28+import androidx.compose.material3.ExperimentalMaterial3Api
29+import androidx.compose.material3.FloatingActionButton
30+import androidx.compose.material3.HorizontalDivider
31+import androidx.compose.material3.Icon
32+import androidx.compose.material3.IconButton
33+import androidx.compose.material3.MaterialTheme
34+import androidx.compose.material3.OutlinedTextField
35+import androidx.compose.material3.Scaffold
36+import androidx.compose.material3.Surface
37+import androidx.compose.material3.Text
38+import androidx.compose.material3.TextButton
39+import androidx.compose.material3.TopAppBar
40+import androidx.compose.runtime.Composable
41+import androidx.compose.runtime.LaunchedEffect
42+import androidx.compose.runtime.getValue
43+import androidx.compose.runtime.mutableStateOf
44+import androidx.compose.runtime.remember
45+import androidx.compose.runtime.setValue
46+import androidx.compose.ui.Alignment
47+import androidx.compose.ui.Modifier
48+import androidx.compose.ui.draw.clip
49+import androidx.compose.ui.text.font.FontFamily
50+import androidx.compose.ui.text.font.FontWeight
51+import androidx.compose.ui.unit.dp
52+import soy.iko.crush.proto.MessageRole
53+import soy.iko.crush.ui.ChatUiState
54+import soy.iko.crush.ui.MessageRenderer
55+
56+@OptIn(ExperimentalMaterial3Api::class)
57+@Composable
58+fun ChatScreen(
59+ state: ChatUiState,
60+ onSend: (String) -> Unit,
61+ onCancel: () -> Unit,
62+ onNewSession: () -> Unit,
63+ onPickSession: (String) -> Unit,
64+ onGrantPermission: (soy.iko.crush.proto.PermissionRequest, Boolean) -> Unit,
65+) {
66+ var input by remember { mutableStateOf("") }
67+ var showSessionPicker by remember { mutableStateOf(false) }
68+ val listState = rememberLazyListState()
69+
70+ // Auto-scroll to bottom when new messages arrive.
71+ LaunchedEffect(state.messages.size) {
72+ if (state.messages.isNotEmpty()) {
73+ listState.animateScrollToItem(state.messages.lastIndex)
74+ }
75+ }
76+
77+ Scaffold(
78+ topBar = {
79+ TopAppBar(
80+ title = {
81+ Column {
82+ Text("Crush", fontWeight = FontWeight.Bold)
83+ val subtitle = state.currentSessionId?.let { "session $it" } ?: "no session"
84+ Text(
85+ subtitle,
86+ style = MaterialTheme.typography.labelSmall,
87+ color = MaterialTheme.colorScheme.onSurfaceVariant,
88+ )
89+ }
90+ },
91+ actions = {
92+ if (state.isBusy) {
93+ CircularProgressIndicator(
94+ modifier = Modifier.size(20.dp),
95+ strokeWidth = 2.dp,
96+ )
97+ IconButton(onClick = onCancel) {
98+ Icon(Icons.Filled.Stop, contentDescription = "Cancel run")
99+ }
100+ }
101+ IconButton(onClick = { showSessionPicker = true }) {
102+ Icon(Icons.Filled.Add, contentDescription = "Sessions")
103+ }
104+ },
105+ )
106+ },
107+ bottomBar = {
108+ InputBar(
109+ text = input,
110+ onTextChange = { input = it },
111+ onSend = {
112+ if (input.isNotBlank()) {
113+ onSend(input)
114+ input = ""
115+ }
116+ },
117+ enabled = state.currentSessionId != null && !state.isBusy,
118+ )
119+ },
120+ ) { padding ->
121+ Column(
122+ modifier = Modifier
123+ .fillMaxSize()
124+ .padding(padding),
125+ ) {
126+ if (state.error != null) {
127+ ErrorBanner(state.error)
128+ }
129+ LazyColumn(
130+ state = listState,
131+ modifier = Modifier
132+ .weight(1f)
133+ .fillMaxWidth()
134+ .padding(horizontal = 12.dp),
135+ verticalArrangement = Arrangement.spacedBy(8.dp),
136+ contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 12.dp),
137+ ) {
138+ items(state.messages, key = { it.id }) { msg ->
139+ MessageBubble(msg)
140+ }
141+ if (state.isBusy && state.messages.isEmpty()) {
142+ item { TypingIndicator() }
143+ }
144+ }
145+ PermissionQueue(
146+ requests = state.pendingPermissions,
147+ onGrant = onGrantPermission,
148+ )
149+ }
150+ }
151+
152+ if (showSessionPicker) {
153+ SessionPickerSheet(
154+ sessions = state.sessions,
155+ currentId = state.currentSessionId,
156+ onPick = {
157+ showSessionPicker = false
158+ if (it != null) onPickSession(it)
159+ },
160+ onNew = {
161+ showSessionPicker = false
162+ onNewSession()
163+ },
164+ onDismiss = { showSessionPicker = false },
165+ )
166+ }
167+}
168+
169+@Composable
170+private fun MessageBubble(message: soy.iko.crush.proto.Message) {
171+ val isUser = message.role == MessageRole.user
172+ val bg = if (isUser) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
173+ else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
174+ val alignment = if (isUser) Alignment.End else Alignment.Start
175+
176+ Column(
177+ modifier = Modifier.fillMaxWidth(),
178+ horizontalAlignment = alignment,
179+ ) {
180+ Text(
181+ MessageRenderer.roleLabel(message.role),
182+ style = MaterialTheme.typography.labelSmall,
183+ color = MaterialTheme.colorScheme.onSurfaceVariant,
184+ modifier = Modifier.padding(bottom = 2.dp, start = 4.dp, end = 4.dp),
185+ )
186+ Box(
187+ modifier = Modifier
188+ .widthIn(max = 340.dp)
189+ .clip(RoundedCornerShape(16.dp))
190+ .background(bg)
191+ .padding(12.dp),
192+ ) {
193+ val text = MessageRenderer.renderText(message)
194+ if (text.isNotEmpty()) {
195+ Text(
196+ text,
197+ style = MaterialTheme.typography.bodyMedium.copy(
198+ fontFamily = if (isUser) FontFamily.Default else FontFamily.Monospace,
199+ ),
200+ color = MaterialTheme.colorScheme.onSurface,
201+ )
202+ } else if (message.parts.isNotEmpty()) {
203+ // Non-text parts (tool calls etc.) — render compactly.
204+ Text(
205+ message.parts.joinToString("\n") { MessageRenderer.renderPart(it) },
206+ style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
207+ color = MaterialTheme.colorScheme.onSurfaceVariant,
208+ )
209+ }
210+ }
211+ }
212+}
213+
214+@Composable
215+private fun InputBar(
216+ text: String,
217+ onTextChange: (String) -> Unit,
218+ onSend: () -> Unit,
219+ enabled: Boolean,
220+) {
221+ Surface(tonalElevation = 3.dp) {
222+ Row(
223+ modifier = Modifier
224+ .fillMaxWidth()
225+ .padding(8.dp),
226+ verticalAlignment = Alignment.CenterVertically,
227+ ) {
228+ OutlinedTextField(
229+ value = text,
230+ onValueChange = onTextChange,
231+ modifier = Modifier.weight(1f),
232+ placeholder = { Text("Message Crush…") },
233+ maxLines = 5,
234+ enabled = enabled,
235+ )
236+ Spacer(Modifier.size(8.dp))
237+ IconButton(onClick = onSend, enabled = enabled && text.isNotBlank()) {
238+ Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send")
239+ }
240+ }
241+ }
242+}
243+
244+@Composable
245+private fun ErrorBanner(message: String) {
246+ Box(
247+ modifier = Modifier
248+ .fillMaxWidth()
249+ .background(MaterialTheme.colorScheme.error.copy(alpha = 0.12f))
250+ .padding(8.dp),
251+ ) {
252+ Text(
253+ message,
254+ color = MaterialTheme.colorScheme.error,
255+ style = MaterialTheme.typography.bodySmall,
256+ )
257+ }
258+}
259+
260+@Composable
261+private fun TypingIndicator() {
262+ Row(
263+ modifier = Modifier.padding(12.dp),
264+ verticalAlignment = Alignment.CenterVertically,
265+ horizontalArrangement = Arrangement.spacedBy(6.dp),
266+ ) {
267+ CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
268+ Text("Crush is thinking…", style = MaterialTheme.typography.bodySmall)
269+ }
270+}
271+
272+@Composable
273+private fun PermissionQueue(
274+ requests: List<soy.iko.crush.proto.PermissionRequest>,
275+ onGrant: (soy.iko.crush.proto.PermissionRequest, Boolean) -> Unit,
276+) {
277+ if (requests.isEmpty()) return
278+ val req = requests.first()
279+ AlertDialog(
280+ onDismissRequest = { onGrant(req, false) },
281+ title = { Text("Permission required") },
282+ text = {
283+ Column {
284+ Text("Tool: ${req.toolName}")
285+ Spacer(Modifier.height(4.dp))
286+ Text(req.description, style = MaterialTheme.typography.bodySmall)
287+ }
288+ },
289+ confirmButton = { Button({ onGrant(req, true) }) { Text("Allow") } },
290+ dismissButton = { TextButton({ onGrant(req, false) }) { Text("Deny") } },
291+ )
292+}
293+
294+@Composable
295+private fun SessionPickerSheet(
296+ sessions: List<soy.iko.crush.proto.Session>,
297+ currentId: String?,
298+ onPick: (String?) -> Unit,
299+ onNew: () -> Unit,
300+ onDismiss: () -> Unit,
301+) {
302+ AlertDialog(
303+ onDismissRequest = onDismiss,
304+ title = { Text("Sessions") },
305+ text = {
306+ Column {
307+ sessions.forEach { s ->
308+ val active = s.id == currentId
309+ Text(
310+ s.title.ifBlank { s.id.take(8) },
311+ fontWeight = if (active) FontWeight.Bold else FontWeight.Normal,
312+ color = if (active) MaterialTheme.colorScheme.primary
313+ else MaterialTheme.colorScheme.onSurface,
314+ modifier = Modifier
315+ .fillMaxWidth()
316+ .padding(8.dp)
317+ .clip(RoundedCornerShape(8.dp))
318+ .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
319+ .padding(8.dp),
320+ )
321+ Spacer(Modifier.height(4.dp))
322+ }
323+ if (sessions.isEmpty()) {
324+ Text("No sessions yet", color = MaterialTheme.colorScheme.onSurfaceVariant)
325+ }
326+ }
327+ },
328+ confirmButton = { Button(onNew) { Text("New session") } },
329+ dismissButton = { TextButton(onDismiss) { Text("Close") } },
330+ )
331+}
app/src/main/java/soy/iko/crush/ui/screens/ConnectScreen.ktnew file+112
@@ -0,0 +1,112 @@
1+package soy.iko.crush.ui.screens
2+
3+import androidx.compose.foundation.layout.Arrangement
4+import androidx.compose.foundation.layout.Column
5+import androidx.compose.foundation.layout.Spacer
6+import androidx.compose.foundation.layout.fillMaxSize
7+import androidx.compose.foundation.layout.fillMaxWidth
8+import androidx.compose.foundation.layout.height
9+import androidx.compose.foundation.layout.padding
10+import androidx.compose.foundation.layout.widthIn
11+import androidx.compose.material3.Button
12+import androidx.compose.material3.CircularProgressIndicator
13+import androidx.compose.material3.Icon
14+import androidx.compose.material3.MaterialTheme
15+import androidx.compose.material3.OutlinedTextField
16+import androidx.compose.material3.Surface
17+import androidx.compose.material3.Text
18+import androidx.compose.material.icons.Icons
19+import androidx.compose.material.icons.filled.Favorite
20+import androidx.compose.runtime.Composable
21+import androidx.compose.runtime.getValue
22+import androidx.compose.runtime.mutableStateOf
23+import androidx.compose.runtime.remember
24+import androidx.compose.runtime.setValue
25+import androidx.compose.ui.Alignment
26+import androidx.compose.ui.Modifier
27+import androidx.compose.ui.text.font.FontWeight
28+import androidx.compose.ui.text.input.ImeAction
29+import androidx.compose.ui.text.input.KeyboardCapitalization
30+import androidx.compose.ui.text.input.KeyboardType
31+import androidx.compose.ui.text.input.TextFieldValue
32+import androidx.compose.ui.unit.dp
33+import androidx.compose.ui.unit.sp
34+
35+@Composable
36+fun ConnectScreen(
37+ defaultHost: String,
38+ defaultWorkspace: String,
39+ error: String?,
40+ onConnect: (host: String, workspace: String) -> Unit,
41+) {
42+ var host by remember { mutableStateOf(defaultHost.ifBlank { "http://192.168.1.100:8080" }) }
43+ var workspace by remember { mutableStateOf(defaultWorkspace.ifBlank { "/home/iko/project" }) }
44+
45+ Surface(
46+ modifier = Modifier.fillMaxSize(),
47+ color = MaterialTheme.colorScheme.background,
48+ ) {
49+ Column(
50+ modifier = Modifier
51+ .fillMaxSize()
52+ .padding(24.dp),
53+ horizontalAlignment = Alignment.CenterHorizontally,
54+ verticalArrangement = Arrangement.Center,
55+ ) {
56+ Icon(
57+ imageVector = Icons.Filled.Favorite,
58+ contentDescription = null,
59+ tint = MaterialTheme.colorScheme.primary,
60+ modifier = Modifier.height(72.dp),
61+ )
62+ Spacer(Modifier.height(12.dp))
63+ Text(
64+ "Crush",
65+ style = MaterialTheme.typography.headlineLarge.copy(
66+ fontWeight = FontWeight.Bold,
67+ fontSize = 32.sp,
68+ ),
69+ color = MaterialTheme.colorScheme.primary,
70+ )
71+ Text(
72+ "Android Client",
73+ style = MaterialTheme.typography.bodyMedium,
74+ color = MaterialTheme.colorScheme.onSurfaceVariant,
75+ )
76+ Spacer(Modifier.height(32.dp))
77+
78+ Column(
79+ modifier = Modifier.widthIn(max = 400.dp),
80+ verticalArrangement = Arrangement.spacedBy(12.dp),
81+ ) {
82+ OutlinedTextField(
83+ value = host,
84+ onValueChange = { host = it },
85+ label = { Text("Server host") },
86+ singleLine = true,
87+ modifier = Modifier.fillMaxWidth(),
88+ )
89+ OutlinedTextField(
90+ value = workspace,
91+ onValueChange = { workspace = it },
92+ label = { Text("Workspace path (on server)") },
93+ singleLine = true,
94+ modifier = Modifier.fillMaxWidth(),
95+ )
96+ if (error != null) {
97+ Text(
98+ error,
99+ color = MaterialTheme.colorScheme.error,
100+ style = MaterialTheme.typography.bodySmall,
101+ )
102+ }
103+ Button(
104+ onClick = { onConnect(host, workspace) },
105+ modifier = Modifier.fillMaxWidth(),
106+ ) {
107+ Text("Connect")
108+ }
109+ }
110+ }
111+ }
112+}
app/src/main/java/soy/iko/crush/ui/theme/CrushTheme.ktnew file+53
@@ -0,0 +1,53 @@
1+package soy.iko.crush.ui.theme
2+
3+import androidx.compose.foundation.isSystemInDarkTheme
4+import androidx.compose.material3.MaterialTheme
5+import androidx.compose.material3.darkColorScheme
6+import androidx.compose.material3.lightColorScheme
7+import androidx.compose.runtime.Composable
8+import androidx.compose.ui.graphics.Color
9+
10+// Charm-style pink/purple accent on a deep navy background.
11+private val CrushPink = Color(0xFFFF6B9D)
12+private val CrushPurple = Color(0xFFB57EDC)
13+private val CrushNavy = Color(0xFF0F0F1A)
14+private val CrushNavyLight = Color(0xFF1A1A2E)
15+private val CrushSurface = Color(0xFF1E1E2E)
16+private val CrushSurfaceLight = Color(0xFFF5F5F7)
17+
18+private val DarkColors = darkColorScheme(
19+ primary = CrushPink,
20+ onPrimary = Color.White,
21+ secondary = CrushPurple,
22+ onSecondary = Color.White,
23+ background = CrushNavy,
24+ onBackground = Color(0xFFE0E0E8),
25+ surface = CrushSurface,
26+ onSurface = Color(0xFFE0E0E8),
27+ surfaceVariant = CrushNavyLight,
28+ onSurfaceVariant = Color(0xFFB0B0C0),
29+)
30+
31+private val LightColors = lightColorScheme(
32+ primary = CrushPink,
33+ onPrimary = Color.White,
34+ secondary = CrushPurple,
35+ onSecondary = Color.White,
36+ background = CrushSurfaceLight,
37+ onBackground = Color(0xFF1A1A2E),
38+ surface = Color.White,
39+ onSurface = Color(0xFF1A1A2E),
40+ surfaceVariant = Color(0xFFE8E8EE),
41+ onSurfaceVariant = Color(0xFF555566),
42+)
43+
44+@Composable
45+fun CrushTheme(
46+ darkTheme: Boolean = isSystemInDarkTheme(),
47+ content: @Composable () -> Unit,
48+) {
49+ MaterialTheme(
50+ colorScheme = if (darkTheme) DarkColors else LightColors,
51+ content = content,
52+ )
53+}
app/src/main/res/drawable/ic_launcher_background.xmlnew file+10
@@ -0,0 +1,10 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="108dp"
4+ android:height="108dp"
5+ android:viewportWidth="108"
6+ android:viewportHeight="108">
7+ <path
8+ android:fillColor="#1a1a2e"
9+ android:pathData="M0,0h108v108h-108z" />
10+</vector>
app/src/main/res/drawable/ic_launcher_foreground.xmlnew file+11
@@ -0,0 +1,11 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="108dp"
4+ android:height="108dp"
5+ android:viewportWidth="108"
6+ android:viewportHeight="108">
7+ <!-- Heart "crush" glyph -->
8+ <path
9+ android:fillColor="#FF6B9D"
10+ android:pathData="M54,72 C54,72 38,58 38,48 C38,42 43,38 48,38 C51,38 53,40 54,42 C55,40 57,38 60,38 C65,38 70,42 70,48 C70,58 54,72 54,72 Z" />
11+</vector>
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xmlnew file+5
@@ -0,0 +1,5 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
3+ <background android:drawable="@drawable/ic_launcher_background" />
4+ <foreground android:drawable="@drawable/ic_launcher_foreground" />
5+</adaptive-icon>
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xmlnew file+5
@@ -0,0 +1,5 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
3+ <background android:drawable="@drawable/ic_launcher_background" />
4+ <foreground android:drawable="@drawable/ic_launcher_foreground" />
5+</adaptive-icon>
app/src/main/res/values/strings.xmlnew file+3
@@ -0,0 +1,3 @@
1+<resources>
2+ <string name="app_name">Crush</string>
3+</resources>
app/src/main/res/values/themes.xmlnew file+3
@@ -0,0 +1,3 @@
1+<resources>
2+ <style name="Theme.CrushAndroid" parent="android:Theme.Material.NoActionBar" />
3+</resources>
app/src/main/res/xml/network_security_config.xmlnew file+9
@@ -0,0 +1,9 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<network-security-config>
3+ <base-config cleartextTrafficPermitted="true">
4+ <trust-anchors>
5+ <certificates src="system" />
6+ <certificates src="user" />
7+ </trust-anchors>
8+ </base-config>
9+</network-security-config>
build.gradle.ktsnew file+5
@@ -0,0 +1,5 @@
1+plugins {
2+ alias(libs.plugins.android.application) apply false
3+ alias(libs.plugins.kotlin.serialization) apply false
4+ alias(libs.plugins.kotlin.compose) apply false
5+}
config/detekt/detekt.ymlnew file+68
@@ -0,0 +1,68 @@
1+# detekt override config.
2+# buildUponDefaultConfig = true, so only the rules below differ from the defaults.
3+# The defaults are intentionally strict; this file relaxes rules that fight Jetpack
4+# Compose / Android idioms (lots of magic dp numbers, long @Composable functions,
5+# long parameter lists) while keeping the potential-bug and correctness rules on.
6+build:
7+ maxIssues: 0
8+ excludeCorrectable: false
9+
10+complexity:
11+ active: true
12+ LongMethod:
13+ active: false
14+ LongParameterList:
15+ active: false
16+ ComplexCondition:
17+ active: true
18+ CyclomaticComplexMethod:
19+ active: true
20+ threshold: 20
21+ NestedBlockDepth:
22+ active: true
23+ threshold: 6
24+ TooManyFunctions:
25+ active: true
26+ ignoreOverridden: true
27+ thresholdInFiles: 16
28+ thresholdInClasses: 16
29+ thresholdInObjects: 16
30+
31+style:
32+ active: true
33+ MagicNumber:
34+ active: false
35+ ReturnCount:
36+ active: false
37+ ThrowsCount:
38+ active: false
39+ LoopWithTooManyJumpStatements:
40+ active: false
41+ UseCheckOrError:
42+ active: false
43+ MaxLineLength:
44+ active: false
45+ WildcardImport:
46+ active: false
47+ FunctionOnlyReturningConstant:
48+ active: false
49+
50+empty-blocks:
51+ active: true
52+ EmptyFunctionBlock:
53+ active: true
54+ # Android listener interfaces (e.g. NsdManager.DiscoveryListener) force overriding
55+ # callbacks the caller doesn't need; empty no-op overrides are idiomatic there.
56+ ignoreOverridden: true
57+
58+exceptions:
59+ active: true
60+ TooGenericExceptionCaught:
61+ active: false # runCatching { } is idiomatic in this codebase
62+
63+naming:
64+ active: true
65+ FunctionNaming:
66+ active: false # @Composable functions use PascalCase
67+ TopLevelPropertyNaming:
68+ active: false
flake.locknew file+61
@@ -0,0 +1,61 @@
1+{
2+ "nodes": {
3+ "flake-utils": {
4+ "inputs": {
5+ "systems": "systems"
6+ },
7+ "locked": {
8+ "lastModified": 1731533236,
9+ "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
10+ "owner": "numtide",
11+ "repo": "flake-utils",
12+ "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
13+ "type": "github"
14+ },
15+ "original": {
16+ "owner": "numtide",
17+ "repo": "flake-utils",
18+ "type": "github"
19+ }
20+ },
21+ "nixpkgs": {
22+ "locked": {
23+ "lastModified": 1783224372,
24+ "narHash": "sha256-8i/87eeoqiGE4yOTjwSA3Eh/ziJRQEmd/unYU+K27sk=",
25+ "owner": "NixOS",
26+ "repo": "nixpkgs",
27+ "rev": "d407951447dcd00442e97087bf374aad70c04cea",
28+ "type": "github"
29+ },
30+ "original": {
31+ "owner": "NixOS",
32+ "ref": "nixos-unstable",
33+ "repo": "nixpkgs",
34+ "type": "github"
35+ }
36+ },
37+ "root": {
38+ "inputs": {
39+ "flake-utils": "flake-utils",
40+ "nixpkgs": "nixpkgs"
41+ }
42+ },
43+ "systems": {
44+ "locked": {
45+ "lastModified": 1681028828,
46+ "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
47+ "owner": "nix-systems",
48+ "repo": "default",
49+ "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
50+ "type": "github"
51+ },
52+ "original": {
53+ "owner": "nix-systems",
54+ "repo": "default",
55+ "type": "github"
56+ }
57+ }
58+ },
59+ "root": "root",
60+ "version": 7
61+}
flake.nixnew file+71
@@ -0,0 +1,71 @@
1+{
2+ description = "Dev environment for crush-android (Kotlin / Jetpack Compose client for the crush server API)";
3+
4+ inputs = {
5+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
6+ flake-utils.url = "github:numtide/flake-utils";
7+ };
8+
9+ outputs = { self, nixpkgs, flake-utils }:
10+ flake-utils.lib.eachDefaultSystem (system:
11+ let
12+ # Versions kept in sync with the Gradle project (compileSdk / build-tools).
13+ platformVersion = "37";
14+ buildToolsVersion = "37.0.0";
15+
16+ pkgs = import nixpkgs {
17+ inherit system;
18+ config = {
19+ allowUnfree = true; # Android SDK is unfree
20+ android_sdk.accept_license = true;
21+ };
22+ };
23+
24+ android = pkgs.androidenv.composeAndroidPackages {
25+ platformVersions = [ platformVersion ];
26+ buildToolsVersions = [ buildToolsVersion ];
27+ cmdLineToolsVersion = "latest";
28+
29+ # Flip these on if the host has /dev/kvm and you want an AVD.
30+ includeEmulator = false;
31+ includeSystemImages = false;
32+ systemImageTypes = [ "google_apis" ];
33+ abiVersions = [ "x86_64" ];
34+
35+ includeNDK = false;
36+ };
37+
38+ sdk = android.androidsdk;
39+ sdkRoot = "${sdk}/libexec/android-sdk";
40+
41+ # Gradle's bundled aapt2 binary is dynamically linked against a loader
42+ # path that does not exist on NixOS, so it segfaults. Point Gradle and
43+ # Android Studio at the Nix-built aapt2 instead.
44+ aapt2Path = "${sdkRoot}/build-tools/${buildToolsVersion}/aapt2";
45+ in {
46+ devShells.default = pkgs.mkShell {
47+ buildInputs = [
48+ pkgs.jdk17
49+ pkgs.gradle
50+ sdk
51+ ];
52+
53+ ANDROID_HOME = sdkRoot;
54+ ANDROID_SDK_ROOT = sdkRoot;
55+ JAVA_HOME = "${pkgs.jdk17}";
56+
57+ # Override Gradle's bundled aapt2 (see note above).
58+ GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${aapt2Path}";
59+
60+ shellHook = ''
61+ echo "crush-android dev shell"
62+ echo " JDK: $(java -version 2>&1 | head -1)"
63+ echo " ANDROID_HOME: $ANDROID_HOME"
64+ echo " aapt2: ${aapt2Path}"
65+ echo ""
66+ echo "Build: ./gradlew assembleDebug"
67+ echo "Device: adb devices"
68+ '';
69+ };
70+ });
71+}
gradle.propertiesnew file+9
@@ -0,0 +1,9 @@
1+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
2+org.gradle.parallel=true
3+org.gradle.caching=true
4+
5+android.useAndroidX=true
6+android.nonTransitiveRClass=true
7+android.suppressUnsupportedCompileSdk=37
8+
9+kotlin.code.style=official
gradle/libs.versions.tomlnew file+55
@@ -0,0 +1,55 @@
1+[versions]
2+agp = "9.2.0"
3+kotlin = "2.4.0"
4+kotlinxSerialization = "1.9.0"
5+kotlinxCoroutines = "1.10.2"
6+composeBom = "2026.06.01"
7+activityCompose = "1.13.0"
8+lifecycle = "2.11.0"
9+navigationCompose = "2.9.8"
10+datastore = "1.2.1"
11+coreKtx = "1.19.0"
12+okhttp = "4.12.0"
13+junit = "4.13.2"
14+detekt = "1.23.8"
15+androidxTestExt = "1.3.0"
16+androidxTestRunner = "1.7.0"
17+androidxTestCore = "1.7.0"
18+espresso = "3.7.0"
19+
20+[libraries]
21+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
22+androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
23+androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
24+androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
25+androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
26+
27+compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
28+compose-ui = { group = "androidx.compose.ui", name = "ui" }
29+compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
30+compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
31+compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
32+compose-material3 = { group = "androidx.compose.material3", name = "material3" }
33+compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
34+
35+kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
36+kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
37+kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
38+
39+okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
40+okhttp-sse = { group = "com.squareup.okhttp3", name = "okhttp-sse", version.ref = "okhttp" }
41+okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
42+
43+androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
44+
45+junit = { group = "junit", name = "junit", version.ref = "junit" }
46+androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestExt" }
47+androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" }
48+androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" }
49+androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" }
50+
51+[plugins]
52+android-application = { id = "com.android.application", version.ref = "agp" }
53+kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
54+kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
55+detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
gradle/wrapper/gradle-wrapper.jarnew file

Binary file not shown.

gradle/wrapper/gradle-wrapper.propertiesnew file+7
@@ -0,0 +1,7 @@
1+distributionBase=GRADLE_USER_HOME
2+distributionPath=wrapper/dists
3+distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
4+networkTimeout=10000
5+validateDistributionUrl=true
6+zipStoreBase=GRADLE_USER_HOME
7+zipStorePath=wrapper/dists
gradlewnew file+252
@@ -0,0 +1,252 @@
1+#!/bin/sh
2+
3+#
4+# Copyright © 2015-2021 the original authors.
5+#
6+# Licensed under the Apache License, Version 2.0 (the "License");
7+# you may not use this file except in compliance with the License.
8+# You may obtain a copy of the License at
9+#
10+# https://www.apache.org/licenses/LICENSE-2.0
11+#
12+# Unless required by applicable law or agreed to in writing, software
13+# distributed under the License is distributed on an "AS IS" BASIS,
14+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+# See the License for the specific language governing permissions and
16+# limitations under the License.
17+#
18+# SPDX-License-Identifier: Apache-2.0
19+#
20+
21+##############################################################################
22+#
23+# Gradle start up script for POSIX generated by Gradle.
24+#
25+# Important for running:
26+#
27+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
28+# noncompliant, but you have some other compliant shell such as ksh or
29+# bash, then to run this script, type that shell name before the whole
30+# command line, like:
31+#
32+# ksh Gradle
33+#
34+# Busybox and similar reduced shells will NOT work, because this script
35+# requires all of these POSIX shell features:
36+# * functions;
37+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
38+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
39+# * compound commands having a testable exit status, especially «case»;
40+# * various built-in commands including «command», «set», and «ulimit».
41+#
42+# Important for patching:
43+#
44+# (2) This script targets any POSIX shell, so it avoids extensions provided
45+# by Bash, Ksh, etc; in particular arrays are avoided.
46+#
47+# The "traditional" practice of packing multiple parameters into a
48+# space-separated string is a well documented source of bugs and security
49+# problems, so this is (mostly) avoided, by progressively accumulating
50+# options in "$@", and eventually passing that to Java.
51+#
52+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
53+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
54+# see the in-line comments for details.
55+#
56+# There are tweaks for specific operating systems such as AIX, CygWin,
57+# Darwin, MinGW, and NonStop.
58+#
59+# (3) This script is generated from the Groovy template
60+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
61+# within the Gradle project.
62+#
63+# You can find Gradle at https://github.com/gradle/gradle/.
64+#
65+##############################################################################
66+
67+# Attempt to set APP_HOME
68+
69+# Resolve links: $0 may be a link
70+app_path=$0
71+
72+# Need this for daisy-chained symlinks.
73+while
74+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
75+ [ -h "$app_path" ]
76+do
77+ ls=$( ls -ld "$app_path" )
78+ link=${ls#*' -> '}
79+ case $link in #(
80+ /*) app_path=$link ;; #(
81+ *) app_path=$APP_HOME$link ;;
82+ esac
83+done
84+
85+# This is normally unused
86+# shellcheck disable=SC2034
87+APP_BASE_NAME=${0##*/}
88+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
89+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
90+' "$PWD" ) || exit
91+
92+# Use the maximum available, or set MAX_FD != -1 to use that value.
93+MAX_FD=maximum
94+
95+warn () {
96+ echo "$*"
97+} >&2
98+
99+die () {
100+ echo
101+ echo "$*"
102+ echo
103+ exit 1
104+} >&2
105+
106+# OS specific support (must be 'true' or 'false').
107+cygwin=false
108+msys=false
109+darwin=false
110+nonstop=false
111+case "$( uname )" in #(
112+ CYGWIN* ) cygwin=true ;; #(
113+ Darwin* ) darwin=true ;; #(
114+ MSYS* | MINGW* ) msys=true ;; #(
115+ NONSTOP* ) nonstop=true ;;
116+esac
117+
118+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
119+
120+
121+# Determine the Java command to use to start the JVM.
122+if [ -n "$JAVA_HOME" ] ; then
123+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
124+ # IBM's JDK on AIX uses strange locations for the executables
125+ JAVACMD=$JAVA_HOME/jre/sh/java
126+ else
127+ JAVACMD=$JAVA_HOME/bin/java
128+ fi
129+ if [ ! -x "$JAVACMD" ] ; then
130+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
131+
132+Please set the JAVA_HOME variable in your environment to match the
133+location of your Java installation."
134+ fi
135+else
136+ JAVACMD=java
137+ if ! command -v java >/dev/null 2>&1
138+ then
139+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
140+
141+Please set the JAVA_HOME variable in your environment to match the
142+location of your Java installation."
143+ fi
144+fi
145+
146+# Increase the maximum file descriptors if we can.
147+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
148+ case $MAX_FD in #(
149+ max*)
150+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
151+ # shellcheck disable=SC2039,SC3045
152+ MAX_FD=$( ulimit -H -n ) ||
153+ warn "Could not query maximum file descriptor limit"
154+ esac
155+ case $MAX_FD in #(
156+ '' | soft) :;; #(
157+ *)
158+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
159+ # shellcheck disable=SC2039,SC3045
160+ ulimit -n "$MAX_FD" ||
161+ warn "Could not set maximum file descriptor limit to $MAX_FD"
162+ esac
163+fi
164+
165+# Collect all arguments for the java command, stacking in reverse order:
166+# * args from the command line
167+# * the main class name
168+# * -classpath
169+# * -D...appname settings
170+# * --module-path (only if needed)
171+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
172+
173+# For Cygwin or MSYS, switch paths to Windows format before running java
174+if "$cygwin" || "$msys" ; then
175+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
176+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
177+
178+ JAVACMD=$( cygpath --unix "$JAVACMD" )
179+
180+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
181+ for arg do
182+ if
183+ case $arg in #(
184+ -*) false ;; # don't mess with options #(
185+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
186+ [ -e "$t" ] ;; #(
187+ *) false ;;
188+ esac
189+ then
190+ arg=$( cygpath --path --ignore --mixed "$arg" )
191+ fi
192+ # Roll the args list around exactly as many times as the number of
193+ # args, so each arg winds up back in the position where it started, but
194+ # possibly modified.
195+ #
196+ # NB: a `for` loop captures its iteration list before it begins, so
197+ # changing the positional parameters here affects neither the number of
198+ # iterations, nor the values presented in `arg`.
199+ shift # remove old arg
200+ set -- "$@" "$arg" # push replacement arg
201+ done
202+fi
203+
204+
205+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
206+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
207+
208+# Collect all arguments for the java command:
209+# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
210+# and any embedded shellness will be escaped.
211+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
212+# treated as '${Hostname}' itself on the command line.
213+
214+set -- \
215+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
216+ -classpath "$CLASSPATH" \
217+ org.gradle.wrapper.GradleWrapperMain \
218+ "$@"
219+
220+# Stop when "xargs" is not available.
221+if ! command -v xargs >/dev/null 2>&1
222+then
223+ die "xargs is not available"
224+fi
225+
226+# Use "xargs" to parse quoted args.
227+#
228+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
229+#
230+# In Bash we could simply go:
231+#
232+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
233+# set -- "${ARGS[@]}" "$@"
234+#
235+# but POSIX shell has neither arrays nor command substitution, so instead we
236+# post-process each arg (as a line of input to sed) to backslash-escape any
237+# character that might be a shell metacharacter, then use eval to reverse
238+# that process (while maintaining the separation between arguments), and wrap
239+# the whole thing up as a single "set" statement.
240+#
241+# This will of course break if any of these variables contains a newline or
242+# an unmatched quote.
243+#
244+
245+eval "set -- $(
246+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
247+ xargs -n1 |
248+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
249+ tr '\n' ' '
250+ )" '"$@"'
251+
252+exec "$JAVACMD" "$@"
gradlew.batnew file+94
@@ -0,0 +1,94 @@
1+@rem
2+@rem Copyright 2015 the original author or authors.
3+@rem
4+@rem Licensed under the Apache License, Version 2.0 (the "License");
5+@rem you may not use this file except in compliance with the License.
6+@rem You may obtain a copy of the License at
7+@rem
8+@rem https://www.apache.org/licenses/LICENSE-2.0
9+@rem
10+@rem Unless required by applicable law or agreed to in writing, software
11+@rem distributed under the License is distributed on an "AS IS" BASIS,
12+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+@rem See the License for the specific language governing permissions and
14+@rem limitations under the License.
15+@rem
16+@rem SPDX-License-Identifier: Apache-2.0
17+@rem
18+
19+@if "%DEBUG%"=="" @echo off
20+@rem ##########################################################################
21+@rem
22+@rem Gradle startup script for Windows
23+@rem
24+@rem ##########################################################################
25+
26+@rem Set local scope for the variables with windows NT shell
27+if "%OS%"=="Windows_NT" setlocal
28+
29+set DIRNAME=%~dp0
30+if "%DIRNAME%"=="" set DIRNAME=.
31+@rem This is normally unused
32+set APP_BASE_NAME=%~n0
33+set APP_HOME=%DIRNAME%
34+
35+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
36+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
37+
38+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
39+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
40+
41+@rem Find java.exe
42+if defined JAVA_HOME goto findJavaFromJavaHome
43+
44+set JAVA_EXE=java.exe
45+%JAVA_EXE% -version >NUL 2>&1
46+if %ERRORLEVEL% equ 0 goto execute
47+
48+echo. 1>&2
49+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
50+echo. 1>&2
51+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
52+echo location of your Java installation. 1>&2
53+
54+goto fail
55+
56+:findJavaFromJavaHome
57+set JAVA_HOME=%JAVA_HOME:"=%
58+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
59+
60+if exist "%JAVA_EXE%" goto execute
61+
62+echo. 1>&2
63+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
64+echo. 1>&2
65+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
66+echo location of your Java installation. 1>&2
67+
68+goto fail
69+
70+:execute
71+@rem Setup the command line
72+
73+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
74+
75+
76+@rem Execute Gradle
77+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
78+
79+:end
80+@rem End local scope for the variables with windows NT shell
81+if %ERRORLEVEL% equ 0 goto mainEnd
82+
83+:fail
84+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
85+rem the _cmd.exe /c_ return code!
86+set EXIT_CODE=%ERRORLEVEL%
87+if %EXIT_CODE% equ 0 set EXIT_CODE=1
88+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
89+exit /b %EXIT_CODE%
90+
91+:mainEnd
92+if "%OS%"=="Windows_NT" endlocal
93+
94+:omega
renovate.jsonnew file+32
@@ -0,0 +1,32 @@
1+{
2+ "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3+ "extends": ["config:recommended"],
4+ "labels": ["dependencies"],
5+ "dependencyDashboard": true,
6+ "prConcurrentLimit": 5,
7+ "commitMessagePrefix": "deps:",
8+ "schedule": ["before 6am on Monday"],
9+ "lockFileMaintenance": {
10+ "enabled": true,
11+ "schedule": ["before 6am on Monday"]
12+ },
13+ "packageRules": [
14+ {
15+ "groupName": "Kotlin and Compose compiler",
16+ "matchPackagePatterns": ["^org.jetbrains.kotlin", "^com.android.tools.build"]
17+ },
18+ {
19+ "groupName": "Jetpack Compose",
20+ "matchPackagePatterns": ["^androidx.compose"]
21+ },
22+ {
23+ "groupName": "OkHttp",
24+ "matchPackagePatterns": ["^com.squareup.okhttp3"]
25+ },
26+ {
27+ "description": "Pin AndroidX to the set the app targets (compileSdk 37); review major bumps manually.",
28+ "matchPackagePatterns": ["^androidx."],
29+ "major": { "enabled": false }
30+ }
31+ ]
32+}
scripts/run-device.shnew file+106
@@ -0,0 +1,106 @@
1+#!/usr/bin/env bash
2+#
3+# Build the debug APK, then install and launch the app on a physically
4+# connected device (USB or wireless). Unlike scripts/run.sh, this never boots
5+# an emulator — it targets real hardware only.
6+#
7+# Usage:
8+# scripts/run-device.sh # build + install + launch on the connected device
9+# scripts/run-device.sh -s <serial> # target a specific device (see `adb devices`)
10+# scripts/run-device.sh -c 192.168.1.5 # adb connect over Wi-Fi first (host or host:port)
11+# scripts/run-device.sh --no-build # skip the Gradle build, just install + launch
12+# scripts/run-device.sh --release # build/install the release variant
13+#
14+set -euo pipefail
15+
16+APP_ID="soy.iko.crush"
17+ACTIVITY=".MainActivity"
18+VARIANT="debug"
19+DO_BUILD=1
20+SERIAL=""
21+CONNECT=""
22+
23+while [[ $# -gt 0 ]]; do
24+ case "$1" in
25+ -s|--serial) SERIAL="$2"; shift 2 ;;
26+ -c|--connect) CONNECT="$2"; shift 2 ;;
27+ --no-build) DO_BUILD=0; shift ;;
28+ --release) VARIANT="release"; shift ;;
29+ -h|--help)
30+ awk 'NR>1 && /^#/ {sub(/^# ?/,""); print; next} NR>1 {exit}' "$0"
31+ exit 0 ;;
32+ *) echo "Unknown argument: $1" >&2; exit 2 ;;
33+ esac
34+done
35+
36+cd "$(dirname "$0")/.."
37+
38+# --- locate adb -------------------------------------------------------------
39+SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
40+find_tool() {
41+ local name="$1"
42+ if command -v "$name" >/dev/null 2>&1; then command -v "$name"; return; fi
43+ for d in "$SDK/platform-tools" "$SDK/cmdline-tools/latest/bin"; do
44+ [[ -x "$d/$name" ]] && { echo "$d/$name"; return; }
45+ done
46+ echo ""
47+}
48+ADB="$(find_tool adb)"
49+[[ -z "$ADB" ]] && { echo "error: 'adb' not found; set ANDROID_HOME / ANDROID_SDK_ROOT" >&2; exit 1; }
50+
51+# --- optional wireless connect ---------------------------------------------
52+if [[ -n "$CONNECT" ]]; then
53+ [[ "$CONNECT" == *:* ]] || CONNECT="$CONNECT:5555"
54+ echo ">> Connecting to $CONNECT..."
55+ "$ADB" connect "$CONNECT"
56+ SERIAL="${SERIAL:-$CONNECT}"
57+fi
58+
59+# --- pick a physical device (exclude emulators) -----------------------------
60+# Lists serials of connected, ready devices whose serial is NOT emulator-*.
61+physical_devices() {
62+ "$ADB" devices | awk 'NR>1 && $2=="device" && $1 !~ /^emulator-/ {print $1}'
63+}
64+
65+if [[ -z "$SERIAL" ]]; then
66+ DEVICES=()
67+ while IFS= read -r line; do [[ -n "$line" ]] && DEVICES+=("$line"); done < <(physical_devices)
68+ case "${#DEVICES[@]}" in
69+ 0) echo "error: no physical device connected (enable USB debugging, or use -c for Wi-Fi)" >&2; exit 1 ;;
70+ 1) SERIAL="${DEVICES[0]}" ;;
71+ *) echo "error: multiple devices connected; choose one with -s <serial>:" >&2
72+ printf ' %s\n' "${DEVICES[@]}" >&2
73+ exit 1 ;;
74+ esac
75+fi
76+echo ">> Target device: $SERIAL"
77+
78+# Confirm the chosen device is actually ready.
79+if ! "$ADB" -s "$SERIAL" get-state 2>/dev/null | grep -q '^device$'; then
80+ echo "error: device '$SERIAL' is not ready (unauthorized or offline?)" >&2
81+ exit 1
82+fi
83+
84+# --- build ------------------------------------------------------------------
85+if [[ "$VARIANT" == "release" ]]; then
86+ GRADLE_TASK="assembleRelease"
87+ APK="app/build/outputs/apk/release/app-release.apk"
88+else
89+ GRADLE_TASK="assembleDebug"
90+ APK="app/build/outputs/apk/debug/app-debug.apk"
91+fi
92+
93+if [[ "$DO_BUILD" -eq 1 ]]; then
94+ echo ">> Building ($GRADLE_TASK)..."
95+ ./gradlew "$GRADLE_TASK"
96+fi
97+[[ -f "$APK" ]] || { echo "error: APK not found at $APK (did the build run?)" >&2; exit 1; }
98+
99+# --- install + launch -------------------------------------------------------
100+echo ">> Installing $APK on $SERIAL..."
101+"$ADB" -s "$SERIAL" install -r "$APK"
102+
103+echo ">> Launching $APP_ID/$ACTIVITY..."
104+"$ADB" -s "$SERIAL" shell am start -n "$APP_ID/$ACTIVITY"
105+
106+echo ">> Done."
scripts/run.shnew file+95
@@ -0,0 +1,95 @@
1+#!/usr/bin/env bash
2+#
3+# Build the debug APK, make sure an emulator is running, then install and launch
4+# the app on it.
5+#
6+# Usage:
7+# scripts/run.sh # build + installation + launch on an emulator
8+# scripts/run.sh -a Pixel_7 # boot a specific AVD if none is running
9+# scripts/run.sh --no-build # skip the Gradle build, just install + launch
10+# scripts/run.sh --release # build/install the release variant
11+#
12+set -euo pipefail
13+
14+APP_ID="soy.iko.crush"
15+ACTIVITY=".MainActivity"
16+VARIANT="debug"
17+DO_BUILD=1
18+AVD=""
19+
20+while [[ $# -gt 0 ]]; do
21+ case "$1" in
22+ -a|--avd) AVD="$2"; shift 2 ;;
23+ --no-build) DO_BUILD=0; shift ;;
24+ --release) VARIANT="release"; shift ;;
25+ -h|--help)
26+ awk 'NR>1 && /^#/ {sub(/^# ?/,""); print; next} NR>1 {exit}' "$0"
27+ exit 0 ;;
28+ *) echo "Unknown argument: $1" >&2; exit 2 ;;
29+ esac
30+done
31+
32+cd "$(dirname "$0")/.."
33+
34+# --- locate the Android SDK tools ------------------------------------------
35+SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
36+find_tool() {
37+ local name="$1"
38+ if command -v "$name" >/dev/null 2>&1; then command -v "$name"; return; fi
39+ for d in "$SDK/platform-tools" "$SDK/emulator" "$SDK/cmdline-tools/latest/bin"; do
40+ [[ -x "$d/$name" ]] && { echo "$d/$name"; return; }
41+ done
42+ echo ""
43+}
44+ADB="$(find_tool adb)"
45+EMULATOR="$(find_tool emulator)"
46+[[ -z "$ADB" ]] && { echo "error: 'adb' not found; set ANDROID_HOME / ANDROID_SDK_ROOT" >&2; exit 1; }
47+
48+# --- ensure an emulator/device is connected --------------------------------
49+device_ready() {
50+ "$ADB" devices | awk 'NR>1 && $2=="device" {found=1} END {exit found?0:1}'
51+}
52+
53+if ! device_ready; then
54+ echo ">> No running device/emulator; booting one..."
55+ [[ -z "$EMULATOR" ]] && { echo "error: 'emulator' not found; start a device manually" >&2; exit 1; }
56+
57+ if [[ -z "$AVD" ]]; then
58+ AVD="$("$EMULATOR" -list-avds | head -n1)"
59+ [[ -z "$AVD" ]] && { echo "error: no AVDs found; create one in Android Studio or pass -a <name>" >&2; exit 1; }
60+ fi
61+ echo ">> Starting AVD: $AVD"
62+ "$EMULATOR" -avd "$AVD" -netdelay none -netspeed full >/dev/null 2>&1 &
63+
64+ echo ">> Waiting for device..."
65+ "$ADB" wait-for-device
66+ # Wait until the OS has finished booting.
67+ until [[ "$("$ADB" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" == "1" ]]; do
68+ sleep 2
69+ done
70+ echo ">> Emulator booted."
71+fi
72+
73+# --- build ------------------------------------------------------------------
74+if [[ "$VARIANT" == "release" ]]; then
75+ GRADLE_TASK="assembleRelease"
76+ APK="app/build/outputs/apk/release/app-release.apk"
77+else
78+ GRADLE_TASK="assembleDebug"
79+ APK="app/build/outputs/apk/debug/app-debug.apk"
80+fi
81+
82+if [[ "$DO_BUILD" -eq 1 ]]; then
83+ echo ">> Building ($GRADLE_TASK)..."
84+ ./gradlew "$GRADLE_TASK"
85+fi
86+[[ -f "$APK" ]] || { echo "error: APK not found at $APK (did the build run?)" >&2; exit 1; }
87+
88+# --- install + launch -------------------------------------------------------
89+echo ">> Installing $APK..."
90+"$ADB" install -r "$APK"
91+
92+echo ">> Launching $APP_ID/$ACTIVITY..."
93+"$ADB" shell am start -n "$APP_ID/$ACTIVITY"
94+
95+echo ">> Done."
settings.gradle.ktsnew file+24
@@ -0,0 +1,24 @@
1+pluginManagement {
2+ repositories {
3+ google {
4+ content {
5+ includeGroupByRegex("com\\.android.*")
6+ includeGroupByRegex("com\\.google.*")
7+ includeGroupByRegex("androidx.*")
8+ }
9+ }
10+ mavenCentral()
11+ gradlePluginPortal()
12+ }
13+}
14+
15+dependencyResolutionManagement {
16+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
17+ repositories {
18+ google()
19+ mavenCentral()
20+ }
21+}
22+
23+rootProject.name = "crush-android"
24+include(":app")