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


commit a6ec78bb8bbc724cad26d061a0291a862ede2d43
equwal <truex@equwal.com>
2026-09-22 20:17:56 -0700

A live preview of the first match above the list

While the user searches, the page of the first match shows in a web
view above the list. Press and hold a bookmark, then Preview, to show
another one. A tap on the address above the preview opens the page. The
page loads 400 ms after the first match stops changing, so fast typing
loads one page only. An http page loads with https, because the app
allows no cleartext traffic. Page preview in the menu turns the preview
off.

The page in the preview runs its scripts, as in a browser, but it
cannot take the focus from the search, open other apps or read the
files of the app. Its links stay in the preview. The web view is made
only when the preview first shows, so the app still starts fast. A page
that stops the renderer does not stop the app: the next preview uses a
new web view.

README and the store description tell that the preview uses the
network.

 README.md                                          |  12 +-
 app/src/main/java/com/equwal/sbm/MainActivity.kt   | 141 +++++++++++++++++++--
 app/src/main/java/com/equwal/sbm/Preview.kt        |  19 +++
 app/src/main/res/layout/activity_main.xml          |  25 ++++
 app/src/main/res/menu/main.xml                     |   5 +
 app/src/main/res/values/strings.xml                |   3 +
 app/src/test/java/com/equwal/sbm/PreviewTest.kt    |  41 ++++++
 .../metadata/android/en-US/full_description.txt    |   4 +-
 8 files changed, 235 insertions(+), 15 deletions(-)
diff --git a/README.md b/README.md
index 61d1b12..57e0a4c 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,10 @@ How sync looks between bm and the browser add-on:
 - **Search as you type**, with the fuzzy match of fzf. Go opens the first
   match. Text that is no bookmark opens as an address, or as a web search,
   as in bm.
+- **Live preview:** while you search, the page of the first match shows
+  above the list. Press and hold a bookmark, then Preview, to see another
+  one. Tap the address above the preview to open the page. An http page
+  shows with https. Turn the preview off with Page preview in the menu.
 - **Tap to open**, press and hold to copy or share.
 - **Share to sbm** from any browser to add the page.
 - **Sync** with bm on your computers and the sbm add-on for Firefox and
@@ -32,9 +36,11 @@ storage.
 
 ## Privacy
 
