commit 6ffddab52b082f1395514ef2eda60ea55986d949 truex <truex@equwal.com> 2026-09-21 00:12:11 -0700 Add action menus, settings export and import, and extra-dim key actions (0.0.6-alpha) Menu of actions. A gesture can open a menu that holds any number of actions, so there can be more actions than keys. The menu is stored in the payload of its own binding as a JSON array of action specs. There is no second store, so export, import and delete treat a menu like any other binding. A menu cannot hold a menu. MenuActivity closes before it runs the action, because Back, a swipe or voice typing act on the app in front. Export and import. SettingsFile turns the settings into one JSON document and back. It has no Android types. SettingsFile.ALLOWED is the only list of what may pass, in both directions. The licence, the firmware values that PowerControl saved, and facts about one device never enter the file, and a file cannot write them. An import shows what it holds and applies it only after a yes. It keeps only bindings whose trigger this version can parse. Extra-dim key actions, full flavour only: darker, brighter, on and off. Voice typing now prefers a speech app that recognises on the device over one that may use a server, and the Voice typing screen offers Whisper from F-Droid when no on-device speech app is installed. With no language tag the speech app detects the language. The app does not bundle a Whisper model: a multilingual model is 140 MB or more, and with no INTERNET permission the app cannot download one. Tests: JUnit 4 and the org.json jar are new test dependencies. There was no test framework before. Android gives unit tests an empty stub of org.json, so the jar is needed to test SettingsFile. 20 tests: the speech app choice rule, the settings round trip on 500 random cases and its allow-list, the light level steps, the menu round trip.
CHANGELOG.md | 25 +++ README.md | 31 +++- app/build.gradle.kts | 7 + app/src/main/AndroidManifest.xml | 11 ++ .../java/dev/equwal/assistkey/display/ExtraDim.kt | 35 ++++ .../main/java/dev/equwal/assistkey/menu/Menu.kt | 28 ++++ .../java/dev/equwal/assistkey/menu/MenuActivity.kt | 103 ++++++++++++ .../dev/equwal/assistkey/menu/MenuEditActivity.kt | 91 +++++++++++ .../java/dev/equwal/assistkey/model/ActionSpec.kt | 8 +- .../dev/equwal/assistkey/route/ActionRouter.kt | 5 + .../dev/equwal/assistkey/store/SettingsFile.kt | 116 +++++++++++++ .../equwal/assistkey/ui/ActionPickerActivity.kt | 44 ++++- .../java/dev/equwal/assistkey/ui/BackupActivity.kt | 181 +++++++++++++++++++++ .../dev/equwal/assistkey/ui/DisplayActivity.kt | 1 + .../java/dev/equwal/assistkey/ui/MainActivity.kt | 4 + .../java/dev/equwal/assistkey/ui/VoiceActivity.kt | 42 +++-- .../java/dev/equwal/assistkey/voice/Dictation.kt | 32 +++- .../equwal/assistkey/display/ExtraDimStepTest.kt | 30 ++++ .../java/dev/equwal/assistkey/menu/MenuTest.kt | 33 ++++ .../dev/equwal/assistkey/store/SettingsFileTest.kt | 114 +++++++++++++ .../equwal/assistkey/voice/DictationPickTest.kt | 37 +++++ gradle.properties | 4 +- play/LISTING.md | 2 + 23 files changed, 960 insertions(+), 24 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md index bee96fb..98bb3a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## 0.0.6-alpha - 2026-09-21 + +Version code 6. + +- **Voice typing prefers speech recognition on the device.** When the user has + not chosen a speech app, an on-device one (Whisper, FUTO Voice Input, Vosk, + Sayboard, Sherpa) wins over one that may use a server. The Voice typing screen + says which kind each app is. +- **A route to local Whisper.** If no on-device speech app is installed, the + screen offers Whisper from F-Droid (`org.woheller69.whisper`): offline after + the model download, many languages, automatic language detection. +- Language is "Detect the language" by default. A fixed language tag stays + possible. +- **Menu of actions.** A gesture can open a menu that holds any number of + actions, so there can be more actions than keys. The menu lives in its own + binding, so export, import and delete treat it like any other binding. +- **Export and import.** Settings as one JSON file: save, share, open, paste. + The licence, saved firmware values and device facts never enter the file, and + a file cannot write them. +- **Extra-dim from a key.** Three actions under Light: darker, brighter, on and + off. `full` build only. +- First unit tests, 20 in all: the speech app choice rule, the settings file + round trip (500 random cases) and its allow-list, the light level steps, the + menu round trip. Run them with `./gradlew testPlayReleaseUnitTest`. + ## 0.0.5-alpha - 2026-09-21 Version code 5. diff --git a/README.md b/README.md index a629b54..38c5403 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,27 @@ every official route including `cmd display set-brightness`. Writing sticks until the system slider is moved. The node belongs to `system`, so it needs Shizuku running as root or a shell that may `su` (userdebug firmware). +## Menus, and export and import + +`menu/`. The action *Menu of actions* binds a gesture to a menu with any number +of actions. The menu is stored in the payload of its own binding, as a JSON +array of action specs. There is no second store. A menu cannot hold a menu. +`MenuActivity` closes before it runs the chosen action, because Back, a swipe +or voice typing act on the app in front. + +`store/SettingsFile.kt` turns the settings into one JSON document and back. It +has no Android types, so tests run it with no device. `SettingsFile.ALLOWED` is +the only list of what may pass, in both directions. The licence, the firmware +values that `PowerControl` saved, and facts about one device are not in it. + +## Tests + +```bash +./gradlew testPlayReleaseUnitTest # logic, on the computer +tools/e2e/power-wake.sh # on a device, through the kernel input node +tools/e2e/ai-key-return.sh +``` + ## Voice typing `voice/`. The action *Typing > Voice typing* turns a key into a dictation key. @@ -95,6 +116,13 @@ that has input focus, through the accessibility service: `ACTION_SET_TEXT` at the selection, with a clipboard paste as the fallback. It never writes into a password field. +The app does not bundle a Whisper model. A multilingual model is 140 MB or +more, and with no `INTERNET` permission the app could not download one. The +F-Droid app Whisper (`org.woheller69.whisper`) already does this work and +offers a `RecognitionService`, so voice typing uses it. `Dictation.pick` chooses +an on-device speech app before one that may use a server. With no language tag +set, the speech app detects the language. + Two facts cost time: - Android gives the microphone to the app in front only, and an accessibility @@ -389,7 +417,8 @@ engine/ the gesture state machine and the accessibility service channel/ the key filter switch and the Power side doors (assistant, camera, wallet) device/ device profiles display/ the extra-dim light -home/ the home screen and the recent-apps list +home/ the home screen and the recent-apps cards +menu/ the menu of actions shell/ shell access through Shizuku, and the managed Power button voice/ voice typing: recognizer choice, the listening screen, text insert license/ licence tiers and Google Play Billing diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 949cd88..d75620a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -141,4 +141,11 @@ dependencies { // wireless debugging; nothing here needs root or a computer. "fullImplementation"("dev.rikka.shizuku:api:13.1.5") "fullImplementation"("dev.rikka.shizuku:provider:13.1.5") + + // Tests only, never in the APK. JUnit 4 is what the Android Gradle plugin + // runs with no more setup; there was no test framework before it. + testImplementation("junit:junit:4.13.2") + // org.json is part of Android, where unit tests get an empty stub of it. + // This is the same library as a plain jar, so SettingsFile can be tested. + testImplementation("org.json:json:20240303") } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9e08b2a..c94933f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -80,6 +80,17 @@ <activity android:name=".ui.ShellActivity" android:exported="false" /> <activity android:name=".ui.DisplayActivity" android:exported="false" /> <activity android:name=".ui.VoiceActivity" android:exported="false" /> + <activity android:name=".ui.BackupActivity" android:exported="false" /> + <activity android:name=".menu.MenuEditActivity" android:exported="false" /> + <!-- The action menu: a box over the app in front, in a task of its own. --> + <activity + android:name=".menu.MenuActivity" + android:exported="false" + android:excludeFromRecents="true" + android:noHistory="true" + android:launchMode="singleInstance" + android:taskAffinity="" + android:theme="@style/Theme.Invisible" /> <!-- See-through and unfocusable: on top only so the microphone is allowed. --> <activity android:name=".voice.DictationActivity" diff --git a/app/src/main/java/dev/equwal/assistkey/display/ExtraDim.kt b/app/src/main/java/dev/equwal/assistkey/display/ExtraDim.kt index 52e8f17..41c9806 100644 --- a/app/src/main/java/dev/equwal/assistkey/display/ExtraDim.kt +++ b/app/src/main/java/dev/equwal/assistkey/display/ExtraDim.kt @@ -41,6 +41,41 @@ object ExtraDim { if (level == 0) restore(done) else write(level, done) } + // ---- from a key --------------------------------------------------------------------- + + private const val K_LAST = "extra_dim_last" + + /** + * The order of brightness, darkest last: off (the system level), then the + * levels under the floor from high to low. [darker] is one step down that + * order and [brighter] one step up; both stop at the ends. + */ + fun darker(level: Int, levels: List<Int>): Int = when { + levels.isEmpty() -> 0 + level == 0 -> levels.first() + else -> levels.getOrElse(levels.indexOf(level) + 1) { levels.last() } + } + + fun brighter(level: Int, levels: List<Int>): Int { + val i = levels.indexOf(level) + return if (i <= 0) 0 else levels[i - 1] + } + + /** Runs a key action: "darker", "brighter" or "toggle". */ + fun act(c: Context, what: String, done: (Boolean) -> Unit = {}) { + val p = c.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val now = level(c) + val next = when (what) { + "darker" -> darker(now, levels()) + "brighter" -> brighter(now, levels()) + // Toggle goes back to the level that was in use, not to a fixed one. + else -> if (now > 0) 0 else p.getInt(K_LAST, levels().getOrElse(levels().size / 2) { 0 }) + } + if (next > 0) p.edit().putInt(K_LAST, next).apply() + if (next == now) return done(true) + set(c, next, done) + } + /** Re-asserts the chosen level; called when the screen comes on. */ fun reapply(c: Context) { val l = level(c) diff --git a/app/src/main/java/dev/equwal/assistkey/menu/Menu.kt b/app/src/main/java/dev/equwal/assistkey/menu/Menu.kt new file mode 100644 index 0000000..71ea6a7 --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/menu/Menu.kt @@ -0,0 +1,28 @@ +package dev.equwal.assistkey.menu + +import dev.equwal.assistkey.model.ActionKind +import dev.equwal.assistkey.model.ActionSpec +import org.json.JSONArray + +/** + * A menu of actions, bound to a gesture like any other action. + * + * The menu lives in the payload of its own binding, as a JSON array of action + * specs. There is no second store, so a menu is exported, imported and deleted + * together with the binding that opens it. A menu cannot hold another menu. + */ +object Menu { + + fun encode(items: List<ActionSpec>): String = + JSONArray().apply { items.forEach { put(it.toJson()) } }.toString() + + fun decode(payload: String): List<ActionSpec> = runCatching { + val a = JSONArray(payload) + (0 until a.length()).mapNotNull { a.optJSONObject(it) } + .map(ActionSpec::fromJson) + .filter { it.kind != ActionKind.MENU && it.kind != ActionKind.PASS_THROUGH } + }.getOrDefault(emptyList()) + + fun spec(items: List<ActionSpec>): ActionSpec = + ActionSpec(ActionKind.MENU, encode(items), "Menu: " + items.joinToString(", ") { it.describe() }) +} diff --git a/app/src/main/java/dev/equwal/assistkey/menu/MenuActivity.kt b/app/src/main/java/dev/equwal/assistkey/menu/MenuActivity.kt new file mode 100644 index 0000000..ad36771 --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/menu/MenuActivity.kt @@ -0,0 +1,103 @@ +package dev.equwal.assistkey.menu + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.TypedValue +import android.view.Gravity +import android.view.View +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView +import dev.equwal.assistkey.model.ActionSpec +import dev.equwal.assistkey.route.ActionRouter +import dev.equwal.assistkey.route.ServiceHolder +import dev.equwal.assistkey.ui.Ui.dp + +/** + * The menu on screen: a box of actions over the app in front. + * + * A tap on an action closes the menu first and runs the action a moment later. + * The order matters: Back, a swipe or voice typing act on the app in front, and + * until the menu is gone the app in front is the menu. A tap outside the box + * closes the menu and does nothing. + */ +class MenuActivity : Activity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val items = Menu.decode(intent.getStringExtra(EXTRA_ITEMS).orEmpty()) + if (items.isEmpty()) return leave() + + val list = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } + items.forEachIndexed { i, spec -> + if (i > 0) list.addView(View(this).apply { setBackgroundColor(Color.BLACK) }, LinearLayout.LayoutParams(MATCH_PARENT, dp(1))) + list.addView(TextView(this).apply { + text = spec.describe() + setTextColor(Color.BLACK) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 20f) + setPadding(dp(20), dp(18), dp(20), dp(18)) + setOnClickListener { pick(spec) } + }) + } + val box = ScrollView(this).apply { + background = GradientDrawable().apply { + setColor(Color.WHITE) + setStroke(dp(2), Color.BLACK) + cornerRadius = dp(12).toFloat() + } + val p = dp(3) + setPadding(p, p, p, p) + addView(list, LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)) + } + setContentView( + FrameLayout(this).apply { + setOnClickListener { leave() } + addView( + box, + FrameLayout.LayoutParams((resources.displayMetrics.widthPixels * 0.8f).toInt(), WRAP_CONTENT, Gravity.CENTER) + ) + } + ) + } + + private fun pick(spec: ActionSpec) { + val app = applicationContext + leave() + Handler(Looper.getMainLooper()).postDelayed( + // The accessibility service may start activities from the background; + // a plain application context may not. + { ActionRouter.run(ServiceHolder.service ?: app, spec) }, + AFTER_CLOSE_MS + ) + } + + private fun leave() { + finish() + @Suppress("DEPRECATION") + overridePendingTransition(0, 0) + } + + companion object { + private const val EXTRA_ITEMS = "items" + + /** Time for the app behind the menu to be in front again. */ + private const val AFTER_CLOSE_MS = 300L + + fun open(c: Context, payload: String): Boolean = runCatching { + c.startActivity( + Intent(c, MenuActivity::class.java).putExtra(EXTRA_ITEMS, payload) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_ANIMATION) + ) + true + }.getOrDefault(false) + } +} diff --git a/app/src/main/java/dev/equwal/assistkey/menu/MenuEditActivity.kt b/app/src/main/java/dev/equwal/assistkey/menu/MenuEditActivity.kt new file mode 100644 index 0000000..f22ef1c --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/menu/MenuEditActivity.kt @@ -0,0 +1,91 @@ +package dev.equwal.assistkey.menu + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Bundle +import dev.equwal.assistkey.model.ActionKind +import dev.equwal.assistkey.model.ActionSpec +import dev.equwal.assistkey.model.Trigger +import dev.equwal.assistkey.store.Store +import dev.equwal.assistkey.ui.ActionPickerActivity +import dev.equwal.assistkey.ui.Ui +import dev.equwal.assistkey.ui.Ui.button +import dev.equwal.assistkey.ui.Ui.header +import dev.equwal.assistkey.ui.Ui.note +import dev.equwal.assistkey.ui.Ui.row +import dev.equwal.assistkey.ui.Ui.title +import org.json.JSONObject + +/** Builds the menu that one gesture opens: add actions, order them, save. */ +class MenuEditActivity : Activity() { + + private lateinit var trigger: Trigger + private val items = ArrayList<ActionSpec>() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + trigger = Trigger.parse(intent.getStringExtra(EXTRA_TRIGGER).orEmpty()) ?: run { finish(); return } + val saved = savedInstanceState?.getString(STATE) + val current = Store.bindings(this).raw(trigger) + items.addAll(Menu.decode(saved ?: if (current.kind == ActionKind.MENU) current.payload else "[]")) + build() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putString(STATE, Menu.encode(items)) + } + + private fun build() { + val col = Ui.page(this) + col.title("Menu for " + trigger.label()) + col.note( + "One gesture opens a menu, and the menu holds as many actions as you " + + "want. That gives you more actions than you have keys." + ) + + col.header(if (items.isEmpty()) "No actions yet" else "Actions, in menu order") + items.forEachIndexed { i, spec -> + col.row(spec.describe(), "Tap to move or remove") { + val choices = listOf("Move up", "Move down", "Remove") + Ui.pick(this, spec.describe(), choices) { which -> + when (which) { + 0 -> if (i > 0) items.add(i - 1, items.removeAt(i)) + 1 -> if (i < items.size - 1) items.add(i + 1, items.removeAt(i)) + 2 -> items.removeAt(i) + } + build() + } + } + } + col.button("Add an action") { + startActivityForResult(ActionPickerActivity.pickIntent(this, trigger), PICK) + } + + col.header("Finish") + col.button(if (items.isEmpty()) "Remove the menu" else "Save the menu") { + Store.bind(this, trigger, if (items.isEmpty()) ActionSpec.PASS else Menu.spec(items)) + setResult(RESULT_OK) + finish() + } + col.button("Try it") { MenuActivity.open(this, Menu.encode(items)) } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode != PICK || resultCode != RESULT_OK) return + val json = data?.getStringExtra(ActionPickerActivity.RESULT_SPEC) ?: return + runCatching { ActionSpec.fromJson(JSONObject(json)) }.getOrNull()?.let(items::add) + build() + } + + companion object { + private const val EXTRA_TRIGGER = "trigger" + private const val STATE = "items" + private const val PICK = 1 + + fun intent(c: Context, t: Trigger): Intent = + Intent(c, MenuEditActivity::class.java).putExtra(EXTRA_TRIGGER, t.id) + } +} diff --git a/app/src/main/java/dev/equwal/assistkey/model/ActionSpec.kt b/app/src/main/java/dev/equwal/assistkey/model/ActionSpec.kt index a407199..fbe7861 100644 --- a/app/src/main/java/dev/equwal/assistkey/model/ActionSpec.kt +++ b/app/src/main/java/dev/equwal/assistkey/model/ActionSpec.kt @@ -42,7 +42,13 @@ enum class ActionKind { SCROLL, /** Speak, and the words go where the cursor is. The payload is not used. */ - VOICE; + VOICE, + + /** The extra-dim light. The payload is "darker", "brighter" or "toggle". */ + DIM, + + /** Opens a menu of actions. The payload is a JSON array of action specs. */ + MENU; companion object { fun fromName(n: String): ActionKind = diff --git a/app/src/main/java/dev/equwal/assistkey/route/ActionRouter.kt b/app/src/main/java/dev/equwal/assistkey/route/ActionRouter.kt index 7a37cef..e5fd816 100644 --- a/app/src/main/java/dev/equwal/assistkey/route/ActionRouter.kt +++ b/app/src/main/java/dev/equwal/assistkey/route/ActionRouter.kt @@ -32,6 +32,11 @@ object ActionRouter { ActionKind.BROADCAST -> broadcast(ctx, spec.payload) ActionKind.SWIPE -> swipe(spec.payload) ActionKind.SCROLL -> scroll(spec.payload) + ActionKind.MENU -> dev.equwal.assistkey.menu.MenuActivity.open(ctx, spec.payload) + ActionKind.DIM -> { + dev.equwal.assistkey.display.ExtraDim.act(ctx.applicationContext, spec.payload) + true + } ActionKind.VOICE -> ServiceHolder.service ?.let { dev.equwal.assistkey.voice.Dictation.toggle(it) } ?: false } diff --git a/app/src/main/java/dev/equwal/assistkey/store/SettingsFile.kt b/app/src/main/java/dev/equwal/assistkey/store/SettingsFile.kt new file mode 100644 index 0000000..b3b24dc --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/store/SettingsFile.kt @@ -0,0 +1,116 @@ +package dev.equwal.assistkey.store + +import org.json.JSONArray +import org.json.JSONObject + +/** + * Settings as one JSON document, to move them to another device or to share. + * + * This file has no Android types, so a test can run it with no device. The + * caller reads and writes the preference files; this object turns their content + * into text and back, and decides what may pass. + * + * Only the names in [ALLOWED] pass, in both directions. The licence, the + * firmware values the app saved, and what is true of one device only (the keys + * it has, its light level) are not settings and never enter the file. A file + * from a stranger therefore cannot unlock the app or write a value this list + * does not name. + * + * Each value carries its type, because JSON has one number type and a + * preference file has three. + */ +object SettingsFile { + + const val APP = "AssistKey" + const val FORMAT = 1 + + /** Preference file name to the keys that may pass. `null` means every key in that file. */ + val ALLOWED: Map<String, Set<String>?> = mapOf( + "assistkey" to setOf("bindings", "multitap_ms", "hold_ms", "chord_ms"), + "assistkey_channels" to null, + "assistkey_nav" to setOf("buttons", "gestures", "keys"), + "assistkey_home" to setOf("favourites", "hidden", "clock", "auto_open"), + "assistkey_voice" to setOf("engine", "language"), + "assistkey_device" to setOf("ai_key_returns"), + "assistkey_power" to setOf("wanted") + ) + + class BadFile(message: String) : Exception(message) + + fun allowed(file: String, key: String): Boolean = + ALLOWED.containsKey(file) && (ALLOWED[file]?.contains(key) ?: true) + + /** [settings] is preference file name to its key-value content. Entries that may not pass are dropped. */ + fun encode(settings: Map<String, Map<String, Any?>>, appVersion: String, device: String): String { + val files = JSONObject() + settings.toSortedMap().forEach { (file, values) -> + val out = JSONObject() + values.toSortedMap().forEach { (key, value) -> + if (allowed(file, key)) wrap(key, value)?.let { out.put(key, it) } + } + if (out.length() > 0) files.put(file, out) + } + return JSONObject() + .put("app", APP) + .put("format", FORMAT) + .put("version", appVersion) + .put("device", device) + .put("settings", files) + .toString(2) + } + + /** The opposite of [encode]. Throws [BadFile] when the text is not a settings file of this app. */ + fun decode(text: String): Map<String, Map<String, Any>> { + val root = try { + JSONObject(text) + } catch (e: Exception) { + throw BadFile("This is not a settings file.") + } + if (root.optString("app") != APP) throw BadFile("This is not an AssistKey settings file.") + if (root.optInt("format", -1) != FORMAT) { + throw BadFile("This settings file is from a newer version of AssistKey.") + } + val files = root.optJSONObject("settings") ?: throw BadFile("This settings file is empty.") + + val result = LinkedHashMap<String, Map<String, Any>>() + files.keys().forEach { file -> + val content = files.optJSONObject(file) ?: return@forEach + val values = LinkedHashMap<String, Any>() + content.keys().forEach { key -> + if (allowed(file, key)) unwrap(key, content.optJSONObject(key))?.let { values[key] = it } + } + if (values.isNotEmpty()) result[file] = values + } + return result + } + + // ---- one value ------------------------------------------------------------------------- + + /** Bindings are JSON already; they go in as an object, so a person can read and edit them. */ + private fun wrap(key: String, value: Any?): JSONObject? = when { + value == null -> null + key == "bindings" && value is String -> + runCatching { JSONObject().put("t", "json").put("v", JSONObject(value)) }.getOrNull() + value is String -> JSONObject().put("t", "s").put("v", value) + value is Boolean -> JSONObject().put("t", "b").put("v", value) + value is Int -> JSONObject().put("t", "i").put("v", value) + value is Long -> JSONObject().put("t", "l").put("v", value) + value is Float -> JSONObject().put("t", "f").put("v", value.toDouble()) + value is Set<*> -> JSONObject().put("t", "set").put("v", JSONArray(value.map { it.toString() }.sorted())) + else -> null + } + + private fun unwrap(key: String, o: JSONObject?): Any? { + if (o == null || !o.has("v")) return null + return when (o.optString("t")) { + "json" -> if (key == "bindings") o.optJSONObject("v")?.toString() else null + "s" -> o.opt("v") as? String + "b" -> o.opt("v") as? Boolean + "i" -> (o.opt("v") as? Number)?.toInt() + "l" -> (o.opt("v") as? Number)?.toLong() + "f" -> (o.opt("v") as? Number)?.toFloat() + "set" -> o.optJSONArray("v")?.let { a -> (0 until a.length()).map { a.optString(it) }.toSet() } + else -> null + } + } +} diff --git a/app/src/main/java/dev/equwal/assistkey/ui/ActionPickerActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/ActionPickerActivity.kt index 8af03ea..e231be6 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/ActionPickerActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/ActionPickerActivity.kt @@ -30,17 +30,43 @@ class ActionPickerActivity : Activity() { super.onCreate(savedInstanceState) trigger = Trigger.parse(intent.getStringExtra(EXTRA_TRIGGER).orEmpty()) ?: run { finish(); return } + pickOnly = intent.getBooleanExtra(EXTRA_PICK_ONLY, false) build() } + /** True when the choice goes back to the caller and is not bound to the trigger. */ + private var pickOnly = false + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + // The menu editor saved: nothing more to pick here. + if (requestCode == MENU && resultCode == RESULT_OK) finish() + } + private fun build() { val col = Ui.page(this) - col.title(trigger.label()) - col.note("Currently: " + Store.bindings(this).raw(trigger).describe()) + col.title(if (pickOnly) "Add to the menu" else trigger.label()) + if (!pickOnly) { + col.note("Currently: " + Store.bindings(this).raw(trigger).describe()) + col.header("More than one") + col.row("Menu of actions", "This gesture opens a menu, and the menu holds as many actions as you want") { + startActivityForResult(dev.equwal.assistkey.menu.MenuEditActivity.intent(this, trigger), MENU) + } + } - basics(col) + if (!pickOnly) basics(col) viwoods(col) navigation(col) + if (dev.equwal.assistkey.shell.Shell.SUPPORTED) { + col.header("Light") + listOf( + "Extra-dim: darker" to "darker", + "Extra-dim: brighter" to "brighter", + "Extra-dim: on and off" to "toggle" + ).forEach { (label, what) -> + col.row(label, null) { choose(ActionSpec(ActionKind.DIM, what, label)) } + } + } col.header("Typing") col.row("Voice typing", "Speak, and the words go where the cursor is") { choose(ActionSpec(ActionKind.VOICE, "", "Voice typing")) @@ -52,7 +78,11 @@ class ActionPickerActivity : Activity() { } private fun choose(spec: ActionSpec) { - Store.bind(this, trigger, spec) + if (pickOnly) { + setResult(RESULT_OK, Intent().putExtra(RESULT_SPEC, spec.toJson().toString())) + } else { + Store.bind(this, trigger, spec) + } finish() } @@ -202,6 +232,12 @@ class ActionPickerActivity : Activity() { companion object { private const val EXTRA_TRIGGER = "trigger" + private const val EXTRA_PICK_ONLY = "pick_only" + private const val MENU = 7 + const val RESULT_SPEC = "spec" + + /** Opens the picker to choose one action for a menu; the result carries [RESULT_SPEC]. */ + fun pickIntent(c: Context, t: Trigger): Intent = intent(c, t).putExtra(EXTRA_PICK_ONLY, true) fun intent(c: Context, t: Trigger): Intent = Intent(c, ActionPickerActivity::class.java).putExtra(EXTRA_TRIGGER, t.id) diff --git a/app/src/main/java/dev/equwal/assistkey/ui/BackupActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/BackupActivity.kt new file mode 100644 index 0000000..de5a6c9 --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/ui/BackupActivity.kt @@ -0,0 +1,181 @@ +package dev.equwal.assistkey.ui + +import android.app.Activity +import android.app.AlertDialog +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.widget.Toast +import dev.equwal.assistkey.BuildConfig +import dev.equwal.assistkey.channel.Channels +import dev.equwal.assistkey.device.Device +import dev.equwal.assistkey.engine.KeyFilterService +import dev.equwal.assistkey.model.Trigger +import dev.equwal.assistkey.route.ServiceHolder +import dev.equwal.assistkey.store.SettingsFile +import dev.equwal.assistkey.store.Store +import dev.equwal.assistkey.ui.Ui.button +import dev.equwal.assistkey.ui.Ui.header +import dev.equwal.assistkey.ui.Ui.note +import dev.equwal.assistkey.ui.Ui.title +import org.json.JSONObject + +/** + * Export and import of the settings, as one JSON file. + * + * What is in the file, and what can never be, is decided by [SettingsFile]. + * This screen moves the text: to a file, to another app, and back in. + */ +class BackupActivity : Activity() { + + private companion object { + const val SAVE = 1 + const val OPEN = 2 + const val FILE_NAME = "assistkey-settings.json" + } + + override fun onResume() { + super.onResume() + build() + } + + private fun build() { + val col = Ui.page(this) + col.title("Export and import") + col.note( + "Your key bindings, timing, navigation setup, home screen and voice " + + "typing choices, as one file. Use it to move to another device, or to " + + "share a setup with other people." + ) + + col.header("Export") + col.button("Save to a file") { + start( + Intent(Intent.ACTION_CREATE_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE) + .setType("application/json").putExtra(Intent.EXTRA_TITLE, FILE_NAME), + SAVE + ) + } + col.button("Share") { + start( + Intent.createChooser( + Intent(Intent.ACTION_SEND).setType("text/plain") + .putExtra(Intent.EXTRA_SUBJECT, "AssistKey settings") + .putExtra(Intent.EXTRA_TEXT, export()), + "Share settings" + ), + 0 + ) + } + col.note("The file does not contain your licence, and nothing about you or your apps beyond the apps you bound to keys or put on the home screen.") + + col.header("Import") + col.button("Open a file") { + start(Intent(Intent.ACTION_OPEN_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType("*/*"), OPEN) + } + col.button("Paste from the clipboard") { + val clip = getSystemService(android.content.ClipboardManager::class.java) + ?.primaryClip?.getItemAt(0)?.coerceToText(this)?.toString() + if (clip.isNullOrBlank()) toast("The clipboard is empty") else offer(clip) + } + col.note( + "An import replaces the settings it contains and leaves the others as " + + "they are. A binding can open an app or send an intent, so import " + + "files only from people you trust." + ) + } + + private fun start(i: Intent, code: Int) { + runCatching { if (code == 0) startActivity(i) else startActivityForResult(i, code) } + .onFailure { toast("No app on this device can do that") } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + val uri: Uri = data?.data ?: return + if (resultCode != RESULT_OK) return + when (requestCode) { + SAVE -> runCatching { + contentResolver.openOutputStream(uri)?.use { it.write(export().toByteArray()) } + }.onSuccess { toast("Saved") }.onFailure { toast("Could not write the file") } + + OPEN -> runCatching { + contentResolver.openInputStream(uri)?.use { it.readBytes().toString(Charsets.UTF_8) } + }.getOrNull()?.let(::offer) ?: toast("Could not read the file") + } + } + + // ---- export --------------------------------------------------------------------------------- + + private fun export(): String { + val settings = SettingsFile.ALLOWED.keys.associateWith { file -> + getSharedPreferences(file, Context.MODE_PRIVATE).all + } + return SettingsFile.encode(settings, BuildConfig.VERSION_NAME, Device.name) + } + + // ---- import --------------------------------------------------------------------------------- + + /** Reads the text, says what is in it, and applies it only after a yes. */ + private fun offer(text: String) { + val settings = try { + SettingsFile.decode(text) + } catch (e: SettingsFile.BadFile) { + return toast(e.message ?: "This is not a settings file.") + } + if (settings.isEmpty()) return toast("That file has no settings in it") + + val bindings = (settings["assistkey"]?.get("bindings") as? String) + ?.let { runCatching { JSONObject(it) }.getOrNull() } + val count = bindings?.keys()?.asSequence()?.count { Trigger.parse(it) != null } ?: 0 + AlertDialog.Builder(this) + .setTitle("Import these settings?") + .setMessage( + count.toString() + " key bindings, and settings for: " + + settings.keys.joinToString(", ") { it.removePrefix("assistkey_").replace("assistkey", "keys") } + + ".\n\nThey replace what you have now." + ) + .setPositiveButton("Import") { _, _ -> apply(settings) } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun apply(settings: Map<String, Map<String, Any>>) { + settings.forEach { (file, values) -> + val e = getSharedPreferences(file, Context.MODE_PRIVATE).edit() + values.forEach { (key, value) -> + when (value) { + is String -> e.putString(key, if (key == "bindings") cleanBindings(value) else value) + is Boolean -> e.putBoolean(key, value) + is Int -> e.putInt(key, value) + is Long -> e.putLong(key, value) + is Float -> e.putFloat(key, value) + is Set<*> -> e.putStringSet(key, value.map { it.toString() }.toSet()) + } + } + e.apply() + } + // Everything that holds a copy of the old settings has to look again. + Store.invalidate() + Channels.syncComponents(this) + (ServiceHolder.service as? KeyFilterService)?.let { + it.reloadTiming() + it.syncPower() + } + toast("Imported") + build() + } + + /** Keeps only the bindings whose trigger this version understands. */ + private fun cleanBindings(json: String): String { + val source = runCatching { JSONObject(json) }.getOrNull() ?: return "{}" + val out = JSONObject() + source.keys().forEach { id -> + val spec = source.optJSONObject(id) + if (Trigger.parse(id) != null && spec != null) out.put(id, spec) + } + return out.toString() + } + + private fun toast(message: String) = Toast.makeText(this, message, Toast.LENGTH_LONG).show() +} diff --git a/app/src/main/java/dev/equwal/assistkey/ui/DisplayActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/DisplayActivity.kt index 7b49483..2f4a3c6 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/DisplayActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/DisplayActivity.kt @@ -56,6 +56,7 @@ class DisplayActivity : Activity() { "put back whenever the screen comes on. If this device does not let " + "the shell reach the light, choosing a level will say so." ) + col.note("To change the level from a key, bind one of the Light actions: darker, brighter, or on and off.") if (Device.brightnessFloor == null) { col.note("This device's floor has not been measured, so these values are a guess.") } diff --git a/app/src/main/java/dev/equwal/assistkey/ui/MainActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/MainActivity.kt index db55f8a..f3fa1f1 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/MainActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/MainActivity.kt @@ -257,6 +257,10 @@ class MainActivity : Activity() { ) { startActivity(Intent(this, ViwoodsActivity::class.java)) } } + col.row("Export and import", "Save your settings to a file, or share them") { + startActivity(Intent(this, BackupActivity::class.java)) + } + col.row( "Device report", "Help get this device fully supported - you see everything before it is sent" diff --git a/app/src/main/java/dev/equwal/assistkey/ui/VoiceActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/VoiceActivity.kt index 20781f7..aed55d9 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/VoiceActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/VoiceActivity.kt @@ -59,26 +59,44 @@ class VoiceActivity : Activity() { col.header("2. Speech recognition app") val engines = Dictation.engines(this) - if (engines.isEmpty()) { + val current = Dictation.engine(this) + engines.forEach { e -> + val mark = if (e.component == current) "* " else " " + val where = if (Dictation.isOnDevice(e)) "Recognises on this device" else "May send speech to its own server" + col.row(mark + e.title, where) { + Dictation.setEngine(this, e.component) + build() + } + } + if (engines.none(Dictation::isOnDevice)) { col.note( - "This device has none. Install a voice input app that offers speech " + - "recognition to other apps. For recognition on the device with a " + - "Whisper model, look for FUTO Voice Input or Whisper by woheller69." + "No app on this device recognises speech offline. Whisper, from " + + "F-Droid, is free, works with no connection after its model is " + + "downloaded, and detects the language by itself." ) - } else { - val current = Dictation.engine(this) - engines.forEach { e -> - val mark = if (e.component == current) "* " else " " - col.row(mark + e.title, e.component.packageName) { - Dictation.setEngine(this, e.component) - build() + col.button("Get Whisper") { + val pages = listOf( + "market://details?id=" + Dictation.WHISPER_PACKAGE, + "https://f-droid.org/packages/" + Dictation.WHISPER_PACKAGE + "/" + ) + pages.firstOrNull { url -> + runCatching { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) }.isSuccess } } + col.note( + "In Whisper, download the multilingual model and allow the microphone. " + + "Then come back here: it appears in this list." + ) } col.header("3. Language") val lang = Dictation.language(this) - col.row(if (lang.isEmpty()) "The speech app decides" else lang, "A tag such as en-US or de-DE") { + col.note( + "Leave this empty to let the speech app detect the language. Whisper " + + "detects it from what you say, so you can change language from one " + + "sentence to the next." + ) + col.row(if (lang.isEmpty()) "Detect the language" else lang, "Or force one: a tag such as en-US or de-DE") { Ui.textInput(this, "Language tag", "en-US", lang) { Dictation.setLanguage(this, it); build() } } if (lang.isNotEmpty()) col.button("Clear the language") { Dictation.setLanguage(this, ""); build() } diff --git a/app/src/main/java/dev/equwal/assistkey/voice/Dictation.kt b/app/src/main/java/dev/equwal/assistkey/voice/Dictation.kt index 5568d72..d018c9b 100644 --- a/app/src/main/java/dev/equwal/assistkey/voice/Dictation.kt +++ b/app/src/main/java/dev/equwal/assistkey/voice/Dictation.kt @@ -40,12 +40,36 @@ object Dictation { ) } - /** The engine the user chose, else the only one, else the system default (null). */ + /** + * Package name fragments of speech apps that recognise on the device. One of + * these is the default when the user has not chosen, because voice typing + * must not send speech to a server unless the user asks for that. + */ + private val onDevice = listOf("whisper", "futo", "vosk", "sayboard", "sherpa") + + /** The F-Droid app that recognises on the device, in many languages, with language detection. */ + const val WHISPER_PACKAGE = "org.woheller69.whisper" + + fun isOnDevice(e: Engine): Boolean = isOnDevice(e.component.packageName) + + fun isOnDevice(packageName: String): Boolean = onDevice.any { it in packageName.lowercase() } + + /** + * The choice rule, on plain strings so that a test can run it with no + * device. [engines] are flattened component names, `package/class`. + * Order: the saved one if it is still installed, else the first on-device + * one, else the only one, else null (the system default). + */ + fun pick(saved: String?, engines: List<String>): String? = + engines.firstOrNull { it == saved } + ?: engines.firstOrNull { isOnDevice(it.substringBefore('/')) } + ?: engines.singleOrNull() + + /** The engine the user chose, else an on-device one, else the only one, else the system default (null). */ fun engine(c: Context): ComponentName? { - val all = engines(c) val saved = c.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(K_ENGINE, null) - return all.firstOrNull { it.component.flattenToString() == saved }?.component - ?: all.singleOrNull()?.component + return pick(saved, engines(c).map { it.component.flattenToString() }) + ?.let(ComponentName::unflattenFromString) } fun setEngine(c: Context, component: ComponentName?) { diff --git a/app/src/test/java/dev/equwal/assistkey/display/ExtraDimStepTest.kt b/app/src/test/java/dev/equwal/assistkey/display/ExtraDimStepTest.kt new file mode 100644 index 0000000..7d95cd8 --- /dev/null +++ b/app/src/test/java/dev/equwal/assistkey/display/ExtraDimStepTest.kt @@ -0,0 +1,30 @@ +package dev.equwal.assistkey.display + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** The order of light levels that the darker and brighter key actions walk. */ +class ExtraDimStepTest { + + // Floor 5: the levels under it, from high to low. 0 means off. + private val levels = listOf(4, 3, 2, 1) + + @Test fun `darker goes from off through every level and stops at the lowest`() { + val walk = generateSequence(0) { ExtraDim.darker(it, levels) }.take(7).toList() + assertEquals(listOf(0, 4, 3, 2, 1, 1, 1), walk) + } + + @Test fun `brighter goes back up and ends at off`() { + val walk = generateSequence(1) { ExtraDim.brighter(it, levels) }.take(7).toList() + assertEquals(listOf(1, 2, 3, 4, 0, 0, 0), walk) + } + + @Test fun `brighter undoes darker at every level but the ends`() { + listOf(4, 3, 2).forEach { assertEquals(it, ExtraDim.brighter(ExtraDim.darker(it, levels), levels)) } + } + + @Test fun `a device with no levels stays off`() { + assertEquals(0, ExtraDim.darker(0, emptyList())) + assertEquals(0, ExtraDim.brighter(0, emptyList())) + } +} diff --git a/app/src/test/java/dev/equwal/assistkey/menu/MenuTest.kt b/app/src/test/java/dev/equwal/assistkey/menu/MenuTest.kt new file mode 100644 index 0000000..fc5a8f6 --- /dev/null +++ b/app/src/test/java/dev/equwal/assistkey/menu/MenuTest.kt @@ -0,0 +1,33 @@ +package dev.equwal.assistkey.menu + +import dev.equwal.assistkey.model.ActionKind +import dev.equwal.assistkey.model.ActionSpec +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class MenuTest { + + private val back = ActionSpec(ActionKind.GLOBAL, "BACK", "Back") + private val voice = ActionSpec(ActionKind.VOICE, "", "Voice typing") + private val app = ActionSpec(ActionKind.LAUNCH_APP, "org.koreader.launcher", "KOReader \"nightly\"") + + @Test fun `decode gives back the items encode was given, in order`() { + val items = listOf(back, voice, app) + assertEquals(items, Menu.decode(Menu.encode(items))) + } + + @Test fun `a menu inside a menu is dropped`() { + val inner = Menu.spec(listOf(back)) + assertEquals(listOf(voice), Menu.decode(Menu.encode(listOf(inner, voice)))) + } + + @Test fun `text that is not a menu is an empty menu`() { + assertTrue(Menu.decode("not json").isEmpty()) + assertTrue(Menu.decode("").isEmpty()) + } + + @Test fun `the binding label names the actions`() { + assertEquals("Menu: Back, Voice typing", Menu.spec(listOf(back, voice)).label) + } +} diff --git a/app/src/test/java/dev/equwal/assistkey/store/SettingsFileTest.kt b/app/src/test/java/dev/equwal/assistkey/store/SettingsFileTest.kt new file mode 100644 index 0000000..33c42d4 --- /dev/null +++ b/app/src/test/java/dev/equwal/assistkey/store/SettingsFileTest.kt @@ -0,0 +1,114 @@ +package dev.equwal.assistkey.store + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Random + +class SettingsFileTest { + + private fun encode(s: Map<String, Map<String, Any?>>) = SettingsFile.encode(s, "test", "bench") + + // ---- round trip, on random allowed settings ------------------------------------------ + + private fun randomText(r: Random): String { + val alphabet = "abcXYZ 09_-./:\"\\{}[]\n\u00e9\u4e2d\u2603" + return (0 until r.nextInt(12)).map { alphabet[r.nextInt(alphabet.length)] }.joinToString("") + } + + private fun randomValue(r: Random): Any = when (r.nextInt(6)) { + 0 -> randomText(r) + 1 -> r.nextBoolean() + 2 -> r.nextInt() + // A long outside the int range, so that the two number types stay apart. + 3 -> Int.MAX_VALUE.toLong() + 1 + r.nextInt(1000) + 4 -> r.nextInt(1000) / 8f + else -> (0 until r.nextInt(4)).map { randomText(r) }.toSet() + } + + private fun randomSettings(r: Random): Map<String, Map<String, Any>> { + val out = LinkedHashMap<String, Map<String, Any>>() + SettingsFile.ALLOWED.forEach { (file, keys) -> + val names = keys?.filter { it != "bindings" } ?: listOf("accessibility", "assistant", "camera") + val values = names.filter { r.nextBoolean() }.associateWith { randomValue(r) } + if (values.isNotEmpty()) out[file] = values + } + return out + } + + @Test fun `decode gives back what encode was given, for 500 random settings`() { + val r = Random(20260921) + repeat(500) { + val settings = randomSettings(r) + assertEquals(settings, SettingsFile.decode(encode(settings))) + } + } + + @Test fun `bindings go in as a JSON object and come back with the same content`() { + val bindings = JSONObject() + .put("power:tap:1", JSONObject().put("kind", "GLOBAL").put("payload", "BACK").put("label", "Back")) + .put("ai:hold:1", JSONObject().put("kind", "VOICE").put("payload", "").put("label", "Voice typing")) + val text = encode(mapOf("assistkey" to mapOf("bindings" to bindings.toString(), "hold_ms" to 450))) + + val stored = JSONObject(text).getJSONObject("settings").getJSONObject("assistkey").getJSONObject("bindings") + assertEquals("json", stored.getString("t")) + assertEquals("BACK", stored.getJSONObject("v").getJSONObject("power:tap:1").getString("payload")) + + val back = SettingsFile.decode(text).getValue("assistkey") + val again = JSONObject(back.getValue("bindings") as String) + assertEquals(2, again.length()) + assertEquals("BACK", again.getJSONObject("power:tap:1").getString("payload")) + assertEquals("VOICE", again.getJSONObject("ai:hold:1").getString("kind")) + assertEquals(450, back.getValue("hold_ms")) + } + + // ---- what may not pass ----------------------------------------------------------------- + + @Test fun `the licence and device facts never enter the file`() { + val text = encode( + mapOf( + "assistkey_license" to mapOf("owned" to setOf("assistkey_pro"), "beta_tester" to true), + "assistkey_power" to mapOf("wanted" to true, "taken" to true, "saved_power_button_short_press" to "4"), + "assistkey_device" to mapOf("ai_key_returns" to false, "seen_keys" to setOf("page_up")), + "assistkey_display" to mapOf("extra_dim" to 2) + ) + ) + assertFalse(text.contains("assistkey_license")) + assertFalse(text.contains("owned")) + assertFalse(text.contains("taken")) + assertFalse(text.contains("saved_")) + assertFalse(text.contains("seen_keys")) + assertFalse(text.contains("extra_dim")) + assertTrue(text.contains("ai_key_returns")) + } + + @Test fun `a file that names the licence cannot write it`() { + val hostile = JSONObject() + .put("app", SettingsFile.APP).put("format", SettingsFile.FORMAT) + .put( + "settings", + JSONObject() + .put("assistkey_license", JSONObject().put("first_run_at", JSONObject().put("t", "l").put("v", 1))) + .put("assistkey_nav", JSONObject().put("keys", JSONObject().put("t", "b").put("v", true))) + ) + assertEquals(mapOf("assistkey_nav" to mapOf("keys" to true)), SettingsFile.decode(hostile.toString())) + } + + @Test fun `a value of the wrong type is dropped, not guessed`() { + val odd = JSONObject() + .put("app", SettingsFile.APP).put("format", SettingsFile.FORMAT) + .put("settings", JSONObject().put("assistkey_nav", JSONObject().put("keys", JSONObject().put("t", "b").put("v", "yes")))) + assertTrue(SettingsFile.decode(odd.toString()).isEmpty()) + } + + @Test fun `text that is not ours is refused with a reason`() { + assertThrows(SettingsFile.BadFile::class.java) { SettingsFile.decode("hello") } + assertThrows(SettingsFile.BadFile::class.java) { SettingsFile.decode("{\"app\":\"Other\",\"format\":1}") } + assertThrows(SettingsFile.BadFile::class.java) { + SettingsFile.decode("{\"app\":\"AssistKey\",\"format\":99,\"settings\":{}}") + } + } +} diff --git a/app/src/test/java/dev/equwal/assistkey/voice/DictationPickTest.kt b/app/src/test/java/dev/equwal/assistkey/voice/DictationPickTest.kt new file mode 100644 index 0000000..14ffe30 --- /dev/null +++ b/app/src/test/java/dev/equwal/assistkey/voice/DictationPickTest.kt @@ -0,0 +1,37 @@ +package dev.equwal.assistkey.voice + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** The rule that chooses the speech recognition app when the user has not. */ +class DictationPickTest { + + private val claude = "com.anthropic.claude/.bell.assist.ClaudeRecognitionService" + private val whisper = "org.woheller69.whisper/.WhisperRecognitionService" + private val google = "com.google.android.tts/.service.GoogleTTSRecognitionService" + + @Test fun `an on-device engine wins over a server engine that was installed first`() { + assertEquals(whisper, Dictation.pick(null, listOf(claude, whisper))) + } + + @Test fun `the choice of the user wins over an on-device engine`() { + assertEquals(claude, Dictation.pick(claude, listOf(claude, whisper))) + } + + @Test fun `a saved engine that is no longer installed is ignored`() { + assertEquals(whisper, Dictation.pick("gone.app/.Service", listOf(claude, whisper))) + } + + @Test fun `the only engine is used when none is on-device`() { + assertEquals(claude, Dictation.pick(null, listOf(claude))) + } + + @Test fun `with several server engines and no choice the system decides`() { + assertNull(Dictation.pick(null, listOf(claude, google))) + } + + @Test fun `with no engine there is nothing to pick`() { + assertNull(Dictation.pick(null, emptyList())) + } +} diff --git a/gradle.properties b/gradle.properties index 2aaf277..77ad451 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,8 +6,8 @@ android.nonTransitiveRClass=true kotlin.code.style=official # Release identity. versionCode must rise with every upload to Play. -assistkey.versionName=0.0.5-alpha -assistkey.versionCode=5 +assistkey.versionName=0.0.6-alpha +assistkey.versionCode=6 # The free beta ends on this date (UTC) at the latest. It is the backstop for # installs that cannot reach Google Play; the real switch is the diff --git a/play/LISTING.md b/play/LISTING.md index 50a404d..9e6088a 100644 --- a/play/LISTING.md +++ b/play/LISTING.md @@ -45,7 +45,9 @@ ACTIONS • Sound and media: volume up, down, mute, play/pause, next, previous • Open any app, or any screen inside an app • Send an intent or a broadcast, for automation apps +• A menu of actions: one gesture opens a menu with as many actions as you want, so you have more actions than keys • Do nothing - disable a key you keep pressing by accident +• Export and import your setup as a file, to move it to another device or share it VOICE TYPING Bind a key to Voice typing: press, speak, and the words appear where the cursor is - in any app, with the keyboard you already have. AssistKey has no speech engine and no internet access; a speech recognition app on your device does the listening.