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/MainActivity.kt (13508 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.net.Uri
10 import android.os.Bundle
11 import android.provider.OpenableColumns
12 import android.text.InputType
13 import android.text.SpannableString
14 import android.text.Spanned
15 import android.text.style.UnderlineSpan
16 import android.view.View
17 import android.widget.Button
18 import android.widget.EditText
19 import android.widget.LinearLayout
20 import android.widget.ScrollView
21 import android.widget.TextView
22 import android.widget.Toast
23 import androidx.core.net.toUri
24 import space.subread.dictionary.core.DictionaryFormatException
25 import space.subread.dictionary.core.YomitanZip
26 import java.io.File
27 import kotlin.concurrent.thread
28 
29 /**
30  * The settings, and the one screen that opens from the launcher: the dictionaries, the local
31  * audio, the remote audio, the text size, and a field to try a lookup.
32  */
33 class MainActivity : Activity() {
34 
35     private lateinit var store: Store
36     private lateinit var dictionaries: Dictionaries
37     private lateinit var content: LinearLayout
38 
39     override fun onCreate(savedInstanceState: Bundle?) {
40         super.onCreate(savedInstanceState)
41         store = Store(this)
42         dictionaries = Dictionaries(this)
43         content = LinearLayout(this).apply {
44             orientation = LinearLayout.VERTICAL
45             setPadding(dp(20), dp(20), dp(20), dp(20))
46             setBackgroundColor(Color.WHITE)
47         }
48         setContentView(ScrollView(this).apply {
49             fitsSystemWindows = true
50             setBackgroundColor(Color.WHITE)
51             addView(content)
52         })
53     }
54 
55     override fun onResume() {
56         super.onResume()
57         draw()
58     }
59 
60     override fun onDestroy() {
61         dictionaries.close()
62         super.onDestroy()
63     }
64 
65     private fun draw() {
66         content.removeAllViews()
67         title(getString(R.string.app_name))
68         note(getString(R.string.about))
69         note(getString(R.string.privacy))
70 
71         // 1. Dictionaries
72         step(R.string.step_dictionaries, getString(R.string.step_dictionaries_why), getString(R.string.import_dictionary)) {
73             startActivityForResult(
74                 Intent(Intent.ACTION_OPEN_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType("*/*"),
75                 PICK_DICTIONARY,
76             )
77         }
78         val list = dictionaries.list()
79         if (list.isEmpty()) note(getString(R.string.dictionaries_none))
80         for (dictionary in list) {
81             note(
82                 resources.getQuantityString(R.plurals.dictionary_terms, dictionary.terms, dictionary.title, dictionary.terms),
83                 color = if (dictionary.enabled) Color.BLACK else Color.GRAY,
84             )
85             row(
86                 button(getString(if (dictionary.enabled) R.string.disable else R.string.enable)) {
87                     dictionaries.setEnabled(dictionary.id, !dictionary.enabled)
88                     draw()
89                 },
90                 button(getString(R.string.up)) {
91                     dictionaries.moveUp(dictionary.id)
92                     draw()
93                 },
94                 button(getString(R.string.delete)) {
95                     AlertDialog.Builder(this).setMessage(getString(R.string.delete_ask, dictionary.title))
96                         .setPositiveButton(R.string.delete) { _, _ -> thread { dictionaries.delete(dictionary.id); runOnUiThread { draw() } } }
97                         .setNegativeButton(android.R.string.cancel, null).show()
98                 },
99             )
100         }
101 
102         // 2. Local audio
103         val audio = store.localAudioFile
104         step(R.string.step_local_audio, getString(R.string.step_local_audio_why), getString(R.string.local_audio_choose)) {
105             startActivityForResult(
106                 Intent(Intent.ACTION_OPEN_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType("*/*"),
107                 PICK_AUDIO,
108             )
109         }
110         if (audio.isFile) {
111             note(getString(R.string.local_audio_have, size(audio.length())))
112             content.addView(button(getString(R.string.local_audio_remove)) { audio.delete(); draw() }, LinearLayout.LayoutParams(-2, -2))
113         } else {
114             note(getString(R.string.local_audio_none))
115         }
116         note(getString(R.string.local_audio_folder, audio.parent ?: ""), color = Color.DKGRAY, size = 13f)
117         note(getString(R.string.local_audio_sources), top = 12)
118         val sources = field(store.localSources, single = true)
119         content.addView(button(getString(R.string.save)) {
120             store.localSources = sources.text.toString()
121             toast(getString(R.string.saved))
122         }, LinearLayout.LayoutParams(-2, -2))
123 
124         // 3. Remote audio
125         val remote = store.remoteEnabled
126         step(
127             R.string.step_remote_audio,
128             getString(R.string.step_remote_audio_why) + "\n\n" + getString(if (remote) R.string.remote_audio_on else R.string.remote_audio_off),
129             getString(if (remote) R.string.turn_off else R.string.turn_on),
130         ) {
131             store.remoteEnabled = !remote
132             draw()
133         }
134         val urls = field(store.remoteUrls, single = false)
135         content.addView(button(getString(R.string.save)) {
136             store.remoteUrls = urls.text.toString()
137             toast(getString(R.string.saved))
138         }, LinearLayout.LayoutParams(-2, -2))
139 
140         // 4. Text size
141         step(R.string.step_size, "${store.textSizeSp.toInt()} sp", null) {}
142         row(button("−") { store.textSizeSp -= 1f; draw() }, button("+") { store.textSizeSp += 1f; draw() })
143 
144         // 5. Try it
145         step(R.string.step_try, "", null) {}
146         val tryField = field("", single = true).apply { hint = getString(R.string.try_hint) }
147         content.addView(button(getString(R.string.look_up)) {
148             startActivity(Intent(this, LookupActivity::class.java).setAction(LOOKUP).putExtra(Intent.EXTRA_TEXT, tryField.text.toString()))
149         }, LinearLayout.LayoutParams(-2, -2))
150 
151         val overlay = packageManager.getLaunchIntentForPackage(OVERLAY_PACKAGE)
152         if (overlay != null) content.addView(button(getString(R.string.open_overlay)) { runCatching { startActivity(overlay) } }, wide(top = 24))
153         note(getString(R.string.anki_note), top = 24)
154         val anki = packageManager.getLaunchIntentForPackage(Anki.PACKAGE)
155         if (anki != null) {
156             content.addView(button(getString(R.string.open_anki)) { runCatching { startActivity(anki) } }, wide())
157         } else {
158             content.addView(button(getString(R.string.install_anki)) { open(Anki.INSTALL) }, wide())
159         }
160         if (BuildConfig.DONATE_LINK) content.addView(button(getString(R.string.donate)) { open(KOFI) }, wide())
161         // The Google Play build (-PplayStore=true) has no Ko-fi link, and no More apps either.
162         if (BuildConfig.DONATE_LINK) moreApps()
163     }
164 
165     /** The other sites and apps of the same author. A tap on an entry opens its page in the browser. */
166     private fun moreApps() {
167         step(R.string.more_apps, "", null) {}
168         for (app in MORE_APPS) {
169             val name = getString(app.name)
170             content.addView(TextView(this).apply {
171                 text = SpannableString("$name\n${getString(app.line)}").apply {
172                     setSpan(UnderlineSpan(), 0, name.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
173                 }
174                 textSize = 15f
175                 setTextColor(Color.BLACK)
176                 setOnClickListener { open(app.url) }
177             }, wide(top = 12))
178         }
179     }
180 
181     @Deprecated("The platform Activity has no other result API, and this app has no AndroidX activity.")
182     override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
183         val uri = data?.data
184         if (resultCode != RESULT_OK || uri == null) return
185         when (requestCode) {
186             PICK_DICTIONARY -> importDictionary(uri)
187             PICK_AUDIO -> copyAudio(uri)
188         }
189     }
190 
191     /** Reads the index, then the banks, on a thread. A dialog shows the count. */
192     private fun importDictionary(uri: Uri) {
193         val dialog = AlertDialog.Builder(this).setMessage(displayName(uri)).setCancelable(false).show()
194         thread {
195             val result = runCatching {
196                 val index = contentResolver.openInputStream(uri)!!.use { YomitanZip.readIndex(it) }
197                 if (index.format != 3) throw DictionaryFormatException(getString(R.string.import_format, index.format))
198                 if (dictionaries.hasTitle(index.title)) throw DictionaryFormatException(getString(R.string.import_exists))
199                 contentResolver.openInputStream(uri)!!.use { zip ->
200                     dictionaries.import(index, zip) { count -> runOnUiThread { dialog.setMessage(getString(R.string.importing, index.title, count)) } }
201                 }
202             }
203             runOnUiThread {
204                 dialog.dismiss()
205                 result.onFailure { toast(getString(R.string.import_failed, it.message ?: it.javaClass.simpleName)) }
206                 draw()
207             }
208         }
209     }
210 
211     /** Copies the android.db into the app folder. It can be gigabytes, so the dialog shows the size so far. */
212     private fun copyAudio(uri: Uri) {
213         val target = store.localAudioFile
214         val dialog = AlertDialog.Builder(this).setMessage(getString(R.string.local_audio_copying, "0 MB")).setCancelable(false).show()
215         thread {
216             val result = runCatching {
217                 val partial = File(target.path + ".part")
218                 contentResolver.openInputStream(uri)!!.use { input ->
219                     partial.outputStream().use { output ->
220                         val buffer = ByteArray(1 shl 20)
221                         var total = 0L
222                         while (true) {
223                             val n = input.read(buffer)
224                             if (n < 0) break
225                             output.write(buffer, 0, n)
226                             total += n
227                             if (total % (32L shl 20) < (1 shl 20)) runOnUiThread { dialog.setMessage(getString(R.string.local_audio_copying, size(total))) }
228                         }
229                     }
230                 }
231                 check(partial.renameTo(target)) { "rename" }
232             }
233             runOnUiThread {
234                 dialog.dismiss()
235                 result.onFailure { toast(getString(R.string.import_failed, it.message ?: it.javaClass.simpleName)) }
236                 draw()
237             }
238         }
239     }
240 
241     private fun displayName(uri: Uri): String =
242         contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
243             if (it.moveToFirst()) it.getString(0) else null
244         } ?: uri.lastPathSegment.orEmpty()
245 
246     private fun size(bytes: Long) = "%.0f MB".format(bytes / 1048576.0)
247 
248     private fun open(link: String) {
249         runCatching { startActivity(Intent(Intent.ACTION_VIEW, link.toUri())) }
250     }
251 
252     private fun toast(message: String) = Toast.makeText(this, message, Toast.LENGTH_LONG).show()
253 
254     // The screen is built in code: a few rows do not need a layout file each.
255 
256     private fun title(value: String) = content.addView(TextView(this).apply {
257         text = value
258         textSize = 26f
259         setTypeface(typeface, Typeface.BOLD)
260         setTextColor(Color.BLACK)
261     })
262 
263     private fun note(value: String, top: Int = 8, color: Int = Color.BLACK, size: Float = 15f) = content.addView(TextView(this).apply {
264         text = value
265         textSize = size
266         setTextColor(color)
267     }, wide(top))
268 
269     private fun field(value: String, single: Boolean) = EditText(this).apply {
270         setText(value)
271         setTextColor(Color.BLACK)
272         textSize = 14f
273         inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS or if (single) 0 else InputType.TYPE_TEXT_FLAG_MULTI_LINE
274         isSingleLine = single
275     }.also { content.addView(it, wide()) }
276 
277     @SuppressLint("SetTextI18n")
278     private fun step(name: Int, detail: String, action: String?, onAction: () -> Unit) {
279         content.addView(View(this).apply { setBackgroundColor(Color.BLACK) }, LinearLayout.LayoutParams(-1, dp(1)).apply { topMargin = dp(16) })
280         content.addView(TextView(this).apply {
281             text = getString(name)
282             textSize = 18f
283             setTypeface(typeface, Typeface.BOLD)
284             setTextColor(Color.BLACK)
285         }, wide(top = 12))
286         if (detail.isNotEmpty()) note(detail, top = 4)
287         if (action != null) content.addView(button(action, onAction), LinearLayout.LayoutParams(-2, -2))
288     }
289 
290     private fun row(vararg views: View) = content.addView(LinearLayout(this).apply { views.forEach { addView(it) } })
291 
292     private fun button(label: String, onClick: () -> Unit) = Button(this).apply {
293         text = label
294         isAllCaps = false
295         setOnClickListener { onClick() }
296     }
297 
298     private fun wide(top: Int = 8) = LinearLayout.LayoutParams(-1, -2).apply { topMargin = dp(top) }
299 
300     private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt()
301 
302     private companion object {
303         const val PICK_DICTIONARY = 1
304         const val PICK_AUDIO = 2
305         const val LOOKUP = "space.subread.dictionary.LOOKUP"
306         const val OVERLAY_PACKAGE = "space.subread.overlay"
307         const val KOFI = "https://ko-fi.com/truex"
308     }
309 }