M12 — the Android client, end to end #2
@@ -0,0 +1,109 @@
|
|||||||
|
name: Android
|
||||||
|
|
||||||
|
# The native Kotlin/Compose client over the shared Rust core (M12).
|
||||||
|
#
|
||||||
|
# Replaces the Tauri-mobile lane deleted in step 2. What changed is what this
|
||||||
|
# builds, not that Android has a lane: the UI is Compose, and the store and sync
|
||||||
|
# engine are `thoughtsync-core` cross-compiled by cargo-ndk and loaded through
|
||||||
|
# uniffi.
|
||||||
|
#
|
||||||
|
# CI can only prove this BUILDS. A Linux runner cannot execute an APK, so anything
|
||||||
|
# about feel, touch or on-device correctness is an operator pass on an emulator or
|
||||||
|
# phone.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [dev, main]
|
||||||
|
paths:
|
||||||
|
- "android/**"
|
||||||
|
# The Rust the .so is built from. A core change reaches the phone exactly
|
||||||
|
# as it reaches the desktop, so this lane has to rebuild on it.
|
||||||
|
- "core/**"
|
||||||
|
- "Cargo.toml"
|
||||||
|
- "Cargo.lock"
|
||||||
|
- ".forgejo/workflows/android.yml"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: android-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Silences the JDK 22+ "restricted method in java.lang.System has been called"
|
||||||
|
# warning that Gradle 9.1's bundled native-platform jar trips at launch. This
|
||||||
|
# targets the LAUNCHER JVM, which is why org.gradle.jvmargs in
|
||||||
|
# gradle.properties is not enough on its own (Minstrel hit the same thing).
|
||||||
|
JAVA_TOOL_OPTIONS: "--enable-native-access=ALL-UNNAMED"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Kotlin + Rust (debug APK)
|
||||||
|
# runs-on is only a scheduling label (Label Model B). flutter-ci is the
|
||||||
|
# proven-working label that can pull our container images.
|
||||||
|
runs-on: flutter-ci
|
||||||
|
container:
|
||||||
|
# The image repurposed from ci-tauri-android in M12 step 3: Rust + the four
|
||||||
|
# Android ABIs + cargo-ndk + SDK/NDK + JDK 25 + ktlint + detekt.
|
||||||
|
image: git.fabledsword.com/bvandeusen/ci-rust-android:1.97
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: android
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Cache Gradle and Cargo
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.gradle/caches
|
||||||
|
~/.gradle/wrapper
|
||||||
|
~/.kotlin
|
||||||
|
target
|
||||||
|
key: android-${{ hashFiles('android/gradle/wrapper/gradle-wrapper.properties', 'android/gradle/libs.versions.toml', 'android/**/*.gradle.kts', 'Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
android-
|
||||||
|
|
||||||
|
- name: Make gradlew executable
|
||||||
|
run: chmod +x ./gradlew
|
||||||
|
|
||||||
|
# Fails loudly here if the wrapper and the image's JDK disagree, rather
|
||||||
|
# than thirty seconds into a compile with an opaque version message.
|
||||||
|
- name: Gradle wrapper check
|
||||||
|
run: ./gradlew --version
|
||||||
|
|
||||||
|
# Cross-compiles the core for four ABIs and generates the Kotlin bindings
|
||||||
|
# from the built .so. Run as its own step so a Rust failure is legible as a
|
||||||
|
# Rust failure instead of arriving inside a Gradle stack trace.
|
||||||
|
- name: Build the native library and bindings
|
||||||
|
run: ./gradlew generateUniffiBindings
|
||||||
|
|
||||||
|
- name: ktlint
|
||||||
|
run: ./gradlew ktlintCheck
|
||||||
|
|
||||||
|
- name: detekt
|
||||||
|
run: ./gradlew detekt
|
||||||
|
|
||||||
|
- name: Unit tests
|
||||||
|
# Host-JVM tests only. Anything touching the core needs an Android
|
||||||
|
# runtime to load the .so, so those are instrumented tests and belong on
|
||||||
|
# an emulator, not here — the Rust side is covered by the workspace
|
||||||
|
# tests in the desktop lane.
|
||||||
|
run: ./gradlew testDebugUnitTest
|
||||||
|
|
||||||
|
- name: Assemble debug APK
|
||||||
|
run: ./gradlew assembleDebug
|
||||||
|
|
||||||
|
- name: Upload debug APK
|
||||||
|
# Mirrored action, never actions/upload-artifact. @v4+ throws
|
||||||
|
# GHESNotSupportedError client-side on this hostname, and @v3 is worse —
|
||||||
|
# it reports success while Gitea serves artifacts back only through the
|
||||||
|
# v4 API, so the upload is stored and invisible. Pinned by SHA because
|
||||||
|
# the mirror auto-syncs; full URL because DEFAULT_ACTIONS_URL sends bare
|
||||||
|
# owner/repo to github.com. See Scribe issues 2255 / 2270.
|
||||||
|
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
||||||
|
with:
|
||||||
|
name: thoughtsync-android-debug-${{ github.sha }}
|
||||||
|
path: android/app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
if-no-files-found: error
|
||||||
+12
@@ -177,3 +177,15 @@ cython_debug/
|
|||||||
|
|
||||||
# Rust workspace build output (one target dir for core + desktop + android)
|
# Rust workspace build output (one target dir for core + desktop + android)
|
||||||
/target/
|
/target/
|
||||||
|
|
||||||
|
# Android / Gradle build output.
|
||||||
|
#
|
||||||
|
# `local.properties` holds the machine's SDK path — it is per-workstation and
|
||||||
|
# must never be committed; CI gets the SDK from ANDROID_HOME in the image.
|
||||||
|
# The wrapper JAR is deliberately NOT ignored: it is how a clean checkout gets
|
||||||
|
# the right Gradle without one installed first.
|
||||||
|
android/.gradle/
|
||||||
|
android/build/
|
||||||
|
android/app/build/
|
||||||
|
android/local.properties
|
||||||
|
.kotlin/
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.compose.compiler)
|
||||||
|
alias(libs.plugins.ktlint)
|
||||||
|
alias(libs.plugins.detekt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 `thoughtsync-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", "thoughtsync-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",
|
||||||
|
"--features",
|
||||||
|
"bindgen",
|
||||||
|
"--bin",
|
||||||
|
"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"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val jniLibsOut = layout.buildDirectory.dir("rustJniLibs")
|
||||||
|
val bindingsOut = layout.buildDirectory.dir("generated/uniffi")
|
||||||
|
|
||||||
|
val cargoNdkDebug =
|
||||||
|
tasks.register<CargoNdkBuild>("cargoNdkDebug") {
|
||||||
|
description = "Cross-compile thoughtsync-ffi for the Android ABIs (debug)."
|
||||||
|
rustSources.from(rustInputs)
|
||||||
|
abis.set(androidAbis)
|
||||||
|
cargoProfile.set("debug")
|
||||||
|
workspaceDir.set(workspaceRoot)
|
||||||
|
jniLibsDir.set(jniLibsOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
val generateBindings =
|
||||||
|
tasks.register<UniffiBindgen>("generateUniffiBindings") {
|
||||||
|
description = "Generate the Kotlin bindings from the compiled .so."
|
||||||
|
dependsOn(cargoNdkDebug)
|
||||||
|
// 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/libthoughtsync_ffi.so") })
|
||||||
|
workspaceDir.set(workspaceRoot)
|
||||||
|
outputDir.set(bindingsOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.fabledsword.thoughtsync"
|
||||||
|
compileSdk = 36
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.fabledsword.thoughtsync"
|
||||||
|
// 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("THOUGHTSYNC_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() }
|
||||||
|
val codeOverride =
|
||||||
|
(project.findProperty("THOUGHTSYNC_VERSION_CODE") as String?)?.toIntOrNull()
|
||||||
|
versionCode = codeOverride ?: 1
|
||||||
|
versionName = nameOverride ?: "dev"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||||
|
// Signing is deliberately absent. A release keystore that has passed
|
||||||
|
// through an agent session or shell history is compromised by
|
||||||
|
// construction (Scribe task 2136) — it has to be generated by the
|
||||||
|
// operator and reach CI only as a secret. Until then a release build
|
||||||
|
// is unsigned and CI builds debug.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
getByName("main") {
|
||||||
|
// The .so and the bindings are build outputs, not checked-in sources.
|
||||||
|
jniLibs.srcDir(jniLibsOut)
|
||||||
|
java.srcDir(bindingsOut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
packaging {
|
||||||
|
resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kotlin compilation needs the generated bindings to exist first.
|
||||||
|
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
||||||
|
dependsOn(generateBindings)
|
||||||
|
}
|
||||||
|
tasks.named("preBuild") { dependsOn(generateBindings) }
|
||||||
|
|
||||||
|
// ktlint must not police generated code — it is uniffi's output, not ours, and
|
||||||
|
// there is no edit that would fix a complaint about it.
|
||||||
|
ktlint {
|
||||||
|
filter {
|
||||||
|
exclude { it.file.path.contains("generated") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
detekt {
|
||||||
|
buildUponDefaultConfig = true
|
||||||
|
config.setFrom(files("$rootDir/config/detekt.yml"))
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
// 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.ui.tooling.preview)
|
||||||
|
debugImplementation(libs.compose.ui.tooling)
|
||||||
|
|
||||||
|
testImplementation(libs.junit)
|
||||||
|
}
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
# JNA reaches the native library reflectively, so R8 must not rename or strip
|
||||||
|
# either it or the uniffi bindings that ride on it. Without these a minified
|
||||||
|
# build fails at runtime with UnsatisfiedLinkError — and only in release, which
|
||||||
|
# is the worst possible time to learn it.
|
||||||
|
-keep class com.sun.jna.** { *; }
|
||||||
|
-keepclassmembers class * extends com.sun.jna.** { public *; }
|
||||||
|
-keep class com.fabledsword.thoughtsync.core.** { *; }
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
INTERNET is requested but nothing uses it until the user links a server.
|
||||||
|
The app is local-first: the store, capture and the whole board work with
|
||||||
|
this permission never exercised.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".ThoughtSyncApplication"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.ThoughtSync">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:windowSoftInputMode="adjustResize"
|
||||||
|
android:theme="@style/Theme.ThoughtSync">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.fabledsword.thoughtsync
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.fabledsword.thoughtsync.ui.BoardScreen
|
||||||
|
import com.fabledsword.thoughtsync.ui.BoardViewModel
|
||||||
|
import com.fabledsword.thoughtsync.ui.StoreUnavailableScreen
|
||||||
|
import com.fabledsword.thoughtsync.ui.ThoughtSyncTheme
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
val app = application as ThoughtSyncApplication
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
ThoughtSyncTheme {
|
||||||
|
val core = app.core
|
||||||
|
if (core == null) {
|
||||||
|
// The store never opened. There is no board to show and no
|
||||||
|
// action that would help, so say what happened plainly rather
|
||||||
|
// than render an empty board that looks like data loss.
|
||||||
|
StoreUnavailableScreen(reason = app.openFailure)
|
||||||
|
} else {
|
||||||
|
val model: BoardViewModel = viewModel(factory = BoardViewModel.factory(core))
|
||||||
|
BoardScreen(
|
||||||
|
state = model.state,
|
||||||
|
onCapture = model::capture,
|
||||||
|
onDismissError = model::dismissError,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.fabledsword.thoughtsync
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.util.Log
|
||||||
|
import com.fabledsword.thoughtsync.core.ThoughtSync
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the shared Rust core once, for the process lifetime.
|
||||||
|
*
|
||||||
|
* The store is a single SQLite file behind a mutex, so one handle is both
|
||||||
|
* sufficient and correct — a second would be two connections racing for the same
|
||||||
|
* lock. This mirrors how the desktop manages it as Tauri app state.
|
||||||
|
*
|
||||||
|
* [filesDir] is app-private storage: readable by this app and nothing else,
|
||||||
|
* removed on uninstall, and never on external media. The core does not guess at
|
||||||
|
* platform paths; Android is the only thing that knows where this is.
|
||||||
|
*/
|
||||||
|
class ThoughtSyncApplication : Application() {
|
||||||
|
/**
|
||||||
|
* Null only if the store could not be opened — a corrupt or unwritable
|
||||||
|
* database. The UI reports that honestly rather than crashing on first
|
||||||
|
* touch, because a user whose notes won't open needs a message, not a
|
||||||
|
* stack trace.
|
||||||
|
*/
|
||||||
|
var core: ThoughtSync? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
var openFailure: String? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
try {
|
||||||
|
val handle = ThoughtSync(filesDir.absolutePath)
|
||||||
|
core = handle
|
||||||
|
Log.i(TAG, "local store ready — ${handle.summary()}")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Deliberately broad: whatever went wrong, the app still has to
|
||||||
|
// start and say so. Narrowing this would mean an unanticipated
|
||||||
|
// failure mode takes the process down at launch instead.
|
||||||
|
openFailure = e.message ?: e.toString()
|
||||||
|
Log.e(TAG, "could not open the local store", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "ThoughtSync"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package com.fabledsword.thoughtsync.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.fabledsword.thoughtsync.R
|
||||||
|
import com.fabledsword.thoughtsync.core.Note
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun BoardScreen(
|
||||||
|
state: BoardState,
|
||||||
|
onCapture: (String) -> Unit,
|
||||||
|
onDismissError: () -> Unit,
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
topBar = { TopAppBar(title = { Text(stringResource(R.string.board_title)) }) },
|
||||||
|
modifier = Modifier.imePadding(),
|
||||||
|
) { padding ->
|
||||||
|
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
|
||||||
|
state.error?.let { message ->
|
||||||
|
ErrorBanner(message = message, onDismiss = onDismissError)
|
||||||
|
}
|
||||||
|
|
||||||
|
CaptureField(saving = state.saving, onCapture = onCapture)
|
||||||
|
|
||||||
|
when {
|
||||||
|
state.loading -> LoadingBoard()
|
||||||
|
state.notes.isEmpty() -> EmptyBoard()
|
||||||
|
else -> NoteList(notes = state.notes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CaptureField(
|
||||||
|
saving: Boolean,
|
||||||
|
onCapture: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
var text by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
fun submit() {
|
||||||
|
if (text.isNotBlank()) {
|
||||||
|
onCapture(text)
|
||||||
|
text = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = text,
|
||||||
|
onValueChange = { text = it },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
placeholder = { Text(stringResource(R.string.capture_hint)) },
|
||||||
|
// The north star is a thought captured in under a second, so the
|
||||||
|
// keyboard's action key saves rather than inserting a newline.
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||||
|
keyboardActions = KeyboardActions(onDone = { submit() }),
|
||||||
|
enabled = !saving,
|
||||||
|
singleLine = false,
|
||||||
|
maxLines = MAX_CAPTURE_LINES,
|
||||||
|
)
|
||||||
|
Button(onClick = ::submit, enabled = !saving && text.isNotBlank()) {
|
||||||
|
Text(stringResource(R.string.capture_action))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NoteList(notes: List<Note>) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
// Keyed by id so Compose reuses rows across a refresh instead of
|
||||||
|
// rebuilding the list — and so a prepended note animates in rather than
|
||||||
|
// making every row below it flicker.
|
||||||
|
items(items = notes, key = { it.id }) { note -> NoteCard(note) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NoteCard(note: Note) {
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Text(
|
||||||
|
// display_title is always present — the core derives it from the
|
||||||
|
// first body line when there is no title, so a body-only note is
|
||||||
|
// still nameable. The fallback is for a note with neither.
|
||||||
|
text = note.displayTitle.ifBlank { stringResource(R.string.board_untitled) },
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (note.body.isNotBlank() && note.body != note.displayTitle) {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = note.body,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = MAX_PREVIEW_LINES,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmptyBoard() {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.board_empty_title),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.board_empty_body),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LoadingBoard() {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ErrorBanner(
|
||||||
|
message: String,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.error_dismiss)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shown when the store could not be opened at all.
|
||||||
|
*
|
||||||
|
* There is no retry: whatever stopped SQLite from opening will stop it again this
|
||||||
|
* launch. Saying so plainly beats a button that does nothing.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun StoreUnavailableScreen(reason: String?) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.store_unavailable_title),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = reason ?: stringResource(R.string.store_unavailable_body),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MAX_CAPTURE_LINES = 5
|
||||||
|
private const val MAX_PREVIEW_LINES = 4
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package com.fabledsword.thoughtsync.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.fabledsword.thoughtsync.core.Note
|
||||||
|
import com.fabledsword.thoughtsync.core.NoteDraft
|
||||||
|
import com.fabledsword.thoughtsync.core.NoteQuery
|
||||||
|
import com.fabledsword.thoughtsync.core.ThoughtSync
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/** Everything the board renders from, in one immutable snapshot. */
|
||||||
|
data class BoardState(
|
||||||
|
val notes: List<Note> = emptyList(),
|
||||||
|
val loading: Boolean = true,
|
||||||
|
val saving: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the board off the shared Rust core.
|
||||||
|
*
|
||||||
|
* Every core call is a BLOCKING FFI call — the store is synchronous SQLite behind
|
||||||
|
* a mutex — so they run on [Dispatchers.IO]. Doing otherwise would block the main
|
||||||
|
* thread on disk, which is exactly the jank a native client is supposed to avoid.
|
||||||
|
* (The sync methods are the exception: those are `suspend` on the Kotlin side
|
||||||
|
* already, because uniffi bridges the Rust async to coroutines.)
|
||||||
|
*/
|
||||||
|
class BoardViewModel(private val core: ThoughtSync) : ViewModel() {
|
||||||
|
var state by mutableStateOf(BoardState())
|
||||||
|
private set
|
||||||
|
|
||||||
|
init {
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
state = state.copy(loading = true)
|
||||||
|
state =
|
||||||
|
try {
|
||||||
|
val notes = withContext(Dispatchers.IO) { core.listNotes(BOARD_QUERY) }
|
||||||
|
state.copy(notes = notes, loading = false, error = null)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Broad by intent: the board must render something for any
|
||||||
|
// failure, and the core reports most problems as one error
|
||||||
|
// type carrying a message meant to be shown.
|
||||||
|
state.copy(loading = false, error = e.message ?: FALLBACK_ERROR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a captured thought.
|
||||||
|
*
|
||||||
|
* Blank input is ignored rather than rejected with a message: an empty save is
|
||||||
|
* a slip, not a mistake worth interrupting someone over.
|
||||||
|
*/
|
||||||
|
fun capture(text: String) {
|
||||||
|
val trimmed = text.trim()
|
||||||
|
if (trimmed.isEmpty()) return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
state = state.copy(saving = true)
|
||||||
|
state =
|
||||||
|
try {
|
||||||
|
// Title left empty on purpose — the core derives display_title
|
||||||
|
// from the first body line, so a captured thought is nameable
|
||||||
|
// without making the user name it. Same behaviour as the
|
||||||
|
// desktop's quick-add.
|
||||||
|
val draft = NoteDraft(title = "", body = trimmed, color = DEFAULT_COLOR, kind = null, items = null)
|
||||||
|
val created = withContext(Dispatchers.IO) { core.createNote(draft) }
|
||||||
|
// Prepend rather than re-query: the new note belongs at the top
|
||||||
|
// of an unsorted board, and a full reload would cost a round
|
||||||
|
// trip to tell us something we already know.
|
||||||
|
state.copy(notes = listOf(created) + state.notes, saving = false, error = null)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
state.copy(saving = false, error = e.message ?: FALLBACK_ERROR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissError() {
|
||||||
|
state = state.copy(error = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val DEFAULT_COLOR = "default"
|
||||||
|
private const val FALLBACK_ERROR = "Something went wrong."
|
||||||
|
|
||||||
|
/** The default board: everything not archived or trashed. */
|
||||||
|
private val BOARD_QUERY = NoteQuery(view = "notes", labelId = null, sort = null, facets = null)
|
||||||
|
|
||||||
|
fun factory(core: ThoughtSync): ViewModelProvider.Factory =
|
||||||
|
object : ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T = BoardViewModel(core) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.fabledsword.thoughtsync.ui
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
|
// The brand colour, the same #F5C518 the web app's manifest and the adaptive icon
|
||||||
|
// use. One answer to "what colour is ThoughtSync" across all three surfaces.
|
||||||
|
private val Brand = Color(0xFFF5C518)
|
||||||
|
|
||||||
|
private val LightColors =
|
||||||
|
lightColorScheme(
|
||||||
|
primary = Brand,
|
||||||
|
// Black on gold, not white: the brand colour is bright enough that white
|
||||||
|
// text on it fails contrast badly.
|
||||||
|
onPrimary = Color(0xFF1A1A1A),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val DarkColors =
|
||||||
|
darkColorScheme(
|
||||||
|
primary = Brand,
|
||||||
|
onPrimary = Color(0xFF1A1A1A),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material 3, following the system light/dark setting.
|
||||||
|
*
|
||||||
|
* Dynamic colour is used where the platform offers it (Android 12+), because a
|
||||||
|
* phone user's expectation is that apps take the wallpaper palette — and falls
|
||||||
|
* back to the brand scheme below that. The desktop makes the equivalent choice by
|
||||||
|
* reading the live window theme rather than hardcoding one.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ThoughtSyncTheme(
|
||||||
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val colors =
|
||||||
|
when {
|
||||||
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
|
||||||
|
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||||
|
darkTheme -> DarkColors
|
||||||
|
else -> LightColors
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialTheme(colorScheme = colors, content = content)
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Adaptive icon. minSdk is 26, so this is the ONLY icon Android will ask for —
|
||||||
|
no legacy raster fallback is needed.
|
||||||
|
|
||||||
|
The foreground is the shared maskable asset the web app already ships
|
||||||
|
(frontend/public/icon-maskable-512.png), which is drawn with the safe-zone
|
||||||
|
padding adaptive icons require. Reusing it means the phone, the web app and the
|
||||||
|
desktop all wear the same face rather than three near-misses.
|
||||||
|
-->
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Adaptive icon. minSdk is 26, so this is the ONLY icon Android will ask for —
|
||||||
|
no legacy raster fallback is needed.
|
||||||
|
|
||||||
|
The foreground is the shared maskable asset the web app already ships
|
||||||
|
(frontend/public/icon-maskable-512.png), which is drawn with the safe-zone
|
||||||
|
padding adaptive icons require. Reusing it means the phone, the web app and the
|
||||||
|
desktop all wear the same face rather than three near-misses.
|
||||||
|
-->
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- The product's brand colour, same value the web app's manifest and
|
||||||
|
<meta name="theme-color"> already use. One source of truth for "what
|
||||||
|
colour is ThoughtSync" across the three surfaces. -->
|
||||||
|
<color name="ic_launcher_background">#F5C518</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">ThoughtSync</string>
|
||||||
|
|
||||||
|
<!-- Capture -->
|
||||||
|
<string name="capture_hint">Take a note…</string>
|
||||||
|
<string name="capture_action">Save</string>
|
||||||
|
|
||||||
|
<!-- Board -->
|
||||||
|
<string name="board_title">Notes</string>
|
||||||
|
<string name="board_empty_title">Nothing here yet</string>
|
||||||
|
<string name="board_empty_body">Your notes stay on this device. Connect a server later if you want them everywhere.</string>
|
||||||
|
<string name="board_untitled">Untitled</string>
|
||||||
|
|
||||||
|
<!-- Store failure -->
|
||||||
|
<string name="store_unavailable_title">Your notes couldn\'t be opened</string>
|
||||||
|
<string name="store_unavailable_body">The note store on this device could not be read. Reinstalling will start a fresh one, but anything not synced to a server would be lost.</string>
|
||||||
|
|
||||||
|
<!-- Errors -->
|
||||||
|
<string name="error_dismiss">Dismiss</string>
|
||||||
|
<string name="error_retry">Try again</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!--
|
||||||
|
A bare Material3 parent. The real palette is applied in Compose
|
||||||
|
(ui/Theme.kt) so light/dark follows the system without a second source of
|
||||||
|
truth in XML — the same reason the desktop reads its live theme rather
|
||||||
|
than hardcoding a window colour.
|
||||||
|
-->
|
||||||
|
<style name="Theme.ThoughtSync" parent="android:Theme.Material.NoActionBar" />
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
// kotlin-android is NOT registered: AGP 9 enables built-in Kotlin, and the
|
||||||
|
// older plugin can't cast AGP 9's ApplicationExtension to the removed
|
||||||
|
// BaseExtension. Same conclusion Minstrel reached on this toolchain pair.
|
||||||
|
alias(libs.plugins.compose.compiler) apply false
|
||||||
|
alias(libs.plugins.ktlint) apply false
|
||||||
|
alias(libs.plugins.detekt) apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Per-rule overrides layered on top of detekt's defaults
|
||||||
|
# (`buildUponDefaultConfig = true` in the :app `detekt {}` block).
|
||||||
|
#
|
||||||
|
# The pre-2.0 `build:` top-level was removed; failure-on-finding is controlled by
|
||||||
|
# the Gradle DSL's `failOnSeverity` option instead.
|
||||||
|
|
||||||
|
naming:
|
||||||
|
# Composables conventionally use PascalCase function names. Matches every
|
||||||
|
# mainstream Compose codebase, and Minstrel's config for the same reason.
|
||||||
|
FunctionNaming:
|
||||||
|
ignoreAnnotated:
|
||||||
|
- "Composable"
|
||||||
|
|
||||||
|
style:
|
||||||
|
# The generated uniffi bindings are excluded at the Gradle level, but a
|
||||||
|
# magic-number complaint about a Compose dp value is noise rather than a smell.
|
||||||
|
MagicNumber:
|
||||||
|
ignoreAnnotated:
|
||||||
|
- "Composable"
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# --enable-native-access=ALL-UNNAMED silences the JDK 22+ "restricted method in
|
||||||
|
# java.lang.System has been called" warning that Gradle 9.1's bundled
|
||||||
|
# native-platform jar trips via System.load(). Same opt-in Minstrel needs on the
|
||||||
|
# same Gradle/JDK pair; future JDKs promote the warning to an error.
|
||||||
|
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 --enable-native-access=ALL-UNNAMED
|
||||||
|
org.gradle.parallel=true
|
||||||
|
org.gradle.caching=true
|
||||||
|
org.gradle.configuration-cache=true
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
|
|
||||||
|
# Matches Minstrel: detekt 2.0-alpha and the ktlint Gradle plugin still have
|
||||||
|
# intermittent configuration-cache holes. Warn rather than fail so the CC speedup
|
||||||
|
# applies where it can.
|
||||||
|
org.gradle.configuration-cache.problems=warn
|
||||||
|
kotlin.code.style=official
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
[versions]
|
||||||
|
# Pinned as a MATRIX, matching Minstrel's proven combination on the same JDK:
|
||||||
|
# - Gradle 9.1.0 supports JDK 25 (see gradle-wrapper.properties)
|
||||||
|
# - AGP 9.0.1 requires Gradle 9.1.0+
|
||||||
|
# - Kotlin 2.3.x is AGP 9's built-in Kotlin path
|
||||||
|
# ci-rust-android ships JDK 25, so the wrapper floor is load-bearing: an older
|
||||||
|
# Gradle fails on that JDK with an opaque "25.0.3" message.
|
||||||
|
agp = "9.0.1"
|
||||||
|
kotlin = "2.3.21"
|
||||||
|
compose-bom = "2026.05.01"
|
||||||
|
lifecycle = "2.8.7"
|
||||||
|
activity-compose = "1.9.3"
|
||||||
|
coroutines = "1.9.0"
|
||||||
|
|
||||||
|
# LOCKSTEP with ci-rust-android's versions.env. Bumping either side alone makes
|
||||||
|
# local and CI analysis disagree; Renovate cannot see the coupling.
|
||||||
|
ktlint-gradle = "12.1.1"
|
||||||
|
detekt = "2.0.0-alpha.3"
|
||||||
|
|
||||||
|
# JNA is not optional: uniffi's Kotlin bindings call into the .so through it.
|
||||||
|
# The @aar classifier matters — the plain jar has no Android native payload and
|
||||||
|
# fails at runtime with UnsatisfiedLinkError rather than at build time.
|
||||||
|
jna = "5.14.0"
|
||||||
|
|
||||||
|
junit = "4.13.2"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.13.1" }
|
||||||
|
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" }
|
||||||
|
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||||
|
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
|
||||||
|
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
|
||||||
|
compose-ui = { module = "androidx.compose.ui:ui" }
|
||||||
|
compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
|
||||||
|
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
|
||||||
|
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
|
||||||
|
compose-material3 = { module = "androidx.compose.material3:material3" }
|
||||||
|
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||||
|
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
|
||||||
|
junit = { module = "junit:junit", version.ref = "junit" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-gradle" }
|
||||||
|
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
+160
@@ -0,0 +1,160 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
##
|
||||||
|
## Gradle start up script for UN*X
|
||||||
|
##
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS=""
|
||||||
|
|
||||||
|
APP_NAME="Gradle"
|
||||||
|
APP_BASE_NAME=`basename "$0"`
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD="maximum"
|
||||||
|
|
||||||
|
warn ( ) {
|
||||||
|
echo "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
die ( ) {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
case "`uname`" in
|
||||||
|
CYGWIN* )
|
||||||
|
cygwin=true
|
||||||
|
;;
|
||||||
|
Darwin* )
|
||||||
|
darwin=true
|
||||||
|
;;
|
||||||
|
MINGW* )
|
||||||
|
msys=true
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
PRG="$0"
|
||||||
|
# Need this for relative symlinks.
|
||||||
|
while [ -h "$PRG" ] ; do
|
||||||
|
ls=`ls -ld "$PRG"`
|
||||||
|
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||||
|
if expr "$link" : '/.*' > /dev/null; then
|
||||||
|
PRG="$link"
|
||||||
|
else
|
||||||
|
PRG=`dirname "$PRG"`"/$link"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
SAVED="`pwd`"
|
||||||
|
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||||
|
APP_HOME="`pwd -P`"
|
||||||
|
cd "$SAVED" >/dev/null
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||||
|
else
|
||||||
|
JAVACMD="$JAVA_HOME/bin/java"
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD="java"
|
||||||
|
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
|
||||||
|
MAX_FD_LIMIT=`ulimit -H -n`
|
||||||
|
if [ $? -eq 0 ] ; then
|
||||||
|
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||||
|
MAX_FD="$MAX_FD_LIMIT"
|
||||||
|
fi
|
||||||
|
ulimit -n $MAX_FD
|
||||||
|
if [ $? -ne 0 ] ; then
|
||||||
|
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# For Darwin, add options to specify how the application appears in the dock
|
||||||
|
if $darwin; then
|
||||||
|
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# For Cygwin, switch paths to Windows format before running java
|
||||||
|
if $cygwin ; then
|
||||||
|
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||||
|
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||||
|
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||||
|
|
||||||
|
# We build the pattern for arguments to be converted via cygpath
|
||||||
|
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||||
|
SEP=""
|
||||||
|
for dir in $ROOTDIRSRAW ; do
|
||||||
|
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||||
|
SEP="|"
|
||||||
|
done
|
||||||
|
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||||
|
# Add a user-defined pattern to the cygpath arguments
|
||||||
|
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||||
|
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||||
|
fi
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
i=0
|
||||||
|
for arg in "$@" ; do
|
||||||
|
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||||
|
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||||
|
|
||||||
|
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||||
|
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||||
|
else
|
||||||
|
eval `echo args$i`="\"$arg\""
|
||||||
|
fi
|
||||||
|
i=$((i+1))
|
||||||
|
done
|
||||||
|
case $i in
|
||||||
|
(0) set -- ;;
|
||||||
|
(1) set -- "$args0" ;;
|
||||||
|
(2) set -- "$args0" "$args1" ;;
|
||||||
|
(3) set -- "$args0" "$args1" "$args2" ;;
|
||||||
|
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||||
|
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||||
|
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||||
|
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||||
|
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||||
|
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
|
||||||
|
function splitJvmOpts() {
|
||||||
|
JVM_OPTS=("$@")
|
||||||
|
}
|
||||||
|
eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
|
||||||
|
JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
|
||||||
|
|
||||||
|
exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
|
||||||
Vendored
+90
@@ -0,0 +1,90 @@
|
|||||||
|
@if "%DEBUG%" == "" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS=
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%" == "" set DIRNAME=.
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if "%ERRORLEVEL%" == "0" goto init
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
echo location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto init
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
echo location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:init
|
||||||
|
@rem Get command-line arguments, handling Windowz variants
|
||||||
|
|
||||||
|
if not "%OS%" == "Windows_NT" goto win9xME_args
|
||||||
|
if "%@eval[2+2]" == "4" goto 4NT_args
|
||||||
|
|
||||||
|
:win9xME_args
|
||||||
|
@rem Slurp the command line arguments.
|
||||||
|
set CMD_LINE_ARGS=
|
||||||
|
set _SKIP=2
|
||||||
|
|
||||||
|
:win9xME_args_slurp
|
||||||
|
if "x%~1" == "x" goto execute
|
||||||
|
|
||||||
|
set CMD_LINE_ARGS=%*
|
||||||
|
goto execute
|
||||||
|
|
||||||
|
:4NT_args
|
||||||
|
@rem Get arguments from the 4NT Shell from JP Software
|
||||||
|
set CMD_LINE_ARGS=%$
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||||
|
exit /b 1
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google {
|
||||||
|
content {
|
||||||
|
includeGroupByRegex("com\\.android.*")
|
||||||
|
includeGroupByRegex("com\\.google.*")
|
||||||
|
includeGroupByRegex("androidx.*")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rootProject.name = "ThoughtSync"
|
||||||
|
|
||||||
|
// `ffi/` sits beside `app/` but is deliberately NOT a Gradle module: it is a Rust
|
||||||
|
// crate belonging to the Cargo workspace at the repo root. Gradle reaches it by
|
||||||
|
// invoking cargo-ndk (see app/build.gradle.kts), not by building it.
|
||||||
|
include(":app")
|
||||||
Reference in New Issue
Block a user