Recently Written · git

subread-anki

One tap makes an Anki card on Android: word, definition, sentence, screenshot, audio. Works with any app, with SubRead Dictionary and SubRead Overlay.

git clone https://github.com/equwal/subread-anki

Log | Files | Refs


app/src/main/kotlin/space/subread/anki/AddActivity.kt (23530 bytes)

1 package space.subread.anki
2 
3 import android.annotation.SuppressLint
4 import android.app.Activity
5 import android.content.Intent
6 import android.content.pm.PackageManager
7 import android.graphics.Bitmap
8 import android.graphics.Color
9 import android.graphics.Typeface
10 import android.media.MediaPlayer
11 import android.os.Bundle
12 import android.os.SystemClock
13 import android.text.Html
14 import android.text.SpannableString
15 import android.text.Spanned
16 import android.text.method.LinkMovementMethod
17 import android.text.style.BackgroundColorSpan
18 import android.text.style.UnderlineSpan
19 import android.view.Gravity
20 import android.view.MotionEvent
21 import android.view.View
22 import android.view.ViewGroup
23 import android.view.WindowManager
24 import android.widget.Button
25 import android.widget.LinearLayout
26 import android.widget.ScrollView
27 import android.widget.TextView
28 import android.widget.Toast
29 import space.subread.anki.core.MineRequest
30 import space.subread.anki.core.Scan
31 import space.subread.anki.core.Scans
32 import java.io.File
33 import java.io.FileInputStream
34 import java.util.concurrent.Callable
35 import java.util.concurrent.ExecutorService
36 import java.util.concurrent.Executors
37 import java.util.concurrent.Future
38 import kotlin.concurrent.thread
39 
40 /**
41  * The pop-up. It takes a text the way a dictionary does: from the text selection menu of any
42  * app, the share sheet, another app, or a link. It shows the text, the terms of SubRead
43  * Dictionary at the word, and a "+ Anki" button for each term. A tap on a character of the
44  * text moves the word there. The card gets the term, the sentence around the word, and the
45  * picture and the sound of the moment the text came in.
46  *
47  * A request that brings its own definition, or the setting "add at once", makes the card with
48  * no pop-up: the window stays clear, and a toast says what happened.
49  *
50  * The result for a caller that started it for one: `RESULT_OK` with [Requests.EXTRA_NOTE_ID]
51  * of the last card, or `RESULT_CANCELED` with [Requests.EXTRA_ERROR].
52  */
53 class AddActivity : Activity() {
54 
55     private lateinit var store: Store
56     private lateinit var miner: Miner
57 
58     /** One thread for the media and the cards, in order: a card waits for the media of its text. */
59     private val work: ExecutorService = Executors.newSingleThreadExecutor()
60     private var request = MineRequest()
61     private var grab: Future<Miner.Grab>? = null
62 
63     /** A card to add with no pop-up, once AnkiDroid allows it. */
64     private var pending: Scan? = null
65     private var text = ""
66     private var generation = 0
67     private var player: MediaPlayer? = null
68     private lateinit var textView: TextView
69     private lateinit var status: TextView
70     private lateinit var results: LinearLayout
71 
72     /** The "+ Anki" button of each term on the screen, with its expression. */
73     private val addButtons = ArrayList<Pair<String, Button>>()
74 
75     override fun onCreate(savedInstanceState: Bundle?) {
76         // The screen of this moment, before a window of this app draws: the app that sent the text.
77         val shot: Bitmap? = CaptureService.instance?.snapshot()
78         val grabbedAt = SystemClock.elapsedRealtimeNanos()
79         store = Store(this)
80         val parsed = Requests.fromIntent(intent)
81         val pictureOnly = parsed != null && parsed.isEmpty && parsed.image != null
82         val popup = parsed != null && !parsed.isEmpty && !addsAtOnce(parsed)
83         // Without the pop-up, the window stays empty and clear.
84         if (!popup) setTheme(android.R.style.Theme_Translucent_NoTitleBar)
85         super.onCreate(savedInstanceState)
86         miner = Miner(this)
87         if (parsed == null || (parsed.isEmpty && !pictureOnly)) {
88             toast(getString(R.string.no_text))
89             finishWith("empty")
90             return
91         }
92         request = parsed
93         if (AnkiClient.packageName(this) == null) {
94             toast(getString(R.string.anki_missing))
95             startActivity(Intent(this, MainActivity::class.java))
96             finishWith("no_ankidroid")
97             return
98         }
99         val image = parsed.image
100         if (pictureOnly && image != null) {
101             work.execute { finishAfter(runCatching { miner.attachPicture(image) }.getOrElse { Miner.Result.Failed(it.message ?: it.toString()) }) }
102             return
103         }
104         val overlay = if (parsed.sentence.isNullOrBlank()) OverlayClient.now(this) else null
105         val scan = Scans.resolve(parsed.expression, parsed.sentence, overlay?.line?.text) ?: run {
106             finishWith("empty")
107             return
108         }
109         val line = if (scan.fromLine) overlay else null
110         val source = parsed.source ?: sourceName(line?.player)
111         grab = work.submit(Callable { miner.grab(parsed, shot, grabbedAt, line, source) })
112         val allowed = AnkiClient.hasPermission(this)
113         if (!allowed) requestPermissions(arrayOf(AnkiClient.PERMISSION), PERMISSION)
114         if (!popup) {
115             if (allowed) addAtOnce(scan) else pending = scan
116             return
117         }
118         draw()
119         // A pop-up that closes with no card answers "cancelled"; each card replaces this answer.
120         setResult(RESULT_CANCELED, Intent().putExtra(Requests.EXTRA_ERROR, "cancelled"))
121         show(scan)
122         work.execute {
123             val grabbed = runCatching { grab?.get() }.getOrNull()
124             val deck = runCatching { AnkiClient(this).deckName() }.getOrDefault(AnkiClient.DECK_NAME)
125             runOnUiThread { if (!isFinishing && grabbed != null) status.text = statusLine(grabbed, deck) }
126         }
127     }
128 
129     /** True when the card goes to Anki with no pop-up. */
130     private fun addsAtOnce(r: MineRequest): Boolean =
131         r.confirm == false || (r.confirm == null && (r.definition != null || store.addAtOnce))
132 
133     override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
134         super.onRequestPermissionsResult(requestCode, permissions, grantResults)
135         val scan = pending
136         pending = null
137         if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) {
138             if (scan != null) addAtOnce(scan)
139         } else {
140             toast(getString(R.string.anki_no_permission))
141             if (scan != null) finishWith("no_permission")
142         }
143     }
144 
145     override fun onDestroy() {
146         player?.release()
147         player = null
148         work.shutdown()
149         super.onDestroy()
150     }
151 
152     /** The card with no pop-up: the definition of the request, else the first term at the word. */
153     private fun addAtOnce(scan: Scan) {
154         work.execute {
155             val result = runCatching {
156                 val word = scan.word
157                 // Where the word is in the text, also in another form: for the bold and the sentence.
158                 val found = if (scan.offset >= 0 || word == null) null else locate(scan.text, word)
159                 val at = if (scan.offset >= 0) scan.offset else found?.first ?: -1
160                 val card = if (request.definition != null) {
161                     when {
162                         found != null -> Miner.card(request, scan.text, found.first, found.count(), null)
163                         word != null && at >= 0 -> Miner.card(request, scan.text, at, word.length, null)
164                         else -> Miner.card(request, scan.text, -1, 0, null)
165                     }
166                 } else if (at >= 0) {
167                     val entry = DictionaryClient.lookup(this, scan.text, at).firstOrNull()
168                     if (entry != null) {
169                         Miner.card(request, scan.text, at, entry.length, entry)
170                     } else {
171                         val range = Miner.wordAt(scan.text, at, selected = isSelection(scan.text, at))
172                         Miner.card(request, scan.text, range.first, range.count(), null)
173                     }
174                 } else {
175                     Miner.card(request, scan.text, -1, 0, DictionaryClient.lookup(this, word.orEmpty()).firstOrNull())
176                 }
177                 miner.add(grab!!.get(), card)
178             }.getOrElse { Miner.Result.Failed(it.message ?: it.toString()) }
179             finishAfter(result)
180         }
181     }
182 
183     /** True for the first scan of a text that is the selection itself: the user chose the ends of the word. */
184     private fun isSelection(text: String, at: Int): Boolean = at == 0 && text == request.expression?.trim()
185 
186     /** From the work thread: says what happened, and closes. */
187     private fun finishAfter(result: Miner.Result) = runOnUiThread {
188         when (result) {
189             is Miner.Result.Added -> {
190                 toast(getString(R.string.added, result.expression))
191                 setResult(RESULT_OK, Intent().putExtra(Requests.EXTRA_NOTE_ID, result.noteId))
192                 finish()
193             }
194             is Miner.Result.Duplicate -> {
195                 toast(getString(R.string.duplicate, result.expression))
196                 finishWith("duplicate")
197             }
198             Miner.Result.Attached -> {
199                 toast(getString(R.string.picture_attached))
200                 setResult(RESULT_OK, Intent().putExtra(Requests.EXTRA_NOTE_ID, store.lastNoteId))
201                 finish()
202             }
203             is Miner.Result.Failed -> {
204                 toast(getString(R.string.failed, result.why))
205                 finishWith(result.why)
206             }
207         }
208     }
209 
210     /** Where the text is from: the player of the subtitle line, else the app that sent the text. */
211     private fun sourceName(player: String?): String {
212         val sender = player ?: referrer?.takeIf { it.scheme == "android-app" }?.host
213         if (sender == null || sender == packageName || sender.startsWith(OverlayClient.PACKAGE)) return ""
214         return runCatching { packageManager.getApplicationLabel(packageManager.getApplicationInfo(sender, 0)).toString() }.getOrDefault("")
215     }
216 
217     // The pop-up. The same frame as the pop-up of SubRead Dictionary: a panel at the bottom,
218     // over the app that sent the text, black on white, nothing that moves.
219 
220     // The touch listener finds the character under the finger; it calls performClick itself.
221     @SuppressLint("ClickableViewAccessibility")
222     private fun draw() {
223         val height = (resources.displayMetrics.heightPixels * 0.6).toInt()
224         textView = TextView(this).apply {
225             textSize = TEXT_SP + 3
226             setTextColor(Color.BLACK)
227             setPadding(dp(16), dp(4), dp(16), dp(4))
228             setOnTouchListener { view, event ->
229                 if (event.action == MotionEvent.ACTION_UP) {
230                     val at = (view as TextView).getOffsetForPosition(event.x, event.y)
231                     if (at in text.indices) scanAt(at)
232                     view.performClick()
233                 }
234                 true
235             }
236         }
237         status = TextView(this).apply {
238             textSize = 13f
239             setTextColor(Color.DKGRAY)
240             setPadding(dp(16), 0, dp(16), dp(8))
241             text = "…"
242         }
243         results = LinearLayout(this).apply {
244             orientation = LinearLayout.VERTICAL
245             setPadding(dp(16), 0, dp(16), dp(16))
246         }
247         val bar = LinearLayout(this).apply {
248             gravity = Gravity.END
249             addView(smallButton(getString(R.string.settings)) { startActivity(Intent(this@AddActivity, MainActivity::class.java)) })
250             addView(smallButton(getString(R.string.close)) { finish() })
251         }
252         // A long selection scrolls in its own box, at most three tenths of the pop-up, so that
253         // each character stays in reach and the terms keep their room.
254         val textCap = height * 3 / 10
255         val textBox = object : ScrollView(this) {
256             override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) =
257                 super.onMeasure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(textCap, MeasureSpec.AT_MOST))
258         }.apply { addView(textView) }
259         setContentView(
260             LinearLayout(this).apply {
261                 orientation = LinearLayout.VERTICAL
262                 setBackgroundColor(Color.WHITE)
263                 addView(bar, LinearLayout.LayoutParams(-1, -2))
264                 addView(textBox, LinearLayout.LayoutParams(-1, -2))
265                 addView(status, LinearLayout.LayoutParams(-1, -2))
266                 addView(View(this@AddActivity).apply { setBackgroundColor(Color.LTGRAY) }, LinearLayout.LayoutParams(-1, dp(1)))
267                 addView(ScrollView(this@AddActivity).apply { addView(results) }, LinearLayout.LayoutParams(-1, 0, 1f))
268             },
269             ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, height),
270         )
271         // After setContentView: it sets a floating window to WRAP_CONTENT, which makes a narrow box.
272         window.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, height)
273         window.setGravity(Gravity.BOTTOM)
274         window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
275     }
276 
277     private fun show(scan: Scan) {
278         text = scan.text
279         textView.text = text
280         if (scan.offset >= 0) {
281             scanAt(Scans.firstLetter(text, scan.offset))
282             return
283         }
284         // The word of the sender is not in the text as it is: the dictionary finds its form there.
285         val word = scan.word.orEmpty()
286         val run = ++generation
287         thread {
288             val range = locate(text, word)
289             runOnUiThread {
290                 if (run != generation || isFinishing) return@runOnUiThread
291                 if (range != null) scanAt(range.first) else lookUpWord(word)
292             }
293         }
294     }
295 
296     /** Where [word] is in [text], also in another form, found with the dictionary. Not on the UI thread. */
297     private fun locate(text: String, word: String): IntRange? =
298         Scans.locate(text, word) { at -> DictionaryClient.lookup(this, text, at).map { it.expression to it.length } }
299 
300     /** Looks up the terms that start at [at] in the text. */
301     private fun scanAt(at: Int) {
302         val run = ++generation
303         mark(null)
304         thread {
305             val found = DictionaryClient.lookup(this, text, at)
306             runOnUiThread { if (run == generation && !isFinishing) showTerms(found, at) }
307         }
308     }
309 
310     /** The word of the sender is not in the text in any form: its terms, with nothing marked. */
311     private fun lookUpWord(word: String) {
312         val run = ++generation
313         thread {
314             val found = DictionaryClient.lookup(this, word)
315             runOnUiThread { if (run == generation && !isFinishing) showTerms(found, -1) }
316         }
317     }
318 
319     /** One block for each term. [at] is where the terms start in the text, -1 when not in it. */
320     private fun showTerms(found: List<DictionaryClient.Entry>, at: Int) {
321         results.removeAllViews()
322         addButtons.clear()
323         if (found.isEmpty()) {
324             // No dictionary, or no term here: the word at the tap goes on the card as it is.
325             val range = if (at >= 0) Miner.wordAt(text, at, selected = isSelection(text, at)) else IntRange.EMPTY
326             if (at >= 0) mark(range)
327             note(getString(if (DictionaryClient.answers(this)) R.string.no_term else R.string.no_dictionary), Color.DKGRAY, TEXT_SP - 3)
328             val word = if (at >= 0) text.substring(range.first, range.last + 1) else request.expression.orEmpty()
329             term(word, null, if (at >= 0) range.first else -1, range.count())
330             return
331         }
332         if (at >= 0) mark(at until at + found.first().length)
333         for (entry in found) term(entry.headword, entry, at, entry.length)
334         markInAnki(found)
335     }
336 
337     private fun term(headword: String, entry: DictionaryClient.Entry?, start: Int, length: Int) {
338         results.addView(View(this).apply { setBackgroundColor(Color.LTGRAY) }, LinearLayout.LayoutParams(-1, dp(1)).apply { topMargin = dp(10) })
339         val add = Button(this).apply {
340             text = getString(R.string.add_card)
341             isAllCaps = false
342             textSize = 16f
343             setTypeface(typeface, Typeface.BOLD)
344             setOnClickListener { addCard(this, entry, start, length) }
345         }
346         addButtons += (entry?.expression ?: headword) to add
347         results.addView(
348             LinearLayout(this).apply {
349                 gravity = Gravity.CENTER_VERTICAL
350                 addView(TextView(this@AddActivity).apply {
351                     text = headword
352                     textSize = TEXT_SP + 5
353                     setTypeface(typeface, Typeface.BOLD)
354                     setTextColor(Color.BLACK)
355                 }, LinearLayout.LayoutParams(0, -2, 1f))
356                 if (entry?.audio != null) addView(smallButton("▶") { play(entry) })
357                 addView(add)
358             },
359             LinearLayout.LayoutParams(-1, -2).apply { topMargin = dp(6) },
360         )
361         if (entry == null) return
362         val meta = listOf(entry.reasons, entry.frequency, entry.pitch).filter { it.isNotEmpty() }
363         if (meta.isNotEmpty()) note(meta.joinToString("   "), Color.DKGRAY, TEXT_SP - 3)
364         results.addView(
365             TextView(this).apply {
366                 text = Html.fromHtml(entry.glossary, Html.FROM_HTML_MODE_COMPACT)
367                 textSize = TEXT_SP
368                 setTextColor(Color.BLACK)
369                 movementMethod = LinkMovementMethod.getInstance()
370             },
371             LinearLayout.LayoutParams(-1, -2).apply { topMargin = dp(2) },
372         )
373     }
374 
375     /** One tap: the card goes to AnkiDroid. The button says when it is there. */
376     private fun addCard(button: Button, entry: DictionaryClient.Entry?, start: Int, length: Int) {
377         val grabbed = grab ?: return
378         // AnkiDroid has not allowed the app yet: ask again, and keep the button for the next tap.
379         if (!AnkiClient.hasPermission(this)) {
380             toast(getString(R.string.anki_no_permission))
381             requestPermissions(arrayOf(AnkiClient.PERMISSION), PERMISSION)
382             return
383         }
384         button.isEnabled = false
385         button.text = "…"
386         val card = Miner.card(request, text, start, length, entry)
387         work.execute {
388             val result = runCatching { miner.add(grabbed.get(), card) }.getOrElse { Miner.Result.Failed(it.message ?: it.toString()) }
389             runOnUiThread {
390                 when (result) {
391                     is Miner.Result.Added -> {
392                         button.text = getString(R.string.added_short)
393                         setResult(RESULT_OK, Intent().putExtra(Requests.EXTRA_NOTE_ID, result.noteId))
394                     }
395                     is Miner.Result.Duplicate -> button.text = getString(R.string.in_anki)
396                     is Miner.Result.Failed -> {
397                         button.isEnabled = true
398                         button.text = getString(R.string.add_card)
399                         toast(getString(R.string.failed, result.why))
400                     }
401                     Miner.Result.Attached -> Unit
402                 }
403             }
404         }
405     }
406 
407     /** A term that is in the deck already gets "In Anki" in place of "+ Anki". */
408     private fun markInAnki(found: List<DictionaryClient.Entry>) {
409         if (!store.skipDuplicates || !AnkiClient.hasPermission(this)) return
410         val run = generation
411         thread {
412             val anki = AnkiClient(this)
413             val inAnki = found.map { it.expression }.distinct().filter { runCatching { anki.inAnki(it) }.getOrDefault(false) }.toSet()
414             runOnUiThread {
415                 if (run != generation) return@runOnUiThread
416                 for ((expression, button) in addButtons) {
417                     if (expression in inAnki) {
418                         button.isEnabled = false
419                         button.text = getString(R.string.in_anki)
420                     }
421                 }
422             }
423         }
424     }
425 
426     /** Marks the word in the text; null clears the mark. */
427     private fun mark(range: IntRange?) {
428         val shown = SpannableString(text)
429         if (range != null && !range.isEmpty() && range.last < text.length) {
430             shown.setSpan(BackgroundColorSpan(0xFFFFE082.toInt()), range.first, range.last + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
431             // The underline stays visible on an e-ink screen, where the colour is a light grey.
432             shown.setSpan(UnderlineSpan(), range.first, range.last + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
433         }
434         textView.text = shown
435     }
436 
437     /** What the card gets besides the term: the picture, the sound, and the deck. */
438     private fun statusLine(grabbed: Miner.Grab, deck: String): String {
439         val parts = ArrayList<String>()
440         val captureOff = CaptureService.instance == null
441         if (captureOff && grabbed.picture == null && grabbed.sentenceAudio == null) {
442             parts += getString(R.string.status_capture_off)
443         } else {
444             parts += getString(if (grabbed.picture != null) R.string.status_picture else R.string.status_no_picture)
445             val ms = grabbed.soundMs
446             parts += when {
447                 grabbed.sentenceAudio == null -> getString(R.string.status_no_sound)
448                 ms != null -> getString(R.string.status_sound_seconds, "%.1f".format(ms / 1000.0))
449                 else -> getString(R.string.status_sound)
450             }
451         }
452         parts += getString(R.string.deck_current, deck)
453         return parts.joinToString("   ")
454     }
455 
456     /**
457      * Plays the audio of the word from the dictionary. The file is written off the UI thread,
458      * and the player prepares on its own thread. A pop-up that closed in the meantime plays
459      * nothing: its onDestroy has already run, and no later player would be released.
460      */
461     private fun play(entry: DictionaryClient.Entry) {
462         val ask = entry.audio ?: return
463         thread {
464             val file = DictionaryClient.audio(this, ask)?.let { bytes -> File(cacheDir, "play.tmp").apply { writeBytes(bytes) } }
465             runOnUiThread {
466                 if (isFinishing || isDestroyed) return@runOnUiThread
467                 if (file == null) {
468                     toast(getString(R.string.no_audio))
469                     return@runOnUiThread
470                 }
471                 player?.release()
472                 player = runCatching {
473                     MediaPlayer().apply {
474                         FileInputStream(file).use { setDataSource(it.fd) }
475                         setOnPreparedListener { it.start() }
476                         prepareAsync()
477                     }
478                 }.getOrNull()
479             }
480         }
481     }
482 
483     private fun finishWith(error: String?) {
484         setResult(RESULT_CANCELED, Intent().putExtra(Requests.EXTRA_ERROR, error.orEmpty()))
485         finish()
486     }
487 
488     private fun note(value: String, color: Int, size: Float) = results.addView(
489         TextView(this).apply {
490             text = value
491             textSize = size
492             setTextColor(color)
493         },
494         LinearLayout.LayoutParams(-1, -2).apply { topMargin = dp(8) },
495     )
496 
497     private fun smallButton(label: String, onClick: () -> Unit) = Button(this).apply {
498         text = label
499         isAllCaps = false
500         textSize = 13f
501         setOnClickListener { onClick() }
502     }
503 
504     private fun toast(message: String) = Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
505 
506     private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt()
507 
508     companion object {
509         private const val PERMISSION = 1
510 
511         /** The text size of the pop-up, in sp: the same as SubRead Dictionary. */
512         private const val TEXT_SP = 17f
513     }
514 }