app/src/main/java/com/equwal/sbm/MainActivity.kt (15087 bytes)
1 package com.equwal.sbm 2 3 import android.annotation.SuppressLint 4 import android.app.Activity 5 import android.app.AlertDialog 6 import android.content.ActivityNotFoundException 7 import android.content.ClipData 8 import android.content.ClipboardManager 9 import android.content.Intent 10 import android.net.Uri 11 import android.os.Build 12 import android.os.Bundle 13 import android.os.Handler 14 import android.os.Looper 15 import android.text.Editable 16 import android.text.TextWatcher 17 import android.view.Menu 18 import android.view.MenuItem 19 import android.view.View 20 import android.view.ViewGroup 21 import android.view.WindowInsets 22 import android.view.inputmethod.EditorInfo 23 import android.webkit.RenderProcessGoneDetail 24 import android.webkit.WebResourceRequest 25 import android.webkit.WebView 26 import android.webkit.WebViewClient 27 import android.widget.BaseAdapter 28 import android.widget.EditText 29 import android.widget.LinearLayout 30 import android.widget.ListView 31 import android.widget.TextView 32 import android.widget.Toast 33 import android.widget.Toolbar 34 35 /** 36 * The list: search as you type, tap to open, press and hold for more. During 37 * a search, the page of the first match shows live above the list. 38 */ 39 class MainActivity : Activity() { 40 private lateinit var search: EditText 41 private lateinit var empty: TextView 42 private lateinit var preview: LinearLayout 43 private lateinit var caption: TextView 44 private val rows = Rows() 45 private var all: List<Bookmark> = emptyList() 46 private var page: WebView? = null // made when the preview first shows 47 private var previewed: Bookmark? = null // the bookmark in the preview 48 private var picked: Bookmark? = null // the bookmark that the user picked with Preview 49 private var framed: String? = null // the address in the web view 50 private val later = Handler(Looper.getMainLooper()) 51 52 override fun onCreate(state: Bundle?) { 53 super.onCreate(state) 54 setContentView(R.layout.activity_main) 55 val toolbar = findViewById<Toolbar>(R.id.toolbar) 56 setActionBar(toolbar) 57 search = findViewById(R.id.search) 58 empty = findViewById(R.id.empty) 59 preview = findViewById(R.id.preview) 60 caption = findViewById(R.id.caption) 61 caption.setOnClickListener { previewed?.let { open(it.url) } } 62 val list = findViewById<ListView>(R.id.list) 63 list.adapter = rows 64 list.emptyView = empty 65 66 // Android 15 and later draw the app under the system bars and the 67 // keyboard. The toolbar colour goes under the status bar, and the 68 // rest of the screen stays clear of the bars and the keyboard. 69 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { 70 findViewById<View>(R.id.root).setOnApplyWindowInsetsListener { v, insets -> 71 val bars = insets.getInsets(WindowInsets.Type.systemBars() or WindowInsets.Type.ime()) 72 toolbar.setPadding(0, bars.top, 0, 0) 73 v.setPadding(bars.left, 0, bars.right, bars.bottom) 74 insets 75 } 76 } 77 78 search.addTextChangedListener(object : TextWatcher { 79 override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} 80 override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} 81 override fun afterTextChanged(s: Editable?) { 82 picked = null 83 show() 84 } 85 }) 86 search.setOnEditorActionListener { _, action, _ -> 87 if (action == EditorInfo.IME_ACTION_GO) { 88 go() 89 true 90 } else { 91 false 92 } 93 } 94 list.setOnItemClickListener { _, _, position, _ -> open(rows.items[position].url) } 95 list.setOnItemLongClickListener { _, _, position, _ -> 96 more(rows.items[position]) 97 true 98 } 99 } 100 101 override fun onResume() { 102 super.onResume() 103 page?.onResume() 104 load() 105 if (Sync.signedIn(this)) sync(quiet = true) 106 } 107 108 override fun onPause() { 109 page?.onPause() 110 super.onPause() 111 } 112 113 override fun onDestroy() { 114 later.removeCallbacksAndMessages(null) 115 page?.destroy() 116 super.onDestroy() 117 } 118 119 /** Sync, then show the file again. A quiet sync reports only what the user must act on. */ 120 private fun sync(quiet: Boolean) { 121 Sync.run(this) { result -> 122 if (isDestroyed) return@run 123 result.onSuccess { changed -> 124 if (changed) load() 125 if (!quiet) toast(resources.getQuantityString(R.plurals.synced, all.size, all.size)) 126 }.onFailure { e -> 127 if (!quiet || (e is Sync.Failure && e.urgent)) toast(getString(R.string.sync_failed, e.message)) 128 if (e is Sync.Failure && e.signIn) invalidateOptionsMenu() 129 } 130 } 131 } 132 133 private fun showSignIn() { 134 val form = layoutInflater.inflate(R.layout.dialog_sign_in, null) 135 val server = form.findViewById<EditText>(R.id.server) 136 val email = form.findViewById<EditText>(R.id.email) 137 val password = form.findViewById<EditText>(R.id.password) 138 server.setText(Sync.server(this)) 139 email.setText(Sync.email(this)) 140 AlertDialog.Builder(this) 141 .setTitle(R.string.sign_in) 142 .setView(form) 143 .setNegativeButton(android.R.string.cancel, null) 144 .setPositiveButton(R.string.sign_in_button) { _, _ -> 145 toast(getString(R.string.signing_in)) 146 Sync.signIn(this, server.text.toString(), email.text.toString(), password.text.toString()) { result -> 147 if (isDestroyed) return@signIn 148 result.onSuccess { n -> 149 toast(resources.getQuantityString(R.plurals.signed_in, n, Sync.email(this), n)) 150 invalidateOptionsMenu() 151 load() 152 }.onFailure { e -> toast(getString(R.string.sync_failed, e.message)) } 153 } 154 } 155 .show() 156 } 157 158 private fun load() { 159 all = try { 160 // Newest first: bm adds bookmarks at the end of the file. 161 Tsv.parse(Store.read(this)).asReversed() 162 } catch (e: Exception) { 163 toast(getString(R.string.cannot_read, e.message)) 164 emptyList() 165 } 166 empty.setText(if (Store.file(this) == null) R.string.no_file else R.string.no_match) 167 show() 168 } 169 170 private fun show() { 171 rows.items = Fuzzy.filter(all, search.text.toString()) { "${it.desc} ${it.tags.joinToString(" ")} ${it.url}" } 172 rows.notifyDataSetChanged() 173 showPage(picked ?: if (search.text.isBlank()) null else rows.items.firstOrNull()) 174 } 175 176 private val settings get() = getSharedPreferences("settings", MODE_PRIVATE) 177 private val previewOn get() = settings.getBoolean("preview", true) 178 179 /** 180 * Show the page of [b] in the preview, or close the preview for null. The 181 * page loads only when [b] stays in the preview for a moment, so that 182 * fast typing does not load a page for each letter. 183 */ 184 private fun showPage(b: Bookmark?) { 185 later.removeCallbacksAndMessages(null) 186 previewed = if (previewOn) b else null 187 val shown = previewed 188 if (shown == null) { 189 preview.visibility = View.GONE 190 loadPage(null) 191 return 192 } 193 preview.visibility = View.VISIBLE 194 val to = Preview.address(shown.url) 195 caption.text = to ?: getString(R.string.no_preview, shown.url) 196 if (to == null) loadPage(null) else later.postDelayed({ loadPage(to) }, PREVIEW_WAIT) 197 } 198 199 /** Load the page at [to] in the preview, or an empty page for null. */ 200 private fun loadPage(to: String?) { 201 if (to == framed) return 202 framed = to 203 val view = page ?: if (to == null) return else newPage() 204 view.loadUrl(to ?: "about:blank") 205 } 206 207 /** 208 * A web view for the preview, under the caption. The page in it is only 209 * to look at: it runs its scripts, as in a browser, but it cannot take 210 * the focus from the search, open other apps or read the files of the 211 * app. Its links stay in the preview, and an http link loads with https. 212 */ 213 @SuppressLint("SetJavaScriptEnabled") // Most pages need scripts to show, and the page gets no interface to the app. 214 private fun newPage(): WebView { 215 val view = WebView(this) 216 view.isFocusable = false 217 view.isFocusableInTouchMode = false 218 view.settings.javaScriptEnabled = true 219 view.settings.domStorageEnabled = true 220 view.settings.allowFileAccess = false 221 view.settings.allowContentAccess = false 222 view.webViewClient = object : WebViewClient() { 223 override fun shouldOverrideUrlLoading(v: WebView, request: WebResourceRequest): Boolean { 224 val url = request.url.toString() 225 val to = Preview.address(url) ?: return true 226 if (to == url) return false 227 v.loadUrl(to) 228 return true 229 } 230 231 // A page that stops the renderer must not stop the app: a new 232 // web view takes the place of the old one. 233 override fun onRenderProcessGone(v: WebView, detail: RenderProcessGoneDetail): Boolean { 234 preview.removeView(v) 235 v.destroy() 236 if (page === v) page = null 237 framed = null 238 return true 239 } 240 } 241 preview.addView(view, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)) 242 page = view 243 return view 244 } 245 246 /** Go opens the first bookmark. Without one, the text is an address or a web search, as in bm. */ 247 private fun go() { 248 val first = rows.items.firstOrNull() 249 when { 250 first != null -> open(first.url) 251 search.text.isNotBlank() -> open(Tsv.target(search.text.toString())) 252 } 253 } 254 255 private fun open(url: String) { 256 try { 257 startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addCategory(Intent.CATEGORY_BROWSABLE)) 258 } catch (e: ActivityNotFoundException) { 259 toast(getString(R.string.no_app, url)) 260 } 261 } 262 263 private fun more(b: Bookmark) { 264 val actions = listOfNotNull( 265 getString(R.string.open) to { open(b.url) }, 266 if (previewOn) { 267 getString(R.string.preview) to { 268 picked = b 269 show() 270 } 271 } else { 272 null 273 }, 274 getString(R.string.copy) to { copy(b.url) }, 275 getString(R.string.share) to { share(b) }, 276 ) 277 AlertDialog.Builder(this) 278 .setTitle(b.desc.ifEmpty { b.url }) 279 .setItems(actions.map { it.first }.toTypedArray()) { _, which -> actions[which].second() } 280 .show() 281 } 282 283 private fun copy(url: String) { 284 getSystemService(ClipboardManager::class.java).setPrimaryClip(ClipData.newPlainText("URL", url)) 285 // Android 13 and later show their own message. 286 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) toast(getString(R.string.copied)) 287 } 288 289 private fun share(b: Bookmark) { 290 val send = Intent(Intent.ACTION_SEND).setType("text/plain") 291 .putExtra(Intent.EXTRA_TEXT, b.url) 292 .putExtra(Intent.EXTRA_SUBJECT, b.desc) 293 startActivity(Intent.createChooser(send, null)) 294 } 295 296 override fun onCreateOptionsMenu(menu: Menu): Boolean { 297 menuInflater.inflate(R.menu.main, menu) 298 return true 299 } 300 301 override fun onPrepareOptionsMenu(menu: Menu): Boolean { 302 val on = Sync.signedIn(this) 303 menu.findItem(R.id.sync_now).isVisible = on 304 menu.findItem(R.id.sign_out).isVisible = on 305 menu.findItem(R.id.sign_in).isVisible = !on 306 menu.findItem(R.id.preview_on).isChecked = previewOn 307 // Google Play does not allow links to payments outside Google Play. 308 menu.findItem(R.id.donate).isVisible = BuildConfig.DONATE 309 // The Google Play build has no More apps either. 310 menu.findItem(R.id.more_apps).isVisible = BuildConfig.DONATE 311 return true 312 } 313 314 override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { 315 R.id.add -> { 316 startActivity(Intent(this, AddActivity::class.java)) 317 true 318 } 319 R.id.sync_now -> { 320 sync(quiet = false) 321 true 322 } 323 R.id.sign_in -> { 324 showSignIn() 325 true 326 } 327 R.id.sign_out -> { 328 Sync.signOut(this) 329 invalidateOptionsMenu() 330 load() 331 true 332 } 333 R.id.preview_on -> { 334 settings.edit().putBoolean("preview", !previewOn).apply() 335 invalidateOptionsMenu() 336 show() 337 true 338 } 339 R.id.donate -> { 340 open(KOFI) 341 true 342 } 343 R.id.more_apps -> { 344 startActivity(Intent(this, MoreAppsActivity::class.java)) 345 true 346 } 347 R.id.open_file -> { 348 val pick = Intent(Intent.ACTION_OPEN_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType("*/*") 349 startActivityForResult(pick, CHOOSE) 350 true 351 } 352 R.id.new_file -> { 353 val make = Intent(Intent.ACTION_CREATE_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE) 354 .setType("text/plain").putExtra(Intent.EXTRA_TITLE, "bookmarks") 355 startActivityForResult(make, CHOOSE) 356 true 357 } 358 else -> super.onOptionsItemSelected(item) 359 } 360 361 override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { 362 val uri = data?.data 363 if (requestCode == CHOOSE && resultCode == RESULT_OK && uri != null) { 364 Store.choose(this, uri) 365 load() 366 } 367 } 368 369 private fun toast(text: String) = Toast.makeText(this, text, Toast.LENGTH_LONG).show() 370 371 private inner class Rows : BaseAdapter() { 372 var items: List<Bookmark> = emptyList() 373 374 override fun getCount() = items.size 375 override fun getItem(position: Int) = items[position] 376 override fun getItemId(position: Int) = position.toLong() 377 378 override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { 379 val view = convertView ?: layoutInflater.inflate(R.layout.item, parent, false) 380 val b = items[position] 381 view.findViewById<TextView>(R.id.title).text = b.desc.ifEmpty { b.url } 382 view.findViewById<TextView>(R.id.detail).text = 383 listOf(Tsv.host(b.url), b.tags.joinToString(" ")).filter { it.isNotEmpty() }.joinToString(" ยท ") 384 return view 385 } 386 } 387 388 private companion object { 389 const val CHOOSE = 1 390 const val PREVIEW_WAIT = 400L // ms 391 const val KOFI = "https://ko-fi.com/truex" 392 } 393 }