CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 11s
Android / Core and FFI clippy and tests (push) Successful in 28s
CI & Build / integration (push) Successful in 1m21s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 6m10s
Android / Build the server image (push) Successful in 1s
The card draws the linked page's og:image in a strip down its left edge, cropped to the card's height: 96dp full, 48dp compact, matching the web's w-24 and w-12. The image is remote, so the phone fetches it from whatever host the link points at, exactly as a browser does for the web card. The operator chose that parity (Scribe #3307). The image is Coil 3's AsyncImage, with OkHttp as its fetcher. Coil's disk cache means a card scrolled past twice costs one download. When there is no image, or it fails to load, nothing is drawn rather than an empty box, and the card is the text card it was before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
300 lines
11 KiB
Kotlin
300 lines
11 KiB
Kotlin
import java.io.File
|
|
import javax.inject.Inject
|
|
|
|
plugins {
|
|
alias(libs.plugins.android.application)
|
|
alias(libs.plugins.compose.compiler)
|
|
}
|
|
|
|
// The Cargo workspace root — two levels up from android/app.
|
|
val workspaceRoot: Directory = layout.projectDirectory.dir("../..")
|
|
|
|
// The ABIs a release APK carries. arm64 is essentially every real device; armv7
|
|
// covers older 32-bit hardware; the two x86 targets are what emulators run on, and
|
|
// dropping them would make the app untestable on a desktop emulator (the reason
|
|
// x86_64 was added to the old Tauri lane in task 1864).
|
|
val androidAbis = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
|
|
|
|
/**
|
|
* Cross-compile `inkwell-ffi` for each Android ABI and drop the resulting
|
|
* `.so` into jniLibs, where AGP packages it.
|
|
*
|
|
* `ExecOperations` injected rather than `project.exec`: the latter was REMOVED in
|
|
* Gradle 9, and reaching for `project` at execution time is also what breaks the
|
|
* configuration cache this build has enabled.
|
|
*/
|
|
abstract class CargoNdkBuild : DefaultTask() {
|
|
@get:Inject
|
|
abstract val execOps: ExecOperations
|
|
|
|
@get:InputFiles
|
|
abstract val rustSources: ConfigurableFileCollection
|
|
|
|
@get:Input
|
|
abstract val abis: ListProperty<String>
|
|
|
|
@get:Input
|
|
abstract val cargoProfile: Property<String>
|
|
|
|
@get:Internal
|
|
abstract val workspaceDir: DirectoryProperty
|
|
|
|
@get:OutputDirectory
|
|
abstract val jniLibsDir: DirectoryProperty
|
|
|
|
@TaskAction
|
|
fun build() {
|
|
val args = mutableListOf("ndk")
|
|
abis.get().forEach { abi ->
|
|
args += "-t"
|
|
args += abi
|
|
}
|
|
args += listOf("-o", jniLibsDir.get().asFile.absolutePath, "build", "-p", "inkwell-ffi")
|
|
// --locked so an Android build cannot silently re-resolve the workspace
|
|
// lockfile the desktop lanes are gated on.
|
|
args += "--locked"
|
|
if (cargoProfile.get() == "release") args += "--release"
|
|
|
|
execOps.exec {
|
|
commandLine(listOf("cargo") + args)
|
|
workingDir = workspaceDir.get().asFile
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate the Kotlin bindings FROM the freshly built `.so`.
|
|
*
|
|
* `--library` mode reads uniffi's metadata straight out of the compiled artifact,
|
|
* so the bindings can never describe a different version of the Rust than the one
|
|
* being packaged — which is the failure the whole in-workspace generator setup
|
|
* exists to prevent.
|
|
*/
|
|
abstract class UniffiBindgen : DefaultTask() {
|
|
@get:Inject
|
|
abstract val execOps: ExecOperations
|
|
|
|
@get:InputFile
|
|
abstract val libraryFile: RegularFileProperty
|
|
|
|
@get:Internal
|
|
abstract val workspaceDir: DirectoryProperty
|
|
|
|
@get:OutputDirectory
|
|
abstract val outputDir: DirectoryProperty
|
|
|
|
@TaskAction
|
|
fun generate() {
|
|
val out = outputDir.get().asFile
|
|
out.deleteRecursively()
|
|
out.mkdirs()
|
|
execOps.exec {
|
|
commandLine(
|
|
"cargo",
|
|
"run",
|
|
"--locked",
|
|
"-p",
|
|
"inkwell-uniffi-bindgen",
|
|
"--",
|
|
"generate",
|
|
"--library",
|
|
libraryFile.get().asFile.absolutePath,
|
|
"--language",
|
|
"kotlin",
|
|
"--out-dir",
|
|
out.absolutePath,
|
|
)
|
|
workingDir = workspaceDir.get().asFile
|
|
}
|
|
}
|
|
}
|
|
|
|
// Only the Rust that actually affects the .so. Deliberately NOT the workspace
|
|
// directory: that would make Gradle hash target/, which is gigabytes.
|
|
val rustInputs =
|
|
files(
|
|
workspaceRoot.dir("core/src"),
|
|
workspaceRoot.dir("android/ffi/src"),
|
|
workspaceRoot.file("core/Cargo.toml"),
|
|
workspaceRoot.file("android/ffi/Cargo.toml"),
|
|
workspaceRoot.file("Cargo.toml"),
|
|
workspaceRoot.file("Cargo.lock"),
|
|
)
|
|
|
|
/**
|
|
* Which Cargo profile the `.so` is built with.
|
|
*
|
|
* A property rather than a debug/release task PAIR, deliberately. This runner has
|
|
* no working Gradle or Cargo cache (`reserveCache failed` on every run), so a cold
|
|
* cross-compile of four ABIs costs about four minutes — and a lane that both
|
|
* type-checks and packages would pay that twice if the two used different
|
|
* profiles. `android.yml` picks one profile and uses it for every Gradle call in
|
|
* the run.
|
|
*
|
|
* CI currently passes `debug` even for a release APK, which is not where this
|
|
* should end up: an unoptimised store and sync engine is a real difference on a
|
|
* phone, not a theoretical one. The blocker is that the workspace's release
|
|
* profile sets `strip = true`, which removes the symbols uniffi reads its
|
|
* interface metadata from — `generateUniffiBindings` then fails with "No UniFFI
|
|
* metadata found" (run 4077). Fixing it means either an Android-specific profile
|
|
* that keeps symbols or generating the bindings from a separate unstripped
|
|
* build, and neither is worth holding signed APKs up for. Scribe #2810.
|
|
*/
|
|
val rustProfile =
|
|
(project.findProperty("INKWELL_CARGO_PROFILE") as String?)?.takeIf { it.isNotBlank() }
|
|
?: "debug"
|
|
|
|
val jniLibsOut = layout.buildDirectory.dir("rustJniLibs")
|
|
val bindingsOut = layout.buildDirectory.dir("generated/uniffi")
|
|
|
|
val cargoNdk =
|
|
tasks.register<CargoNdkBuild>("cargoNdk") {
|
|
description = "Cross-compile inkwell-ffi for the Android ABIs."
|
|
rustSources.from(rustInputs)
|
|
abis.set(androidAbis)
|
|
cargoProfile.set(rustProfile)
|
|
workspaceDir.set(workspaceRoot)
|
|
jniLibsDir.set(jniLibsOut)
|
|
}
|
|
|
|
val generateBindings =
|
|
tasks.register<UniffiBindgen>("generateUniffiBindings") {
|
|
description = "Generate the Kotlin bindings from the compiled .so."
|
|
dependsOn(cargoNdk)
|
|
// arm64 is arbitrary — every ABI carries the same uniffi metadata, and
|
|
// reading one is cheaper than reading four.
|
|
libraryFile.set(jniLibsOut.map { it.file("arm64-v8a/libinkwell_ffi.so") })
|
|
workspaceDir.set(workspaceRoot)
|
|
outputDir.set(bindingsOut)
|
|
}
|
|
|
|
android {
|
|
namespace = "com.fabledsword.inkwell"
|
|
compileSdk = 36
|
|
|
|
defaultConfig {
|
|
applicationId = "com.fabledsword.inkwell"
|
|
// 26 (Android 8, 2017) matches Minstrel and clears the NDK's floor with
|
|
// room to spare.
|
|
minSdk = 26
|
|
targetSdk = 36
|
|
// Injected by CI from the git tag + commit count for a release; "dev"
|
|
// locally so the About screen reads honestly rather than claiming 1.0.
|
|
val nameOverride =
|
|
(project.findProperty("INKWELL_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() }
|
|
val codeOverride =
|
|
(project.findProperty("INKWELL_VERSION_CODE") as String?)?.toIntOrNull()
|
|
versionCode = codeOverride ?: 1
|
|
versionName = nameOverride ?: "dev"
|
|
|
|
// Package ONLY the ABIs we build for.
|
|
//
|
|
// Without this the APK also carries armeabi, mips and mips64 — dead
|
|
// architectures Android dropped years ago, which arrive because JNA's
|
|
// .aar still ships a libjnidispatch.so for each. They can never be
|
|
// loaded on any device this app supports, so they are pure payload.
|
|
ndk {
|
|
abiFilters += androidAbis
|
|
}
|
|
}
|
|
|
|
// The signing key reaches this build only through the environment: CI decodes
|
|
// it from a secret into a file and points ANDROID_KEYSTORE_FILE at that path.
|
|
// It is never in the repo and never in this file. Generated by the operator
|
|
// and never seen by an agent session, because an Android signing key cannot be
|
|
// rotated without the original — v3 lineage needs it — so a leaked or lost one
|
|
// means every install has to be removed and replaced by hand.
|
|
val keystoreFile = System.getenv("ANDROID_KEYSTORE_FILE")?.takeIf { it.isNotBlank() }
|
|
val keystorePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")?.takeIf { it.isNotBlank() }
|
|
|
|
signingConfigs {
|
|
if (keystoreFile != null && keystorePassword != null) {
|
|
create("release") {
|
|
storeFile = File(keystoreFile)
|
|
storePassword = keystorePassword
|
|
// Hardcoded, and NOT a secret: the alias is fixed for the life of
|
|
// this app and is written into the certificate every install
|
|
// already carries. Hiding it would buy nothing and stop this file
|
|
// describing its own signing setup. It still says `thoughtsync`
|
|
// after the rename to Inkwell, because it names the key inside the
|
|
// existing keystore, and renaming it would only stop that key being
|
|
// found. Same key, so the signing certificate did not change either.
|
|
keyAlias = "thoughtsync"
|
|
// PKCS12 cannot hold a key password distinct from the store
|
|
// password — keytool refuses to set one — so this is the same
|
|
// value by necessity rather than by shortcut.
|
|
keyPassword = keystorePassword
|
|
}
|
|
}
|
|
}
|
|
|
|
buildTypes {
|
|
release {
|
|
isMinifyEnabled = false
|
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
|
// Null when no keystore reached this build, which leaves the APK
|
|
// unsigned and therefore uninstallable. `android.yml` builds debug in
|
|
// that case rather than producing an artifact nobody can put on a
|
|
// phone.
|
|
signingConfig = signingConfigs.findByName("release")
|
|
}
|
|
}
|
|
|
|
compileOptions {
|
|
sourceCompatibility = JavaVersion.VERSION_17
|
|
targetCompatibility = JavaVersion.VERSION_17
|
|
}
|
|
|
|
buildFeatures {
|
|
compose = true
|
|
}
|
|
|
|
packaging {
|
|
resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Register the `.so` and the generated bindings as GENERATED sources.
|
|
*
|
|
* NOT `sourceSets { ... srcDir(task) }`: AGP 9 rejects a Provider there outright,
|
|
* because it cannot tell whether the directory holds generated (read-only) or
|
|
* hand-written (read-write) files — a distinction the IDE needs. The Variant API
|
|
* is the supported route and, unlike a bare path, `addGeneratedSourceDirectory`
|
|
* carries the task dependency, so Kotlin cannot compile before the bindings
|
|
* exist and the APK cannot package a stale `.so`.
|
|
*/
|
|
androidComponents {
|
|
onVariants { variant ->
|
|
variant.sources.kotlin?.addGeneratedSourceDirectory(generateBindings, UniffiBindgen::outputDir)
|
|
variant.sources.jniLibs?.addGeneratedSourceDirectory(cargoNdk, CargoNdkBuild::jniLibsDir)
|
|
}
|
|
}
|
|
|
|
dependencies {
|
|
implementation(libs.androidx.core.ktx)
|
|
implementation(libs.androidx.activity.compose)
|
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
|
implementation(libs.kotlinx.coroutines.android)
|
|
implementation(libs.androidx.work.runtime)
|
|
|
|
// Required by the uniffi bindings — see the catalog note on the @aar
|
|
// classifier; the plain jar builds fine and fails at runtime.
|
|
implementation(variantOf(libs.jna) { artifactType("aar") })
|
|
|
|
implementation(platform(libs.compose.bom))
|
|
implementation(libs.compose.ui)
|
|
implementation(libs.compose.ui.graphics)
|
|
implementation(libs.compose.material3)
|
|
implementation(libs.compose.material.icons.core)
|
|
implementation(libs.compose.ui.tooling.preview)
|
|
debugImplementation(libs.compose.ui.tooling)
|
|
|
|
// A link preview's image (LinkPreviewRow.kt).
|
|
implementation(libs.coil.compose)
|
|
implementation(libs.coil.network.okhttp)
|
|
|
|
testImplementation(libs.junit)
|
|
}
|