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 }