commit c5c6ed4ad5d2a525c47ec2995c7b1785add8e3f4 equwal <13551856+equwal@users.noreply.github.com> 2026-09-26 14:28:00 -0700 Talk to AnkiDroid without the JitPack library, and turn on R8 F-Droid builds from Maven Central and source only, and the AnkiDroid API library is on JitPack only. AnkiClient now uses the content provider of AnkiDroid (the FlashCardsContract) directly, with the same calls as the library, and keeps its own methods, so the callers do not change. A duplicate check is an Anki search on the first field. R8 removes unused code; F-Droid asks for it.
app/build.gradle.kts | 11 +- .../main/kotlin/space/subread/anki/AnkiClient.kt | 176 +++++++++++++++++---- .../main/kotlin/space/subread/anki/MainActivity.kt | 6 +- gradle/libs.versions.toml | 3 - settings.gradle.kts | 5 - 5 files changed, 148 insertions(+), 53 deletions(-)
diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 604a4d6..df62df3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -47,7 +47,10 @@ android { buildTypes { debug { applicationIdSuffix = ".debug" } release { - isMinifyEnabled = false + // R8 removes unused code and resources. F-Droid asks for it. + isMinifyEnabled = true + isShrinkResources = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) if (signingReady) signingConfig = signingConfigs.getByName("release") } } @@ -58,17 +61,11 @@ android { } buildFeatures { buildConfig = true } - lint { - // The AnkiDroid API library ships the lint rules of the AnkiDroid app itself. This one - // wants the clock of the Anki collection; this app has no collection. - disable += "DirectSystemCurrentTimeMillisUsage" - } } dependencies { implementation(project(":core")) implementation(libs.androidx.core.ktx) - implementation(libs.ankidroid.api) androidTestImplementation(libs.junit) androidTestImplementation(libs.androidx.test.runner) diff --git a/app/src/main/kotlin/space/subread/anki/AnkiClient.kt b/app/src/main/kotlin/space/subread/anki/AnkiClient.kt index 5315d56..7c72751 100644 --- a/app/src/main/kotlin/space/subread/anki/AnkiClient.kt +++ b/app/src/main/kotlin/space/subread/anki/AnkiClient.kt @@ -1,29 +1,58 @@ package space.subread.anki +import android.content.ContentValues import android.content.Context import android.content.Intent import android.content.pm.PackageManager -import com.ichi2.anki.api.AddContentApi -import com.ichi2.anki.api.NoteInfo +import android.net.Uri +import android.os.Build import space.subread.anki.core.Fields import space.subread.anki.core.NoteType import space.subread.anki.core.Source import java.io.File -/** The AnkiDroid API: the deck, the note type, the media files and the notes. */ +/** + * AnkiDroid, through its content provider: the deck, the note type, the media files and the + * notes. The authority, the paths and the columns are the FlashCardsContract of AnkiDroid. The + * API library of AnkiDroid is not used: it comes from JitPack alone, and F-Droid builds from + * Maven Central and source alone. + */ class AnkiClient(private val context: Context) { - private val api = AddContentApi(context) + /** A note of the collection: its id, its fields in order, and its tags. */ + class Note(val id: Long, val fields: Array<String>, val tags: Set<String>) + + private val resolver get() = context.contentResolver private val store = Store(context) /** The decks of the collection, by id. Empty when AnkiDroid does not answer. */ - fun decks(): Map<Long, String> = runCatching { api.deckList }.getOrNull().orEmpty() + fun decks(): Map<Long, String> = runCatching { + resolver.query(DECKS, arrayOf("deck_id", "deck_name"), null, null, null)?.use { c -> + buildMap { + while (c.moveToNext()) put(c.getLong(0), c.getString(1)) + } + } + }.getOrNull().orEmpty() /** The note types of the collection, by id. */ - fun models(): Map<Long, String> = runCatching { api.modelList }.getOrNull().orEmpty() + fun models(): Map<Long, String> = runCatching { + resolver.query(MODELS, arrayOf("_id", "name"), null, null, null)?.use { c -> + buildMap { + while (c.moveToNext()) put(c.getLong(0), c.getString(1)) + } + } + }.getOrNull().orEmpty() /** The fields of a note type, in order. Empty when the note type is gone. */ - fun fields(modelId: Long): List<String> = runCatching { api.getFieldList(modelId) }.getOrNull()?.toList().orEmpty() + fun fields(modelId: Long): List<String> = runCatching { + resolver.query(Uri.withAppendedPath(MODELS, modelId.toString()), arrayOf("field_names"), null, null, null)?.use { c -> + if (c.moveToFirst()) c.getString(0).split(SEPARATOR) else null + } + }.getOrNull().orEmpty() + + /** Makes a deck with this name. Returns its id, or null when AnkiDroid refused. */ + fun addDeck(name: String): Long? = + resolver.insert(DECKS, ContentValues().apply { put("deck_name", name) })?.lastPathSegment?.toLongOrNull() /** The deck the cards go to. The chosen one, else "SubRead", made when it is not there. */ fun ensureDeck(): Long { @@ -31,7 +60,7 @@ class AnkiClient(private val context: Context) { val chosen = store.deckId if (chosen != Store.NONE && decks.containsKey(chosen)) return chosen val id = decks.entries.firstOrNull { it.value == DECK_NAME }?.key - ?: api.addNewDeck(DECK_NAME) + ?: addDeck(DECK_NAME) ?: throw IllegalStateException("AnkiDroid did not make the deck") store.deckId = id return id @@ -43,22 +72,39 @@ class AnkiClient(private val context: Context) { val chosen = store.modelId if (chosen != Store.NONE && models.containsKey(chosen)) return chosen val id = models.entries.firstOrNull { it.value == NoteType.NAME }?.key - ?: api.addNewCustomModel( - NoteType.NAME, - NoteType.FIELDS.toTypedArray(), - NoteType.CARD_NAMES.toTypedArray(), - NoteType.QUESTION_FORMATS.toTypedArray(), - NoteType.ANSWER_FORMATS.toTypedArray(), - NoteType.CSS, - deckId, - 0, - ) + ?: addModel(deckId) ?: throw IllegalStateException("AnkiDroid did not make the note type") store.modelId = id store.mapping = NoteType.MAPPING return id } + /** + * Makes the note type of the app: first the note type with its fields, then the template + * of each card. Returns its id, or null when AnkiDroid refused. + */ + private fun addModel(deckId: Long): Long? { + val values = ContentValues().apply { + put("name", NoteType.NAME) + put("field_names", NoteType.FIELDS.joinToString(SEPARATOR)) + put("num_cards", NoteType.CARD_NAMES.size) + put("css", NoteType.CSS) + put("deck_id", deckId) + put("sort_field_index", 0) + } + val model = resolver.insert(MODELS, values) ?: return null + val templates = Uri.withAppendedPath(model, "templates") + NoteType.CARD_NAMES.forEachIndexed { i, card -> + val template = ContentValues().apply { + put("card_template_name", card) + put("question_format", NoteType.QUESTION_FORMATS[i]) + put("answer_format", NoteType.ANSWER_FORMATS[i]) + } + resolver.update(Uri.withAppendedPath(templates, i.toString()), template, null, null) + } + return model.lastPathSegment?.toLongOrNull() + } + /** The note type of the cards when it exists already, or null. Makes nothing: for a look before the first card. */ fun existingModel(): Long? { val models = models() @@ -89,41 +135,105 @@ class AnkiClient(private val context: Context) { /** * Copies a file into the media folder of AnkiDroid and returns its name there. [kind] is - * `audio` or `image`. AnkiDroid reads the file through [MediaProvider], so the read is - * granted to it for the copy. + * `audio` or `image`; AnkiDroid takes the kind from the file. AnkiDroid reads the file + * through [MediaProvider], so the read is granted to it for the copy. */ + @Suppress("UNUSED_PARAMETER") fun addMedia(file: File, kind: String): String? { val uri = Media.uriFor(context, file) val anki = packageName(context) ?: return null context.grantUriPermission(anki, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) try { - val stem = file.nameWithoutExtension - val formatted = api.addMediaFromUri(uri, stem, kind) ?: return null - // The API answers with the field text, `[sound:name]` or `<img src="name" />`: take the name. - return Regex("\\[sound:(.+)]|src=\"([^\"]+)\"").find(formatted)?.let { it.groupValues[1].ifEmpty { it.groupValues[2] } } + val values = ContentValues().apply { + put("file_uri", uri.toString()) + put("preferred_name", file.nameWithoutExtension.replace(' ', '_')) + } + val stored = runCatching { resolver.insert(MEDIA, values) }.getOrNull() ?: return null + // The path of the answer is the name of the file in the media folder, after a slash. + return stored.path?.trimStart('/')?.takeIf { it.isNotEmpty() } } finally { context.revokeUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) } } /** True when a note of [modelId] has [key] as its first field. */ - fun isDuplicate(modelId: Long, key: String): Boolean = - key.isNotEmpty() && runCatching { api.findDuplicateNotes(modelId, key) }.getOrNull().orEmpty().isNotEmpty() + fun isDuplicate(modelId: Long, key: String): Boolean { + if (key.isBlank()) return false + return runCatching { + val model = models()[modelId] ?: return@runCatching false + val first = fields(modelId).firstOrNull() ?: return@runCatching false + // The selection of the notes path is an Anki search. The quotes make one term of + // "field:text", and the escapes stop Anki from reading _ and * as wildcards. + val search = "\"${escape(first)}:${escape(key)}\" \"note:${escape(model)}\"" + resolver.query(NOTES, arrayOf("_id"), search, null, null)?.use { it.count > 0 } ?: false + }.getOrDefault(false) + } - /** Adds a note. Null when AnkiDroid refused it. */ - fun addNote(modelId: Long, deckId: Long, fields: Array<String>, tags: Set<String>): Long? = - api.addNote(modelId, deckId, fields, tags) + /** Adds a note and puts its cards in the deck. Null when AnkiDroid refused it. */ + fun addNote(modelId: Long, deckId: Long, fields: Array<String>, tags: Set<String>): Long? { + val values = ContentValues().apply { + put("mid", modelId) + put("flds", fields.joinToString(SEPARATOR)) + put("tags", tags.joinToString(" ") { it.replace(' ', '_') }) + } + val note = resolver.insert(NOTES, values) ?: return null + val cards = Uri.withAppendedPath(note, "cards") + resolver.query(cards, arrayOf("ord"), null, null, null)?.use { c -> + while (c.moveToNext()) { + val deck = ContentValues().apply { put("deck_id", deckId) } + resolver.update(Uri.withAppendedPath(cards, c.getString(0)), deck, null, null) + } + } + return note.lastPathSegment?.toLongOrNull() + } - fun note(noteId: Long): NoteInfo? = runCatching { api.getNote(noteId) }.getOrNull() + fun note(noteId: Long): Note? = runCatching { + resolver.query(Uri.withAppendedPath(NOTES, noteId.toString()), arrayOf("_id", "flds", "tags"), null, null, null)?.use { c -> + if (!c.moveToFirst()) { + null + } else { + Note( + c.getLong(0), + c.getString(1).split(SEPARATOR).toTypedArray(), + c.getString(2).orEmpty().split(' ').filter { it.isNotBlank() }.toSet(), + ) + } + } + }.getOrNull() - fun updateFields(noteId: Long, fields: Array<String>): Boolean = runCatching { api.updateNoteFields(noteId, fields) }.getOrDefault(false) + fun updateFields(noteId: Long, fields: Array<String>): Boolean = runCatching { + val values = ContentValues().apply { put("flds", fields.joinToString(SEPARATOR)) } + resolver.update(Uri.withAppendedPath(NOTES, noteId.toString()), values, null, null) > 0 + }.getOrDefault(false) companion object { const val DECK_NAME = "SubRead" - const val PERMISSION = AddContentApi.READ_WRITE_PERMISSION + const val PERMISSION = "com.ichi2.anki.permission.READ_WRITE_DATABASE" + + private const val AUTHORITY = "com.ichi2.anki.flashcards" + private val ROOT: Uri = Uri.parse("content://$AUTHORITY") + private val NOTES: Uri = Uri.withAppendedPath(ROOT, "notes") + private val MODELS: Uri = Uri.withAppendedPath(ROOT, "models") + private val DECKS: Uri = Uri.withAppendedPath(ROOT, "decks") + private val MEDIA: Uri = Uri.withAppendedPath(ROOT, "media") + + /** Anki keeps the fields of a note in one string, with this character between them. */ + private const val SEPARATOR = "\u001f" + + /** A text for an Anki search between quotes: \, ", _ and * get a backslash. */ + private fun escape(text: String): String = + text.replace("\\", "\\\\").replace("\"", "\\\"").replace("_", "\\_").replace("*", "\\*") /** The package of AnkiDroid, or null when it is not installed. */ - fun packageName(context: Context): String? = AddContentApi.getAnkiDroidPackageName(context) + fun packageName(context: Context): String? { + val info = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.packageManager.resolveContentProvider(AUTHORITY, PackageManager.ComponentInfoFlags.of(0)) + } else { + @Suppress("DEPRECATION") + context.packageManager.resolveContentProvider(AUTHORITY, 0) + } + return info?.packageName + } fun hasPermission(context: Context): Boolean = context.checkSelfPermission(PERMISSION) == PackageManager.PERMISSION_GRANTED diff --git a/app/src/main/kotlin/space/subread/anki/MainActivity.kt b/app/src/main/kotlin/space/subread/anki/MainActivity.kt index 0147eae..8362ae5 100644 --- a/app/src/main/kotlin/space/subread/anki/MainActivity.kt +++ b/app/src/main/kotlin/space/subread/anki/MainActivity.kt @@ -170,7 +170,7 @@ class MainActivity : Activity() { val name = field.text.toString().trim() if (name.isEmpty()) return@setPositiveButton thread { - val id = runCatching { AnkiClient(this).run { decks().entries.firstOrNull { it.value == name }?.key ?: addNewDeckOrNull(name) } }.getOrNull() + val id = runCatching { AnkiClient(this).run { decks().entries.firstOrNull { it.value == name }?.key ?: addDeck(name) } }.getOrNull() runOnUiThread { if (id != null) store.deckId = id else toast(getString(R.string.failed, name)) draw() @@ -179,10 +179,6 @@ class MainActivity : Activity() { }.setNegativeButton(R.string.cancel, null).show() } - private fun AnkiClient.addNewDeckOrNull(name: String): Long? = runCatching { - com.ichi2.anki.api.AddContentApi(this@MainActivity).addNewDeck(name) - }.getOrNull() - private fun chooseModel() { thread { val models = AnkiClient(this).models().entries.sortedBy { it.value } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index aa5aae8..0cec3c2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,7 +3,6 @@ agp = "9.2.0" kotlin = "2.3.21" coreKtx = "1.16.0" kotlinxCoroutines = "1.10.2" -ankidroidApi = "api-v1.1.0" kotestProperty = "5.9.1" junit = "4.13.2" androidxTestRunner = "1.7.0" @@ -14,8 +13,6 @@ androidxTestCore = "1.7.0" androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } -# The AnkiDroid API: a thin client of the AnkiDroid content provider, from the AnkiDroid project itself. -ankidroid-api = { group = "com.github.ankidroid", name = "Anki-Android", version.ref = "ankidroidApi" } kotest-property = { group = "io.kotest", name = "kotest-property", version.ref = "kotestProperty" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 8edf401..c9431bd 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,11 +16,6 @@ dependencyResolutionManagement { repositories { google() mavenCentral() - // The AnkiDroid API library is published on JitPack only. - maven { - url = uri("https://jitpack.io") - content { includeGroup("com.github.ankidroid") } - } } }