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