summaryrefslogtreecommitdiff
path: root/android/src/main/kotlin
diff options
context:
space:
mode:
authorElizabeth Alexander Hunt <me@liz.coffee>2026-11-13 15:28:48 -0800
committerElizabeth Alexander Hunt <me@liz.coffee>2026-11-13 15:37:09 -0800
commit009525bd46edfc77068da212e36f38d410503f2c (patch)
treef042565bb01d070f626213b72865800ec0dbf576 /android/src/main/kotlin
parent60426ce45763d0a4a5572db6926e8e677cbc0dc0 (diff)
downloadrookery-009525bd46edfc77068da212e36f38d410503f2c.tar.gz
rookery-009525bd46edfc77068da212e36f38d410503f2c.zip
Smart-cube timing, macOS Bluetooth, Android app, stackmat, pairing UI
- Timing with a smart cube: the scramble stays put and is marked off move by move (ScrambleTracker); a stray turn gets the shortest path to the same scrambled state from the two-phase solver, kept until the next stray turn. Wide moves in BLD scrambles become face turns. Once scrambled, inspection (or blind memo) starts by itself after a configurable delay; the first turn starts the clock and the solved cube stops it. The cube's own state reports are authoritative, so a move lost over Bluetooth can't keep the timer running. Blind solves show a MEMO/EXEC annunciator and store the memo split. A live view of the cube sits beside the timer. - Bluetooth through Kable on macOS and Windows (Linux keeps BlueZ) and on Android; the cube's MAC is read from its advertisement where the platform hides it. Scan/pair/connect/forget UI in settings. - Android app (:android): core and ui gain Android targets, with a jvmShared source set for code desktop and Android share. Android host for storage, sound, file import, images, Bluetooth and audio. Phone layouts: stacked panes and a full-screen timer with touch start. Scramble drawings (SVG) are desktop-only for now. - Stackmat: audio decoder and session; the mat can drive the timer. - Timer readout always shows the tens-of-seconds digit. - UI copy reduced to plain labels; marquee, snow, pointer-following and penguin speech removed. Brave key can be entered in settings. - Phone timer: one-line header, session picker and averages on one line, solves/stats in the footer; sessions can be renamed and deleted and are named <event>-<date>. Display mode fills the screen (cut-out included), fits the scramble on one line, shows the live cube, and the screen stays awake. - Readout: digits keep one size (inspection included), whole seconds while running unless set otherwise; previous scramble shown; the scramble no longer blanks while the next is generated. - Fixes: stackmat and smart cube no longer both drive the timer; a solved cube or a sub-quarter-second tap can't record a solve. - Blind memo indicator is an LCD penguin pacing the glass. - Android: system-bar insets, folder picker (all files access), Brave key synced with the data. - Algorithm trainer: PLL and OLL from J Perm's sheets, each algorithm verified by simulation; case plans, your choice of algorithm (or your own, checked against the case), new/learning/known, and a drill weighted towards weak cases. - Pair cards show the pair on a 3D see-through cube with an arrow; the memo cube can be dragged between targets. - CI image installs the Android SDK and runs core tests + desktop packaging (untested). Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Diffstat (limited to 'android/src/main/kotlin')
-rw-r--r--android/src/main/kotlin/coffee/liz/rookery/android/MainActivity.kt281
1 files changed, 281 insertions, 0 deletions
diff --git a/android/src/main/kotlin/coffee/liz/rookery/android/MainActivity.kt b/android/src/main/kotlin/coffee/liz/rookery/android/MainActivity.kt
new file mode 100644
index 0000000..f4cde6c
--- /dev/null
+++ b/android/src/main/kotlin/coffee/liz/rookery/android/MainActivity.kt
@@ -0,0 +1,281 @@
+package coffee.liz.rookery.android
+
+import android.Manifest
+import android.content.Context
+import android.content.pm.PackageManager
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.media.AudioAttributes
+import android.media.AudioFormat
+import android.media.AudioRecord
+import android.media.AudioTrack
+import android.media.MediaRecorder
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.safeDrawing
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.ui.Modifier
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+import coffee.liz.rookery.audio.Chime
+import coffee.liz.rookery.bt.BleAdvertisement
+import coffee.liz.rookery.bt.CubeBackend
+import coffee.liz.rookery.bt.GanCubeConnector
+import coffee.liz.rookery.bt.JvmAes128
+import coffee.liz.rookery.bt.KableTransport
+import coffee.liz.rookery.bt.SmartCube
+import coffee.liz.rookery.images.BraveImages
+import coffee.liz.rookery.images.ImageHit
+import coffee.liz.rookery.model.Event
+import coffee.liz.rookery.scramble.TnoodleScrambler
+import coffee.liz.rookery.store.FolderBlobStore
+import coffee.liz.rookery.store.FolderLogStorage
+import coffee.liz.rookery.store.Repo
+import coffee.liz.rookery.store.Store
+import coffee.liz.rookery.ui.AppModel
+import coffee.liz.rookery.ui.Host
+import coffee.liz.rookery.ui.RookeryApp
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.emitAll
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.net.HttpURLConnection
+import java.net.URL
+import java.util.UUID
+import java.util.zip.ZipFile
+
+class MainActivity : ComponentActivity() {
+ private var pendingFile: CompletableDeferred<Uri?>? = null
+ private val openDocument = registerForActivityResult(ActivityResultContracts.OpenDocument()) { pendingFile?.complete(it) }
+ private var pendingPermissions: CompletableDeferred<Boolean>? = null
+ private val askPermissions = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { r -> pendingPermissions?.complete(r.values.all { it }) }
+
+ private var pendingTree: CompletableDeferred<Uri?>? = null
+ private val openTree = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { pendingTree?.complete(it) }
+ private var pendingSettings: CompletableDeferred<Unit>? = null
+ private val openSettings = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { pendingSettings?.complete(Unit) }
+
+ /**
+ * Choose a folder in shared storage and return it as a plain path. The data is ordinary files
+ * that a sync app must also reach, so this needs Android's "all files access", asked for first.
+ */
+ suspend fun pickFolder(): String? {
+ if (Build.VERSION.SDK_INT >= 30 && !android.os.Environment.isExternalStorageManager()) {
+ val d = CompletableDeferred<Unit>(); pendingSettings = d
+ val intent = android.content.Intent(android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, Uri.parse("package:$packageName"))
+ runCatching { openSettings.launch(intent) }.onFailure { openSettings.launch(android.content.Intent(android.provider.Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)) }
+ d.await()
+ if (!android.os.Environment.isExternalStorageManager()) return null
+ } else if (Build.VERSION.SDK_INT < 30 && !ensure(Manifest.permission.WRITE_EXTERNAL_STORAGE)) return null
+ val d = CompletableDeferred<Uri?>(); pendingTree = d
+ openTree.launch(null)
+ val uri = d.await() ?: return null
+ // tree ids look like "primary:Sync/rookery" or "1A2B-3C4D:Sync/rookery"
+ val id = android.provider.DocumentsContract.getTreeDocumentId(uri)
+ val volume = id.substringBefore(':'); val rel = id.substringAfter(':', "")
+ val base = if (volume.equals("primary", true)) android.os.Environment.getExternalStorageDirectory() else File("/storage/$volume")
+ return File(base, rel).path
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val model = holder ?: run {
+ val host = AndroidHost(applicationContext)
+ val root = host.root.toPath()
+ val store = Store(host.deviceId, FolderLogStorage(root, host.deviceId), FolderBlobStore(root), System::currentTimeMillis)
+ AppModel(Repo(store), TnoodleScrambler().apply { warm(Event.E333) }, host, MainScope()).also { holder = it }
+ }
+ (model.host as AndroidHost).activity = this
+ enableEdgeToEdge()
+ window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) // a timer mustn't go dark mid-solve
+ setContent { RookeryApp(model, Modifier.windowInsetsPadding(WindowInsets.safeDrawing)) }
+ }
+
+ override fun onDestroy() {
+ (holder?.host as? AndroidHost)?.let { if (it.activity === this) it.activity = null }
+ super.onDestroy()
+ }
+
+ suspend fun pick(mimeTypes: Array<String>): Uri? {
+ val d = CompletableDeferred<Uri?>(); pendingFile = d
+ openDocument.launch(mimeTypes)
+ return d.await()
+ }
+
+ suspend fun ensure(vararg permissions: String): Boolean {
+ val missing = permissions.filter { checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED }
+ if (missing.isEmpty()) return true
+ val d = CompletableDeferred<Boolean>(); pendingPermissions = d
+ askPermissions.launch(missing.toTypedArray())
+ return d.await()
+ }
+
+ fun fullscreen(on: Boolean) {
+ val c = WindowInsetsControllerCompat(window, window.decorView)
+ if (on) { c.hide(WindowInsetsCompat.Type.systemBars()); c.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE }
+ else c.show(WindowInsetsCompat.Type.systemBars())
+ }
+
+ companion object { private var holder: AppModel? = null } // survives the activity being recreated
+}
+
+/** What rookery needs from Android. */
+class AndroidHost(private val context: Context) : Host {
+ var activity: MainActivity? = null
+ private val prefs = context.getSharedPreferences("local", Context.MODE_PRIVATE)
+
+ /** All data: app storage that file managers (and sync apps given access) can see, else private storage. */
+ val root: File get() {
+ val own = (context.getExternalFilesDir(null) ?: context.filesDir).resolve("sync")
+ val chosen = localPref("syncFolder")?.let(::File)
+ // fall back to the app's own folder if the chosen one can't be written (permission withdrawn, card removed)
+ return (chosen?.takeIf { (it.isDirectory || it.mkdirs()) && it.canWrite() } ?: own).apply { mkdirs() }
+ }
+
+ override val deviceId: String = prefs.getString("deviceId", null)
+ ?: "${Build.MODEL.filter { it.isLetterOrDigit() }.take(16).lowercase()}-${UUID.randomUUID().toString().take(6)}".also { prefs.edit().putString("deviceId", it).apply() }
+ override val syncFolder: String get() = root.path
+ override fun setSyncFolder(path: String) = setLocalPref("syncFolder", path)
+ override fun localPref(key: String): String? = prefs.getString("local.$key", null)
+ override fun setLocalPref(key: String, value: String?) { prefs.edit().apply { if (value == null) remove("local.$key") else putString("local.$key", value) }.apply() }
+ override val braveApiKey: String? get() = localPref("braveApiKey")?.takeIf { it.isNotBlank() }
+ override fun setBraveApiKey(key: String?) = setLocalPref("braveApiKey", key)
+ override val touchInput: Boolean get() = true
+
+ override fun playChime(notes: List<Chime.Note>) {
+ Thread {
+ runCatching {
+ val pcm = Chime.render(notes)
+ val track = AudioTrack.Builder()
+ .setAudioAttributes(AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_GAME).setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION).build())
+ .setAudioFormat(AudioFormat.Builder().setEncoding(AudioFormat.ENCODING_PCM_16BIT).setSampleRate(Chime.SAMPLE_RATE).setChannelMask(AudioFormat.CHANNEL_OUT_MONO).build())
+ .setBufferSizeInBytes(pcm.size * 2).setTransferMode(AudioTrack.MODE_STATIC).build()
+ track.write(pcm, 0, pcm.size); track.play()
+ Thread.sleep(pcm.size * 1000L / Chime.SAMPLE_RATE + 200)
+ track.release()
+ }
+ }.apply { isDaemon = true }.start()
+ }
+
+ /** Copies the chosen document into the cache and returns that path, so the rest of the app can use plain files. */
+ override suspend fun pickFile(title: String, extensions: List<String>): String? {
+ val uri = activity?.pick(arrayOf("*/*")) ?: return null
+ return withContext(Dispatchers.IO) {
+ val name = context.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use { c -> if (c.moveToFirst()) c.getString(0) else null } ?: "import"
+ val out = File(context.cacheDir, "picked").apply { mkdirs() }.resolve(name.replace('/', '_'))
+ context.contentResolver.openInputStream(uri)?.use { i -> out.outputStream().use { i.copyTo(it) } } ?: return@withContext null
+ out.path
+ }
+ }
+
+ override suspend fun pickFolder(title: String): String? = activity?.pickFolder()
+ override suspend fun readFile(path: String): ByteArray = withContext(Dispatchers.IO) { File(path).readBytes() }
+
+ override suspend fun readImport(path: String, mainEntry: String?): Pair<String, (String) -> ByteArray?> = withContext(Dispatchers.IO) {
+ if (!path.lowercase().endsWith(".zip")) File(path).readText() to { _: String -> null }
+ else {
+ val entries = ZipFile(path).use { z -> z.entries().toList().filter { !it.isDirectory }.associate { it.name to z.getInputStream(it).readBytes() } }
+ val main = entries.entries.firstOrNull { it.key == mainEntry || it.key.endsWith("/$mainEntry") } ?: error("no $mainEntry in the zip")
+ val prefix = main.key.removeSuffix(mainEntry ?: "")
+ main.value.decodeToString() to { rel: String -> entries[prefix + rel] }
+ }
+ }
+
+ private fun get(url: String, headers: Map<String, String> = emptyMap()): ByteArray {
+ require(url.startsWith("https://")) { "refusing a non-https address" }
+ val c = (URL(url).openConnection() as HttpURLConnection).apply {
+ connectTimeout = 10_000; readTimeout = 20_000; setRequestProperty("User-Agent", "rookery")
+ headers.forEach { (k, v) -> setRequestProperty(k, v) }
+ }
+ try {
+ if (c.responseCode !in 200..299) error("the server said ${c.responseCode}")
+ val out = ByteArrayOutputStream(); val buf = ByteArray(16384)
+ c.inputStream.use { i -> while (true) { val n = i.read(buf); if (n < 0) break; out.write(buf, 0, n); check(out.size() <= 12 * 1024 * 1024) { "that file is too large" } } }
+ return out.toByteArray()
+ } finally { c.disconnect() }
+ }
+
+ override suspend fun searchImages(query: String, key: String): List<ImageHit> = withContext(Dispatchers.IO) {
+ BraveImages.parse(get(BraveImages.url(query), mapOf("Accept" to "application/json", BraveImages.KEY_HEADER to key)).decodeToString())
+ }
+
+ override suspend fun fetchBytes(url: String): ByteArray = withContext(Dispatchers.IO) { get(url) }
+
+ /** Picture bytes from the clipboard, which other apps leave as a content uri. */
+ override suspend fun clipboardImage(): ByteArray? = withContext(Dispatchers.Main) {
+ val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
+ val clip = runCatching { cm.primaryClip }.getOrNull() ?: return@withContext null
+ if (clip.description?.hasMimeType("image/*") != true) return@withContext null
+ val uri = clip.getItemAt(0)?.uri ?: return@withContext null
+ withContext(Dispatchers.IO) { runCatching { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } }.getOrNull() }
+ }
+
+ override fun shrinkImage(bytes: ByteArray): Pair<ByteArray, String>? = runCatching {
+ val src = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null
+ val k = minOf(1f, 640f / maxOf(src.width, src.height))
+ val scaled = if (k < 1f) Bitmap.createScaledBitmap(src, (src.width * k).toInt().coerceAtLeast(1), (src.height * k).toInt().coerceAtLeast(1), true) else src
+ val out = ByteArrayOutputStream()
+ @Suppress("DEPRECATION")
+ scaled.compress(if (Build.VERSION.SDK_INT >= 30) Bitmap.CompressFormat.WEBP_LOSSY else Bitmap.CompressFormat.WEBP, 82, out)
+ out.toByteArray() to "webp"
+ }.getOrNull()
+
+ override fun setFullscreen(on: Boolean) { activity?.fullscreen(on) }
+
+ override val cubes: CubeBackend = object : CubeBackend {
+ private val transport by lazy { KableTransport() }
+ private suspend fun allowed(): Boolean {
+ val a = activity ?: return false
+ return if (Build.VERSION.SDK_INT >= 31) a.ensure(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT) else a.ensure(Manifest.permission.ACCESS_FINE_LOCATION)
+ }
+ override fun scan(): Flow<BleAdvertisement> = flow {
+ if (!allowed()) error("Bluetooth permission was not granted")
+ emitAll(transport.scan().filter { GanCubeConnector.isGanCube(it) })
+ }
+ override suspend fun connect(scope: CoroutineScope, address: String, mac: String): SmartCube {
+ if (!allowed()) error("Bluetooth permission was not granted")
+ return GanCubeConnector(transport, JvmAes128).connect(scope, address, mac)
+ }
+ }
+
+ override fun audioInputs(): List<String> = emptyList()
+ override val audioSampleRate: Int get() = 44_100
+
+ @android.annotation.SuppressLint("MissingPermission")
+ override fun openAudio(input: String?, onSamples: (FloatArray, Int) -> Unit): AutoCloseable {
+ if (context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
+ activity?.let { a -> MainScope().launch { a.ensure(Manifest.permission.RECORD_AUDIO) } }
+ error("allow the microphone, then switch the stackmat on again")
+ }
+ val size = maxOf(AudioRecord.getMinBufferSize(44_100, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT), 8192)
+ // UNPROCESSED where available: the timer's signal must not be "cleaned up" as if it were speech
+ val rec = AudioRecord(MediaRecorder.AudioSource.UNPROCESSED, 44_100, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, size)
+ check(rec.state == AudioRecord.STATE_INITIALIZED) { "couldn't open the audio input" }
+ rec.startRecording()
+ val thread = Thread {
+ val buf = ShortArray(1024); val samples = FloatArray(1024)
+ while (rec.recordingState == AudioRecord.RECORDSTATE_RECORDING) {
+ val n = rec.read(buf, 0, buf.size)
+ if (n <= 0) break
+ for (i in 0 until n) samples[i] = buf[i] / 32768f
+ onSamples(samples, n)
+ }
+ }.apply { isDaemon = true; name = "stackmat-audio"; start() }
+ return AutoCloseable { runCatching { rec.stop() }; rec.release(); thread.interrupt() }
+ }
+}