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 6329d890dc6358762ddfe2a1562ab174a4cfd16a
equwal <13551856+equwal@users.noreply.github.com>
2026-09-25 02:24:44 -0700

Take the provider and the intent extras of the main branch

SubRead Dictionary on main answers terms?text= with definition_html and
gives the word audio in two steps. SubRead Overlay and SubRead Dictionary
send the extras WORD, SENTENCE, AUDIO, SENTENCE_AUDIO and SHOW. The app
takes both, so the buttons of those apps make cards with this branch too.

 README.md                                          | 18 +++++++-----
 .../kotlin/space/subread/anki/DictionaryClient.kt  | 32 ++++++++++++++++------
 app/src/main/kotlin/space/subread/anki/Media.kt    |  5 ++++
 app/src/main/kotlin/space/subread/anki/Miner.kt    |  6 ++--
 app/src/main/kotlin/space/subread/anki/Requests.kt | 28 ++++++++++++++-----
 5 files changed, 64 insertions(+), 25 deletions(-)
diff --git a/README.md b/README.md
index a2572e0..bc0b0b8 100644
--- a/README.md
+++ b/README.md
@@ -31,9 +31,10 @@ one for an audio or a picture.
 3. Install SubRead Dictionary and import a dictionary into it, for the
    reading, the definition, the pitch accent, the frequency and the word
    audio.
-4. Install SubRead Overlay, and choose "Anki" as its dictionary. A tap on a
-   word of the subtitle line then makes the card: the line is the sentence,
-   and its times cut the sound.
+4. Install SubRead Overlay, and choose "Anki" as its dictionary, or use its
+   "Anki" button under a selected word. A tap on a word of the subtitle
+   line then makes the card: the line is the sentence, and its times cut
+   the sound.
 5. Start the capture, from the app or from its tile in the quick settings.
    Android asks each time. While the capture is on, the app keeps the last
    minute and a half of the sound of the device and the last picture of the
@@ -97,9 +98,12 @@ startActivityForResult(add, REQUEST_CARD)   // or startActivity
 ```
 
 A `content://` Uri must be one the app may grant: its own `FileProvider`, or
-a document Uri it holds a permission for. A `file://` Uri is refused. Declare
-the package in the manifest of the calling app, or Android 11 and later hide
-it:
+a document Uri it holds a permission for. A `file://` Uri is refused. The
+extras of the intent API of the `main` branch are taken too, with their
+meaning there: `WORD`, `TEXT`, `AUDIO` (the word audio), `SENTENCE_AUDIO`,
+`SHOW`. The "Anki" buttons of SubRead Overlay and SubRead Dictionary send
+those. Declare the package in the manifest of the calling app, or Android 11
+and later hide it:
 
 ```xml
 <queries>
@@ -174,7 +178,7 @@ moments. The clip is encoded with the AAC encoder of Android.
 
 SubRead Overlay answers `content://space.subread.overlay.player/line` with
 the line of now and the report of the player. SubRead Dictionary answers
-`content://space.subread.dictionary.lookup/lookup?text=…` with the terms,
+`content://space.subread.dictionary.lookup/terms?text=…` with the terms,
 and gives the word audio through the same provider. Their READMEs have the
 details.
 
