Recently Written · git

sbm-android

sbm bookmarks for Android: fuzzy search, share to add, sync with bm and the browser add-on

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

Log | Files | Refs


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 }