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


app/src/main/kotlin/space/subread/dictionary/LookupProvider.kt (5200 bytes)

1 package space.subread.dictionary
2 
3 import android.content.ContentProvider
4 import android.content.ContentValues
5 import android.database.Cursor
6 import android.database.MatrixCursor
7 import android.net.Uri
8 import android.os.ParcelFileDescriptor
9 import space.subread.dictionary.core.AudioType
10 import space.subread.dictionary.core.Glossary
11 import space.subread.dictionary.core.Lookup
12 import java.io.File
13 import java.io.FileNotFoundException
14 
15 /**
16  * Gives the terms of a text, and the audio of a term, to another app. SubRead Anki uses it for
17  * the definition, the reading and the word audio of a card. See docs/provider-api.md.
18  *
19  * `terms?text=…&offset=0`: one row per term at the offset, the longest first, with the columns
20  * `expression`, `reading`, `length`, `reasons`, `dictionary`, `definition_html`, `score`.
21  *
22  * `audio?expression=…&reading=…`: one row with the columns `file` and `mime` when a source has
23  * the audio, in the order of the settings; no row when none has. The bytes are then at
24  * `audio/<file>`, to open with openInputStream.
25  */
26 class LookupProvider : ContentProvider() {
27 
28     private val dictionaries by lazy { Dictionaries(context!!) }
29     private val store by lazy { Store(context!!) }
30 
31     override fun onCreate(): Boolean = true
32 
33     override fun query(uri: Uri, projection: Array<String>?, selection: String?, selectionArgs: Array<String>?, sortOrder: String?): Cursor? =
34         when (uri.pathSegments.firstOrNull()) {
35             "terms" -> terms(uri.getQueryParameter("text").orEmpty(), uri.getQueryParameter("offset")?.toIntOrNull() ?: 0)
36             "audio" -> audio(uri.getQueryParameter("expression").orEmpty(), uri.getQueryParameter("reading").orEmpty())
37             else -> null
38         }
39 
40     private fun terms(text: String, offset: Int): Cursor {
41         val cursor = MatrixCursor(arrayOf("expression", "reading", "length", "reasons", "dictionary", "definition_html", "score"))
42         if (text.isEmpty() || !dictionaries.anyEnabled()) return cursor
43         for (match in Lookup.find(text, offset, dictionaries)) {
44             val term = match.stored.term
45             cursor.addRow(
46                 arrayOf<Any>(term.expression, term.reading, match.length, match.reasons.joinToString(" "), match.stored.dictionaryTitle, Glossary.toHtml(term.glossary), term.score),
47             )
48         }
49         return cursor
50     }
51 
52     /** The first source that has the audio writes it to the cache; the row names the file. */
53     private fun audio(expression: String, reading: String): Cursor {
54         val cursor = MatrixCursor(arrayOf("file", "mime"))
55         if (expression.isEmpty()) return cursor
56         val local = LocalAudio(store.localAudioFile, store.localSources.split(',').map { it.trim() })
57         val sources = runCatching { local.sources(expression, reading) }.getOrDefault(emptyList()) +
58             if (store.remoteEnabled) RemoteAudio.sources(store.remoteUrls.lines().map { it.trim() }.filter { it.isNotEmpty() }, expression, reading) else emptyList()
59         for (source in sources) {
60             val bytes = runCatching { source.load() }.getOrNull() ?: continue
61             if (bytes.isEmpty()) continue
62             val extension = AudioType.extension(bytes)
63             val name = "${(expression + "\u0000" + reading).hashCode().toUInt()}.$extension"
64             File(dir(), name).writeBytes(bytes)
65             cursor.addRow(arrayOf(name, AudioType.mime(extension)))
66             break
67         }
68         return cursor
69     }
70 
71     override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor {
72         val segments = uri.pathSegments
73         if (segments.size != 2 || segments[0] != "audio") throw FileNotFoundException(uri.toString())
74         val file = File(dir(), File(segments[1]).name)
75         if (!file.isFile) throw FileNotFoundException(uri.toString())
76         return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
77     }
78 
79     override fun getType(uri: Uri): String? {
80         val segments = uri.pathSegments
81         return when {
82             segments.firstOrNull() == "terms" -> "vnd.android.cursor.dir/vnd.space.subread.dictionary.term"
83             segments.firstOrNull() == "audio" && segments.size == 1 -> "vnd.android.cursor.item/vnd.space.subread.dictionary.audio"
84             segments.firstOrNull() == "audio" && segments.size == 2 -> AudioType.mime(segments[1].substringAfterLast('.', ""))
85             else -> null
86         }
87     }
88 
89     override fun insert(uri: Uri, values: ContentValues?): Uri? = throw UnsupportedOperationException("read only")
90 
91     override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<String>?): Int = throw UnsupportedOperationException("read only")
92 
93     override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?): Int = throw UnsupportedOperationException("read only")
94 
95     /** The audio files on their way out. Files older than an hour go. */
96     private fun dir(): File {
97         val dir = File(context!!.cacheDir, "audio-out").apply { mkdirs() }
98         val limit = System.currentTimeMillis() - 60 * 60_000L
99         dir.listFiles()?.forEach { if (it.lastModified() < limit) it.delete() }
100         return dir
101     }
102 }