Recently Written · git

subrep-android

Subrep on Android: live captions of the sound of the phone, made on the phone by Whisper, shared by link.

git clone https://github.com/equwal/subrep-android

Log | Files | Refs


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 }