app/src/main/kotlin/space/subread/anki/AnkiClient.kt (11106 bytes)
1 package space.subread.anki 2 3 import android.content.ContentValues 4 import android.content.Context 5 import android.content.Intent 6 import android.content.pm.PackageManager 7 import android.net.Uri 8 import android.os.Build 9 import space.subread.anki.core.Fields 10 import space.subread.anki.core.NoteType 11 import space.subread.anki.core.Source 12 import java.io.File 13 14 /** 15 * AnkiDroid, through its content provider: the deck, the note type, the media files and the 16 * notes. The authority, the paths and the columns are the FlashCardsContract of AnkiDroid. The 17 * API library of AnkiDroid is not used: it comes from JitPack alone, and F-Droid builds from 18 * Maven Central and source alone. 19 */ 20 class AnkiClient(private val context: Context) { 21 22 /** A note of the collection: its id, its fields in order, and its tags. */ 23 class Note(val id: Long, val fields: Array<String>, val tags: Set<String>) 24 25 private val resolver get() = context.contentResolver 26 private val store = Store(context) 27 28 /** The decks of the collection, by id. Empty when AnkiDroid does not answer. */ 29 fun decks(): Map<Long, String> = runCatching { 30 resolver.query(DECKS, arrayOf("deck_id", "deck_name"), null, null, null)?.use { c -> 31 buildMap { 32 while (c.moveToNext()) put(c.getLong(0), c.getString(1)) 33 } 34 } 35 }.getOrNull().orEmpty() 36 37 /** The note types of the collection, by id. */ 38 fun models(): Map<Long, String> = runCatching { 39 resolver.query(MODELS, arrayOf("_id", "name"), null, null, null)?.use { c -> 40 buildMap { 41 while (c.moveToNext()) put(c.getLong(0), c.getString(1)) 42 } 43 } 44 }.getOrNull().orEmpty() 45 46 /** The fields of a note type, in order. Empty when the note type is gone. */ 47 fun fields(modelId: Long): List<String> = runCatching { 48 resolver.query(Uri.withAppendedPath(MODELS, modelId.toString()), arrayOf("field_names"), null, null, null)?.use { c -> 49 if (c.moveToFirst()) c.getString(0).split(SEPARATOR) else null 50 } 51 }.getOrNull().orEmpty() 52 53 /** Makes a deck with this name. Returns its id, or null when AnkiDroid refused. */ 54 fun addDeck(name: String): Long? = 55 resolver.insert(DECKS, ContentValues().apply { put("deck_name", name) })?.lastPathSegment?.toLongOrNull() 56 57 /** The deck the cards go to. The chosen one, else "SubRead", made when it is not there. */ 58 fun ensureDeck(): Long { 59 val decks = decks() 60 val chosen = store.deckId 61 if (chosen != Store.NONE && decks.containsKey(chosen)) return chosen 62 val id = decks.entries.firstOrNull { it.value == DECK_NAME }?.key 63 ?: addDeck(DECK_NAME) 64 ?: throw IllegalStateException("AnkiDroid did not make the deck") 65 store.deckId = id 66 return id 67 } 68 69 /** The note type of the cards. The chosen one, else the app's own, made when it is not there. */ 70 fun ensureModel(deckId: Long): Long { 71 val models = models() 72 val chosen = store.modelId 73 if (chosen != Store.NONE && models.containsKey(chosen)) return chosen 74 val id = models.entries.firstOrNull { it.value == NoteType.NAME }?.key 75 ?: addModel(deckId) 76 ?: throw IllegalStateException("AnkiDroid did not make the note type") 77 store.modelId = id 78 store.mapping = NoteType.MAPPING 79 return id 80 } 81 82 /** 83 * Makes the note type of the app: first the note type with its fields, then the template 84 * of each card. Returns its id, or null when AnkiDroid refused. 85 */ 86 private fun addModel(deckId: Long): Long? { 87 val values = ContentValues().apply { 88 put("name", NoteType.NAME) 89 put("field_names", NoteType.FIELDS.joinToString(SEPARATOR)) 90 put("num_cards", NoteType.CARD_NAMES.size) 91 put("css", NoteType.CSS) 92 put("deck_id", deckId) 93 put("sort_field_index", 0) 94 } 95 val model = resolver.insert(MODELS, values) ?: return null 96 val templates = Uri.withAppendedPath(model, "templates") 97 NoteType.CARD_NAMES.forEachIndexed { i, card -> 98 val template = ContentValues().apply { 99 put("card_template_name", card) 100 put("question_format", NoteType.QUESTION_FORMATS[i]) 101 put("answer_format", NoteType.ANSWER_FORMATS[i]) 102 } 103 resolver.update(Uri.withAppendedPath(templates, i.toString()), template, null, null) 104 } 105 return model.lastPathSegment?.toLongOrNull() 106 } 107 108 /** The note type of the cards when it exists already, or null. Makes nothing: for a look before the first card. */ 109 fun existingModel(): Long? { 110 val models = models() 111 val chosen = store.modelId 112 if (chosen != Store.NONE && models.containsKey(chosen)) return chosen 113 return models.entries.firstOrNull { it.value == NoteType.NAME }?.key 114 } 115 116 /** The name of the deck that the cards go to. */ 117 fun deckName(): String = decks()[store.deckId] ?: DECK_NAME 118 119 /** 120 * True when a note of the note type has [expression] in its first field, and that field 121 * holds the word. False when the note type does not exist yet. 122 */ 123 fun inAnki(expression: String): Boolean { 124 val modelId = existingModel() ?: return false 125 val fields = fields(modelId) 126 if (fields.isEmpty() || mapping(modelId, fields)[fields.first()] != Source.EXPRESSION) return false 127 return isDuplicate(modelId, expression) 128 } 129 130 /** What fills each field of [modelId]: the mapping the user set, else a guess from the names. */ 131 fun mapping(modelId: Long, fields: List<String>): Map<String, Source> { 132 val set = store.mapping 133 return if (set.isNotEmpty() && set.keys.containsAll(fields)) set else Fields.guess(fields) 134 } 135 136 /** 137 * Copies a file into the media folder of AnkiDroid and returns its name there. [kind] is 138 * `audio` or `image`; AnkiDroid takes the kind from the file. AnkiDroid reads the file 139 * through [MediaProvider], so the read is granted to it for the copy. 140 */ 141 @Suppress("UNUSED_PARAMETER") 142 fun addMedia(file: File, kind: String): String? { 143 val uri = Media.uriFor(context, file) 144 val anki = packageName(context) ?: return null 145 context.grantUriPermission(anki, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) 146 try { 147 val values = ContentValues().apply { 148 put("file_uri", uri.toString()) 149 put("preferred_name", file.nameWithoutExtension.replace(' ', '_')) 150 } 151 val stored = runCatching { resolver.insert(MEDIA, values) }.getOrNull() ?: return null 152 // The path of the answer is the name of the file in the media folder, after a slash. 153 return stored.path?.trimStart('/')?.takeIf { it.isNotEmpty() } 154 } finally { 155 context.revokeUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) 156 } 157 } 158 159 /** True when a note of [modelId] has [key] as its first field. */ 160 fun isDuplicate(modelId: Long, key: String): Boolean { 161 if (key.isBlank()) return false 162 return runCatching { 163 val model = models()[modelId] ?: return@runCatching false 164 val first = fields(modelId).firstOrNull() ?: return@runCatching false 165 // The selection of the notes path is an Anki search. The quotes make one term of 166 // "field:text", and the escapes stop Anki from reading _ and * as wildcards. 167 val search = "\"${escape(first)}:${escape(key)}\" \"note:${escape(model)}\"" 168 resolver.query(NOTES, arrayOf("_id"), search, null, null)?.use { it.count > 0 } ?: false 169 }.getOrDefault(false) 170 } 171 172 /** Adds a note and puts its cards in the deck. Null when AnkiDroid refused it. */ 173 fun addNote(modelId: Long, deckId: Long, fields: Array<String>, tags: Set<String>): Long? { 174 val values = ContentValues().apply { 175 put("mid", modelId) 176 put("flds", fields.joinToString(SEPARATOR)) 177 put("tags", tags.joinToString(" ") { it.replace(' ', '_') }) 178 } 179 val note = resolver.insert(NOTES, values) ?: return null 180 val cards = Uri.withAppendedPath(note, "cards") 181 resolver.query(cards, arrayOf("ord"), null, null, null)?.use { c -> 182 while (c.moveToNext()) { 183 val deck = ContentValues().apply { put("deck_id", deckId) } 184 resolver.update(Uri.withAppendedPath(cards, c.getString(0)), deck, null, null) 185 } 186 } 187 return note.lastPathSegment?.toLongOrNull() 188 } 189 190 fun note(noteId: Long): Note? = runCatching { 191 resolver.query(Uri.withAppendedPath(NOTES, noteId.toString()), arrayOf("_id", "flds", "tags"), null, null, null)?.use { c -> 192 if (!c.moveToFirst()) { 193 null 194 } else { 195 Note( 196 c.getLong(0), 197 c.getString(1).split(SEPARATOR).toTypedArray(), 198 c.getString(2).orEmpty().split(' ').filter { it.isNotBlank() }.toSet(), 199 ) 200 } 201 } 202 }.getOrNull() 203 204 fun updateFields(noteId: Long, fields: Array<String>): Boolean = runCatching { 205 val values = ContentValues().apply { put("flds", fields.joinToString(SEPARATOR)) } 206 resolver.update(Uri.withAppendedPath(NOTES, noteId.toString()), values, null, null) > 0 207 }.getOrDefault(false) 208 209 companion object { 210 const val DECK_NAME = "SubRead" 211 const val PERMISSION = "com.ichi2.anki.permission.READ_WRITE_DATABASE" 212 213 private const val AUTHORITY = "com.ichi2.anki.flashcards" 214 private val ROOT: Uri = Uri.parse("content://$AUTHORITY") 215 private val NOTES: Uri = Uri.withAppendedPath(ROOT, "notes") 216 private val MODELS: Uri = Uri.withAppendedPath(ROOT, "models") 217 private val DECKS: Uri = Uri.withAppendedPath(ROOT, "decks") 218 private val MEDIA: Uri = Uri.withAppendedPath(ROOT, "media") 219 220 /** Anki keeps the fields of a note in one string, with this character between them. */ 221 private const val SEPARATOR = "\u001f" 222 223 /** A text for an Anki search between quotes: \, ", _ and * get a backslash. */ 224 private fun escape(text: String): String = 225 text.replace("\\", "\\\\").replace("\"", "\\\"").replace("_", "\\_").replace("*", "\\*") 226 227 /** The package of AnkiDroid, or null when it is not installed. */ 228 fun packageName(context: Context): String? { 229 val info = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { 230 context.packageManager.resolveContentProvider(AUTHORITY, PackageManager.ComponentInfoFlags.of(0)) 231 } else { 232 @Suppress("DEPRECATION") 233 context.packageManager.resolveContentProvider(AUTHORITY, 0) 234 } 235 return info?.packageName 236 } 237 238 fun hasPermission(context: Context): Boolean = 239 context.checkSelfPermission(PERMISSION) == PackageManager.PERMISSION_GRANTED 240 } 241 }