app/src/main/kotlin/com/honjimaku/subrep/MainActivity.kt (19071 bytes)
1 package com.honjimaku.subrep 2 3 import android.Manifest 4 import android.annotation.SuppressLint 5 import android.app.Activity 6 import android.content.ClipData 7 import android.content.ClipboardManager 8 import android.content.Intent 9 import android.content.pm.PackageManager 10 import android.graphics.Color 11 import android.graphics.Typeface 12 import android.media.projection.MediaProjectionManager 13 import android.net.Uri 14 import android.os.Build 15 import android.os.Bundle 16 import android.text.InputType 17 import android.text.SpannableString 18 import android.text.Spanned 19 import android.text.style.UnderlineSpan 20 import android.view.View 21 import android.widget.Button 22 import android.widget.CheckBox 23 import android.widget.EditText 24 import android.widget.LinearLayout 25 import android.widget.RadioButton 26 import android.widget.RadioGroup 27 import android.widget.ScrollView 28 import android.widget.TextView 29 import android.widget.Toast 30 import okhttp3.OkHttpClient 31 import kotlin.concurrent.thread 32 33 /** 34 * The one screen of the app: the speech model, the language, the sound, the share link, the 35 * overlay, and the button. The state of the service and the last captions are under the button. 36 */ 37 class MainActivity : Activity(), Feed.Listener { 38 39 private lateinit var store: Store 40 private val http = OkHttpClient() 41 private lateinit var whereGroup: RadioGroup 42 private lateinit var cloudStatus: TextView 43 private lateinit var packButtons: LinearLayout 44 private lateinit var content: LinearLayout 45 private lateinit var modelGroup: RadioGroup 46 private lateinit var modelStatus: TextView 47 private lateinit var downloadButton: Button 48 private lateinit var langField: EditText 49 private lateinit var titleField: EditText 50 private lateinit var sourceGroup: RadioGroup 51 private lateinit var shareBox: CheckBox 52 private lateinit var overlayBox: CheckBox 53 private lateinit var linkView: TextView 54 private lateinit var mainButton: Button 55 private lateinit var statusView: TextView 56 private lateinit var linesView: TextView 57 58 override fun onCreate(savedInstanceState: Bundle?) { 59 super.onCreate(savedInstanceState) 60 store = Store(this) 61 content = LinearLayout(this).apply { 62 orientation = LinearLayout.VERTICAL 63 setPadding(dp(20), dp(20), dp(20), dp(20)) 64 setBackgroundColor(Color.WHITE) 65 } 66 setContentView(ScrollView(this).apply { 67 fitsSystemWindows = true 68 setBackgroundColor(Color.WHITE) 69 addView(content) 70 }) 71 draw() 72 } 73 74 override fun onResume() { 75 super.onResume() 76 Feed.add(this) 77 onChange() 78 // After a payment in the browser, the user comes back here: show the new hours. 79 // Ask subread.space only when the user chose cloud captions. 80 if (whereGroup.checkedRadioButtonId == CLOUD_ID) refreshCloud() 81 } 82 83 override fun onPause() { 84 Feed.remove(this) 85 save() 86 super.onPause() 87 } 88 89 private fun draw() { 90 title(getString(R.string.app_name)) 91 note(getString(R.string.about)) 92 93 step(R.string.step_where, "") 94 whereGroup = RadioGroup(this).apply { 95 addView(RadioButton(this@MainActivity).apply { 96 id = PHONE_ID 97 text = getString(R.string.where_phone) 98 setTextColor(Color.BLACK) 99 }) 100 addView(RadioButton(this@MainActivity).apply { 101 id = CLOUD_ID 102 text = getString(R.string.where_cloud) 103 setTextColor(Color.BLACK) 104 }) 105 check(if (store.cloud) CLOUD_ID else PHONE_ID) 106 } 107 whereGroup.setOnCheckedChangeListener { _, id -> if (id == CLOUD_ID) refreshCloud() } 108 content.addView(whereGroup, wide()) 109 cloudStatus = TextView(this).apply { 110 setTextColor(Color.BLACK) 111 textSize = 15f 112 setTextIsSelectable(true) 113 } 114 content.addView(cloudStatus, wide()) 115 packButtons = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } 116 content.addView(packButtons, wide()) 117 note(getString(R.string.cloud_privacy), top = 4) 118 119 step(R.string.step_model, getString(R.string.step_model_why)) 120 modelGroup = RadioGroup(this) 121 for (model in Model.entries) { 122 modelGroup.addView(RadioButton(this).apply { 123 id = MODEL_ID + model.ordinal 124 text = "${model.label}: ${model.about}" 125 setTextColor(Color.BLACK) 126 }) 127 } 128 modelGroup.check(MODEL_ID + Model.byId(store.model).ordinal) 129 modelGroup.setOnCheckedChangeListener { _, _ -> showModel() } 130 content.addView(modelGroup, wide()) 131 modelStatus = TextView(this).apply { 132 setTextColor(Color.BLACK) 133 textSize = 15f 134 } 135 content.addView(modelStatus, wide()) 136 downloadButton = button(getString(R.string.download)) { download() } 137 content.addView(downloadButton, LinearLayout.LayoutParams(-2, -2)) 138 139 step(R.string.step_lang, getString(R.string.step_lang_why)) 140 langField = field(store.lang, "ja", InputType.TYPE_CLASS_TEXT) 141 142 step(R.string.step_source, "") 143 // The group keeps one button checked only when each button has an id before it joins. 144 sourceGroup = RadioGroup(this).apply { 145 addView(RadioButton(this@MainActivity).apply { 146 id = SCREEN_ID 147 text = getString(R.string.source_screen) 148 setTextColor(Color.BLACK) 149 }) 150 addView(RadioButton(this@MainActivity).apply { 151 id = MIC_ID 152 text = getString(R.string.source_mic) 153 setTextColor(Color.BLACK) 154 }) 155 check(if (store.screen) SCREEN_ID else MIC_ID) 156 } 157 content.addView(sourceGroup, wide()) 158 159 step(R.string.step_share, "") 160 shareBox = CheckBox(this).apply { 161 text = getString(R.string.share_on) 162 setTextColor(Color.BLACK) 163 isChecked = store.share 164 } 165 content.addView(shareBox, wide()) 166 titleField = field(store.title, getString(R.string.share_title_hint), InputType.TYPE_CLASS_TEXT) 167 linkView = TextView(this).apply { 168 setTextColor(Color.BLACK) 169 textSize = 15f 170 setTextIsSelectable(true) 171 } 172 content.addView(linkView, wide()) 173 row( 174 button(getString(R.string.copy)) { copyLink() }, 175 button(getString(R.string.share)) { shareLink() }, 176 button(getString(R.string.new_link)) { 177 store.newRoom() 178 showLink() 179 }, 180 ) 181 showLink() 182 183 step(R.string.step_overlay, "") 184 overlayBox = CheckBox(this).apply { 185 text = getString(R.string.overlay_on) 186 setTextColor(Color.BLACK) 187 isChecked = store.overlay 188 } 189 if (Overlay.installed(this)) { 190 content.addView(overlayBox, wide()) 191 content.addView(button(getString(R.string.open_overlay)) { 192 Overlay.launch(this)?.let { runCatching { startActivity(it) } } 193 }, LinearLayout.LayoutParams(-2, -2)) 194 } else { 195 note(getString(R.string.overlay_missing)) 196 content.addView(button(getString(R.string.install_overlay)) { open(Overlay.INSTALL) }, LinearLayout.LayoutParams(-2, -2)) 197 } 198 199 mainButton = button(getString(R.string.start)) { toggle() }.apply { setTypeface(typeface, Typeface.BOLD) } 200 content.addView(mainButton, wide(top = 24)) 201 statusView = TextView(this).apply { 202 setTextColor(Color.BLACK) 203 textSize = 15f 204 } 205 content.addView(statusView, wide()) 206 linesView = TextView(this).apply { 207 setTextColor(Color.BLACK) 208 textSize = 18f 209 setTextIsSelectable(true) 210 } 211 content.addView(linesView, wide()) 212 moreApps() 213 showModel() 214 } 215 216 /** The other sites and apps of the same author. A tap on an entry opens its page in the browser. */ 217 private fun moreApps() { 218 step(R.string.more_apps, "") 219 for (app in MORE_APPS) { 220 val name = getString(app.name) 221 content.addView(TextView(this).apply { 222 text = SpannableString("$name\n${getString(app.line)}").apply { 223 setSpan(UnderlineSpan(), 0, name.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) 224 } 225 textSize = 15f 226 setTextColor(Color.BLACK) 227 setOnClickListener { open(app.url) } 228 }, wide(top = 12)) 229 } 230 } 231 232 private fun chosenModel(): Model = Model.entries[(modelGroup.checkedRadioButtonId - MODEL_ID).coerceIn(0, Model.entries.size - 1)] 233 234 private fun save() { 235 store.cloud = whereGroup.checkedRadioButtonId == CLOUD_ID 236 store.model = chosenModel().id 237 store.lang = langField.text.toString() 238 store.title = titleField.text.toString() 239 store.screen = sourceGroup.checkedRadioButtonId != MIC_ID 240 store.share = shareBox.isChecked 241 store.overlay = overlayBox.isChecked 242 } 243 244 /** The line under the model buttons: downloaded, not downloaded, or the download so far. */ 245 private fun showModel() { 246 val model = chosenModel() 247 val downloading = Feed.downloading 248 modelStatus.text = when { 249 downloading == model && Feed.downloadTotal > 0 -> 250 getString(R.string.model_downloading, Feed.downloadDone * 100 / Feed.downloadTotal) 251 downloading == model -> getString(R.string.model_downloading_size, Feed.downloadDone / (1024 * 1024)) 252 Feed.downloadError.isNotEmpty() && downloading == null && !model.downloaded(this) -> 253 getString(R.string.model_failed, Feed.downloadError) 254 model.downloaded(this) -> getString(R.string.model_ready) 255 else -> getString(R.string.model_missing) 256 } 257 downloadButton.isEnabled = downloading == null && !model.downloaded(this) 258 } 259 260 private fun download() { 261 val model = chosenModel() 262 if (model.downloaded(this) || Feed.downloading != null) return 263 Feed.download(model, 0, -1) 264 val app = applicationContext 265 thread(name = "download") { 266 val error = Downloader(OkHttpClient()).download(app, model) { done, total -> Feed.download(model, done, total) } 267 Feed.download(null, 0, -1, error.orEmpty()) 268 } 269 } 270 271 private fun toggle() { 272 if (Feed.running) CaptureService.stop(this) else startCapture() 273 } 274 275 private fun startCapture() { 276 save() 277 if (store.cloud) { 278 if (Feed.cloudSecondsLeft == 0L) return toast(R.string.need_hours) 279 } else if (!chosenModel().downloaded(this)) { 280 return toast(R.string.need_model) 281 } 282 val missing = buildList { 283 if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) add(Manifest.permission.RECORD_AUDIO) 284 if (Build.VERSION.SDK_INT >= 33 && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { 285 add(Manifest.permission.POST_NOTIFICATIONS) 286 } 287 } 288 if (missing.isNotEmpty()) return requestPermissions(missing.toTypedArray(), REQUEST_PERMISSIONS) 289 if (!store.screen) return CaptureService.start(this, screen = false) 290 if (Build.VERSION.SDK_INT < 29) return toast(R.string.need_android_10) 291 // Android asks the user for the consent. The answer comes to onActivityResult. 292 val manager = getSystemService(MediaProjectionManager::class.java) 293 startActivityForResult(manager.createScreenCaptureIntent(), REQUEST_PROJECTION) 294 } 295 296 override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) { 297 if (requestCode != REQUEST_PERMISSIONS) return 298 val audio = permissions.indexOf(Manifest.permission.RECORD_AUDIO) 299 if (audio >= 0 && grantResults[audio] != PackageManager.PERMISSION_GRANTED) return toast(R.string.need_audio_permission) 300 startCapture() 301 } 302 303 @Deprecated("The platform Activity has no other result API, and this app has no AndroidX activity.") 304 override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { 305 if (requestCode != REQUEST_PROJECTION) return 306 if (resultCode != RESULT_OK || data == null) return toast(R.string.capture_refused) 307 CaptureService.start(this, screen = true, resultCode = resultCode, resultData = data) 308 } 309 310 override fun onChange() { 311 mainButton.text = getString(if (Feed.running) R.string.stop else R.string.start) 312 statusView.text = status() 313 linesView.text = Feed.text() 314 showModel() 315 cloud?.let { showCloud(it.copy(secondsLeft = Feed.cloudSecondsLeft.takeIf { s -> s >= 0 } ?: it.secondsLeft)) } 316 } 317 318 /** The last answer of subread.space about the cloud hours, or null before the first. */ 319 private var cloud: Cloud.State? = null 320 321 private fun refreshCloud() { 322 val app = Cloud(http, store) 323 thread(name = "cloud") { 324 val state = runCatching { app.state() }.getOrNull() 325 runOnUiThread { 326 cloud = state 327 if (state == null) { 328 cloudStatus.text = getString(R.string.cloud_offline) 329 packButtons.removeAllViews() 330 } else { 331 Feed.cloudLeft(state.secondsLeft) 332 showCloud(state) 333 } 334 } 335 } 336 } 337 338 private fun showCloud(state: Cloud.State) { 339 if (!state.available) { 340 cloudStatus.text = getString(R.string.cloud_closed) 341 packButtons.removeAllViews() 342 return 343 } 344 cloudStatus.text = getString(R.string.cloud_left, Cloud.hours(state.secondsLeft), state.accountId) 345 if (packButtons.childCount == state.packs.size) return 346 packButtons.removeAllViews() 347 for (pack in state.packs) { 348 packButtons.addView(button(resources.getQuantityString(R.plurals.cloud_buy, pack.hours, pack.hours, pack.price)) { buy(pack) }) 349 } 350 } 351 352 private fun buy(pack: Cloud.Pack) { 353 val app = Cloud(http, store) 354 thread(name = "checkout") { 355 val url = runCatching { app.checkout(pack.id) } 356 runOnUiThread { 357 url.onSuccess { open(it) }.onFailure { 358 Toast.makeText(this, getString(R.string.cloud_buy_failed, it.message), Toast.LENGTH_LONG).show() 359 } 360 } 361 } 362 } 363 364 private fun status(): String { 365 if (!Feed.running) return Feed.error 366 val model = if (store.cloud) "Cloud" else "Whisper ${Model.byId(store.model).label}" 367 val engine = when { 368 Feed.engine == Engine.STATE_LOADING -> "loading…" 369 Feed.engine == Engine.STATE_READY && Feed.speed > 0f -> "ready, %.1f s for each second of speech".format(Feed.speed) 370 Feed.engine == Engine.STATE_READY -> "ready" 371 else -> Feed.engine 372 } 373 val late = if (Feed.late > 0) ", ${Feed.late} pieces dropped" else "" 374 val relay = when { 375 !Feed.relayOn -> "off" 376 Feed.relayUp -> "connected" 377 else -> "connecting…" 378 } 379 val overlay = when (Feed.overlayAnswer) { 380 "" -> if (store.overlay && Overlay.installed(this)) "waits for a line" else "off" 381 Overlay.OK -> "shows the line" 382 "panel_hidden" -> "press \"Show the subtitles\" in SubRead Overlay" 383 "no_overlay_permission", "no_notification_access" -> "allow steps 1 and 2 in SubRead Overlay" 384 Overlay.TOO_OLD -> "update SubRead Overlay: this version takes no lines" 385 else -> Feed.overlayAnswer 386 } 387 val bars = Feed.bars() 388 val level = "▮".repeat(bars) + "▯".repeat(10 - bars) 389 val error = if (Feed.error.isEmpty()) "" else "\n${Feed.error}" 390 return "$model: $engine$late\nShare link: $relay\nSubRead Overlay: $overlay\nSound: $level$error" 391 } 392 393 private fun showLink() { 394 linkView.text = Room.watchUrl(Room.RELAY, store.room) 395 } 396 397 private fun copyLink() { 398 getSystemService(ClipboardManager::class.java).setPrimaryClip(ClipData.newPlainText("link", linkView.text)) 399 toast(R.string.copied) 400 } 401 402 private fun shareLink() { 403 val send = Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, linkView.text.toString()) 404 runCatching { startActivity(Intent.createChooser(send, null)) } 405 } 406 407 private fun open(link: String) { 408 runCatching { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link))) } 409 } 410 411 private fun toast(message: Int) = Toast.makeText(this, message, Toast.LENGTH_LONG).show() 412 413 // The screen is built in code: a few rows do not need a layout file each. 414 415 private fun title(value: String) = content.addView(TextView(this).apply { 416 text = value 417 textSize = 26f 418 setTypeface(typeface, Typeface.BOLD) 419 setTextColor(Color.BLACK) 420 }) 421 422 private fun note(value: String, top: Int = 8) = content.addView(TextView(this).apply { 423 text = value 424 textSize = 15f 425 setTextColor(Color.BLACK) 426 }, wide(top)) 427 428 @SuppressLint("SetTextI18n") 429 private fun step(name: Int, detail: String) { 430 content.addView(View(this).apply { setBackgroundColor(Color.BLACK) }, LinearLayout.LayoutParams(-1, dp(1)).apply { topMargin = dp(16) }) 431 content.addView(TextView(this).apply { 432 text = getString(name) 433 textSize = 18f 434 setTypeface(typeface, Typeface.BOLD) 435 setTextColor(Color.BLACK) 436 }, wide(top = 12)) 437 if (detail.isNotEmpty()) note(detail, top = 4) 438 } 439 440 private fun field(value: String, hint: String, type: Int) = EditText(this).apply { 441 setText(value) 442 this.hint = hint 443 inputType = type 444 setTextColor(Color.BLACK) 445 setHintTextColor(Color.GRAY) 446 isSingleLine = true 447 }.also { content.addView(it, wide()) } 448 449 private fun row(vararg views: View) = content.addView(LinearLayout(this).apply { views.forEach { addView(it) } }) 450 451 private fun button(label: String, onClick: () -> Unit) = Button(this).apply { 452 text = label 453 isAllCaps = false 454 setOnClickListener { onClick() } 455 } 456 457 private fun wide(top: Int = 8) = LinearLayout.LayoutParams(-1, -2).apply { topMargin = dp(top) } 458 459 private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt() 460 461 private companion object { 462 const val REQUEST_PERMISSIONS = 1 463 const val REQUEST_PROJECTION = 2 464 const val SCREEN_ID = 1 465 const val MIC_ID = 2 466 const val PHONE_ID = 3 467 const val CLOUD_ID = 4 468 const val MODEL_ID = 10 469 } 470 }