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 }