Recently Written · git

subread-dictionary

Pop-up dictionary for Android that reads Yomitan dictionaries, with local audio

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

Log | Files | Refs


commit 74eea275729be49b9862c379b4f307f18aa64043
equwal <truex@equwal.com>
2026-09-25 02:14:05 -0700

Send a card to SubRead Anki, and give the terms to other apps

Each term in the pop-up gets an "Anki" button when SubRead Anki is installed.
One tap sends the word, the reading, the definitions of each dictionary, the
text as the sentence, the name of the app the text came from, and the first
audio file. The pop-up hides for a moment, so that the screenshot of SubRead
Anki shows the app under it.

A content provider, space.subread.dictionary.lookup, gives the terms of a
text and the audio of a term to another app. SubRead Anki uses it for a word
that comes from the text selection menu. AudioType names the format of an
audio file from its first bytes: the sources give bytes and no name.

 README.md                                          |  12 +++
 app/src/main/AndroidManifest.xml                   |  29 +++++-
 .../main/kotlin/space/subread/dictionary/Anki.kt   |  51 +++++++++++
 .../space/subread/dictionary/LookupActivity.kt     |  62 +++++++++++--
 .../space/subread/dictionary/LookupProvider.kt     | 102 +++++++++++++++++++++
 .../space/subread/dictionary/MainActivity.kt       |   7 ++
 app/src/main/res/values/strings.xml                |   5 +
 app/src/main/res/xml/files.xml                     |   5 +
 .../space/subread/dictionary/core/AudioType.kt     |  27 ++++++
 .../space/subread/dictionary/core/AudioTypeTest.kt |  41 +++++++++
 docs/provider-api.md                               |  63 +++++++++++++
 11 files changed, 396 insertions(+), 8 deletions(-)
diff --git a/README.md b/README.md
index d42aeaa..75808f0 100644
--- a/README.md
+++ b/README.md
@@ -62,6 +62,18 @@ Send a text with any of these; the pop-up opens over your app:
 - `Intent.ACTION_SEND`, `text/plain`, with `EXTRA_TEXT` (the share sheet).
 - The action `space.subread.dictionary.LOOKUP` with `EXTRA_TEXT`.
 