diff --git a/app/src/main/kotlin/space/subread/anki/DictionaryClient.kt b/app/src/main/kotlin/space/subread/anki/DictionaryClient.kt
index 351ac7e..d20214d 100644
--- a/app/src/main/kotlin/space/subread/anki/DictionaryClient.kt
+++ b/app/src/main/kotlin/space/subread/anki/DictionaryClient.kt
@@ -7,8 +7,11 @@ import androidx.core.net.toUri
 
 /**
  * Asks SubRead Dictionary for the terms of a text, through its content provider
- * `content://space.subread.dictionary.lookup/lookup?text=...`. One row for each term of each
- * dictionary, the longest term first. The rows of one term and reading make one [Entry].
+ * `content://space.subread.dictionary.lookup` (see docs/provider-api.md of that app).
+ *
+ * `terms?text=…` gives one row for each term of each dictionary, the longest term first. The
+ * rows of one term and reading make one [Entry]. `audio?expression=…&reading=…` gives one row
+ * with the file of the first source that has the audio, and `audio/<file>` its bytes.
  */
 object DictionaryClient {
 
@@ -25,7 +28,7 @@ object DictionaryClient {
         val glossary: String,
         val frequency: String,
         val pitch: String,
-        /** Where the audio of the word is, or null. */
+        /** Where the audio of the word is asked for, or null. */
         val audio: Uri?,
     ) {
         val headword: String get() = if (reading.isEmpty() || reading == expression) expression else "$expression【$reading】"
@@ -42,16 +45,16 @@ object DictionaryClient {
     /** 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/lookup".toUri().buildUpon().appendQueryParameter("text", text).build()
+        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 audio: String?)
+        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 ->
             buildList {
                 while (c.moveToNext()) add(
                     Row(
                         c.text("expression") ?: continue, c.text("reading").orEmpty(), c.long("length")?.toInt() ?: 0,
-                        c.text("dictionary").orEmpty(), c.text("glossary").orEmpty(), c.text("frequency").orEmpty(),
-                        c.text("pitch").orEmpty(), c.text("audio"),
+                        c.text("dictionary").orEmpty(), c.text("definition_html") ?: c.text("glossary").orEmpty(),
+                        c.text("frequency").orEmpty(), c.text("pitch").orEmpty(),
                     ),
                 )
             }
@@ -67,11 +70,24 @@ object DictionaryClient {
                 },
                 frequency = group.map { it.frequency }.firstOrNull { it.isNotEmpty() }.orEmpty(),
                 pitch = group.map { it.pitch }.firstOrNull { it.isNotEmpty() }.orEmpty(),
-                audio = first.audio?.toUri(),
+                audio = "content://$AUTHORITY/audio".toUri().buildUpon()
+                    .appendQueryParameter("expression", key.first).appendQueryParameter("reading", key.second).build(),
             )
         }
     }
 
+    /**
+     * The bytes of the audio of a term, or null when no source has it. The provider names the
+     * file in a row first, then gives the bytes at `audio/<file>`.
+     */
+    fun audio(context: Context, ask: Uri): ByteArray? = runCatching {
+        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()
+        context.contentResolver.openInputStream(file)?.use { it.readBytes() }?.takeIf { it.isNotEmpty() }
+    }.getOrNull()
+
     private fun escape(text: String) = text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
 
     private fun Cursor.text(column: String): String? {
diff --git a/app/src/main/kotlin/space/subread/anki/Media.kt b/app/src/main/kotlin/space/subread/anki/Media.kt
index 5d15abe..49ae8b1 100644
--- a/app/src/main/kotlin/space/subread/anki/Media.kt
+++ b/app/src/main/kotlin/space/subread/anki/Media.kt
@@ -38,6 +38,11 @@ object Media {
      */
     fun fetch(context: Context, source: String, stem: String, kind: Kind): File? {
         val bytes = runCatching { bytes(context, source) }.getOrNull() ?: return null
+        return write(context, bytes, stem, kind)
+    }
+
+    /** Writes the bytes of a file of [kind] to the cache, named by what the bytes are. Null for an unknown or empty file. */
+    fun write(context: Context, bytes: ByteArray, stem: String, kind: Kind): File? {
         if (bytes.isEmpty()) return null
         val extension = extension(bytes, kind) ?: return null
         val out = file(context, "$stem.$extension")
diff --git a/app/src/main/kotlin/space/subread/anki/Miner.kt b/app/src/main/kotlin/space/subread/anki/Miner.kt
index 16f893a..91c5768 100644
--- a/app/src/main/kotlin/space/subread/anki/Miner.kt
+++ b/app/src/main/kotlin/space/subread/anki/Miner.kt
@@ -106,9 +106,9 @@ class Miner(private val context: Context) {
         }
         val sentenceAudio = plan.sentenceAudio?.let { anki.addMedia(it, "audio") }.orEmpty()
         val image = plan.image?.let { anki.addMedia(it, "image") }.orEmpty()
-        val wordAudio = (request.wordAudio ?: entry?.audio?.toString())
-            ?.let { Media.fetch(context, it, "${plan.stem}_word", Media.Kind.AUDIO) }
-            ?.let { anki.addMedia(it, "audio") }.orEmpty()
+        val wordAudioFile = request.wordAudio?.let { Media.fetch(context, it, "${plan.stem}_word", Media.Kind.AUDIO) }
+            ?: entry?.audio?.let { ask -> DictionaryClient.audio(context, ask)?.let { Media.write(context, it, "${plan.stem}_word", Media.Kind.AUDIO) } }
+        val wordAudio = wordAudioFile?.let { anki.addMedia(it, "audio") }.orEmpty()
         val note = Note(
             expression = expression,
             reading = request.reading ?: entry?.reading.orEmpty(),
diff --git a/app/src/main/kotlin/space/subread/anki/Requests.kt b/app/src/main/kotlin/space/subread/anki/Requests.kt
index 492fba0..260292b 100644
--- a/app/src/main/kotlin/space/subread/anki/Requests.kt
+++ b/app/src/main/kotlin/space/subread/anki/Requests.kt
@@ -44,17 +44,31 @@ object Requests {
         else -> null
     }
 
-    /** The extras of the request, as strings. A Uri, a number and a boolean are written as text. */
+    /**
+     * The extras of the request, as strings. A Uri, a number and a boolean are written as text.
+     *
+     * The extras of the intent API of the main branch are taken too, with their meaning there:
+     * `WORD` is the word, `TEXT` a text with no word chosen, `AUDIO` the audio of the word,
+     * `SENTENCE_AUDIO` the audio of the sentence, `SHOW` the same as `CONFIRM`. SubRead
+     * Overlay and SubRead Dictionary send those.
+     */
     fun fromExtras(intent: Intent): Map<String, String> {
         val extras = intent.extras ?: return emptyMap()
         val out = LinkedHashMap<String, String>()
-        for (key in MineRequest.KEYS) {
+        fun text(name: String): String? {
             @Suppress("DEPRECATION") // The value can be of any type: only the untyped get tells which.
-            val value = extras.get(extraName(key)) ?: continue
-            out[key] = when (value) {
-                is Boolean -> if (value) "1" else "0"
-                else -> value.toString()
-            }
+            val value = extras.get(name) ?: return null
+            return if (value is Boolean) (if (value) "1" else "0") else value.toString()
+        }
+        for (key in MineRequest.KEYS) text(extraName(key))?.let { out[key] = it }
+        val other = text(EXTRA_PREFIX + "WORD") != null || text(EXTRA_PREFIX + "TEXT") != null
+        if (other) {
+            text(EXTRA_PREFIX + "WORD")?.let { out[MineRequest.KEY_EXPRESSION] = it }
+            text(EXTRA_PREFIX + "TEXT")?.let { out.putIfAbsent(MineRequest.KEY_SENTENCE, it) }
+            text(EXTRA_PREFIX + "AUDIO")?.let { out[MineRequest.KEY_WORD_AUDIO] = it }
+            out.remove(MineRequest.KEY_AUDIO)
+            text(EXTRA_PREFIX + "SENTENCE_AUDIO")?.let { out[MineRequest.KEY_AUDIO] = it }
+            text(EXTRA_PREFIX + "SHOW")?.let { out.putIfAbsent(MineRequest.KEY_CONFIRM, it) }
         }
         return out
     }