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 (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 }