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 }