Recently Written · git

subread-anki

One tap makes an Anki card on Android: word, definition, sentence, screenshot, audio. Works with any app, with SubRead Dictionary and SubRead Overlay.

git clone https://github.com/equwal/subread-anki

Log | Files | Refs


commit f5707bb44dadbd6789adc959b7f414ebebfb76bc
equwal <13551856+equwal@users.noreply.github.com>
2026-09-25 13:18:03 -0700

Fix two faults found on a phone

URLDecoder.decode(String, Charset) is Java 10 and Android 13: the app
crashed on Android 11 at the first link. The charset now goes by name.
The core module is plain JVM, so its tests and lint do not see the API
level of Android: keep it to Java 8 APIs.

With a release and a debug SubRead Overlay side by side, the client took
the first authority and got no line. It now takes the overlay that shows
a line, then the one that sees a player. The dictionary client tries the
debug authority too.

 .../kotlin/space/subread/anki/DictionaryClient.kt  | 22 +++++++++++-------
 .../kotlin/space/subread/anki/OverlayClient.kt     | 27 +++++++++++++++++-----
 .../kotlin/space/subread/anki/core/MineRequest.kt  |  5 ++--
 3 files changed, 38 insertions(+), 16 deletions(-)
diff --git a/app/src/main/kotlin/space/subread/anki/DictionaryClient.kt b/app/src/main/kotlin/space/subread/anki/DictionaryClient.kt
index d20214d..ac7fb11 100644
--- a/app/src/main/kotlin/space/subread/anki/DictionaryClient.kt
+++ b/app/src/main/kotlin/space/subread/anki/DictionaryClient.kt
@@ -16,7 +16,13 @@ import androidx.core.net.toUri
 object DictionaryClient {
 
     const val PACKAGE = "space.subread.dictionary"
-    const val AUTHORITY = "space.subread.dictionary.lookup"
+
+    /** The release authority, then the one of a debug build, which installs next to the release. */
+    val AUTHORITIES = listOf("space.subread.dictionary.lookup", "space.subread.dictionary.debug.lookup")
+
+    /** The authority of the installed dictionary that answers, or null. */
+    fun authority(context: Context): String? =
+        AUTHORITIES.firstOrNull { context.packageManager.resolveContentProvider(it, 0) != null }
 
     /** One term: the entries of every dictionary for it, joined. */
     data class Entry(
@@ -36,16 +42,16 @@ object DictionaryClient {
 
     fun installed(context: Context): Boolean = runCatching {
         context.packageManager.getPackageInfo(PACKAGE, 0)
-    }.isSuccess
+    }.isSuccess || authority(context) != null
 
     /** True when the installed dictionary has the provider: the versions before it do not. */
-    fun answers(context: Context): Boolean =
-        context.packageManager.resolveContentProvider(AUTHORITY, 0) != null
+    fun answers(context: Context): Boolean = authority(context) != null
 
     /** The terms at the start of [text]. Empty when the dictionary is not there or has no term. */
     fun lookup(context: Context, text: String): List<Entry> {
-        if (text.isEmpty() || !answers(context)) return emptyList()
-        val uri = "content://$AUTHORITY/terms".toUri().buildUpon().appendQueryParameter("text", text).build()
+        val authority = authority(context)
+        if (text.isEmpty() || authority == null) return emptyList()
+        val uri = "content://$authority/terms".toUri().buildUpon().appendQueryParameter("text", text).build()
         val cursor = runCatching { context.contentResolver.query(uri, null, null, null, null) }.getOrNull() ?: return emptyList()
         data class Row(val expression: String, val reading: String, val length: Int, val dictionary: String, val glossary: String, val frequency: String, val pitch: String)
         val rows = cursor.use { c ->
@@ -70,7 +76,7 @@ object DictionaryClient {
                 },
                 frequency = group.map { it.frequency }.firstOrNull { it.isNotEmpty() }.orEmpty(),
                 pitch = group.map { it.pitch }.firstOrNull { it.isNotEmpty() }.orEmpty(),
-                audio = "content://$AUTHORITY/audio".toUri().buildUpon()
+                audio = "content://$authority/audio".toUri().buildUpon()
                     .appendQueryParameter("expression", key.first).appendQueryParameter("reading", key.second).build(),
             )
         }
@@ -84,7 +90,7 @@ object DictionaryClient {
         val name = context.contentResolver.query(ask, null, null, null, null)?.use { c ->
             if (c.moveToFirst()) c.text("file") else null
         } ?: return null
-        val file = "content://$AUTHORITY/audio/$name".toUri()
+        val file = "content://${ask.authority}/audio/$name".toUri()
         context.contentResolver.openInputStream(file)?.use { it.readBytes() }?.takeIf { it.isNotEmpty() }
     }.getOrNull()
 
diff --git a/app/src/main/kotlin/space/subread/anki/OverlayClient.kt b/app/src/main/kotlin/space/subread/anki/OverlayClient.kt
index 41501f2..5527f34 100644
--- a/app/src/main/kotlin/space/subread/anki/OverlayClient.kt
+++ b/app/src/main/kotlin/space/subread/anki/OverlayClient.kt
@@ -16,7 +16,13 @@ import space.subread.anki.core.PlayerReport
 object OverlayClient {
 
     const val PACKAGE = "space.subread.overlay"
-    const val AUTHORITY = "space.subread.overlay.player"
+
+    /** The release authority, then the one of a debug build, which installs next to the release. */
+    val AUTHORITIES = listOf("space.subread.overlay.player", "space.subread.overlay.debug.player")
+
+    /** The authority of the installed overlay, or null without one. */
+    fun authority(context: Context): String? =
+        AUTHORITIES.firstOrNull { context.packageManager.resolveContentProvider(it, 0) != null }
 
     /** The subtitle line of now. The times are on the clock of the subtitle file. */
     data class Line(
@@ -33,14 +39,23 @@ object OverlayClient {
     /** What the overlay knows now. [report] is on the clock of `SystemClock.elapsedRealtimeNanos()`. */
     data class Now(val report: PlayerReport?, val player: String?, val line: Line?)
 
-    fun installed(context: Context): Boolean =
-        context.packageManager.resolveContentProvider(AUTHORITY, 0) != null
+    fun installed(context: Context): Boolean = authority(context) != null
 
-    /** The line of now and the player. Null when the overlay is not installed or does not answer. */
+    /**
+     * The line of now and the player. Null when the overlay is not installed or does not answer.
+     * With a release and a debug overlay side by side, the one that shows a line wins, else the
+     * one that sees a player.
+     */
     fun now(context: Context): Now? {
-        if (!installed(context)) return null
+        val answers = AUTHORITIES.mapNotNull { authority ->
+            if (context.packageManager.resolveContentProvider(authority, 0) == null) null else now(context, authority)
+        }
+        return answers.firstOrNull { it.line != null } ?: answers.firstOrNull { it.report != null } ?: answers.firstOrNull()
+    }
+
+    private fun now(context: Context, authority: String): Now? {
         val cursor = runCatching {
-            context.contentResolver.query("content://$AUTHORITY/line".toUri(), null, null, null, null)
+            context.contentResolver.query("content://$authority/line".toUri(), null, null, null, null)
         }.getOrNull() ?: return null
         cursor.use { c ->
             if (!c.moveToFirst()) return null
diff --git a/core/src/main/kotlin/space/subread/anki/core/MineRequest.kt b/core/src/main/kotlin/space/subread/anki/core/MineRequest.kt
index b308c2d..34ea139 100644
--- a/core/src/main/kotlin/space/subread/anki/core/MineRequest.kt
+++ b/core/src/main/kotlin/space/subread/anki/core/MineRequest.kt
@@ -163,10 +163,11 @@ data class MineRequest(
             return out
         }
 
-        private fun encode(value: String): String = URLEncoder.encode(value, Charsets.UTF_8)
+        // The charset goes by name: the Charset overloads are Java 10, and Android has Java 8 here.
+        private fun encode(value: String): String = URLEncoder.encode(value, "UTF-8")
 
         private fun decode(value: String): String = try {
-            URLDecoder.decode(value, Charsets.UTF_8)
+            URLDecoder.decode(value, "UTF-8")
         } catch (e: IllegalArgumentException) {
             // A stray `%`: keep the text as it is instead of losing the request.
             value