Recently Written · git

subread-dictionary

Pop-up dictionary for Android that reads Yomitan dictionaries, with local audio

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

Log | Files | Refs


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 }