Recently Written · git

assistkey

Hardware key remapper for the Viwoods AiPaper Reader: AI key, volume keys and power, with multi-tap, hold and chords

git clone https://github.com/equwal/assistkey

Log | Files | Refs


commit 189bad36a4deca54fb94fdddba9c7f460c53f396
truex <truex@equwal.com>
2026-09-21 04:05:05 -0700

Add the Viwoods AI voice prompt as an action

On a hold of the AI key, the firmware starts WebViewAiActivity of the
Viwoods AI app with the text extra recodeKey=recode_key_start. The
action AI voice prompt does the same, so that any button can have it.

The payload of LAUNCH_COMPONENT can carry text extras after a question
mark. ComponentPayload parses it with no Android types. A payload from
before reads as it did.

 CHANGELOG.md                                       |  5 +++
 .../dev/equwal/assistkey/model/ComponentPayload.kt | 30 ++++++++++++++++++
 .../java/dev/equwal/assistkey/model/Presets.kt     |  2 ++
 .../dev/equwal/assistkey/route/ActionRouter.kt     |  4 ++-
 .../equwal/assistkey/model/ComponentPayloadTest.kt | 37 ++++++++++++++++++++++
 5 files changed, 77 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3831071..f30fae8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,11 @@
 - **Change a button from the Done step.** "Change what it does" and "Change
   how you press it" go back to those steps with the button kept. Back on the
   Done step goes to the actions too. Tests: `Route.back`.
+- **AI voice prompt** is an action of its own (Viwoods). It is what the
+  firmware does on a hold of the AI key: the Viwoods AI screen, told to start
+  its voice prompt. It needs the Viwoods AI account, as the stock hold does.
+  A component launch can now carry text extras (`pkg/class?key=value`).
+  Tests: `ComponentPayloadTest`.
 - **Ask when the user tries it.** The first run asks for every grant once.
   After that the app asks at the moment something is missing: after a menu is
   saved, after the Power switch on the Navigation screen, when a Power press