+Ask for the terms of a text, or the audio of a term, without the pop-up: the
+content provider `space.subread.dictionary.lookup`, see
+[docs/provider-api.md](docs/provider-api.md). SubRead Anki uses it.
+
+## Anki cards
+
+With [SubRead Anki](https://github.com/equwal/subread-anki) installed, each
+term in the pop-up has an "Anki" button. One tap makes a card in AnkiDroid:
+the word, the reading, the definitions of each dictionary, the text as the
+sentence, the first audio, and, from SubRead Anki, a screenshot and the
+sentence read by the voice of the device.
+
 ## Build
 
 ```
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 12d4bf8..872cf21 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,9 +1,15 @@
 <?xml version="1.0" encoding="utf-8"?>
-<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools">
 
     <!-- For the remote audio sources only. They are off until the user turns them on. -->
     <uses-permission android:name="android.permission.INTERNET" />
 
+    <!-- SubRead Anki takes a card from the pop-up. Android hides other apps without this list. -->
+    <queries>
+        <package android:name="space.subread.anki" />
+    </queries>
+
     <application
         android:allowBackup="false"
         android:icon="@drawable/icon"
@@ -50,5 +56,26 @@
                 <category android:name="android.intent.category.DEFAULT" />
             </intent-filter>
         </activity>
+
+        <!--
+            Gives the terms of a text and the audio of a term to another app: SubRead Anki. The
+            dictionaries are public data, so the provider has no permission. See LookupProvider.
+        -->
+        <provider
+            android:name=".LookupProvider"
+            android:authorities="${applicationId}.lookup"
+            android:exported="true"
+            tools:ignore="ExportedContentProvider" />
+
+        <!-- SubRead Anki reads the audio of a card through this provider. -->
+        <provider
+            android:name="androidx.core.content.FileProvider"
+            android:authorities="${applicationId}.files"
+            android:exported="false"
+            android:grantUriPermissions="true">
+            <meta-data
+                android:name="android.support.FILE_PROVIDER_PATHS"
+                android:resource="@xml/files" />
+        </provider>
     </application>
 </manifest>
diff --git a/app/src/main/kotlin/space/subread/dictionary/Anki.kt b/app/src/main/kotlin/space/subread/dictionary/Anki.kt
new file mode 100644
index 0000000..706443c
--- /dev/null
+++ b/app/src/main/kotlin/space/subread/dictionary/Anki.kt
@@ -0,0 +1,51 @@
+package space.subread.dictionary
+
+import android.content.ClipData
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import androidx.core.content.FileProvider
+import java.io.File
+
+/**
+ * Sends a card to SubRead Anki. The strings are the intent contract of that app, see its
+ * docs/intent-api.md. SubRead Anki adds the sentence and the screenshot of the moment, the
+ * sentence audio, and puts the card in AnkiDroid.
+ */
+object Anki {
+    const val PACKAGE = "space.subread.anki"
+    const val INSTALL = "https://github.com/equwal/subread-anki/releases/latest"
+    private const val ACTION_ADD = "space.subread.anki.action.ADD"
+    private const val EXTRA_WORD = "space.subread.anki.extra.WORD"
+    private const val EXTRA_READING = "space.subread.anki.extra.READING"
+    private const val EXTRA_DEFINITION = "space.subread.anki.extra.DEFINITION"
+    private const val EXTRA_SENTENCE = "space.subread.anki.extra.SENTENCE"
+    private const val EXTRA_AUDIO = "space.subread.anki.extra.AUDIO"
+    private const val EXTRA_SOURCE = "space.subread.anki.extra.SOURCE"
+    private const val EXTRA_SCREENSHOT = "space.subread.anki.extra.SCREENSHOT"
+
+    fun installed(context: Context): Boolean =
+        context.packageManager.resolveActivity(Intent(ACTION_ADD).setPackage(PACKAGE), 0) != null
+
+    /**
+     * The intent for one card. The audio file, when there is one, goes through the file provider
+     * of this app; the clip data of the intent carries the read permission for it.
+     */
+    fun intent(context: Context, word: String, reading: String, definition: String, sentence: String, source: String, audio: File?): Intent {
+        val intent = Intent(ACTION_ADD)
+            .setPackage(PACKAGE)
+            .putExtra(EXTRA_WORD, word)
+            .putExtra(EXTRA_READING, reading)
+            .putExtra(EXTRA_DEFINITION, definition)
+            .putExtra(EXTRA_SENTENCE, sentence)
+            .putExtra(EXTRA_SOURCE, source)
+            .putExtra(EXTRA_SCREENSHOT, true)
+        if (audio != null) {
+            val uri: Uri = FileProvider.getUriForFile(context, context.packageName + ".files", audio)
+            intent.putExtra(EXTRA_AUDIO, uri.toString())
+            intent.clipData = ClipData.newUri(context.contentResolver, "audio", uri)
+            intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+        }
+        return intent
+    }
+}
diff --git a/app/src/main/kotlin/space/subread/dictionary/LookupActivity.kt b/app/src/main/kotlin/space/subread/dictionary/LookupActivity.kt
index 8f6e275..f94cb4e 100644
--- a/app/src/main/kotlin/space/subread/dictionary/LookupActivity.kt
+++ b/app/src/main/kotlin/space/subread/dictionary/LookupActivity.kt
@@ -22,15 +22,18 @@ import android.widget.LinearLayout
 import android.widget.ScrollView
 import android.widget.TextView
 import android.widget.Toast
+import space.subread.dictionary.core.AudioType
 import space.subread.dictionary.core.Glossary
 import space.subread.dictionary.core.Lookup
 import space.subread.dictionary.core.Match
+import java.io.File
 import kotlin.concurrent.thread
 
 /**
  * The pop-up. It takes a text from the text selection menu, from the share sheet, or from an
  * intent, and shows the terms at the scan position: the longest first. A tap on a character of
- * the text moves the scan position there.
+ * the text moves the scan position there. With SubRead Anki installed, each term has an "Anki"
+ * button: one tap makes the card.
  */
 class LookupActivity : Activity() {
 
@@ -42,6 +45,7 @@ class LookupActivity : Activity() {
     private var text = ""
     private var offset = 0
     private var generation = 0
+    private var anki = false
 
     // The touch listener finds the character under the finger; it calls performClick itself.
     @SuppressLint("ClickableViewAccessibility")
@@ -50,6 +54,7 @@ class LookupActivity : Activity() {
         store = Store(this)
         dictionaries = Dictionaries(this)
         player = AudioPlayer(this)
+        anki = Anki.installed(this)
 
         // A panel at the bottom, over the app that sent the text. The height is fixed, so the
         // panel does not grow with the results: they scroll inside it.
@@ -165,6 +170,7 @@ class LookupActivity : Activity() {
                 addView(smallButton("▶ " + getString(R.string.audio_play)) { play(expression, reading, choose = false) }.apply {
                     setOnLongClickListener { play(expression, reading, choose = true); true }
                 })
+                if (anki) addView(smallButton(getString(R.string.anki)) { sendToAnki(expression, reading, matches) })
             }, LinearLayout.LayoutParams(-1, -2).apply { topMargin = dp(6) })
 
             val reasons = matches.first().reasons
@@ -200,11 +206,15 @@ class LookupActivity : Activity() {
         }
     }
 
-    /** Plays the first source that has audio: the local ones in their order, then the remote ones. */
+    /** The audio sources of a term: the local ones in their order, then the remote ones. */
+    private fun sources(expression: String, reading: String): List<AudioSource> =
+        LocalAudio(store.localAudioFile, store.localSources.split(',').map { it.trim() }).sources(expression, reading) +
+            if (store.remoteEnabled) RemoteAudio.sources(store.remoteUrls.lines().map { it.trim() }.filter { it.isNotEmpty() }, expression, reading) else emptyList()
+
+    /** Plays the first source that has audio. */
     private fun play(expression: String, reading: String, choose: Boolean) {
         thread {
-            val sources = LocalAudio(store.localAudioFile, store.localSources.split(',').map { it.trim() }).sources(expression, reading) +
-                if (store.remoteEnabled) RemoteAudio.sources(store.remoteUrls.lines().map { it.trim() }.filter { it.isNotEmpty() }, expression, reading) else emptyList()
+            val sources = sources(expression, reading)
             if (choose) {
                 runOnUiThread {
                     if (sources.isEmpty()) toast(getString(R.string.no_audio)) else {
@@ -218,14 +228,47 @@ class LookupActivity : Activity() {
     }
 
     private fun playFirst(sources: List<AudioSource>) {
+        val bytes = loadFirst(sources)
+        runOnUiThread {
+            if (bytes == null) toast(getString(R.string.no_audio))
+            else runCatching { player.play(bytes) }.onFailure { toast(getString(R.string.audio_failed, it.message ?: "")) }
+        }
+    }
+
+    /** The bytes of the first source that has them. */
+    private fun loadFirst(sources: List<AudioSource>): ByteArray? {
         for (source in sources) {
             val bytes = runCatching { source.load() }.getOrNull() ?: continue
+            if (bytes.isNotEmpty()) return bytes
+        }
+        return null
+    }
+
+    /**
+     * One tap: the card goes to SubRead Anki with the word, the reading, the definitions of
+     * each dictionary, the text as the sentence, and the first audio. The pop-up hides for a
+     * moment, so that the screenshot of SubRead Anki shows the app under it.
+     */
+    private fun sendToAnki(expression: String, reading: String, matches: List<Match>) {
+        val definition = matches.joinToString("<br><br>") {
+            "<i>${Glossary.escape(it.stored.dictionaryTitle)}</i><br>${Glossary.toHtml(it.stored.term.glossary)}"
+        }
+        val source = referrer?.takeIf { it.scheme == "android-app" }?.host?.let { pkg ->
+            runCatching { packageManager.getApplicationLabel(packageManager.getApplicationInfo(pkg, 0)).toString() }.getOrNull()
+        } ?: ""
+        val decor = window.decorView
+        decor.visibility = View.INVISIBLE
+        thread {
+            val audio = loadFirst(sources(expression, reading))?.let { bytes ->
+                val dir = File(cacheDir, "anki").apply { mkdirs() }
+                File(dir, "word-${System.nanoTime()}.${AudioType.extension(bytes)}").also { it.writeBytes(bytes) }
+            }
             runOnUiThread {
-                runCatching { player.play(bytes) }.onFailure { toast(getString(R.string.audio_failed, it.message ?: "")) }
+                runCatching { startActivity(Anki.intent(this, expression, reading, definition, text, source, audio)) }
+                    .onFailure { toast(getString(R.string.anki_failed)) }
+                decor.postDelayed({ decor.visibility = View.VISIBLE }, HIDE_MS)
             }
-            return
         }
-        runOnUiThread { toast(getString(R.string.no_audio)) }
     }
 
     private fun toast(message: String) = Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
@@ -247,4 +290,9 @@ class LookupActivity : Activity() {
     }
 
     private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt()
+
+    private companion object {
+        /** How long the pop-up hides while SubRead Anki takes its screenshot. */
+        const val HIDE_MS = 1500L
+    }
 }
diff --git a/app/src/main/kotlin/space/subread/dictionary/LookupProvider.kt b/app/src/main/kotlin/space/subread/dictionary/LookupProvider.kt
new file mode 100644
index 0000000..9ae86af
--- /dev/null
+++ b/app/src/main/kotlin/space/subread/dictionary/LookupProvider.kt
@@ -0,0 +1,102 @@
+package space.subread.dictionary
+
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.database.Cursor
+import android.database.MatrixCursor
+import android.net.Uri
+import android.os.ParcelFileDescriptor
+import space.subread.dictionary.core.AudioType
+import space.subread.dictionary.core.Glossary
+import space.subread.dictionary.core.Lookup
+import java.io.File
+import java.io.FileNotFoundException
+
+/**
+ * Gives the terms of a text, and the audio of a term, to another app. SubRead Anki uses it for
+ * the definition, the reading and the word audio of a card. See docs/provider-api.md.
+ *
+ * `terms?text=…&offset=0`: one row per term at the offset, the longest first, with the columns
+ * `expression`, `reading`, `length`, `reasons`, `dictionary`, `definition_html`, `score`.
+ *
+ * `audio?expression=…&reading=…`: one row with the columns `file` and `mime` when a source has
+ * the audio, in the order of the settings; no row when none has. The bytes are then at
+ * `audio/<file>`, to open with openInputStream.
+ */
+class LookupProvider : ContentProvider() {
+
+    private val dictionaries by lazy { Dictionaries(context!!) }
+    private val store by lazy { Store(context!!) }
+
+    override fun onCreate(): Boolean = true
+
+    override fun query(uri: Uri, projection: Array<String>?, selection: String?, selectionArgs: Array<String>?, sortOrder: String?): Cursor? =
+        when (uri.pathSegments.firstOrNull()) {
+            "terms" -> terms(uri.getQueryParameter("text").orEmpty(), uri.getQueryParameter("offset")?.toIntOrNull() ?: 0)
+            "audio" -> audio(uri.getQueryParameter("expression").orEmpty(), uri.getQueryParameter("reading").orEmpty())
+            else -> null
+        }
+
+    private fun terms(text: String, offset: Int): Cursor {
+        val cursor = MatrixCursor(arrayOf("expression", "reading", "length", "reasons", "dictionary", "definition_html", "score"))
+        if (text.isEmpty() || !dictionaries.anyEnabled()) return cursor
+        for (match in Lookup.find(text, offset, dictionaries)) {
+            val term = match.stored.term
+            cursor.addRow(
+                arrayOf<Any>(term.expression, term.reading, match.length, match.reasons.joinToString(" "), match.stored.dictionaryTitle, Glossary.toHtml(term.glossary), term.score),
+            )
+        }
+        return cursor
+    }
+
+    /** The first source that has the audio writes it to the cache; the row names the file. */
+    private fun audio(expression: String, reading: String): Cursor {
+        val cursor = MatrixCursor(arrayOf("file", "mime"))
+        if (expression.isEmpty()) return cursor
+        val local = LocalAudio(store.localAudioFile, store.localSources.split(',').map { it.trim() })
+        val sources = runCatching { local.sources(expression, reading) }.getOrDefault(emptyList()) +
+            if (store.remoteEnabled) RemoteAudio.sources(store.remoteUrls.lines().map { it.trim() }.filter { it.isNotEmpty() }, expression, reading) else emptyList()
+        for (source in sources) {
+            val bytes = runCatching { source.load() }.getOrNull() ?: continue
+            if (bytes.isEmpty()) continue
+            val extension = AudioType.extension(bytes)
+            val name = "${(expression + "\u0000" + reading).hashCode().toUInt()}.$extension"
+            File(dir(), name).writeBytes(bytes)
+            cursor.addRow(arrayOf(name, AudioType.mime(extension)))
+            break
+        }
+        return cursor
+    }
+
+    override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor {
+        val segments = uri.pathSegments
+        if (segments.size != 2 || segments[0] != "audio") throw FileNotFoundException(uri.toString())
+        val file = File(dir(), File(segments[1]).name)
+        if (!file.isFile) throw FileNotFoundException(uri.toString())
+        return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
+    }
+
+    override fun getType(uri: Uri): String? {
+        val segments = uri.pathSegments
+        return when {
+            segments.firstOrNull() == "terms" -> "vnd.android.cursor.dir/vnd.space.subread.dictionary.term"
+            segments.firstOrNull() == "audio" && segments.size == 1 -> "vnd.android.cursor.item/vnd.space.subread.dictionary.audio"
+            segments.firstOrNull() == "audio" && segments.size == 2 -> AudioType.mime(segments[1].substringAfterLast('.', ""))
+            else -> null
+        }
+    }
+
+    override fun insert(uri: Uri, values: ContentValues?): Uri? = throw UnsupportedOperationException("read only")
+
+    override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<String>?): Int = throw UnsupportedOperationException("read only")
+
+    override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?): Int = throw UnsupportedOperationException("read only")
+
+    /** The audio files on their way out. Files older than an hour go. */
+    private fun dir(): File {
+        val dir = File(context!!.cacheDir, "audio-out").apply { mkdirs() }
+        val limit = System.currentTimeMillis() - 60 * 60_000L
+        dir.listFiles()?.forEach { if (it.lastModified() < limit) it.delete() }
+        return dir
+    }
+}
diff --git a/app/src/main/kotlin/space/subread/dictionary/MainActivity.kt b/app/src/main/kotlin/space/subread/dictionary/MainActivity.kt
index 7656408..b59124d 100644
--- a/app/src/main/kotlin/space/subread/dictionary/MainActivity.kt
+++ b/app/src/main/kotlin/space/subread/dictionary/MainActivity.kt
@@ -147,6 +147,13 @@ class MainActivity : Activity() {
 
         val overlay = packageManager.getLaunchIntentForPackage(OVERLAY_PACKAGE)
         if (overlay != null) content.addView(button(getString(R.string.open_overlay)) { runCatching { startActivity(overlay) } }, wide(top = 24))
+        note(getString(R.string.anki_note), top = 24)
+        val anki = packageManager.getLaunchIntentForPackage(Anki.PACKAGE)
+        if (anki != null) {
+            content.addView(button(getString(R.string.open_anki)) { runCatching { startActivity(anki) } }, wide())
+        } else {
+            content.addView(button(getString(R.string.install_anki)) { open(Anki.INSTALL) }, wide())
+        }
         if (BuildConfig.DONATE_LINK) content.addView(button(getString(R.string.donate)) { open(KOFI) }, wide())
     }
 
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 175addd..da306f0 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -49,6 +49,11 @@
     <string name="look_up">Look up</string>
 
     <string name="open_overlay">Open SubRead Overlay</string>
+    <string name="anki_note">One tap on "Anki" in the pop-up makes a card in AnkiDroid: the word, the reading, the definition, the sentence, a screenshot and the audio. SubRead Anki does this.</string>
+    <string name="open_anki">Open SubRead Anki</string>
+    <string name="install_anki">Install SubRead Anki</string>
+    <string name="anki">Anki</string>
+    <string name="anki_failed">SubRead Anki did not take the card.</string>
     <string name="donate">Support the app on Ko-fi</string>
 
     <string name="no_text">The text is empty.</string>
diff --git a/app/src/main/res/xml/files.xml b/app/src/main/res/xml/files.xml
new file mode 100644
index 0000000..709713b
--- /dev/null
+++ b/app/src/main/res/xml/files.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- The audio of a card, on its way to SubRead Anki. -->
+<paths>
+    <cache-path name="anki" path="anki/" />
+</paths>
diff --git a/core/src/main/kotlin/space/subread/dictionary/core/AudioType.kt b/core/src/main/kotlin/space/subread/dictionary/core/AudioType.kt
new file mode 100644
index 0000000..646b42c
--- /dev/null
+++ b/core/src/main/kotlin/space/subread/dictionary/core/AudioType.kt
@@ -0,0 +1,27 @@
+package space.subread.dictionary.core
+
+/** The format of an audio file, from its first bytes: the audio sources give bytes and no name. */
+object AudioType {
+
+    /** The file extension: mp3, ogg, wav, flac or m4a. Bytes that say nothing count as mp3. */
+    fun extension(bytes: ByteArray): String {
+        fun startsWith(prefix: String, at: Int = 0) = bytes.size >= at + prefix.length &&
+            prefix.indices.all { bytes[at + it] == prefix[it].code.toByte() }
+        return when {
+            startsWith("OggS") -> "ogg"
+            startsWith("RIFF") && startsWith("WAVE", 8) -> "wav"
+            startsWith("fLaC") -> "flac"
+            startsWith("ftyp", 4) -> "m4a"
+            else -> "mp3"
+        }
+    }
+
+    fun mime(extension: String): String = when (extension) {
+        "mp3" -> "audio/mpeg"
+        "ogg" -> "audio/ogg"
+        "wav" -> "audio/wav"
+        "flac" -> "audio/flac"
+        "m4a" -> "audio/mp4"
+        else -> "application/octet-stream"
+    }
+}
diff --git a/core/src/test/kotlin/space/subread/dictionary/core/AudioTypeTest.kt b/core/src/test/kotlin/space/subread/dictionary/core/AudioTypeTest.kt
new file mode 100644
index 0000000..6d0466a
--- /dev/null
+++ b/core/src/test/kotlin/space/subread/dictionary/core/AudioTypeTest.kt
@@ -0,0 +1,41 @@
+package space.subread.dictionary.core
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class AudioTypeTest {
+
+    private fun bytes(vararg parts: Any): ByteArray = parts.flatMap { part ->
+        when (part) {
+            is String -> part.toByteArray(Charsets.ISO_8859_1).toList()
+            is Int -> listOf(part.toByte())
+            else -> error("a string or a byte")
+        }
+    }.toByteArray()
+
+    @Test
+    fun theFirstBytesNameTheFormat() {
+        assertEquals("ogg", AudioType.extension(bytes("OggS", 0, 2, 0)))
+        assertEquals("wav", AudioType.extension(bytes("RIFF", 1, 2, 3, 4, "WAVEfmt ")))
+        assertEquals("flac", AudioType.extension(bytes("fLaC", 0)))
+        assertEquals("m4a", AudioType.extension(bytes(0, 0, 0, 24, "ftypM4A ")))
+        assertEquals("mp3", AudioType.extension(bytes("ID3", 3, 0)))
+        assertEquals("mp3", AudioType.extension(bytes(0xFF, 0xFB, 0x90)))
+    }
+
+    @Test
+    fun bytesThatSayNothingCountAsMp3() {
+        assertEquals("mp3", AudioType.extension(ByteArray(0)))
+        assertEquals("mp3", AudioType.extension(bytes("RIFF", 1, 2, 3, 4, "AVI ")))
+    }
+
+    @Test
+    fun eachExtensionHasItsMime() {
+        assertEquals("audio/mpeg", AudioType.mime("mp3"))
+        assertEquals("audio/ogg", AudioType.mime("ogg"))
+        assertEquals("audio/wav", AudioType.mime("wav"))
+        assertEquals("audio/flac", AudioType.mime("flac"))
+        assertEquals("audio/mp4", AudioType.mime("m4a"))
+        assertEquals("application/octet-stream", AudioType.mime("bin"))
+    }
+}
diff --git a/docs/provider-api.md b/docs/provider-api.md
new file mode 100644
index 0000000..003fe89
--- /dev/null
+++ b/docs/provider-api.md
@@ -0,0 +1,63 @@
+# The content provider of SubRead Dictionary
+
+Another app can ask SubRead Dictionary for the terms of a text and for the audio of a term.
+SubRead Anki does this for the definition, the reading and the word audio of a card. The
+provider reads the dictionaries and the audio sources that the user set up in the app.
+
+The authority is `space.subread.dictionary.lookup` (`space.subread.dictionary.debug.lookup`
+for a debug build). On Android 11 and later, the manifest of the caller needs it in its
+queries:
+
+```xml
+<queries>
+    <provider android:authorities="space.subread.dictionary.lookup" />
+</queries>
+```
+
+## Terms
+
+```
+content://space.subread.dictionary.lookup/terms?text=食べた&offset=0
+```
+
+One row per term that starts at `offset` in `text`, the longest first, then the dictionaries
+in the order of the settings, then the higher score first. No row when nothing matches or no
+dictionary is enabled.
+
+| Column | Type | What |
+|---|---|---|
+| `expression` | text | The term as the dictionary writes it: `食べる`. |
+| `reading` | text | The reading, in kana. The expression itself when the dictionary gives none. |
+| `length` | integer | How many characters of the text, from the offset, the term covers. |
+| `reasons` | text | The deinflection from the text to the term, separated by spaces: `past`. Empty for the dictionary form. |
+| `dictionary` | text | The title of the dictionary. |
+| `definition_html` | text | The glossary as simple HTML: `b`, `i`, `u`, `br`, `ul`, `li`, `a`. |
+| `score` | real | The score of the term in its dictionary. |
+
+## Audio
+
+```
+content://space.subread.dictionary.lookup/audio?expression=食べる&reading=たべる
+```
+
+One row when a source has the audio: the local sources in the order of the settings, then
+the remote ones when they are on. No row when none has.
+
+| Column | What |
+|---|---|
+| `file` | The name of the file in the cache of the app. |
+| `mime` | `audio/mpeg`, `audio/ogg`, `audio/wav`, `audio/flac` or `audio/mp4`. |
+
+The bytes are then at `content://space.subread.dictionary.lookup/audio/<file>`, to read with
+`openInputStream`. The file stays for an hour.
+
+```kotlin
+val uri = Uri.parse("content://space.subread.dictionary.lookup/audio")
+    .buildUpon().appendQueryParameter("expression", "食べる").appendQueryParameter("reading", "たべる").build()
+val name = contentResolver.query(uri, null, null, null, null)?.use { c ->
+    if (c.moveToFirst()) c.getString(c.getColumnIndexOrThrow("file")) else null
+}
+val bytes = name?.let { contentResolver.openInputStream(Uri.parse("content://space.subread.dictionary.lookup/audio/$it"))?.readBytes() }
+```
+
+The provider is read only: `insert`, `update` and `delete` throw.