app/src/main/java/com/equwal/sbm/MainActivity.kt (10029 bytes)
1 package com.equwal.sbm 2 3 import android.app.Activity 4 import android.app.AlertDialog 5 import android.content.ActivityNotFoundException 6 import android.content.ClipData 7 import android.content.ClipboardManager 8 import android.content.Intent 9 import android.net.Uri 10 import android.os.Build 11 import android.os.Bundle 12 import android.text.Editable 13 import android.text.TextWatcher 14 import android.view.Menu 15 import android.view.MenuItem 16 import android.view.View 17 import android.view.ViewGroup 18 import android.view.WindowInsets 19 import android.view.inputmethod.EditorInfo 20 import android.widget.BaseAdapter 21 import android.widget.EditText 22 import android.widget.ListView 23 import android.widget.TextView 24 import android.widget.Toast 25 import android.widget.Toolbar 26 27 /** The list: search as you type, tap to open, press and hold for more. */ 28 class MainActivity : Activity() { 29 private lateinit var search: EditText 30 private lateinit var empty: TextView 31 private val rows = Rows() 32 private var all: List<Bookmark> = emptyList() 33 34 override fun onCreate(state: Bundle?) { 35 super.onCreate(state) 36 setContentView(R.layout.activity_main) 37 val toolbar = findViewById<Toolbar>(R.id.toolbar) 38 setActionBar(toolbar) 39 search = findViewById(R.id.search) 40 empty = findViewById(R.id.empty) 41 val list = findViewById<ListView>(R.id.list) 42 list.adapter = rows 43 list.emptyView = empty 44 45 // Android 15 and later draw the app under the system bars and the 46 // keyboard. The toolbar colour goes under the status bar, and the 47 // rest of the screen stays clear of the bars and the keyboard. 48 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { 49 findViewById<View>(R.id.root).setOnApplyWindowInsetsListener { v, insets -> 50 val bars = insets.getInsets(WindowInsets.Type.systemBars() or WindowInsets.Type.ime()) 51 toolbar.setPadding(0, bars.top, 0, 0) 52 v.setPadding(bars.left, 0, bars.right, bars.bottom) 53 insets 54 } 55 } 56 57 search.addTextChangedListener(object : TextWatcher { 58 override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} 59 override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} 60 override fun afterTextChanged(s: Editable?) = show() 61 }) 62 search.setOnEditorActionListener { _, action, _ -> 63 if (action == EditorInfo.IME_ACTION_GO) { 64 go() 65 true 66 } else { 67 false 68 } 69 } 70 list.setOnItemClickListener { _, _, position, _ -> open(rows.items[position].url) } 71 list.setOnItemLongClickListener { _, _, position, _ -> 72 more(rows.items[position]) 73 true 74 } 75 } 76 77 override fun onResume() { 78 super.onResume() 79 load() 80 if (Sync.signedIn(this)) sync(quiet = true) 81 } 82 83 /** Sync, then show the file again. A quiet sync reports only what the user must act on. */ 84 private fun sync(quiet: Boolean) { 85 Sync.run(this) { result -> 86 if (isDestroyed) return@run 87 result.onSuccess { changed -> 88 if (changed) load() 89 if (!quiet) toast(resources.getQuantityString(R.plurals.synced, all.size, all.size)) 90 }.onFailure { e -> 91 if (!quiet || (e is Sync.Failure && e.urgent)) toast(getString(R.string.sync_failed, e.message)) 92 if (e is Sync.Failure && e.signIn) invalidateOptionsMenu() 93 } 94 } 95 } 96 97 private fun showSignIn() { 98 val form = layoutInflater.inflate(R.layout.dialog_sign_in, null) 99 val server = form.findViewById<EditText>(R.id.server) 100 val email = form.findViewById<EditText>(R.id.email) 101 val password = form.findViewById<EditText>(R.id.password) 102 server.setText(Sync.server(this)) 103 email.setText(Sync.email(this)) 104 AlertDialog.Builder(this) 105 .setTitle(R.string.sign_in) 106 .setView(form) 107 .setNegativeButton(android.R.string.cancel, null) 108 .setPositiveButton(R.string.sign_in_button) { _, _ -> 109 toast(getString(R.string.signing_in)) 110 Sync.signIn(this, server.text.toString(), email.text.toString(), password.text.toString()) { result -> 111 if (isDestroyed) return@signIn 112 result.onSuccess { n -> 113 toast(resources.getQuantityString(R.plurals.signed_in, n, Sync.email(this), n)) 114 invalidateOptionsMenu() 115 load() 116 }.onFailure { e -> toast(getString(R.string.sync_failed, e.message)) } 117 } 118 } 119 .show() 120 } 121 122 private fun load() { 123 all = try { 124 // Newest first: bm adds bookmarks at the end of the file. 125 Tsv.parse(Store.read(this)).asReversed() 126 } catch (e: Exception) { 127 toast(getString(R.string.cannot_read, e.message)) 128 emptyList() 129 } 130 empty.setText(if (Store.file(this) == null) R.string.no_file else R.string.no_match) 131 show() 132 } 133 134 private fun show() { 135 rows.items = Fuzzy.filter(all, search.text.toString()) { "${it.desc} ${it.tags.joinToString(" ")} ${it.url}" } 136 rows.notifyDataSetChanged() 137 } 138 139 /** Go opens the first bookmark. Without one, the text is an address or a web search, as in bm. */ 140 private fun go() { 141 val first = rows.items.firstOrNull() 142 when { 143 first != null -> open(first.url) 144 search.text.isNotBlank() -> open(Tsv.target(search.text.toString())) 145 } 146 } 147 148 private fun open(url: String) { 149 try { 150 startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addCategory(Intent.CATEGORY_BROWSABLE)) 151 } catch (e: ActivityNotFoundException) { 152 toast(getString(R.string.no_app, url)) 153 } 154 } 155 156 private fun more(b: Bookmark) { 157 val actions = arrayOf(getString(R.string.open), getString(R.string.copy), getString(R.string.share)) 158 AlertDialog.Builder(this) 159 .setTitle(b.desc.ifEmpty { b.url }) 160 .setItems(actions) { _, which -> 161 when (which) { 162 0 -> open(b.url) 163 1 -> copy(b.url) 164 else -> share(b) 165 } 166 } 167 .show() 168 } 169 170 private fun copy(url: String) { 171 getSystemService(ClipboardManager::class.java).setPrimaryClip(ClipData.newPlainText("URL", url)) 172 // Android 13 and later show their own message. 173 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) toast(getString(R.string.copied)) 174 } 175 176 private fun share(b: Bookmark) { 177 val send = Intent(Intent.ACTION_SEND).setType("text/plain") 178 .putExtra(Intent.EXTRA_TEXT, b.url) 179 .putExtra(Intent.EXTRA_SUBJECT, b.desc) 180 startActivity(Intent.createChooser(send, null)) 181 } 182 183 override fun onCreateOptionsMenu(menu: Menu): Boolean { 184 menuInflater.inflate(R.menu.main, menu) 185 return true 186 } 187 188 override fun onPrepareOptionsMenu(menu: Menu): Boolean { 189 val on = Sync.signedIn(this) 190 menu.findItem(R.id.sync_now).isVisible = on 191 menu.findItem(R.id.sign_out).isVisible = on 192 menu.findItem(R.id.sign_in).isVisible = !on 193 // Google Play does not allow links to payments outside Google Play. 194 menu.findItem(R.id.donate).isVisible = BuildConfig.DONATE 195 return true 196 } 197 198 override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { 199 R.id.add -> { 200 startActivity(Intent(this, AddActivity::class.java)) 201 true 202 } 203 R.id.sync_now -> { 204 sync(quiet = false) 205 true 206 } 207 R.id.sign_in -> { 208 showSignIn() 209 true 210 } 211 R.id.sign_out -> { 212 Sync.signOut(this) 213 invalidateOptionsMenu() 214 load() 215 true 216 } 217 R.id.donate -> { 218 open(KOFI) 219 true 220 } 221 R.id.open_file -> { 222 val pick = Intent(Intent.ACTION_OPEN_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType("*/*") 223 startActivityForResult(pick, CHOOSE) 224 true 225 } 226 R.id.new_file -> { 227 val make = Intent(Intent.ACTION_CREATE_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE) 228 .setType("text/plain").putExtra(Intent.EXTRA_TITLE, "bookmarks") 229 startActivityForResult(make, CHOOSE) 230 true 231 } 232 else -> super.onOptionsItemSelected(item) 233 } 234 235 override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { 236 val uri = data?.data 237 if (requestCode == CHOOSE && resultCode == RESULT_OK && uri != null) { 238 Store.choose(this, uri) 239 load() 240 } 241 } 242 243 private fun toast(text: String) = Toast.makeText(this, text, Toast.LENGTH_LONG).show() 244 245 private inner class Rows : BaseAdapter() { 246 var items: List<Bookmark> = emptyList() 247 248 override fun getCount() = items.size 249 override fun getItem(position: Int) = items[position] 250 override fun getItemId(position: Int) = position.toLong() 251 252 override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { 253 val view = convertView ?: layoutInflater.inflate(R.layout.item, parent, false) 254 val b = items[position] 255 view.findViewById<TextView>(R.id.title).text = b.desc.ifEmpty { b.url } 256 view.findViewById<TextView>(R.id.detail).text = 257 listOf(Tsv.host(b.url), b.tags.joinToString(" ")).filter { it.isNotEmpty() }.joinToString(" ยท ") 258 return view 259 } 260 } 261 262 private companion object { 263 const val CHOOSE = 1 264 const val KOFI = "https://ko-fi.com/truex" 265 } 266 }