app/src/main/kotlin/space/subread/dictionary/LookupActivity.kt (13252 bytes)
1 package space.subread.dictionary 2 3 import android.annotation.SuppressLint 4 import android.app.Activity 5 import android.app.AlertDialog 6 import android.content.Intent 7 import android.graphics.Color 8 import android.graphics.Typeface 9 import android.os.Bundle 10 import android.text.Html 11 import android.text.SpannableString 12 import android.text.Spanned 13 import android.text.method.LinkMovementMethod 14 import android.text.style.BackgroundColorSpan 15 import android.view.Gravity 16 import android.view.MotionEvent 17 import android.view.View 18 import android.view.ViewGroup 19 import android.view.WindowManager 20 import android.widget.Button 21 import android.widget.LinearLayout 22 import android.widget.ScrollView 23 import android.widget.TextView 24 import android.widget.Toast 25 import space.subread.dictionary.core.AudioType 26 import space.subread.dictionary.core.Glossary 27 import space.subread.dictionary.core.Lookup 28 import space.subread.dictionary.core.Match 29 import java.io.File 30 import kotlin.concurrent.thread 31 32 /** 33 * The pop-up. It takes a text from the text selection menu, from the share sheet, or from an 34 * intent, and shows the terms at the scan position: the longest first. A tap on a character of 35 * the text moves the scan position there. With SubRead Anki installed, each term has an "Anki" 36 * button: one tap makes the card. 37 */ 38 class LookupActivity : Activity() { 39 40 private lateinit var store: Store 41 private lateinit var dictionaries: Dictionaries 42 private lateinit var player: AudioPlayer 43 private lateinit var textView: TextView 44 private lateinit var results: LinearLayout 45 private var text = "" 46 private var offset = 0 47 private var generation = 0 48 private var anki = false 49 50 // The touch listener finds the character under the finger; it calls performClick itself. 51 @SuppressLint("ClickableViewAccessibility") 52 override fun onCreate(savedInstanceState: Bundle?) { 53 super.onCreate(savedInstanceState) 54 store = Store(this) 55 dictionaries = Dictionaries(this) 56 player = AudioPlayer(this) 57 anki = Anki.installed(this) 58 59 // A panel at the bottom, over the app that sent the text. The height is fixed, so the 60 // panel does not grow with the results: they scroll inside it. 61 val height = (resources.displayMetrics.heightPixels * 0.6).toInt() 62 window.attributes = window.attributes.apply { 63 width = ViewGroup.LayoutParams.MATCH_PARENT 64 this.height = height 65 gravity = Gravity.BOTTOM 66 } 67 window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) 68 69 textView = TextView(this).apply { 70 textSize = store.textSizeSp + 3 71 setTextColor(Color.BLACK) 72 maxLines = 4 73 setPadding(dp(16), dp(12), dp(16), dp(8)) 74 setOnTouchListener { view, event -> 75 if (event.action == MotionEvent.ACTION_UP) { 76 val at = (view as TextView).getOffsetForPosition(event.x, event.y) 77 if (at in text.indices) scan(at) 78 view.performClick() 79 } 80 true 81 } 82 } 83 results = LinearLayout(this).apply { 84 orientation = LinearLayout.VERTICAL 85 setPadding(dp(16), 0, dp(16), dp(16)) 86 } 87 val bar = LinearLayout(this).apply { 88 gravity = Gravity.END 89 addView(smallButton(getString(R.string.settings)) { startActivity(Intent(this@LookupActivity, MainActivity::class.java)) }) 90 addView(smallButton(getString(R.string.close)) { finish() }) 91 } 92 setContentView( 93 LinearLayout(this).apply { 94 orientation = LinearLayout.VERTICAL 95 setBackgroundColor(Color.WHITE) 96 addView(bar, LinearLayout.LayoutParams(-1, -2)) 97 addView(textView, LinearLayout.LayoutParams(-1, -2)) 98 addView(View(this@LookupActivity).apply { setBackgroundColor(Color.LTGRAY) }, LinearLayout.LayoutParams(-1, dp(1))) 99 addView(ScrollView(this@LookupActivity).apply { addView(results) }, LinearLayout.LayoutParams(-1, 0, 1f)) 100 }, 101 ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, height), 102 ) 103 take(intent) 104 } 105 106 override fun onNewIntent(intent: Intent) { 107 super.onNewIntent(intent) 108 setIntent(intent) 109 take(intent) 110 } 111 112 override fun onDestroy() { 113 player.stop() 114 dictionaries.close() 115 super.onDestroy() 116 } 117 118 /** The text of the intent, from whichever of the three ways in. */ 119 private fun take(intent: Intent) { 120 text = (intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT) ?: intent.getCharSequenceExtra(Intent.EXTRA_TEXT) ?: "") 121 .toString().trim() 122 if (text.isEmpty()) { 123 Toast.makeText(this, R.string.no_text, Toast.LENGTH_SHORT).show() 124 finish() 125 return 126 } 127 scan(0) 128 } 129 130 private fun scan(at: Int) { 131 offset = at 132 val shown = SpannableString(text) 133 textView.text = shown 134 val run = ++generation 135 thread { 136 val found = if (dictionaries.anyEnabled()) Lookup.find(text, at, dictionaries) else null 137 val metas = found.orEmpty().map { it.stored.term.expression }.distinct().associateWith { dictionaries.meta(it) } 138 runOnUiThread { if (run == generation) show(found, metas) } 139 } 140 } 141 142 private fun show(found: List<Match>?, metas: Map<String, List<StoredMeta>>) { 143 results.removeAllViews() 144 if (found == null) { 145 note(getString(R.string.no_dictionaries)) 146 return 147 } 148 if (found.isEmpty()) { 149 note(getString(R.string.no_results)) 150 return 151 } 152 // The longest match is marked in the text. 153 val shown = SpannableString(text) 154 shown.setSpan(BackgroundColorSpan(0xFFFFE082.toInt()), offset, offset + found.first().length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) 155 textView.text = shown 156 157 // One card per expression and reading, with the entries of each dictionary inside it. 158 val groups = found.groupBy { it.stored.term.expression to it.stored.term.reading } 159 for ((key, matches) in groups) { 160 val (expression, reading) = key 161 results.addView(View(this).apply { setBackgroundColor(Color.LTGRAY) }, LinearLayout.LayoutParams(-1, dp(1)).apply { topMargin = dp(10) }) 162 results.addView(LinearLayout(this).apply { 163 gravity = Gravity.CENTER_VERTICAL 164 addView(TextView(this@LookupActivity).apply { 165 text = if (reading.isEmpty() || reading == expression) expression else "$expression【$reading】" 166 textSize = store.textSizeSp + 5 167 setTypeface(typeface, Typeface.BOLD) 168 setTextColor(Color.BLACK) 169 }, LinearLayout.LayoutParams(0, -2, 1f)) 170 addView(smallButton("▶ " + getString(R.string.audio_play)) { play(expression, reading, choose = false) }.apply { 171 setOnLongClickListener { play(expression, reading, choose = true); true } 172 }) 173 if (anki) addView(smallButton(getString(R.string.anki)) { sendToAnki(expression, reading, matches) }) 174 }, LinearLayout.LayoutParams(-1, -2).apply { topMargin = dp(6) }) 175 176 val reasons = matches.first().reasons 177 val meta = metas[expression].orEmpty().filter { m -> 178 val forReading = when (m.meta.mode) { "freq" -> Glossary.frequencyReading(m.meta.data); "pitch" -> Glossary.pitchReading(m.meta.data); else -> null } 179 forReading == null || forReading == reading || reading.isEmpty() 180 } 181 val line = buildList { 182 if (reasons.isNotEmpty()) add(reasons.joinToString(" ‹ ")) 183 for (m in meta) when (m.meta.mode) { 184 "freq" -> add("${m.dictionaryTitle}: ${Glossary.frequencyText(m.meta.data)}") 185 "pitch" -> add(Glossary.pitchText(m.meta.data)) 186 } 187 } 188 if (line.isNotEmpty()) note(line.joinToString(" "), color = Color.DKGRAY, size = store.textSizeSp - 3) 189 190 for (match in matches) { 191 val term = match.stored.term 192 val tags = (term.definitionTags.split(' ') + term.termTags.split(' ')).filter { it.isNotEmpty() }.distinct() 193 note( 194 listOf(match.stored.dictionaryTitle).plus(tags).joinToString(" "), 195 color = Color.GRAY, 196 size = store.textSizeSp - 4, 197 top = 6, 198 ) 199 results.addView(TextView(this).apply { 200 text = Html.fromHtml(Glossary.toHtml(term.glossary), Html.FROM_HTML_MODE_COMPACT) 201 textSize = store.textSizeSp 202 setTextColor(Color.BLACK) 203 movementMethod = LinkMovementMethod.getInstance() 204 }, LinearLayout.LayoutParams(-1, -2).apply { topMargin = dp(2) }) 205 } 206 } 207 } 208 209 /** The audio sources of a term: the local ones in their order, then the remote ones. */ 210 private fun sources(expression: String, reading: String): List<AudioSource> = 211 LocalAudio(store.localAudioFile, store.localSources.split(',').map { it.trim() }).sources(expression, reading) + 212 if (store.remoteEnabled) RemoteAudio.sources(store.remoteUrls.lines().map { it.trim() }.filter { it.isNotEmpty() }, expression, reading) else emptyList() 213 214 /** Plays the first source that has audio. */ 215 private fun play(expression: String, reading: String, choose: Boolean) { 216 thread { 217 val sources = sources(expression, reading) 218 if (choose) { 219 runOnUiThread { 220 if (sources.isEmpty()) toast(getString(R.string.no_audio)) else { 221 AlertDialog.Builder(this).setItems(sources.map { it.name }.toTypedArray()) { _, i -> thread { playFirst(listOf(sources[i])) } }.show() 222 } 223 } 224 } else { 225 playFirst(sources) 226 } 227 } 228 } 229 230 private fun playFirst(sources: List<AudioSource>) { 231 val bytes = loadFirst(sources) 232 runOnUiThread { 233 if (bytes == null) toast(getString(R.string.no_audio)) 234 else runCatching { player.play(bytes) }.onFailure { toast(getString(R.string.audio_failed, it.message ?: "")) } 235 } 236 } 237 238 /** The bytes of the first source that has them. */ 239 private fun loadFirst(sources: List<AudioSource>): ByteArray? { 240 for (source in sources) { 241 val bytes = runCatching { source.load() }.getOrNull() ?: continue 242 if (bytes.isNotEmpty()) return bytes 243 } 244 return null 245 } 246 247 /** 248 * One tap: the card goes to SubRead Anki with the word, the reading, the definitions of 249 * each dictionary, the text as the sentence, and the first audio. The pop-up hides for a 250 * moment, so that the screenshot of SubRead Anki shows the app under it. 251 */ 252 private fun sendToAnki(expression: String, reading: String, matches: List<Match>) { 253 val definition = matches.joinToString("<br><br>") { 254 "<i>${Glossary.escape(it.stored.dictionaryTitle)}</i><br>${Glossary.toHtml(it.stored.term.glossary)}" 255 } 256 val source = referrer?.takeIf { it.scheme == "android-app" }?.host?.let { pkg -> 257 runCatching { packageManager.getApplicationLabel(packageManager.getApplicationInfo(pkg, 0)).toString() }.getOrNull() 258 } ?: "" 259 val decor = window.decorView 260 decor.visibility = View.INVISIBLE 261 thread { 262 val audio = loadFirst(sources(expression, reading))?.let { bytes -> 263 val dir = File(cacheDir, "anki").apply { mkdirs() } 264 File(dir, "word-${System.nanoTime()}.${AudioType.extension(bytes)}").also { it.writeBytes(bytes) } 265 } 266 runOnUiThread { 267 runCatching { startActivity(Anki.intent(this, expression, reading, definition, text, source, audio)) } 268 .onFailure { toast(getString(R.string.anki_failed)) } 269 decor.postDelayed({ decor.visibility = View.VISIBLE }, HIDE_MS) 270 } 271 } 272 } 273 274 private fun toast(message: String) = Toast.makeText(this, message, Toast.LENGTH_SHORT).show() 275 276 private fun note(value: String, color: Int = Color.BLACK, size: Float = store.textSizeSp, top: Int = 8) = results.addView( 277 TextView(this).apply { 278 text = value 279 textSize = size 280 setTextColor(color) 281 }, 282 LinearLayout.LayoutParams(-1, -2).apply { topMargin = dp(top) }, 283 ) 284 285 private fun smallButton(label: String, onClick: () -> Unit) = Button(this).apply { 286 text = label 287 isAllCaps = false 288 textSize = 13f 289 setOnClickListener { onClick() } 290 } 291 292 private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt() 293 294 private companion object { 295 /** How long the pop-up hides while SubRead Anki takes its screenshot. */ 296 const val HIDE_MS = 1500L 297 } 298 }