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 }