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