diff --git a/app/src/main/java/dev/equwal/assistkey/model/ComponentPayload.kt b/app/src/main/java/dev/equwal/assistkey/model/ComponentPayload.kt
new file mode 100644
index 0000000..4aea663
--- /dev/null
+++ b/app/src/main/java/dev/equwal/assistkey/model/ComponentPayload.kt
@@ -0,0 +1,30 @@
+package dev.equwal.assistkey.model
+
+/**
+ * The payload of [ActionKind.LAUNCH_COMPONENT]: "pkg/class", and after a "?"
+ * the text extras of the intent as "key=value" with "&" between them.
+ *
+ * The extras exist for a screen that does something else when it gets one.
+ * The Viwoods AI screen starts its voice prompt with recodeKey=recode_key_start.
+ * A payload from before has no "?" and reads as it always did.
+ */
+object ComponentPayload {
+
+    data class Parts(val component: String, val extras: Map<String, String>)
+
+    fun parse(payload: String): Parts {
+        val component = payload.substringBefore('?')
+        val extras = LinkedHashMap<String, String>()
+        if ('?' in payload) {
+            payload.substringAfter('?').split('&').forEach { pair ->
+                val key = pair.substringBefore('=')
+                if (key.isNotEmpty() && '=' in pair) extras[key] = pair.substringAfter('=')
+            }
+        }
+        return Parts(component, extras)
+    }
+
+    fun format(component: String, extras: Map<String, String>): String =
+        if (extras.isEmpty()) component
+        else component + "?" + extras.entries.joinToString("&") { it.key + "=" + it.value }
+}
diff --git a/app/src/main/java/dev/equwal/assistkey/model/Presets.kt b/app/src/main/java/dev/equwal/assistkey/model/Presets.kt
index e5f1388..69bf482 100644
--- a/app/src/main/java/dev/equwal/assistkey/model/Presets.kt
+++ b/app/src/main/java/dev/equwal/assistkey/model/Presets.kt
@@ -23,6 +23,8 @@ object Presets {
         Preset("AI quick prompt (transparent)", AI + "/com.wisky.wiskyai.AiTransparentActivity"),
         Preset("AI lookup", AI + "/com.wisky.wiskyai.AIActivity"),
         Preset("AI assistant (full UI)", AI + "/com.wisky.wiskyai.WebViewAiActivity"),
+        // What the firmware does on a hold of the AI key: the same screen, told to start the voice prompt.
+        Preset("AI voice prompt", AI + "/com.wisky.wiskyai.WebViewAiActivity?recodeKey=recode_key_start"),
         Preset("AI history", AI + "/com.wisky.wiskyai.HistroyActivity"),
         Preset("AI repository", AI + "/com.wisky.wiskyai.RepositoryActivity"),
         Preset("Viwoods home", LAUNCHER + "/com.viwoods.launcher.main.LauncherMainActivity")
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 e5fd816..0a4324c 100644
--- a/app/src/main/java/dev/equwal/assistkey/route/ActionRouter.kt
+++ b/app/src/main/java/dev/equwal/assistkey/route/ActionRouter.kt
@@ -115,9 +115,11 @@ object ActionRouter {
      * the internal Viwoods AI activities get reached.
      */
     private fun launchComponent(ctx: Context, flat: String): Boolean {
-        val cn = ComponentName.unflattenFromString(flat) ?: return false
+        val parts = dev.equwal.assistkey.model.ComponentPayload.parse(flat)
+        val cn = ComponentName.unflattenFromString(parts.component) ?: return false
         val i = Intent()
         i.component = cn
+        parts.extras.forEach { (key, value) -> i.putExtra(key, value) }
         i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
         ctx.startActivity(i)
         return true
diff --git a/app/src/test/java/dev/equwal/assistkey/model/ComponentPayloadTest.kt b/app/src/test/java/dev/equwal/assistkey/model/ComponentPayloadTest.kt
new file mode 100644
index 0000000..495a462
--- /dev/null
+++ b/app/src/test/java/dev/equwal/assistkey/model/ComponentPayloadTest.kt
@@ -0,0 +1,37 @@
+package dev.equwal.assistkey.model
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import java.util.Random
+
+class ComponentPayloadTest {
+
+    @Test fun `a payload from before has no extras and reads as it did`() {
+        val old = "com.viwoods.viwoodsai/com.wisky.wiskyai.AIActivity"
+        assertEquals(ComponentPayload.Parts(old, emptyMap()), ComponentPayload.parse(old))
+    }
+
+    @Test fun `the Viwoods voice prompt carries its one extra`() {
+        val parts = ComponentPayload.parse(
+            "com.viwoods.viwoodsai/com.wisky.wiskyai.WebViewAiActivity?recodeKey=recode_key_start"
+        )
+        assertEquals("com.viwoods.viwoodsai/com.wisky.wiskyai.WebViewAiActivity", parts.component)
+        assertEquals(mapOf("recodeKey" to "recode_key_start"), parts.extras)
+    }
+
+    @Test fun `junk after the question mark is skipped, not thrown`() {
+        assertEquals(mapOf("a" to "1"), ComponentPayload.parse("p/c?&=x&novalue&a=1").extras)
+        assertEquals(mapOf("a" to "b=c"), ComponentPayload.parse("p/c?a=b=c").extras)
+    }
+
+    @Test fun `parse gives back what format was given`() {
+        val r = Random(21)
+        val letters = "abcXYZ019_."
+        fun word() = (0..r.nextInt(6)).map { letters[r.nextInt(letters.length)] }.joinToString("")
+        repeat(500) {
+            val extras = (0 until r.nextInt(4)).associate { word() to word() }
+            val parts = ComponentPayload.parse(ComponentPayload.format("pkg/" + word(), extras))
+            assertEquals(extras, parts.extras)
+        }
+    }
+}