Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/android/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.gradle/
.kotlin/
local.properties
/app/build/
/build/
*.iml
.idea/
64 changes: 64 additions & 0 deletions apps/android/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Zeron for Android

A Jetpack Compose viewport onto the zeron mesh, built on the same Rust mobile
core as the iOS app (`crates/mobile`). **Rust decides what to paint and
where; Kotlin paints, scrolls and handles gestures.** The transcript's text
measurement, markdown layout, prefix-sum virtualization and display lists are
the exact code iOS runs — see [`docs/mobile-rewrite.md`](../../docs/mobile-rewrite.md).

The UI is Material 3 Expressive (`MaterialExpressiveTheme`, expressive motion,
flexible app bars, shape-morphing loading indicators, connected button groups,
segmented lists) themed with Zeron's own palette and Geist type.

## Build & run

Requires JDK 21, the Android SDK (platform 37, NDK 29.0.14206865), and Rust
with `cargo install cargo-ndk` and
`rustup target add aarch64-linux-android x86_64-linux-android`.

```sh
cd apps/android
./gradlew :app:installDebug
```

The `buildCore` task runs `scripts/android/build-core.sh`, which builds
`crates/mobile` for Android (`jniLibs`) and generates its Kotlin bindings into
`target/android-core/`. `-PzeronSkipCore` reuses the last build while
iterating on Kotlin; `ZERON_ANDROID_ABIS=arm64-v8a` builds one ABI.
`scripts/android/gen-icons.sh` rasterizes the shared tool/file SVG icons
(needs `rsvg-convert`). The Geist fonts are read straight from the iOS app's
`Fonts/` folder, so both platforms measure and draw the same bytes.

## Layout

```
core/ AppModel (owns CoreClient, republishes snapshots as flows),
CredentialStore, Fonts + AndroidMeasurer (Minikin fallback
measurement for glyphs Geist lacks)
design/ ZeronTheme (Material 3 Expressive), transcript palette
transcript/ TranscriptState (layout engine + viewport: anchoring, follow the
tail), Transcript (virtualized rows over LayoutFrame), RowModel
(canvas painter for Rust display lists, streaming veil, fades),
Widgets (copy, disclosures, tool rail, shimmer, images…)
ui/ Sign-in, sessions, session + composer, new session, search,
settings
```

## Launch extras

Mirrors the iOS launch arguments:

```sh
adb shell am start -n sh.zeron.android/.MainActivity \
--ez demo true --es route chat:chat-veil
```

| Extra | Effect |
| --- | --- |
| `--ez demo true` | Offline demo workspace (Rust `DemoHost`) |
| `--ez fast true` / `--ez longreply true` | Demo stream speed / reply length |
| `--ez big true` / `--ez huge true` | Demo transcripts with 120 / 600 turns |
| `--es route chat:<id>` / `new` / `search` / `settings` | Open a screen at launch |
| `--ez signedout true` | Clear stored credentials |
| `--es wallpaper <path>` / `none` | Set (or clear) the wallpaper from a file the app can read, e.g. `adb push art.jpg /data/local/tmp/ && adb shell run-as sh.zeron.android cp /data/local/tmp/art.jpg files/` then `--es wallpaper /data/user/0/sh.zeron.android/files/art.jpg` |
| `--es wallpaper-effect <none\|dither\|ascii\|halftone\|scanlines>` | Wallpaper effect |
108 changes: 108 additions & 0 deletions apps/android/app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.compose.compiler)
}

// The Rust mobile core (crates/mobile) — the library the iOS app links —
// built for Android with its generated Kotlin bindings. `-PzeronSkipCore`
// reuses the last build while iterating on Kotlin only.
val NDK_VERSION = "29.0.14206865"
val repoRoot = rootProject.projectDir.resolve("../..").canonicalFile
val coreOut = repoRoot.resolve("target/android-core")
val iconsOut = layout.buildDirectory.dir("generated/zeron-icons")
val skipCore = providers.gradleProperty("zeronSkipCore").isPresent

val buildCore by tasks.registering(Exec::class) {
description = "Builds crates/mobile for Android and generates Kotlin bindings."
val ndk = System.getenv("ANDROID_NDK_HOME")
?: androidComponents.sdkComponents.sdkDirectory.get().dir("ndk/$NDK_VERSION").asFile.path
val lib = coreOut.resolve("jniLibs/arm64-v8a/libzeron_mobile.so")
val skip = skipCore
workingDir = repoRoot
commandLine("bash", "scripts/android/build-core.sh", coreOut.path)
environment("ANDROID_NDK_HOME", ndk)
onlyIf { !skip || !lib.exists() }
}

