app/src/main/kotlin/space/subread/overlay/MainActivity.kt (12633 bytes)
1 package space.subread.overlay 2 3 import android.annotation.SuppressLint 4 import android.app.Activity 5 import android.app.AlertDialog 6 import android.content.ComponentName 7 import android.content.Intent 8 import android.graphics.Color 9 import android.graphics.Typeface 10 import android.net.Uri 11 import android.os.Bundle 12 import android.provider.OpenableColumns 13 import android.provider.Settings 14 import android.text.SpannableString 15 import android.text.Spanned 16 import android.text.style.UnderlineSpan 17 import android.view.Gravity 18 import android.view.View 19 import android.widget.Button 20 import android.widget.LinearLayout 21 import android.widget.ScrollView 22 import android.widget.SeekBar 23 import android.widget.TextView 24 import android.widget.Toast 25 import androidx.core.net.toUri 26 import space.subread.overlay.core.Srt 27 28 /** 29 * The one screen of the app: the two permissions, the subtitle file, the dictionary, the text 30 * size, and the button for the panel. Each part is on the screen from the start, so that the 31 * user sees what the app does before a button is pressed. 32 */ 33 class MainActivity : Activity() { 34 35 private lateinit var store: Store 36 private lateinit var content: LinearLayout 37 38 override fun onCreate(savedInstanceState: Bundle?) { 39 super.onCreate(savedInstanceState) 40 store = Store(this) 41 content = LinearLayout(this).apply { 42 orientation = LinearLayout.VERTICAL 43 setPadding(dp(20), dp(20), dp(20), dp(20)) 44 setBackgroundColor(Color.WHITE) 45 } 46 setContentView(ScrollView(this).apply { 47 fitsSystemWindows = true 48 setBackgroundColor(Color.WHITE) 49 addView(content) 50 }) 51 } 52 53 /** The user comes back from the settings of Android here, so the state is read again. */ 54 override fun onResume() { 55 super.onResume() 56 draw() 57 } 58 59 private fun draw() { 60 content.removeAllViews() 61 val overlay = Settings.canDrawOverlays(this) 62 val listener = MediaListener.isAllowed(this) 63 64 title(getString(R.string.app_name)) 65 note(getString(R.string.about)) 66 note(getString(R.string.privacy)) 67 68 step(R.string.step_overlay, getString(R.string.step_overlay_why), if (overlay) null else getString(R.string.allow), overlay) { 69 startActivity(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, "package:$packageName".toUri())) 70 } 71 step(R.string.step_listener, getString(R.string.step_listener_why), if (listener) null else getString(R.string.allow), listener) { 72 startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) 73 } 74 step(R.string.step_file, store.subtitlesName ?: getString(R.string.step_file_none), getString(R.string.choose)) { 75 startActivityForResult( 76 Intent(Intent.ACTION_OPEN_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType("*/*"), 77 PICK_SUBTITLES, 78 ) 79 } 80 step(R.string.step_dictionary, dictionaryName(), getString(R.string.choose)) { chooseDictionary() } 81 step(R.string.step_size, "${store.textSizeSp.toInt()} sp", null) {} 82 row(button("−") { size(-2f) }, button("+") { size(2f) }) 83 step(R.string.step_transparency, getString(R.string.step_transparency_why), null) {} 84 content.addView(transparencySlider(), wide()) 85 val around = store.linesAround 86 step( 87 R.string.step_lines, 88 getString(if (around) R.string.lines_three else R.string.lines_one), 89 getString(if (around) R.string.lines_show_one else R.string.lines_show_three), 90 ) { 91 store.linesAround = !around 92 MediaListener.instance?.reload() 93 draw() 94 } 95 96 val shown = store.shown && MediaListener.instance != null 97 content.addView(button(getString(if (shown) R.string.hide else R.string.show)) { togglePanel() }.apply { 98 setTypeface(typeface, Typeface.BOLD) 99 }, wide(top = 24)) 100 101 note(getString(R.string.make_subtitles), top = 32) 102 val subread = packageManager.getLaunchIntentForPackage(SUBREAD_PACKAGE) 103 if (subread != null) { 104 content.addView(button(getString(R.string.open_subread)) { runCatching { startActivity(subread) } }, wide()) 105 } else { 106 content.addView(button(getString(R.string.install_subread)) { open(SUBREAD_INSTALL) }, wide()) 107 } 108 note(getString(R.string.anki_note), top = 24) 109 val anki = packageManager.getLaunchIntentForPackage(Anki.PACKAGE) 110 if (anki != null) { 111 content.addView(button(getString(R.string.open_anki)) { runCatching { startActivity(anki) } }, wide()) 112 } else { 113 content.addView(button(getString(R.string.install_anki)) { open(Anki.INSTALL) }, wide()) 114 } 115 if (BuildConfig.DONATE_LINK) content.addView(button(getString(R.string.donate)) { open(KOFI) }, wide()) 116 // The Google Play build (-PplayStore=true) has no Ko-fi link, and no More apps either. 117 if (BuildConfig.DONATE_LINK) moreApps() 118 } 119 120 /** The other sites and apps of the same author. A tap on an entry opens its page in the browser. */ 121 private fun moreApps() { 122 content.addView(View(this).apply { setBackgroundColor(Color.BLACK) }, LinearLayout.LayoutParams(-1, dp(1)).apply { topMargin = dp(32) }) 123 content.addView(TextView(this).apply { 124 text = getString(R.string.more_apps) 125 textSize = 18f 126 setTypeface(typeface, Typeface.BOLD) 127 setTextColor(Color.BLACK) 128 }, wide(top = 12)) 129 for (app in MORE_APPS) { 130 val name = getString(app.name) 131 content.addView(TextView(this).apply { 132 text = SpannableString("$name\n${getString(app.line)}").apply { 133 setSpan(UnderlineSpan(), 0, name.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) 134 } 135 textSize = 15f 136 setTextColor(Color.BLACK) 137 setOnClickListener { open(app.url) } 138 }, wide(top = 12)) 139 } 140 } 141 142 private fun togglePanel() { 143 val service = MediaListener.instance 144 when { 145 !Settings.canDrawOverlays(this) || !MediaListener.isAllowed(this) -> toast(R.string.need_both) 146 service == null -> toast(R.string.listener_starting) 147 store.shown -> service.hidePanel() 148 else -> service.showPanel() 149 } 150 draw() 151 } 152 153 private fun size(change: Float) { 154 store.textSizeSp += change 155 MediaListener.instance?.reload() 156 draw() 157 } 158 159 /** A slider from 0 % (a white panel) to 90 %. The panel changes while the finger moves. */ 160 @SuppressLint("SetTextI18n") 161 private fun transparencySlider(): View { 162 val label = TextView(this).apply { 163 setTextColor(Color.BLACK) 164 textSize = 15f 165 } 166 val slider = SeekBar(this).apply { 167 max = 90 168 progress = store.transparencyPercent 169 setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { 170 override fun onProgressChanged(bar: SeekBar, value: Int, fromUser: Boolean) { 171 label.text = "$value %" 172 if (fromUser) { 173 store.transparencyPercent = value 174 MediaListener.instance?.reload() 175 } 176 } 177 178 override fun onStartTrackingTouch(bar: SeekBar) = Unit 179 180 override fun onStopTrackingTouch(bar: SeekBar) = Unit 181 }) 182 } 183 label.text = "${slider.progress} %" 184 return LinearLayout(this).apply { 185 gravity = Gravity.CENTER_VERTICAL 186 addView(slider, LinearLayout.LayoutParams(0, -2, 1f)) 187 addView(label, LinearLayout.LayoutParams(-2, -2).apply { leftMargin = dp(12) }) 188 } 189 } 190 191 private fun dictionaryName(): String { 192 val chosen = store.dictionary ?: return getString(R.string.dictionary_ask) 193 return dictionaries().firstOrNull { it.second == chosen }?.first ?: getString(R.string.dictionary_ask) 194 } 195 196 /** The apps that take a selected text, with the name that each one gives the entry. */ 197 private fun dictionaries(): List<Pair<String, ComponentName>> = 198 packageManager.queryIntentActivities(Lookup.probe(), 0).map { 199 val app = it.activityInfo.applicationInfo.loadLabel(packageManager) 200 val entry = it.loadLabel(packageManager) 201 val name = if (entry.toString() == app.toString()) "$app" else "$app: $entry" 202 name to ComponentName(it.activityInfo.packageName, it.activityInfo.name) 203 }.sortedBy { it.first } 204 205 private fun chooseDictionary() { 206 val found = dictionaries() 207 if (found.isEmpty()) { 208 AlertDialog.Builder(this).setMessage(R.string.dictionary_none).setPositiveButton(android.R.string.ok, null).show() 209 return 210 } 211 val names = listOf(getString(R.string.dictionary_ask)) + found.map { it.first } 212 AlertDialog.Builder(this).setItems(names.toTypedArray()) { _, which -> 213 store.dictionary = found.getOrNull(which - 1)?.second 214 draw() 215 }.show() 216 } 217 218 @Deprecated("The platform Activity has no other result API, and this app has no AndroidX activity.") 219 override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { 220 val uri = data?.data 221 if (requestCode != PICK_SUBTITLES || resultCode != RESULT_OK || uri == null) return 222 // The listener reads the file later, also after a restart of the device. 223 runCatching { contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) } 224 val lines = runCatching { 225 contentResolver.openInputStream(uri)!!.use { Srt.parse(it.readBytes().decodeToString()) }.size 226 }.getOrDefault(0) 227 store.subtitles = uri 228 store.subtitlesName = resources.getQuantityString(R.plurals.step_file_cues, lines, displayName(uri), lines) 229 MediaListener.instance?.reload() 230 draw() 231 } 232 233 private fun displayName(uri: Uri): String = 234 contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { 235 if (it.moveToFirst()) it.getString(0) else null 236 } ?: uri.lastPathSegment.orEmpty() 237 238 private fun open(link: String) { 239 runCatching { startActivity(Intent(Intent.ACTION_VIEW, link.toUri())) } 240 } 241 242 private fun toast(message: Int) = Toast.makeText(this, message, Toast.LENGTH_LONG).show() 243 244 // The screen is built in code: six rows do not need a layout file each. 245 246 private fun title(value: String) = content.addView(TextView(this).apply { 247 text = value 248 textSize = 26f 249 setTypeface(typeface, Typeface.BOLD) 250 setTextColor(Color.BLACK) 251 }) 252 253 private fun note(value: String, top: Int = 8) = content.addView(TextView(this).apply { 254 text = value 255 textSize = 15f 256 setTextColor(Color.BLACK) 257 }, wide(top)) 258 259 @SuppressLint("SetTextI18n") 260 private fun step(name: Int, detail: String, action: String?, done: Boolean = false, onAction: () -> Unit) { 261 content.addView(View(this).apply { setBackgroundColor(Color.BLACK) }, LinearLayout.LayoutParams(-1, dp(1)).apply { topMargin = dp(16) }) 262 content.addView(TextView(this).apply { 263 text = getString(name) + if (done) " ✓ ${getString(R.string.allowed)}" else "" 264 textSize = 18f 265 setTypeface(typeface, Typeface.BOLD) 266 setTextColor(Color.BLACK) 267 }, wide(top = 12)) 268 note(detail, top = 4) 269 if (action != null) content.addView(button(action, onAction), LinearLayout.LayoutParams(-2, -2)) 270 } 271 272 private fun row(vararg views: View) = content.addView(LinearLayout(this).apply { views.forEach { addView(it) } }) 273 274 private fun button(label: String, onClick: () -> Unit) = Button(this).apply { 275 text = label 276 isAllCaps = false 277 setOnClickListener { onClick() } 278 } 279 280 private fun wide(top: Int = 8) = LinearLayout.LayoutParams(-1, -2).apply { topMargin = dp(top) } 281 282 private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt() 283 284 private companion object { 285 const val PICK_SUBTITLES = 1 286 const val SUBREAD_PACKAGE = "space.subread.app" 287 // The release page of the SubRead app. Each release has the APK as SubRead.apk. 288 const val SUBREAD_INSTALL = "https://github.com/equwal/subread-android/releases/latest" 289 const val KOFI = "https://ko-fi.com/truex" 290 } 291 }