-The app has no ads, no analytics and no trackers. It uses the network only
-for sync, after you sign in, and only with the server that you choose. See
-the [privacy policy](https://sbm.subread.space/privacy).
+The app has no ads, no analytics and no trackers. It uses the network for
+two things only. Sync, after you sign in, and only with the server that you
+choose. And the live preview, which loads the page of a bookmark from its
+site while you search, as a browser does; Page preview in the menu turns it
+off. See the [privacy policy](https://sbm.subread.space/privacy).
 
 ## Build
 
diff --git a/app/src/main/java/com/equwal/sbm/MainActivity.kt b/app/src/main/java/com/equwal/sbm/MainActivity.kt
index 1b4bf24..7b76588 100644
--- a/app/src/main/java/com/equwal/sbm/MainActivity.kt
+++ b/app/src/main/java/com/equwal/sbm/MainActivity.kt
@@ -1,5 +1,6 @@
 package com.equwal.sbm
 
+import android.annotation.SuppressLint
 import android.app.Activity
 import android.app.AlertDialog
 import android.content.ActivityNotFoundException
@@ -9,6 +10,8 @@ import android.content.Intent
 import android.net.Uri
 import android.os.Build
 import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
 import android.text.Editable
 import android.text.TextWatcher
 import android.view.Menu
@@ -17,19 +20,34 @@ import android.view.View
 import android.view.ViewGroup
 import android.view.WindowInsets
 import android.view.inputmethod.EditorInfo
+import android.webkit.RenderProcessGoneDetail
+import android.webkit.WebResourceRequest
+import android.webkit.WebView
+import android.webkit.WebViewClient
 import android.widget.BaseAdapter
 import android.widget.EditText
+import android.widget.LinearLayout
 import android.widget.ListView
 import android.widget.TextView
 import android.widget.Toast
 import android.widget.Toolbar
 
-/** The list: search as you type, tap to open, press and hold for more. */
+/**
+ * The list: search as you type, tap to open, press and hold for more. During
+ * a search, the page of the first match shows live above the list.
+ */
 class MainActivity : Activity() {
     private lateinit var search: EditText
     private lateinit var empty: TextView
+    private lateinit var preview: LinearLayout
+    private lateinit var caption: TextView
     private val rows = Rows()
     private var all: List<Bookmark> = emptyList()
+    private var page: WebView? = null // made when the preview first shows
+    private var previewed: Bookmark? = null // the bookmark in the preview
+    private var picked: Bookmark? = null // the bookmark that the user picked with Preview
+    private var framed: String? = null // the address in the web view
+    private val later = Handler(Looper.getMainLooper())
 
     override fun onCreate(state: Bundle?) {
         super.onCreate(state)
@@ -38,6 +56,9 @@ class MainActivity : Activity() {
         setActionBar(toolbar)
         search = findViewById(R.id.search)
         empty = findViewById(R.id.empty)
+        preview = findViewById(R.id.preview)
+        caption = findViewById(R.id.caption)
+        caption.setOnClickListener { previewed?.let { open(it.url) } }
         val list = findViewById<ListView>(R.id.list)
         list.adapter = rows
         list.emptyView = empty
@@ -57,7 +78,10 @@ class MainActivity : Activity() {
         search.addTextChangedListener(object : TextWatcher {
             override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
             override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
-            override fun afterTextChanged(s: Editable?) = show()
+            override fun afterTextChanged(s: Editable?) {
+                picked = null
+                show()
+            }
         })
         search.setOnEditorActionListener { _, action, _ ->
             if (action == EditorInfo.IME_ACTION_GO) {
@@ -76,10 +100,22 @@ class MainActivity : Activity() {
 
     override fun onResume() {
         super.onResume()
+        page?.onResume()
         load()
         if (Sync.signedIn(this)) sync(quiet = true)
     }
 
+    override fun onPause() {
+        page?.onPause()
+        super.onPause()
+    }
+
+    override fun onDestroy() {
+        later.removeCallbacksAndMessages(null)
+        page?.destroy()
+        super.onDestroy()
+    }
+
     /** Sync, then show the file again. A quiet sync reports only what the user must act on. */
     private fun sync(quiet: Boolean) {
         Sync.run(this) { result ->
@@ -134,6 +170,77 @@ class MainActivity : Activity() {
     private fun show() {
         rows.items = Fuzzy.filter(all, search.text.toString()) { "${it.desc} ${it.tags.joinToString(" ")} ${it.url}" }
         rows.notifyDataSetChanged()
+        showPage(picked ?: if (search.text.isBlank()) null else rows.items.firstOrNull())
+    }
+
+    private val settings get() = getSharedPreferences("settings", MODE_PRIVATE)
+    private val previewOn get() = settings.getBoolean("preview", true)
+
+    /**
+     * Show the page of [b] in the preview, or close the preview for null. The
+     * page loads only when [b] stays in the preview for a moment, so that
+     * fast typing does not load a page for each letter.
+     */
+    private fun showPage(b: Bookmark?) {
+        later.removeCallbacksAndMessages(null)
+        previewed = if (previewOn) b else null
+        val shown = previewed
+        if (shown == null) {
+            preview.visibility = View.GONE
+            loadPage(null)
+            return
+        }
+        preview.visibility = View.VISIBLE
+        val to = Preview.address(shown.url)
+        caption.text = to ?: getString(R.string.no_preview, shown.url)
+        if (to == null) loadPage(null) else later.postDelayed({ loadPage(to) }, PREVIEW_WAIT)
+    }
+
+    /** Load the page at [to] in the preview, or an empty page for null. */
+    private fun loadPage(to: String?) {
+        if (to == framed) return
+        framed = to
+        val view = page ?: if (to == null) return else newPage()
+        view.loadUrl(to ?: "about:blank")
+    }
+
+    /**
+     * A web view for the preview, under the caption. The page in it is only
+     * to look at: it runs its scripts, as in a browser, but it cannot take
+     * the focus from the search, open other apps or read the files of the
+     * app. Its links stay in the preview, and an http link loads with https.
+     */
+    @SuppressLint("SetJavaScriptEnabled") // Most pages need scripts to show, and the page gets no interface to the app.
+    private fun newPage(): WebView {
+        val view = WebView(this)
+        view.isFocusable = false
+        view.isFocusableInTouchMode = false
+        view.settings.javaScriptEnabled = true
+        view.settings.domStorageEnabled = true
+        view.settings.allowFileAccess = false
+        view.settings.allowContentAccess = false
+        view.webViewClient = object : WebViewClient() {
+            override fun shouldOverrideUrlLoading(v: WebView, request: WebResourceRequest): Boolean {
+                val url = request.url.toString()
+                val to = Preview.address(url) ?: return true
+                if (to == url) return false
+                v.loadUrl(to)
+                return true
+            }
+
+            // A page that stops the renderer must not stop the app: a new
+            // web view takes the place of the old one.
+            override fun onRenderProcessGone(v: WebView, detail: RenderProcessGoneDetail): Boolean {
+                preview.removeView(v)
+                v.destroy()
+                if (page === v) page = null
+                framed = null
+                return true
+            }
+        }
+        preview.addView(view, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f))
+        page = view
+        return view
     }
 
     /** Go opens the first bookmark. Without one, the text is an address or a web search, as in bm. */
@@ -154,16 +261,22 @@ class MainActivity : Activity() {
     }
 
     private fun more(b: Bookmark) {
-        val actions = arrayOf(getString(R.string.open), getString(R.string.copy), getString(R.string.share))
+        val actions = listOfNotNull(
+            getString(R.string.open) to { open(b.url) },
+            if (previewOn) {
+                getString(R.string.preview) to {
+                    picked = b
+                    show()
+                }
+            } else {
+                null
+            },
+            getString(R.string.copy) to { copy(b.url) },
+            getString(R.string.share) to { share(b) },
+        )
         AlertDialog.Builder(this)
             .setTitle(b.desc.ifEmpty { b.url })
-            .setItems(actions) { _, which ->
-                when (which) {
-                    0 -> open(b.url)
-                    1 -> copy(b.url)
-                    else -> share(b)
-                }
-            }
+            .setItems(actions.map { it.first }.toTypedArray()) { _, which -> actions[which].second() }
             .show()
     }
 
@@ -190,6 +303,7 @@ class MainActivity : Activity() {
         menu.findItem(R.id.sync_now).isVisible = on
         menu.findItem(R.id.sign_out).isVisible = on
         menu.findItem(R.id.sign_in).isVisible = !on
+        menu.findItem(R.id.preview_on).isChecked = previewOn
         // Google Play does not allow links to payments outside Google Play.
         menu.findItem(R.id.donate).isVisible = BuildConfig.DONATE
         return true
@@ -214,6 +328,12 @@ class MainActivity : Activity() {
             load()
             true
         }
+        R.id.preview_on -> {
+            settings.edit().putBoolean("preview", !previewOn).apply()
+            invalidateOptionsMenu()
+            show()
+            true
+        }
         R.id.donate -> {
             open(KOFI)
             true
@@ -261,6 +381,7 @@ class MainActivity : Activity() {
 
     private companion object {
         const val CHOOSE = 1
+        const val PREVIEW_WAIT = 400L // ms
         const val KOFI = "https://ko-fi.com/truex"
     }
 }
diff --git a/app/src/main/java/com/equwal/sbm/Preview.kt b/app/src/main/java/com/equwal/sbm/Preview.kt
new file mode 100644
index 0000000..945a96f
--- /dev/null
+++ b/app/src/main/java/com/equwal/sbm/Preview.kt
@@ -0,0 +1,19 @@
+package com.equwal.sbm
+
+/**
+ * The live preview: the page of a bookmark, shown under the search. This
+ * file has no Android code, so the tests run on any JVM.
+ */
+object Preview {
+    private val web = Regex("^(?i)https?://([^/?#\\s]+)(.*)$")
+
+    /**
+     * The address that the preview loads for the URL of a bookmark, or null
+     * when the page cannot show. Only web pages show. An http page loads with
+     * https, because the app allows no cleartext traffic.
+     */
+    fun address(url: String): String? {
+        val m = web.matchEntire(url.trim()) ?: return null
+        return "https://" + m.groupValues[1] + m.groupValues[2]
+    }
+}
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 804ed4d..60a7fbd 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -25,6 +25,31 @@
         android:inputType="text|textNoSuggestions"
         android:singleLine="true" />
 
+    <!-- The live preview: the page of the first match, or of the bookmark
+         that the user picks with Preview. It shows only during a search.
+         MainActivity adds the web view under the caption when it is first
+         necessary, so that the app starts fast. -->
+    <LinearLayout
+        android:id="@+id/preview"
+        android:layout_width="match_parent"
+        android:layout_height="0dp"
+        android:layout_weight="1"
+        android:orientation="vertical"
+        android:visibility="gone">
+
+        <TextView
+            android:id="@+id/caption"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:background="?android:attr/selectableItemBackground"
+            android:ellipsize="middle"
+            android:paddingHorizontal="16dp"
+            android:paddingVertical="6dp"
+            android:singleLine="true"
+            android:textAppearance="?android:attr/textAppearanceSmall"
+            android:textColor="?android:attr/textColorSecondary" />
+    </LinearLayout>
+
     <FrameLayout
         android:layout_width="match_parent"
         android:layout_height="0dp"
diff --git a/app/src/main/res/menu/main.xml b/app/src/main/res/menu/main.xml
index ae31135..e8e11cc 100644
--- a/app/src/main/res/menu/main.xml
+++ b/app/src/main/res/menu/main.xml
@@ -13,6 +13,11 @@
         android:id="@+id/sign_in"
         android:showAsAction="never"
         android:title="@string/sign_in" />
+    <item
+        android:id="@+id/preview_on"
+        android:checkable="true"
+        android:showAsAction="never"
+        android:title="@string/preview_on" />
     <item
         android:id="@+id/open_file"
         android:showAsAction="never"
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 946e080..6db0a41 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -6,6 +6,9 @@
     <string name="no_file">Sign in to sync with the menu. Or open your sbm bookmark file: Open file. To start a new file, use New file.</string>
     <string name="no_match">No bookmark matches. Press Go to search the web.</string>
     <string name="open">Open</string>
+    <string name="preview">Preview</string>
+    <string name="preview_on">Page preview</string>
+    <string name="no_preview">No preview: %1$s is not a web page</string>
     <string name="copy">Copy the URL</string>
     <string name="share">Share</string>
     <string name="copied">The URL is copied</string>
diff --git a/app/src/test/java/com/equwal/sbm/PreviewTest.kt b/app/src/test/java/com/equwal/sbm/PreviewTest.kt
new file mode 100644
index 0000000..15cc0b4
--- /dev/null
+++ b/app/src/test/java/com/equwal/sbm/PreviewTest.kt
@@ -0,0 +1,41 @@
+package com.equwal.sbm
+
+import net.jqwik.api.Arbitraries
+import net.jqwik.api.Arbitrary
+import net.jqwik.api.Combinators
+import net.jqwik.api.Example
+import net.jqwik.api.ForAll
+import net.jqwik.api.Property
+import net.jqwik.api.Provide
+
+class PreviewTest {
+    // The part of a web address after the scheme: a host, then a path.
+    @Provide
+    fun rests(): Arbitrary<String> {
+        val host = Arbitraries.strings().withCharRange('a', 'z').withChars('.', '-', '0', '9').ofMinLength(1).ofMaxLength(20)
+        val path = Arbitraries.strings().withCharRange('!', '~').ofMaxLength(40)
+            .map { if (it.isEmpty() || it[0] in "/?#") it else "/$it" }
+        return Combinators.combine(host, path).`as` { h, p -> h + p }
+    }
+
+    @Provide
+    fun schemes(): Arbitrary<String> = Arbitraries.of("http://", "https://", "HTTP://", "Https://")
+
+    @Property
+    fun `a web page shows at its address with https`(@ForAll("schemes") scheme: String, @ForAll("rests") rest: String): Boolean =
+        Preview.address(scheme + rest) == "https://$rest"
+
+    @Property
+    fun `the preview only ever loads https addresses, and loads them as they are`(@ForAll text: String): Boolean {
+        val a = Preview.address(text) ?: return true
+        return a.startsWith("https://") && Preview.address(a) == a
+    }
+
+    @Example
+    fun `addresses that are no web page have no preview`() {
+        for (u in listOf("javascript:alert(1)", "data:text/html,x", "file:///etc/passwd", "intent://x#Intent;end",
+            "market://details?id=x", "ftp://example.org/", "about:blank", "example.org", "", "https://", "http:///x")) {
+            check(Preview.address(u) == null) { u }
+        }
+    }
+}
diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt
index 93ef36c..be754eb 100644
--- a/fastlane/metadata/android/en-US/full_description.txt
+++ b/fastlane/metadata/android/en-US/full_description.txt
@@ -1,7 +1,7 @@
 sbm keeps your bookmarks in one plain text file, one bookmark per line: the address, a description and tags. This app brings that file to your phone.
 
 <b>Find a bookmark fast</b>
-Type a few letters. The search is fuzzy, as in fzf: the letters of each word must come in order, but not always together. Tap a bookmark to open it; press and hold to copy the address or share it. Text that is no bookmark opens as an address, or as a web search.
+Type a few letters. The search is fuzzy, as in fzf: the letters of each word must come in order, but not always together. The page of the first match shows live above the list. Tap a bookmark to open it; press and hold to preview it, copy the address or share it. Text that is no bookmark opens as an address, or as a web search.
 
 <b>Add from any app</b>
 Share a page from your browser to sbm, add a description and tags, and save.
@@ -12,6 +12,6 @@ The server is free software, so you can run your own. Sync on the default server
 Or do not sign in: open a bookmark file that Syncthing, git or a file app keeps on your phone.
 
 <b>Small and private</b>
-No ads, no analytics, no trackers, no Google libraries. The app uses the network only for sync, and only after you sign in.
+No ads, no analytics, no trackers, no Google libraries. The app uses the network only for sync, after you sign in, and for the live preview, which loads the page of a bookmark from its site. Page preview in the menu turns the preview off.
 
 sbm is free software under the AGPL-3.0.