// Tool and file icons: the iOS asset catalog's SVGs, rasterized.
val genIcons by tasks.registering(Exec::class) {
description = "Rasterizes the shared transcript icons."
val out = iconsOut.get().asFile
inputs.dir(repoRoot.resolve("apps/ios/Zeron/Assets.xcassets"))
inputs.dir(repoRoot.resolve("crates/ui/assets/icons"))
inputs.file(repoRoot.resolve("scripts/android/svg2vd.py"))
outputs.dir(out)
commandLine("bash", repoRoot.resolve("scripts/android/gen-icons.sh").path, out.resolve("assets/icons").path, out.resolve("res").path)
}

android {
namespace = "sh.zeron.android"
compileSdk = 37
ndkVersion = NDK_VERSION

defaultConfig {
applicationId = "sh.zeron.android"
minSdk = 29
targetSdk = 37
versionCode = 1
versionName = "0.2.97"
ndk { abiFilters += listOf("arm64-v8a", "x86_64") }
}

buildTypes {
release {
isMinifyEnabled = false
signingConfig = signingConfigs.getByName("debug")
}
}

compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}

buildFeatures { compose = true }

sourceSets["main"].apply {
kotlin.directories.add(coreOut.resolve("kotlin").path)
jniLibs.directories.add(coreOut.resolve("jniLibs").path)
// The exact font bytes the Rust layout engine measures.
assets.directories.add(repoRoot.resolve("apps/ios/Zeron/Fonts").path)
assets.directories.add(iconsOut.get().asFile.resolve("assets").path)
res.directories.add(iconsOut.get().asFile.resolve("res").path)
}

packaging { jniLibs { useLegacyPackaging = false } }
}

tasks.named("preBuild") { dependsOn(buildCore, genIcons) }

dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.browser)
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.ui.graphics)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.foundation)
implementation(libs.compose.material3)
implementation(libs.compose.material.icons)
implementation(libs.kotlinx.coroutines.android)
implementation("${libs.jna.get()}@aar")
debugImplementation(libs.compose.ui.tooling)
}

kotlin {
compilerOptions {
optIn.addAll(
"androidx.compose.material3.ExperimentalMaterial3Api",
"androidx.compose.material3.ExperimentalMaterial3ExpressiveApi",
)
}
}
34 changes: 34 additions & 0 deletions apps/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

<application
android:name=".ZeronApplication"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:enableOnBackInvokedCallback="true"
android:theme="@style/Theme.Zeron.Starting">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- WorkOS sign-in returns to zeron://callback (shared with iOS). -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="zeron" android:host="callback" />
</intent-filter>
</activity>
</application>
</manifest>
59 changes: 59 additions & 0 deletions apps/android/app/src/main/java/sh/zeron/android/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package sh.zeron.android

import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import sh.zeron.android.core.LaunchOptions
import sh.zeron.android.ui.ZeronRoot

/**
* Launch extras mirror the iOS launch arguments, e.g.
* `adb shell am start -n sh.zeron.android/.MainActivity --ez demo true --es route chat:chat-veil`
* (`--es wallpaper <path>` sets the wallpaper from a file the app can read).
*/
class MainActivity : ComponentActivity() {
private val model get() = (application as ZeronApplication).model

override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
enableEdgeToEdge()
super.onCreate(savedInstanceState)
val extras = intent.extras
model.boot(
LaunchOptions(
demo = extras?.getBoolean("demo") == true,
fast = extras?.getBoolean("fast") == true,
longReply = extras?.getBoolean("longreply") == true,
big = extras?.getBoolean("big") == true,
huge = extras?.getBoolean("huge") == true,
noProjects = extras?.getBoolean("noprojects") == true,
signedOut = extras?.getBoolean("signedout") == true,
route = extras?.getString("route"),
wallpaper = extras?.getString("wallpaper"),
wallpaperEffect = extras?.getString("wallpaper-effect"),
),
)
handleCallback(intent)
ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) = model.onForeground()
override fun onStop(owner: LifecycleOwner) = model.onBackground()
})
setContent { ZeronRoot(model) }
}

override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleCallback(intent)
}

private fun handleCallback(intent: Intent?) {
val data = intent?.data ?: return
if (data.scheme == "zeron") model.handleCallback(data.toString())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package sh.zeron.android

import android.app.Application
import sh.zeron.android.core.AppModel
import sh.zeron.android.core.Fonts
import sh.zeron.android.design.LocalAssets

class ZeronApplication : Application() {
lateinit var model: AppModel
private set

override fun onCreate() {
super.onCreate()
LocalAssets.manager = assets
Fonts.init(assets)
model = AppModel(this)
}
}
Loading
Loading