diff options
| author | Elizabeth Alexander Hunt <me@liz.coffee> | 2026-11-13 15:28:48 -0800 |
|---|---|---|
| committer | Elizabeth Alexander Hunt <me@liz.coffee> | 2026-11-13 15:37:09 -0800 |
| commit | 009525bd46edfc77068da212e36f38d410503f2c (patch) | |
| tree | f042565bb01d070f626213b72865800ec0dbf576 /android/src | |
| parent | 60426ce45763d0a4a5572db6926e8e677cbc0dc0 (diff) | |
| download | rookery-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')
| -rw-r--r-- | android/src/main/AndroidManifest.xml | 25 | ||||
| -rw-r--r-- | android/src/main/kotlin/coffee/liz/rookery/android/MainActivity.kt | 281 |
2 files changed, 306 insertions, 0 deletions
diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..7266865 --- /dev/null +++ b/android/src/main/AndroidManifest.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> + <uses-permission android:name="android.permission.INTERNET" /> + <uses-permission android:name="android.permission.RECORD_AUDIO" /> + <!-- so the sync folder can be an ordinary shared folder that Syncthing also sees --> + <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" /> + <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" /> + <uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" tools:targetApi="s" /> + <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> + <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" /> + <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" /> + <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" /> + <uses-feature android:name="android.hardware.bluetooth_le" android:required="false" /> + <uses-feature android:name="android.hardware.microphone" android:required="false" /> + + <application android:label="rookery" android:theme="@android:style/Theme.Black.NoTitleBar" android:allowBackup="true"> + <activity android:name=".MainActivity" android:exported="true" android:windowSoftInputMode="adjustResize" + android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|keyboard|uiMode"> + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + <category android:name="android.intent.category.LAUNCHER" /> + </intent-filter> + </activity> + </application> +</manifest> 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() } + } +} |
