commit c3fc0dc67216010d3f228ecd16e8e819ace65812 equwal <13551856+equwal@users.noreply.github.com> 2026-09-26 14:33:36 -0700 Fix the pop-up faults that a test in Brave found A test on a phone (Android 11) shared a sentence from Brave to the pop-up. The test found four faults: - The pop-up was a narrow box in the middle of the screen. setContentView sets a floating window to WRAP_CONTENT. The pop-up now sets its width, height and gravity after setContentView. - The text had quotes and the link of the page. Brave shares "selection" and the URL. SharedText.clean removes the link, and then the quotes around the rest. A text without a link stays as it is. - The first scan looked up the quote. Scans.firstLetter moves the first scan to the next letter or digit. - The pop-up said "No dictionary" with the debug build of SubRead Dictionary. Android 11 hides a provider of a package that the manifest does not name. The manifest now names the providers of the dictionary and the overlay, release and debug. The label of the pop-up is now "SubRead Anki". "Anki card" was too near to the "Anki Card" entry of AnkiDroid in the same menu. The activity takes the label of the app. The README says to use Share in a browser that shows only some of the apps in its text selection menu.
README.md | 10 +++-- app/src/main/AndroidManifest.xml | 13 ++++-- .../main/kotlin/space/subread/anki/AddActivity.kt | 12 +++--- app/src/main/kotlin/space/subread/anki/Requests.kt | 8 ++-- app/src/main/res/values/strings.xml | 3 +- .../main/kotlin/space/subread/anki/core/Scans.kt | 9 ++++ .../kotlin/space/subread/anki/core/SharedText.kt | 24 +++++++++++ .../kotlin/space/subread/anki/core/CoreTest.kt | 49 ++++++++++++++++++++++ .../metadata/android/en-US/full_description.txt | 2 +- 9 files changed, 111 insertions(+), 19 deletions(-)
diff --git a/README.md b/README.md index 5ad2e82..5154bc8 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # SubRead Anki A pop-up for Anki cards, used the same way as a dictionary. Select a text -in any app (a reader, a browser, an OCR app) and choose "Anki card" in the +in any app (a reader, a browser, an OCR app) and choose "SubRead Anki" in the text selection menu. The pop-up opens over the app and shows the text and the terms at the word, from [SubRead Dictionary](https://github.com/equwal/subread-dictionary). A tap on @@ -36,19 +36,23 @@ an audio or a picture. the device and the newest picture of the screen. Stop it from the notification or the tile. -Then, in any app: select a sentence, choose "Anki card", tap the word in the +Then, in any app: select a sentence, choose "SubRead Anki", tap the word in the pop-up, and tap "+ Anki". A single selected word works too; the card then has no sentence, unless the word is in the subtitle line of SubRead Overlay. The button says "✓ Added", and a term that is in the deck already says "✓ In Anki". A tap outside the pop-up closes it. +Some browsers (Firefox, Brave) show only some of the apps in the text +selection menu. There, choose Share and then "SubRead Anki". The app +removes the link of the page and the quotes that the browser adds. + The line under the text says what the card gets: "Picture ✓ Sound 5.3 s ✓ Deck: SubRead". The picture is the screen at the moment the text was selected, from before the pop-up opened. The sound is the last eight seconds before that moment: the audiobook that plays while you read. A clip of silence is left out. -Share a picture to "Anki card" within an hour of a card, and it goes onto +Share a picture to "SubRead Anki" within an hour of a card, and it goes onto that card. The option "add the first term at once, with no pop-up" makes a selection a card with no pop-up. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1de962f..d27a8ad 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -16,11 +16,19 @@ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> - <!-- The apps this one talks to. Android 11 and later hide other apps without this. --> + <!-- + The apps this one talks to. Android 11 and later hide other apps without this. The + providers are named too, so that the debug builds of the dictionary and the overlay + are found as well. + --> <queries> <package android:name="com.ichi2.anki" /> <package android:name="space.subread.overlay" /> <package android:name="space.subread.dictionary" /> + <provider android:authorities="space.subread.dictionary.lookup" /> + <provider android:authorities="space.subread.dictionary.debug.lookup" /> + <provider android:authorities="space.subread.overlay.player" /> + <provider android:authorities="space.subread.overlay.debug.player" /> </queries> <application @@ -42,7 +50,7 @@ <!-- The pop-up, the same as a dictionary: the text, the terms at the word, and one - "+ Anki" button for each. Doors: the text selection menu of Android ("Anki card"), + "+ Anki" button for each. Doors: the text selection menu of Android ("SubRead Anki"), the share sheet (a text or a picture), the action space.subread.anki.action.ADD from another app, and the link subreadanki://add from a web page. A request that brings its own definition is added at once, with no pop-up. See README.md. @@ -51,7 +59,6 @@ android:name=".AddActivity" android:excludeFromRecents="true" android:exported="true" - android:label="@string/add_label" android:taskAffinity="" android:theme="@style/Popup"> <intent-filter> diff --git a/app/src/main/kotlin/space/subread/anki/AddActivity.kt b/app/src/main/kotlin/space/subread/anki/AddActivity.kt index ab9bac4..154b3cc 100644 --- a/app/src/main/kotlin/space/subread/anki/AddActivity.kt +++ b/app/src/main/kotlin/space/subread/anki/AddActivity.kt @@ -221,12 +221,6 @@ class AddActivity : Activity() { @SuppressLint("ClickableViewAccessibility") private fun draw() { val height = (resources.displayMetrics.heightPixels * 0.6).toInt() - window.attributes = window.attributes.apply { - width = ViewGroup.LayoutParams.MATCH_PARENT - this.height = height - gravity = Gravity.BOTTOM - } - window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) textView = TextView(this).apply { textSize = TEXT_SP + 3 setTextColor(Color.BLACK) @@ -274,13 +268,17 @@ class AddActivity : Activity() { }, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, height), ) + // After setContentView: it sets a floating window to WRAP_CONTENT, which makes a narrow box. + window.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, height) + window.setGravity(Gravity.BOTTOM) + window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) } private fun show(scan: Scan) { text = scan.text textView.text = text if (scan.offset >= 0) { - scanAt(scan.offset) + scanAt(Scans.firstLetter(text, scan.offset)) return } // The word of the sender is not in the text as it is: the dictionary finds its form there. diff --git a/app/src/main/kotlin/space/subread/anki/Requests.kt b/app/src/main/kotlin/space/subread/anki/Requests.kt index 260292b..11411bb 100644 --- a/app/src/main/kotlin/space/subread/anki/Requests.kt +++ b/app/src/main/kotlin/space/subread/anki/Requests.kt @@ -4,6 +4,7 @@ import android.content.Intent import android.net.Uri import androidx.core.content.IntentCompat import space.subread.anki.core.MineRequest +import space.subread.anki.core.SharedText /** Reads a [MineRequest] from each kind of Intent that [AddActivity] takes. */ object Requests { @@ -26,14 +27,15 @@ object Requests { /** * The request in an Intent, or null when the Intent is none of the kinds the app takes. * A text from the selection menu or the share sheet is the word. A picture from the - * share sheet is the picture, with the text beside it as the word. + * share sheet is the picture, with the text beside it as the word. A browser shares the + * link of the page with the text: the link does not go on the card. */ fun fromIntent(intent: Intent): MineRequest? = when (intent.action) { Intent.ACTION_PROCESS_TEXT -> MineRequest(expression = intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT)?.toString()) Intent.ACTION_SEND -> { val stream = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java) - val text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString() - MineRequest(expression = text, image = stream?.toString()) + val text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString()?.let(SharedText::clean) + MineRequest(expression = text?.ifEmpty { null }, image = stream?.toString()) } Intent.ACTION_VIEW -> { // A web page: the query of the link. An intent: link can carry extras too; they win. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 42f57f0..737232b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,10 +1,9 @@ <?xml version="1.0" encoding="utf-8"?> <resources> <string name="app_name">SubRead Anki</string> - <string name="add_label">Anki card</string> <string name="tile_label">Anki capture</string> - <string name="about">Select a text in any app and choose "Anki card" in the menu. A pop-up shows the terms at the word, the same as a dictionary. A tap on another character moves the word. Tap "+ Anki": the card gets the word, its reading and definition, the sentence, a picture of the screen and the sound of the sentence.</string> + <string name="about">Select a text in any app and choose "SubRead Anki" in the menu. A pop-up shows the terms at the word, the same as a dictionary. A tap on another character moves the word. Tap "+ Anki": the card gets the word, its reading and definition, the sentence, a picture of the screen and the sound of the sentence.</string> <string name="privacy">Nothing leaves the device. The app talks to AnkiDroid and SubRead Dictionary on the device. It opens a URL only when a sender gives one for an audio or a picture.</string> <string name="step_anki">1. AnkiDroid</string> 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 c2d3e76..fd1b747 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,15 @@ object Scans { return Scan(word, 0, word, false) } + /** + * Where the first scan of the pop-up looks up: the first letter or digit of [text] at or + * after [from]. A quote or a bracket is no word. [from] when no letter follows. + */ + fun firstLetter(text: String, from: Int): Int { + for (at in from until text.length) if (text[at].isLetterOrDigit()) return at + return from + } + /** * 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 diff --git a/core/src/main/kotlin/space/subread/anki/core/SharedText.kt b/core/src/main/kotlin/space/subread/anki/core/SharedText.kt new file mode 100644 index 0000000..0b61084 --- /dev/null +++ b/core/src/main/kotlin/space/subread/anki/core/SharedText.kt @@ -0,0 +1,24 @@ +package space.subread.anki.core + +/** The text that an app shares. A browser adds the link of the page to the selection. */ +object SharedText { + + private val LINK = Regex("""https?://\S+""") + + /** Quotes that a browser puts around the selection that it shares. */ + private val QUOTES = listOf('"' to '"', '“' to '”') + + /** + * The selection in a shared text. Chrome and Brave share `"selection"` and the link of the + * page: the link goes, and then the quotes around the rest. A text without a link stays as + * it is, quotes and all: the user selected them. + */ + fun clean(text: String): String { + if (!LINK.containsMatchIn(text)) return text.trim() + val rest = LINK.replace(text, "").trim() + for ((open, close) in QUOTES) { + if (rest.length >= 2 && rest.first() == open && rest.last() == close) return rest.substring(1, rest.length - 1).trim() + } + return rest + } +} 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 0100e31..5c0efa7 100644 --- a/core/src/test/kotlin/space/subread/anki/core/CoreTest.kt +++ b/core/src/test/kotlin/space/subread/anki/core/CoreTest.kt @@ -447,6 +447,27 @@ class ScansTest { } } + @Test + fun theFirstScanSkipsQuotesAndBrackets() { + // Found on a phone: Brave shared `"老人の…"`, and the first scan landed on the quote. + assertEquals(1, Scans.firstLetter("\"老人の尋ね方\"", 0)) + assertEquals(1, Scans.firstLetter("「猫だ。」", 0)) + assertEquals(0, Scans.firstLetter("食べた", 0)) + assertEquals(4, Scans.firstLetter("猫だ。「犬」", 3)) + assertEquals(0, Scans.firstLetter("。。", 0)) + } + + @Test + fun theFirstScanIsAtTheNextLetter() = property { + checkAll(anyText, Arb.int(0..40)) { text, wanted -> + val from = wanted.coerceAtMost(text.length) + val at = Scans.firstLetter(text, from) + val skipped = text.substring(from, maxOf(from, at)) + assertTrue(skipped.none { it.isLetterOrDigit() }) + if (text.substring(from).any { it.isLetterOrDigit() }) assertTrue(text[at].isLetterOrDigit()) else assertEquals(from, at) + } + } + @Test fun aWordAloneIsNoSentence() { assertEquals("", Scans.sentence("食べた", 0, 3)) @@ -455,6 +476,34 @@ class ScansTest { } } +/** The text that a browser shares. */ +class SharedTextTest { + + @Test + fun theLinkAndTheQuotesOfABrowserGo() { + // Found on a phone: the share of Brave, a selection in quotes and a link with a text fragment. + val brave = "\"老人の尋ね方が急でしたから、杜子春はさすがに眼を伏せて、思はず正直な答をしました。\"\n" + + "https://www.aozora.gr.jp/cards/000879/files/170_15144.html#:~:text=%E8%80%81%E4%BA%BA" + assertEquals("老人の尋ね方が急でしたから、杜子春はさすがに眼を伏せて、思はず正直な答をしました。", SharedText.clean(brave)) + assertEquals("猫だ。", SharedText.clean("“猫だ。” https://example.com/a")) + assertEquals("", SharedText.clean("https://example.com/a")) + } + + @Test + fun aTextWithoutALinkStaysAsItIs() = property { + // The pieces hold no ":", so no text of them holds a link. Quotes stay: the user selected them. + checkAll(anyText) { plain -> assertEquals(plain.trim(), SharedText.clean(plain)) } + } + + @Test + fun noLinkIsLeft() = property { + checkAll(anyText, anyText) { before, after -> + val cleaned = SharedText.clean("$before https://example.com/x?y=1 $after") + assertFalse(cleaned, cleaned.contains("https://")) + } + } +} + /** Silence is not sound for a card. */ class LoudnessTest { diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index 6ef19d6..0df67f3 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -1,4 +1,4 @@ -SubRead Anki is a pop-up for Anki cards, used the same way as a dictionary. Select a text in any app and choose "Anki card" in the text selection menu. The pop-up shows the text and the terms at the word. Tap "+ Anki": the card goes into AnkiDroid with the word, its reading and definition, the sentence around the word, a picture of the screen and the sound of the sentence. It is made for people who read and listen in a language that they learn. +SubRead Anki is a pop-up for Anki cards, used the same way as a dictionary. Select a text in any app and choose "SubRead Anki" in the text selection menu. The pop-up shows the text and the terms at the word. Tap "+ Anki": the card goes into AnkiDroid with the word, its reading and definition, the sentence around the word, a picture of the screen and the sound of the sentence. It is made for people who read and listen in a language that they learn. <b>What it does</b>