commit d91de61d45447b0cd65ca1805db0def0213a0b5e equwal <13551856+equwal@users.noreply.github.com> 2026-09-26 13:19:31 -0700 Fix the faults that a review of the pop-up found - A sender that gives the dictionary form (食べる) with a sentence that holds another form (食べた) got no bold and the whole text as the sentence. Scans.locate now finds the form of the text with the dictionary, and the card keeps the word of the sender. - The sound of a word played after the pop-up closed, and prepare() blocked the UI thread. The player now prepares on its own thread and starts only while the pop-up is open. - A long selection lost its lines after the fifth. The text now scrolls in a box of at most three tenths of the pop-up. - A pop-up closed with no card now answers RESULT_CANCELED with "cancelled", as the README says. A picture added to the last card answers RESULT_OK. - "+ Anki" before AnkiDroid allowed the app now asks for the permission again, in place of an error from AnkiDroid. - A clip cut from the audio of a sender is left out when it is silence, the same as a clip of the capture. - Sentences.around, unescape and stripTags had no user left. They are gone; the tests keep their cases with local helpers.
.../main/kotlin/space/subread/anki/AddActivity.kt | 76 +++++++++++++++++----- app/src/main/kotlin/space/subread/anki/AudioCut.kt | 4 +- app/src/main/kotlin/space/subread/anki/Miner.kt | 5 +- .../main/kotlin/space/subread/anki/core/Scans.kt | 18 +++++ .../kotlin/space/subread/anki/core/Sentences.kt | 21 ------ .../kotlin/space/subread/anki/core/CoreTest.kt | 54 ++++++++++++--- 6 files changed, 129 insertions(+), 49 deletions(-)
diff --git a/app/src/main/kotlin/space/subread/anki/AddActivity.kt b/app/src/main/kotlin/space/subread/anki/AddActivity.kt index 6455f14..ab9bac4 100644 --- a/app/src/main/kotlin/space/subread/anki/AddActivity.kt +++ b/app/src/main/kotlin/space/subread/anki/AddActivity.kt @@ -116,6 +116,8 @@ class AddActivity : Activity() { return } draw() + // A pop-up that closes with no card answers "cancelled"; each card replaces this answer. + setResult(RESULT_CANCELED, Intent().putExtra(Requests.EXTRA_ERROR, "cancelled")) show(scan) work.execute { val grabbed = runCatching { grab?.get() }.getOrNull() @@ -152,15 +154,21 @@ class AddActivity : Activity() { work.execute { val result = runCatching { val word = scan.word + // Where the word is in the text, also in another form: for the bold and the sentence. + val found = if (scan.offset >= 0 || word == null) null else locate(scan.text, word) + val at = if (scan.offset >= 0) scan.offset else found?.first ?: -1 val card = if (request.definition != null) { - if (word != null && scan.offset >= 0) Miner.card(request, scan.text, scan.offset, word.length, null) - else Miner.card(request, scan.text, -1, 0, null) - } else if (scan.offset >= 0) { - val entry = DictionaryClient.lookup(this, scan.text, scan.offset).firstOrNull() + when { + found != null -> Miner.card(request, scan.text, found.first, found.count(), null) + word != null && at >= 0 -> Miner.card(request, scan.text, at, word.length, null) + else -> Miner.card(request, scan.text, -1, 0, null) + } + } else if (at >= 0) { + val entry = DictionaryClient.lookup(this, scan.text, at).firstOrNull() if (entry != null) { - Miner.card(request, scan.text, scan.offset, entry.length, entry) + Miner.card(request, scan.text, at, entry.length, entry) } else { - val range = Miner.wordAt(scan.text, scan.offset, selected = isSelection(scan.text, scan.offset)) + val range = Miner.wordAt(scan.text, at, selected = isSelection(scan.text, at)) Miner.card(request, scan.text, range.first, range.count(), null) } } else { @@ -189,7 +197,8 @@ class AddActivity : Activity() { } Miner.Result.Attached -> { toast(getString(R.string.picture_attached)) - finishWith(null) + setResult(RESULT_OK, Intent().putExtra(Requests.EXTRA_NOTE_ID, store.lastNoteId)) + finish() } is Miner.Result.Failed -> { toast(getString(R.string.failed, result.why)) @@ -221,7 +230,6 @@ class AddActivity : Activity() { textView = TextView(this).apply { textSize = TEXT_SP + 3 setTextColor(Color.BLACK) - maxLines = 5 setPadding(dp(16), dp(4), dp(16), dp(4)) setOnTouchListener { view, event -> if (event.action == MotionEvent.ACTION_UP) { @@ -247,12 +255,19 @@ class AddActivity : Activity() { addView(smallButton(getString(R.string.settings)) { startActivity(Intent(this@AddActivity, MainActivity::class.java)) }) addView(smallButton(getString(R.string.close)) { finish() }) } + // A long selection scrolls in its own box, at most three tenths of the pop-up, so that + // each character stays in reach and the terms keep their room. + val textCap = height * 3 / 10 + val textBox = object : ScrollView(this) { + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) = + super.onMeasure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(textCap, MeasureSpec.AT_MOST)) + }.apply { addView(textView) } setContentView( LinearLayout(this).apply { orientation = LinearLayout.VERTICAL setBackgroundColor(Color.WHITE) addView(bar, LinearLayout.LayoutParams(-1, -2)) - addView(textView, LinearLayout.LayoutParams(-1, -2)) + addView(textBox, LinearLayout.LayoutParams(-1, -2)) addView(status, LinearLayout.LayoutParams(-1, -2)) addView(View(this@AddActivity).apply { setBackgroundColor(Color.LTGRAY) }, LinearLayout.LayoutParams(-1, dp(1))) addView(ScrollView(this@AddActivity).apply { addView(results) }, LinearLayout.LayoutParams(-1, 0, 1f)) @@ -264,9 +279,26 @@ class AddActivity : Activity() { private fun show(scan: Scan) { text = scan.text textView.text = text - if (scan.offset >= 0) scanAt(scan.offset) else lookUpWord(scan.word.orEmpty()) + if (scan.offset >= 0) { + scanAt(scan.offset) + return + } + // The word of the sender is not in the text as it is: the dictionary finds its form there. + val word = scan.word.orEmpty() + val run = ++generation + thread { + val range = locate(text, word) + runOnUiThread { + if (run != generation || isFinishing) return@runOnUiThread + if (range != null) scanAt(range.first) else lookUpWord(word) + } + } } + /** Where [word] is in [text], also in another form, found with the dictionary. Not on the UI thread. */ + private fun locate(text: String, word: String): IntRange? = + Scans.locate(text, word) { at -> DictionaryClient.lookup(this, text, at).map { it.expression to it.length } } + /** Looks up the terms that start at [at] in the text. */ private fun scanAt(at: Int) { val run = ++generation @@ -277,7 +309,7 @@ class AddActivity : Activity() { } } - /** The word of the sender is not in the text: its terms, with nothing marked. */ + /** The word of the sender is not in the text in any form: its terms, with nothing marked. */ private fun lookUpWord(word: String) { val run = ++generation thread { @@ -345,6 +377,12 @@ class AddActivity : Activity() { /** One tap: the card goes to AnkiDroid. The button says when it is there. */ private fun addCard(button: Button, entry: DictionaryClient.Entry?, start: Int, length: Int) { val grabbed = grab ?: return + // AnkiDroid has not allowed the app yet: ask again, and keep the button for the next tap. + if (!AnkiClient.hasPermission(this)) { + toast(getString(R.string.anki_no_permission)) + requestPermissions(arrayOf(AnkiClient.PERMISSION), PERMISSION) + return + } button.isEnabled = false button.text = "…" val card = Miner.card(request, text, start, length, entry) @@ -417,23 +455,27 @@ class AddActivity : Activity() { return parts.joinToString(" ") } - /** Plays the audio of the word from the dictionary. */ + /** + * Plays the audio of the word from the dictionary. The file is written off the UI thread, + * and the player prepares on its own thread. A pop-up that closed in the meantime plays + * nothing: its onDestroy has already run, and no later player would be released. + */ private fun play(entry: DictionaryClient.Entry) { val ask = entry.audio ?: return thread { - val bytes = DictionaryClient.audio(this, ask) + val file = DictionaryClient.audio(this, ask)?.let { bytes -> File(cacheDir, "play.tmp").apply { writeBytes(bytes) } } runOnUiThread { - if (bytes == null) { + if (isFinishing || isDestroyed) return@runOnUiThread + if (file == null) { toast(getString(R.string.no_audio)) return@runOnUiThread } player?.release() - val file = File(cacheDir, "play.tmp").apply { writeBytes(bytes) } player = runCatching { MediaPlayer().apply { FileInputStream(file).use { setDataSource(it.fd) } - prepare() - start() + setOnPreparedListener { it.start() } + prepareAsync() } }.getOrNull() } diff --git a/app/src/main/kotlin/space/subread/anki/AudioCut.kt b/app/src/main/kotlin/space/subread/anki/AudioCut.kt index 13f4c0d..4f85192 100644 --- a/app/src/main/kotlin/space/subread/anki/AudioCut.kt +++ b/app/src/main/kotlin/space/subread/anki/AudioCut.kt @@ -6,6 +6,7 @@ import android.media.MediaCodec import android.media.MediaExtractor import android.media.MediaFormat import androidx.core.net.toUri +import space.subread.anki.core.Loudness import java.io.File import java.nio.ByteOrder @@ -45,7 +46,8 @@ object AudioCut { val decoded = decode(extractor, format, startMs * 1000, endMs * 1000) ?: return null val (pcm, sampleRate, channels) = decoded // Less than a tenth of a second is no sentence: the range was outside the file. - if (pcm.size < sampleRate / 10 * channels) return null + // Silence is no sentence either: the times of the sender missed the speech. + if (pcm.size < sampleRate / 10 * channels || Loudness.isSilent(pcm)) return null AacEncoder.encode(pcm, sampleRate, channels, out) return out } catch (e: Exception) { diff --git a/app/src/main/kotlin/space/subread/anki/Miner.kt b/app/src/main/kotlin/space/subread/anki/Miner.kt index 7f12cc9..f9b056c 100644 --- a/app/src/main/kotlin/space/subread/anki/Miner.kt +++ b/app/src/main/kotlin/space/subread/anki/Miner.kt @@ -186,8 +186,11 @@ class Miner(private val context: Context) { fun card(request: MineRequest, text: String, start: Int, length: Int, entry: DictionaryClient.Entry?): Card { val inText = start >= 0 && length > 0 && start + length <= text.length val selection = if (inText) text.substring(start, start + length) else "" + // A sender with its own definition names the word of the card. The text can hold + // that word in another form: 食べる of the sender is 食べた in the text. + val given = request.expression?.trim()?.takeIf { it.isNotEmpty() && request.definition != null } return Card( - expression = entry?.expression ?: selection.ifEmpty { request.expression?.trim().orEmpty() }, + expression = entry?.expression ?: given ?: selection.ifEmpty { request.expression?.trim().orEmpty() }, reading = request.reading ?: entry?.reading.orEmpty(), definition = request.definition ?: entry?.glossary.orEmpty(), sentence = if (inText) Scans.sentence(text, start, length) else request.sentence?.trim().orEmpty(), diff --git a/core/src/main/kotlin/space/subread/anki/core/Scans.kt b/core/src/main/kotlin/space/subread/anki/core/Scans.kt index d4399b8..c2d3e76 100644 --- a/core/src/main/kotlin/space/subread/anki/core/Scans.kt +++ b/core/src/main/kotlin/space/subread/anki/core/Scans.kt @@ -38,6 +38,24 @@ object Scans { return Scan(word, 0, word, false) } + /** + * Where [expression] is in [text], also in another form. A sender gives the dictionary form + * (`食べる`) and a sentence with the form of the text (`食べた`). [termsAt] gives the terms that + * start at a position of the text, with the characters that each covers: the dictionary finds + * `食べる` at `食べた`. Null when the text holds the expression in no form. + */ + fun locate(text: String, expression: String, termsAt: (Int) -> List<Pair<String, Int>>): IntRange? { + if (expression.isEmpty()) return null + val direct = text.indexOf(expression) + if (direct >= 0) return direct until direct + expression.length + for (at in text.indices) { + if (text[at].isWhitespace()) continue + val length = termsAt(at).firstOrNull { it.first == expression }?.second ?: continue + if (length > 0) return at until minOf(text.length, at + length) + } + return null + } + /** * The sentence of the card for a word that starts at [start] and is [length] characters * long: the sentence of [text] around it. Empty when the text is the word alone: a word is diff --git a/core/src/main/kotlin/space/subread/anki/core/Sentences.kt b/core/src/main/kotlin/space/subread/anki/core/Sentences.kt index f5eb858..b95f083 100644 --- a/core/src/main/kotlin/space/subread/anki/core/Sentences.kt +++ b/core/src/main/kotlin/space/subread/anki/core/Sentences.kt @@ -42,17 +42,6 @@ object Sentences { return start until end } - /** - * The sentence of [text] that holds [word], trimmed, or null when the word is not in the - * text. The first occurrence of the word counts. - */ - fun around(text: String, word: String): String? { - if (word.isEmpty()) return null - val at = text.indexOf(word) - if (at < 0) return null - return text.substring(rangeAround(text, at)).trim() - } - /** * The sentence as HTML, with the first occurrence of [word] in `<b>`. When the word is * not in the sentence, the sentence is only escaped. @@ -77,14 +66,4 @@ object Sentences { } } } - - /** The reverse of [escape]. */ - fun unescape(html: String): String = html - .replace("<", "<") - .replace(">", ">") - .replace(""", "\"") - .replace("&", "&") - - /** HTML with its tags removed. Entities stay as they are. */ - fun stripTags(html: String): String = html.replace(Regex("<[^>]*>"), "") } diff --git a/core/src/test/kotlin/space/subread/anki/core/CoreTest.kt b/core/src/test/kotlin/space/subread/anki/core/CoreTest.kt index d9f078d..0100e31 100644 --- a/core/src/test/kotlin/space/subread/anki/core/CoreTest.kt +++ b/core/src/test/kotlin/space/subread/anki/core/CoreTest.kt @@ -128,6 +128,16 @@ class MineRequestTest { /** Sentences around a word. */ class SentencesTest { + /** The sentence of [text] that holds the first [word], or null when the word is not in the text. */ + private fun around(text: String, word: String): String? { + val at = text.indexOf(word) + return if (word.isEmpty() || at < 0) null else text.substring(Sentences.rangeAround(text, at)).trim() + } + + /** The text of HTML that [Sentences.emphasize] and [Sentences.escape] wrote. */ + private fun plain(html: String): String = html.replace(Regex("<[^>]*>"), "") + .replace("<", "<").replace(">", ">").replace(""", "\"").replace("&", "&") + private val sentenceBody: Arb<String> = text(12, listOf("a", "b", "c", "d", "猫", "ね", "こ", " ", "「", "」"), least = 1) private val terminator: Arb<String> = Arb.element(listOf("。", "!", "?", "!", "?", "…", "\n", "。」", "!?")) @@ -143,7 +153,7 @@ class SentencesTest { val word = body.trim().takeIf { it.isNotEmpty() && '」' !in it && '「' !in it } ?: return@checkAll // The first occurrence of the word counts: skip a word that is in an earlier sentence. if (sentences.take(i).any { it.contains(word) }) return@checkAll - val found = Sentences.around(text, word) + val found = around(text, word) assertEquals(sentences[i].trim(), found) } } @@ -151,7 +161,7 @@ class SentencesTest { @Test fun theSentenceIsInTheTextAndHoldsTheWord() = property { checkAll(anyText, text(3, listOf("a", "b", "猫", "ね", "。"), least = 1)) { text, word -> - val found = Sentences.around(text, word) + val found = around(text, word) if (!text.contains(word)) { assertNull(found) } else { @@ -164,19 +174,19 @@ class SentencesTest { @Test fun closersStayWithTheirSentence() { - assertEquals("「猫だ。」", Sentences.around("「猫だ。」犬だ。", "猫")) - assertEquals("犬だ。", Sentences.around("「猫だ。」犬だ。", "犬")) - assertEquals("It rains!?", Sentences.around("Sun. It rains!? Yes.", "rains")) - assertEquals("two", Sentences.around("one\ntwo\nthree", "two")) - assertEquals("It is 3.5 km.", Sentences.around("Far. It is 3.5 km. Go.", "km")) - assertEquals("「猫だ」と言った。", Sentences.around("「猫だ」と言った。犬だ。", "猫")) + assertEquals("「猫だ。」", around("「猫だ。」犬だ。", "猫")) + assertEquals("犬だ。", around("「猫だ。」犬だ。", "犬")) + assertEquals("It rains!?", around("Sun. It rains!? Yes.", "rains")) + assertEquals("two", around("one\ntwo\nthree", "two")) + assertEquals("It is 3.5 km.", around("Far. It is 3.5 km. Go.", "km")) + assertEquals("「猫だ」と言った。", around("「猫だ」と言った。犬だ。", "猫")) } @Test fun theEmphasisKeepsTheText() = property { checkAll(anyText, anyText.orNull(0.2)) { sentence, word -> val html = Sentences.emphasize(sentence, word) - assertEquals(sentence, Sentences.unescape(Sentences.stripTags(html))) + assertEquals(sentence, plain(html)) if (!word.isNullOrEmpty() && word in sentence) assertTrue(html.contains("<b>" + Sentences.escape(word) + "</b>")) } } @@ -411,6 +421,32 @@ class ScansTest { } } + /** A dictionary of two terms: 食べる for 食べ..., and 猫 for 猫. */ + private fun termsAt(text: String, at: Int): List<Pair<String, Int>> = buildList { + if (text.startsWith("食べ", at)) add("食べる" to minOf(3, text.length - at)) + if (text.startsWith("猫", at)) add("猫" to 1) + } + + @Test + fun theDictionaryFormIsFoundInTheFormOfTheText() { + // A sender gives 食べる and a sentence with 食べた: the bold and the sentence need 食べた. + assertEquals(4..6, Scans.locate("猫が魚を食べた。犬だ。", "食べる") { termsAt("猫が魚を食べた。犬だ。", it) }) + assertEquals(0..0, Scans.locate("猫が魚を食べた。", "猫") { termsAt("猫が魚を食べた。", it) }) + assertNull(Scans.locate("犬だ。", "食べる") { termsAt("犬だ。", it) }) + assertNull(Scans.locate("犬だ。", "") { termsAt("犬だ。", it) }) + // The card of such a word gets one sentence, not the whole text. + assertEquals("猫が魚を食べた。", Scans.sentence("猫が魚を食べた。犬だ。", 4, 3)) + } + + @Test + fun aLocatedWordIsInTheText() = property { + checkAll(piece, piece) { text, expression -> + val range = Scans.locate(text, expression) { termsAt(text, it) } ?: return@checkAll + assertTrue("$range in $text", range.first >= 0 && range.last < text.length && !range.isEmpty()) + if (text.contains(expression)) assertEquals(text.indexOf(expression), range.first) + } + } + @Test fun aWordAloneIsNoSentence() { assertEquals("", Scans.sentence("食べた", 0, 3))