From 009525bd46edfc77068da212e36f38d410503f2c Mon Sep 17 00:00:00 2001 From: Elizabeth Alexander Hunt Date: Fri, 13 Nov 2026 15:28:48 -0800 Subject: 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 -. 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 --- .../kotlin/coffee/liz/rookery/Platform.jvm.kt | 6 -- .../kotlin/coffee/liz/rookery/bt/JvmAes128.kt | 17 ---- .../liz/rookery/scramble/TnoodleScrambler.kt | 50 ----------- .../coffee/liz/rookery/store/FolderStorage.kt | 96 ---------------------- 4 files changed, 169 deletions(-) delete mode 100644 core/src/jvmMain/kotlin/coffee/liz/rookery/Platform.jvm.kt delete mode 100644 core/src/jvmMain/kotlin/coffee/liz/rookery/bt/JvmAes128.kt delete mode 100644 core/src/jvmMain/kotlin/coffee/liz/rookery/scramble/TnoodleScrambler.kt delete mode 100644 core/src/jvmMain/kotlin/coffee/liz/rookery/store/FolderStorage.kt (limited to 'core/src/jvmMain') diff --git a/core/src/jvmMain/kotlin/coffee/liz/rookery/Platform.jvm.kt b/core/src/jvmMain/kotlin/coffee/liz/rookery/Platform.jvm.kt deleted file mode 100644 index bbccb57..0000000 --- a/core/src/jvmMain/kotlin/coffee/liz/rookery/Platform.jvm.kt +++ /dev/null @@ -1,6 +0,0 @@ -package coffee.liz.rookery - -actual fun epochMillis(): Long = System.currentTimeMillis() -actual fun monoMillis(): Long = System.nanoTime() / 1_000_000 -actual fun randomId(): String = java.util.UUID.randomUUID().toString().replace("-", "").take(16) -actual fun locked(lock: Any, block: () -> T): T = synchronized(lock, block) diff --git a/core/src/jvmMain/kotlin/coffee/liz/rookery/bt/JvmAes128.kt b/core/src/jvmMain/kotlin/coffee/liz/rookery/bt/JvmAes128.kt deleted file mode 100644 index b96748c..0000000 --- a/core/src/jvmMain/kotlin/coffee/liz/rookery/bt/JvmAes128.kt +++ /dev/null @@ -1,17 +0,0 @@ -package coffee.liz.rookery.bt - -import javax.crypto.Cipher -import javax.crypto.spec.SecretKeySpec - -/** [Aes128] over javax.crypto (AES/ECB/NoPadding); works on desktop JVMs and Android. */ -object JvmAes128 : Aes128 { - private fun run(mode: Int, key: ByteArray, block: ByteArray): ByteArray { - require(key.size == 16 && block.size == 16) { "AES-128 needs a 16-byte key and block" } - val c = Cipher.getInstance("AES/ECB/NoPadding") - c.init(mode, SecretKeySpec(key, "AES")) - return c.doFinal(block) - } - - override fun encryptBlock(key: ByteArray, block: ByteArray): ByteArray = run(Cipher.ENCRYPT_MODE, key, block) - override fun decryptBlock(key: ByteArray, block: ByteArray): ByteArray = run(Cipher.DECRYPT_MODE, key, block) -} diff --git a/core/src/jvmMain/kotlin/coffee/liz/rookery/scramble/TnoodleScrambler.kt b/core/src/jvmMain/kotlin/coffee/liz/rookery/scramble/TnoodleScrambler.kt deleted file mode 100644 index e0e7b31..0000000 --- a/core/src/jvmMain/kotlin/coffee/liz/rookery/scramble/TnoodleScrambler.kt +++ /dev/null @@ -1,50 +0,0 @@ -package coffee.liz.rookery.scramble - -import coffee.liz.rookery.model.Event -import org.worldcubeassociation.tnoodle.scrambles.PuzzleRegistry -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.Executors -import java.util.concurrent.Future - -/** - * WCA-official random-state scrambles from tnoodle-lib, with one scramble generated ahead per event - * so pressing "next" is instant even for 4x4 (whose tables take a few seconds to build). - */ -class TnoodleScrambler : Scrambler { - private val pool = Executors.newSingleThreadExecutor { r -> Thread(r, "scrambler").apply { isDaemon = true; priority = Thread.MIN_PRIORITY } } - private val ahead = ConcurrentHashMap>() - - private fun registry(e: Event): PuzzleRegistry = when (e) { - Event.E333, Event.E333OH -> PuzzleRegistry.THREE - Event.E222 -> PuzzleRegistry.TWO - Event.E444 -> PuzzleRegistry.FOUR - Event.E555 -> PuzzleRegistry.FIVE - Event.E666 -> PuzzleRegistry.SIX - Event.E777 -> PuzzleRegistry.SEVEN - Event.E333BF, Event.E333MBF -> PuzzleRegistry.THREE_NI - Event.E444BF -> PuzzleRegistry.FOUR_NI - Event.E555BF -> PuzzleRegistry.FIVE_NI - Event.E333FM -> PuzzleRegistry.THREE_FM - Event.CLOCK -> PuzzleRegistry.CLOCK - Event.MINX -> PuzzleRegistry.MEGA - Event.PYRAM -> PuzzleRegistry.PYRA - Event.SKEWB -> PuzzleRegistry.SKEWB - Event.SQ1 -> PuzzleRegistry.SQ1 - Event.FTO -> PuzzleRegistry.FTO - } - - private fun submit(e: Event): Future = pool.submit { registry(e).scrambler.generateScramble() } - - override fun warm(event: Event) { ahead.computeIfAbsent(event, ::submit) } - - override fun next(event: Event): String { - val f = ahead.remove(event) ?: submit(event) - val s = f.get() - ahead[event] = submit(event) - return s - } - - override fun previewSvg(event: Event, scramble: String): String? = runCatching { - registry(event).scrambler.drawScramble(scramble, null).toString() - }.getOrNull() -} diff --git a/core/src/jvmMain/kotlin/coffee/liz/rookery/store/FolderStorage.kt b/core/src/jvmMain/kotlin/coffee/liz/rookery/store/FolderStorage.kt deleted file mode 100644 index 64064b5..0000000 --- a/core/src/jvmMain/kotlin/coffee/liz/rookery/store/FolderStorage.kt +++ /dev/null @@ -1,96 +0,0 @@ -package coffee.liz.rookery.store - -import java.io.RandomAccessFile -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.StandardOpenOption -import java.security.MessageDigest -import kotlin.io.path.createDirectories -import kotlin.io.path.exists -import kotlin.io.path.isDirectory -import kotlin.io.path.listDirectoryEntries -import kotlin.io.path.name -import kotlin.io.path.readBytes -import kotlin.io.path.writeBytes - -/** - * Op log in a plain folder, safe under any file-syncing tool: - * - * ``` - * /devices//ops-000001.jsonl only ever writes here - * /blobs/. write-once, content addressed - * ``` - * - * Readers remember a byte offset per file and only consume complete lines, so a half-synced - * file is simply read again later. - */ -class FolderLogStorage(private val root: Path, private val nodeId: String, private val segmentBytes: Long = 8L shl 20) : LogStorage { - private val devices = root.resolve("devices") - private val ownDir = devices.resolve(nodeId).also { it.createDirectories() } - private val offsets = HashMap() - - private fun currentSegment(): Path { - val segs = ownDir.listDirectoryEntries("ops-*.jsonl").sorted() - val last = segs.lastOrNull() - if (last != null && Files.size(last) < segmentBytes) return last - val next = (last?.name?.removePrefix("ops-")?.removeSuffix(".jsonl")?.toIntOrNull() ?: 0) + 1 - return ownDir.resolve("ops-" + next.toString().padStart(6, '0') + ".jsonl") - } - - override fun append(lines: List) { - if (lines.isEmpty()) return - val bytes = lines.joinToString("\n", postfix = "\n").toByteArray() - val seg = currentSegment() - Files.newByteChannel(seg, StandardOpenOption.CREATE, StandardOpenOption.APPEND, StandardOpenOption.WRITE).use { ch -> - val buf = java.nio.ByteBuffer.wrap(bytes) - while (buf.hasRemaining()) ch.write(buf) - (ch as? java.nio.channels.FileChannel)?.force(false) - } - // Our own writes are already applied by the Store; skip them on the next read. - offsets[seg] = (offsets[seg] ?: 0) + bytes.size - } - - override fun readNew(): List { - if (!devices.exists()) return emptyList() - val out = mutableListOf() - for (dev in devices.listDirectoryEntries().filter { it.isDirectory() }.sorted()) { - for (seg in dev.listDirectoryEntries("ops-*.jsonl").sorted()) { - val from = offsets[seg] ?: 0 - val size = Files.size(seg) - if (size <= from) continue - RandomAccessFile(seg.toFile(), "r").use { f -> - f.seek(from) - val buf = ByteArray((size - from).toInt()) - f.readFully(buf) - val end = buf.lastIndexOf('\n'.code.toByte()) - if (end < 0) return@use // torn line still syncing - String(buf, 0, end, Charsets.UTF_8).split('\n').filterTo(out) { it.isNotBlank() } - offsets[seg] = from + end + 1 - } - } - } - return out - } -} - -class FolderBlobStore(root: Path) : BlobStore { - private val dir = root.resolve("blobs").also { it.createDirectories() } - - override fun put(bytes: ByteArray, ext: String): String { - val hash = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } - val name = "$hash.${ext.lowercase().filter(Char::isLetterOrDigit).ifEmpty { "bin" }}" - val path = dir.resolve(name) - if (!path.exists()) { - val tmp = dir.resolve(".$name.tmp") - tmp.writeBytes(bytes) - Files.move(tmp, path, java.nio.file.StandardCopyOption.ATOMIC_MOVE) - } - return name - } - - override fun get(name: String): ByteArray? { - if (name.contains('/') || name.contains("..")) return null - val p = dir.resolve(name) - return if (p.exists()) p.readBytes() else null - } -} -- cgit v1.3