瀏覽代碼

Android native UX (#12254)

feat: android native UX
Tienson Qin 1 周之前
父節點
當前提交
f910fcfea8
共有 32 個文件被更改,包括 2791 次插入131 次删除
  1. 19 0
      android/app/build.gradle
  2. 1 1
      android/app/src/main/AndroidManifest.xml
  3. 302 0
      android/app/src/main/java/com/logseq/app/ComposeHost.kt
  4. 534 0
      android/app/src/main/java/com/logseq/app/LiquidTabsPlugin.kt
  5. 44 0
      android/app/src/main/java/com/logseq/app/LogseqTheme.kt
  6. 129 3
      android/app/src/main/java/com/logseq/app/MainActivity.java
  7. 85 0
      android/app/src/main/java/com/logseq/app/MaterialIconResolver.kt
  8. 182 0
      android/app/src/main/java/com/logseq/app/NativeBottomSheetPlugin.kt
  9. 263 0
      android/app/src/main/java/com/logseq/app/NativeEditorToolbarPlugin.kt
  10. 279 0
      android/app/src/main/java/com/logseq/app/NativeSelectionActionBarPlugin.kt
  11. 331 0
      android/app/src/main/java/com/logseq/app/NativeTopBarPlugin.kt
  12. 31 0
      android/app/src/main/java/com/logseq/app/NativeUiUtils.kt
  13. 74 0
      android/app/src/main/java/com/logseq/app/NavigationCoordinator.kt
  14. 61 6
      android/app/src/main/java/com/logseq/app/UILocal.kt
  15. 38 0
      android/app/src/main/java/com/logseq/app/Utils.kt
  16. 185 0
      android/app/src/main/java/com/logseq/app/WebViewSnapshotManager.kt
  17. 3 1
      android/app/src/main/res/values/colors.xml
  18. 5 0
      android/app/src/main/res/values/ids.xml
  19. 1 0
      android/app/src/main/res/values/styles.xml
  20. 5 3
      android/build.gradle
  21. 4 0
      android/variables.gradle
  22. 36 13
      src/main/frontend/mobile/util.cljs
  23. 4 4
      src/main/frontend/state.cljs
  24. 93 67
      src/main/mobile/bottom_tabs.cljs
  25. 12 4
      src/main/mobile/components/app.cljs
  26. 6 0
      src/main/mobile/components/app.css
  27. 7 9
      src/main/mobile/components/editor_toolbar.cljs
  28. 12 4
      src/main/mobile/components/header.cljs
  29. 7 9
      src/main/mobile/components/popup.cljs
  30. 3 2
      src/main/mobile/components/selection_toolbar.cljs
  31. 0 3
      src/main/mobile/init.cljs
  32. 35 2
      src/main/mobile/navigation.cljs

+ 19 - 0
android/app/build.gradle

@@ -2,6 +2,7 @@ apply plugin: 'com.android.application'
 
 apply from: 'capacitor.build.gradle'
 apply plugin: 'kotlin-android'
+apply plugin: 'org.jetbrains.kotlin.plugin.compose'
 
 android {
     namespace "com.logseq.app"
@@ -26,6 +27,14 @@ android {
         }
     }
 
+    buildFeatures {
+        compose true
+    }
+
+    composeOptions {
+        kotlinCompilerExtensionVersion rootProject.ext.composeCompilerVersion
+    }
+
     kotlinOptions {
         jvmTarget = '21'
     }
@@ -42,6 +51,16 @@ dependencies {
     implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
     implementation fileTree(include: ['*.jar'], dir: 'libs')
     implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
+    implementation platform("androidx.compose:compose-bom:$composeBomVersion")
+    implementation "androidx.activity:activity-compose:$androidxActivityVersion"
+    implementation "androidx.compose.ui:ui"
+    implementation "androidx.compose.foundation:foundation"
+    implementation "androidx.compose.material3:material3"
+    implementation "androidx.compose.material:material-icons-extended"
+    implementation "androidx.compose.runtime:runtime"
+    implementation "androidx.compose.animation:animation"
+    implementation "androidx.navigation:navigation-compose:$androidxNavigationVersion"
+    implementation "com.google.android.material:material:$materialVersion"
     implementation project(':capacitor-android')
     implementation 'androidx.documentfile:documentfile:1.0.1'
     testImplementation "junit:junit:$junitVersion"

+ 1 - 1
android/app/src/main/AndroidManifest.xml

@@ -24,7 +24,7 @@
             android:name="com.logseq.app.MainActivity"
             android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
             android:exported="true"
-            android:windowSoftInputMode="adjustNothing"
+            android:windowSoftInputMode="adjustResize"
             android:label="@string/title_activity_main"
             android:launchMode="singleTask"
             android:theme="@style/AppTheme.NoActionBarLaunch">

+ 302 - 0
android/app/src/main/java/com/logseq/app/ComposeHost.kt

@@ -0,0 +1,302 @@
+package com.logseq.app
+
+import android.graphics.Color
+import android.app.Activity
+import android.net.Uri
+import android.util.Log
+import android.view.ViewGroup
+import android.webkit.WebView
+import android.widget.FrameLayout
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.ExperimentalAnimationApi
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.systemBars
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.ViewCompositionStrategy
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.navigation.NavHostController
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import androidx.navigation.navArgument
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+private const val ROOT_ROUTE = "web/{encodedPath}"
+
+data class NavigationEvent(
+    val navigationType: String,
+    val path: String
+)
+
+/**
+ * Hosts the existing WebView inside Compose and drives Compose Navigation
+ * so we get back gestures/animations while delegating actual routing to the JS layer.
+ */
+object ComposeHost {
+    private val navEvents = MutableSharedFlow<NavigationEvent>(extraBufferCapacity = 64)
+
+    fun applyNavigation(navigationType: String?, path: String?) {
+        val type = (navigationType ?: "push").lowercase()
+        val safePath = path?.takeIf { it.isNotBlank() } ?: "/"
+        navEvents.tryEmit(NavigationEvent(type, safePath))
+    }
+
+    fun renderWithSystemInsets(
+        activity: Activity,
+        webView: WebView,
+        onBackRequested: () -> Unit,
+        onExit: () -> Unit = { activity.finish() }
+    ) {
+        WebViewSnapshotManager.registerWindow(activity.window)
+        val root = activity.findViewById<FrameLayout>(android.R.id.content)
+
+        // WebView already created by BridgeActivity; just reparent it into Compose.
+        (webView.parent as? ViewGroup)?.removeView(webView)
+
+        val composeView = ComposeView(activity).apply {
+            tag = "compose-host-webview"
+            setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
+            setContent {
+                ComposeNavigationHost(
+                    navEvents = navEvents,
+                    webView = webView,
+                    onBackRequested = onBackRequested,
+                    onExit = onExit
+                )
+            }
+        }
+
+        if (root.findViewWithTag<ComposeView>("compose-host-webview") == null) {
+            root.addView(
+                composeView,
+                FrameLayout.LayoutParams(
+                    FrameLayout.LayoutParams.MATCH_PARENT,
+                    FrameLayout.LayoutParams.MATCH_PARENT
+                )
+            )
+        }
+    }
+}
+
+private fun encodePath(path: String): String =
+    Uri.encode(if (path.isBlank()) "/" else path)
+
+private fun routeFor(path: String): String =
+    "web/${encodePath(path)}"
+
+@OptIn(ExperimentalAnimationApi::class)
+@Composable
+private fun ComposeNavigationHost(
+    navEvents: SharedFlow<NavigationEvent>,
+    webView: WebView,
+    onBackRequested: () -> Unit,
+    onExit: () -> Unit
+) {
+    val navController = rememberNavController()
+
+    // Track the last navigation type so we can change slide direction.
+    val lastNavTypeState = remember { mutableStateOf("push") }
+
+    HandleNavigationEvents(
+        navController = navController,
+        navEvents = navEvents,
+        webView = webView
+    ) { type ->
+        lastNavTypeState.value = type
+    }
+
+    // You can comment this out if you want to rely purely on JS for back.
+
+    NavHost(
+        navController = navController,
+        startDestination = ROOT_ROUTE,
+        modifier = Modifier
+            .fillMaxSize()
+            .padding(WindowInsets.systemBars.asPaddingValues())
+    ) {
+        composable(
+            route = ROOT_ROUTE,
+            arguments = listOf(
+                navArgument("encodedPath") {
+                    defaultValue = encodePath("/")
+                }
+            ),
+            // ---- PUSH: A -> B ----
+            enterTransition = {
+                if (lastNavTypeState.value == "pop") {
+                    slideInHorizontally(
+                        initialOffsetX = { fullWidth -> -fullWidth / 3 },
+                        animationSpec = tween(220)
+                    ) + fadeIn(animationSpec = tween(180))
+                } else {
+                    slideInHorizontally(
+                        initialOffsetX = { fullWidth -> fullWidth },
+                        animationSpec = tween(220)
+                    ) + fadeIn(animationSpec = tween(180))
+                }
+            },
+            exitTransition = {
+                if (lastNavTypeState.value == "pop") {
+                    slideOutHorizontally(
+                        targetOffsetX = { fullWidth -> fullWidth },
+                        animationSpec = tween(200)
+                    ) + fadeOut(animationSpec = tween(160))
+                } else {
+                    slideOutHorizontally(
+                        targetOffsetX = { fullWidth -> -fullWidth / 4 },
+                        animationSpec = tween(220)
+                    ) + fadeOut(animationSpec = tween(180))
+                }
+            },
+            // ---- POP: B -> A ----
+            popEnterTransition = {
+                slideInHorizontally(
+                    initialOffsetX = { fullWidth -> -fullWidth / 4 },
+                    animationSpec = tween(200)
+                ) + fadeIn(animationSpec = tween(160))
+            },
+            popExitTransition = {
+                slideOutHorizontally(
+                    targetOffsetX = { fullWidth -> fullWidth },
+                    animationSpec = tween(200)
+                ) + fadeOut(animationSpec = tween(160))
+            }
+        ) {
+            AndroidView(
+                factory = { context ->
+                    FrameLayout(context).apply {
+                        layoutParams = FrameLayout.LayoutParams(
+                            FrameLayout.LayoutParams.MATCH_PARENT,
+                            FrameLayout.LayoutParams.MATCH_PARENT
+                        )
+
+                        val webContainer = FrameLayout(context).apply {
+                            id = R.id.webview_container
+                            layoutParams = FrameLayout.LayoutParams(
+                                FrameLayout.LayoutParams.MATCH_PARENT,
+                                FrameLayout.LayoutParams.MATCH_PARENT
+                            )
+                        }
+
+                        val overlayContainer = FrameLayout(context).apply {
+                            id = R.id.webview_overlay_container
+                            layoutParams = FrameLayout.LayoutParams(
+                                FrameLayout.LayoutParams.MATCH_PARENT,
+                                FrameLayout.LayoutParams.MATCH_PARENT
+                            )
+                            isClickable = false
+                            isFocusable = false
+                        }
+
+                        overlayContainer.setBackgroundColor(Color.TRANSPARENT)
+                        overlayContainer.alpha = 1f
+                        overlayContainer.visibility = android.view.View.GONE
+
+                        addView(webContainer)
+                        addView(overlayContainer)
+
+                        WebViewSnapshotManager.registerOverlay(overlayContainer)
+
+                        (webView.parent as? ViewGroup)?.removeView(webView)
+                        webContainer.addView(
+                            webView,
+                            FrameLayout.LayoutParams(
+                                FrameLayout.LayoutParams.MATCH_PARENT,
+                                FrameLayout.LayoutParams.MATCH_PARENT
+                            )
+                        )
+                    }
+                },
+                modifier = Modifier.fillMaxSize(),
+                update = { root ->
+                    val webContainer =
+                        root.findViewById<FrameLayout>(R.id.webview_container)
+                    val overlayContainer =
+                        root.findViewById<FrameLayout>(R.id.webview_overlay_container)
+                    WebViewSnapshotManager.registerOverlay(overlayContainer)
+                    if (webView.parent !== webContainer) {
+                        (webView.parent as? ViewGroup)?.removeView(webView)
+                        webContainer.addView(
+                            webView,
+                            FrameLayout.LayoutParams(
+                                FrameLayout.LayoutParams.MATCH_PARENT,
+                                FrameLayout.LayoutParams.MATCH_PARENT
+                            )
+                        )
+                    }
+                }
+            )
+        }
+    }
+}
+
+@Composable
+private fun HandleNavigationEvents(
+    navController: NavHostController,
+    navEvents: SharedFlow<NavigationEvent>,
+    webView: WebView,
+    onNavType: (String) -> Unit
+) {
+    LaunchedEffect(navController) {
+        var snapshotVersion = 0
+        navEvents.collect { event ->
+            snapshotVersion += 1
+            val currentSnapshotVersion = snapshotVersion
+            WebViewSnapshotManager.showSnapshot("navigation", webView)
+            onNavType(event.navigationType)
+            val route = routeFor(event.path)
+            when (event.navigationType) {
+                "push" -> navController.navigate(route)
+
+                "replace" -> {
+                    navController.popBackStack()
+                    navController.navigate(route) {
+                        launchSingleTop = true
+                    }
+                }
+
+                "pop" -> {
+                    if (!navController.popBackStack()) {
+                        // Already at root; nothing to pop.
+                    }
+                }
+
+                "reset" -> {
+                    navController.popBackStack(route = ROOT_ROUTE, inclusive = false)
+                    navController.navigate(route) {
+                        popUpTo(ROOT_ROUTE) {
+                            inclusive = true
+                        }
+                        launchSingleTop = true
+                    }
+                }
+
+                else -> navController.navigate(route)
+            }
+
+            launch {
+                delay(260)
+                if (currentSnapshotVersion == snapshotVersion) {
+                    WebViewSnapshotManager.clearSnapshot("navigation")
+                }
+            }
+        }
+    }
+}

+ 534 - 0
android/app/src/main/java/com/logseq/app/LiquidTabsPlugin.kt

@@ -0,0 +1,534 @@
+package com.logseq.app
+
+import android.graphics.Color
+import android.text.Editable
+import android.text.TextWatcher
+import android.view.Gravity
+import android.view.KeyEvent
+import android.view.View
+import android.view.ViewGroup
+import android.widget.EditText
+import android.widget.FrameLayout
+import android.widget.LinearLayout
+import android.widget.ScrollView
+import android.widget.TextView
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Circle
+import androidx.compose.material3.Icon
+import androidx.compose.material3.NavigationBar
+import androidx.compose.material3.NavigationBarItem
+import androidx.compose.material3.NavigationBarItemDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.ui.graphics.Color as ComposeColor
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.ViewCompositionStrategy
+import androidx.compose.ui.unit.dp // New Import for DP units
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.doOnNextLayout
+import com.getcapacitor.JSArray
+import com.getcapacitor.JSObject
+import com.getcapacitor.Plugin
+import com.getcapacitor.PluginCall
+import com.getcapacitor.PluginMethod
+import com.getcapacitor.annotation.CapacitorPlugin
+
+// NOTE: NativeUiUtils and MaterialIconResolver are assumed to be defined elsewhere in your project
+// and are necessary for this code to compile.
+
+@CapacitorPlugin(name = "LiquidTabsPlugin")
+class LiquidTabsPlugin : Plugin() {
+    private var bottomNav: ComposeView? = null
+    private var searchContainer: LinearLayout? = null
+    private var searchInput: EditText? = null
+    private var resultsContainer: LinearLayout? = null
+    private var closeButton: TextView? = null
+    private var originalBottomPadding: Int? = null
+
+    private var tabsState by mutableStateOf<List<TabSpec>>(emptyList())
+    private var currentTabId by mutableStateOf<String?>(null)
+
+    // Define a standard horizontal padding for consistency
+    private val HORIZONTAL_PADDING_DP = 16f
+    private val VERTICAL_PADDING_DP = 12f
+    private val RESULT_ROW_VERTICAL_PADDING_DP = 10f
+
+    // 💡 NEW: Define padding for the Tab Bar edges (makes it compact and adds left/right space)
+    private val TAB_BAR_HORIZONTAL_PADDING = 12.dp
+    private val ACCENT_COLOR_HEX = "#6097c7"
+
+    @PluginMethod
+    fun configureTabs(call: PluginCall) {
+        val activity = activity ?: run {
+            call.reject("No activity")
+            return
+        }
+        val tabs = parseTabs(call.getArray("tabs"))
+
+        activity.runOnUiThread {
+            tabsState = tabs
+            val activeId = currentTabId?.takeIf { id -> tabs.any { it.id == id } }
+                ?: tabs.firstOrNull()?.id
+            currentTabId = activeId
+            ensureNav()
+            currentTabId?.let { id ->
+                tabsState.find { it.id == id }?.let { tab ->
+                    handleSelection(tab, reselected = false)
+                }
+            } ?: hideSearchUi()
+            adjustWebViewPadding()
+            call.resolve()
+        }
+    }
+
+    @PluginMethod
+    fun selectTab(call: PluginCall) {
+        val id = call.getString("id") ?: run {
+            call.reject("Missing id")
+            return
+        }
+        val tab = tabsState.find { it.id == id }
+        if (tab == null) {
+            call.resolve()
+            return
+        }
+        val nav = bottomNav
+        if (nav == null) {
+            call.resolve()
+            return
+        }
+        nav.post {
+            val reselected = currentTabId == tab.id
+            handleSelection(tab, reselected)
+            call.resolve()
+        }
+    }
+
+    @PluginMethod
+    fun updateNativeSearchResults(call: PluginCall) {
+        val results = parseResults(call.getArray("results"))
+        activity?.runOnUiThread {
+            ensureSearchUi()
+            val container = resultsContainer ?: return@runOnUiThread
+            container.removeAllViews()
+            results.forEach { result ->
+                container.addView(makeResultRow(result))
+            }
+            call.resolve()
+        } ?: call.resolve()
+    }
+
+    /**
+     * FIX: Allows the web view to explicitly show the search UI again,
+     * typically after backing out of an opened search result item.
+     */
+    @PluginMethod
+    fun showSearchUiNative(call: PluginCall) {
+        activity?.runOnUiThread {
+            showSearchUi()
+            // Ensure padding is correct when search UI is manually shown
+            adjustWebViewPadding()
+            call.resolve()
+        } ?: call.resolve()
+    }
+
+
+    private fun ensureNav(): ComposeView {
+        val activity = activity ?: throw IllegalStateException("No activity")
+        val root = NativeUiUtils.contentRoot(activity)
+        val nav = bottomNav ?: ComposeView(activity).also { view ->
+            view.id = R.id.liquid_tabs_bottom_nav // Assuming R.id.liquid_tabs_bottom_nav is defined
+            view.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
+            view.layoutParams = FrameLayout.LayoutParams(
+                ViewGroup.LayoutParams.MATCH_PARENT,
+                ViewGroup.LayoutParams.WRAP_CONTENT,
+                Gravity.BOTTOM
+            )
+            view.setBackgroundColor(LogseqTheme.current().background)
+            bottomNav = view
+            root.addView(view)
+            setupImeBehaviorForNav(view)
+        }
+
+        nav.setContent {
+            BottomNavBar(
+                tabs = tabsState,
+                currentId = currentTabId,
+                onSelect = { tab ->
+                    val reselected = tab.id == currentTabId
+                    handleSelection(tab, reselected)
+                }
+            )
+        }
+
+        nav.doOnNextLayout { adjustWebViewPadding() }
+        return nav
+    }
+
+    private fun handleSelection(tab: TabSpec, reselected: Boolean) {
+        currentTabId = tab.id
+        if (tab.role == "search") {
+            showSearchUi()
+        } else {
+            hideSearchUi()
+        }
+
+        notifyListeners("tabSelected", JSObject().put("id", tab.id).put("reselected", reselected))
+    }
+
+    private fun adjustWebViewPadding() {
+        val webView = bridge.webView ?: return
+        val nav = bottomNav ?: return
+        if (originalBottomPadding == null) {
+            originalBottomPadding = webView.paddingBottom
+        }
+        nav.post {
+            val padding = originalBottomPadding ?: 0
+            val h = nav.height
+            val newPadding = if (searchContainer?.visibility == View.VISIBLE) {
+                padding
+            } else {
+                padding + h
+            }
+            webView.setPadding(webView.paddingLeft, webView.paddingTop, webView.paddingRight, newPadding)
+        }
+    }
+
+    private fun setupImeBehaviorForNav(nav: View) {
+        ViewCompat.setOnApplyWindowInsetsListener(nav) { v, insets ->
+            val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime())
+            val imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime())
+
+            val extra = if (imeVisible) {
+                imeInsets.bottom
+            } else {
+                0
+            }
+            v.translationY = extra.toFloat()
+            insets
+        }
+
+        ViewCompat.requestApplyInsets(nav)
+    }
+
+    private fun ensureSearchUi() {
+        if (searchContainer != null) return
+        showSearchUi()
+    }
+
+    private fun showSearchUi() {
+        val activity = activity ?: return
+        val root = NativeUiUtils.contentRoot(activity)
+        val theme = LogseqTheme.current()
+        val labelColor = if (theme.isDark) theme.tint else Color.BLACK
+        val secondaryLabelColor =
+            if (theme.isDark) Color.argb(200, 245, 247, 250) else Color.DKGRAY
+
+        // Calculate status bar height for safe area padding
+        val insets = ViewCompat.getRootWindowInsets(root)
+        val statusBarHeight = insets?.getInsets(WindowInsetsCompat.Type.statusBars())?.top ?: 0
+
+        val container = searchContainer ?: LinearLayout(activity).also { layout ->
+            layout.orientation = LinearLayout.VERTICAL
+            layout.setBackgroundColor(theme.background)
+
+            val lp = FrameLayout.LayoutParams(
+                ViewGroup.LayoutParams.MATCH_PARENT,
+                ViewGroup.LayoutParams.MATCH_PARENT
+            )
+
+            // Set bottom margin to clear the bottom navigation bar
+            lp.setMargins(0, 0, 0, bottomNav?.height ?: NativeUiUtils.dp(activity, 56f))
+
+            // Remove elevation/shadow
+            layout.elevation = 0f
+
+            // Apply status bar height as top padding for safe area
+            layout.setPadding(0, statusBarHeight, 0, 0)
+
+            root.addView(layout, lp)
+            searchContainer = layout
+        }
+
+        // Re-apply top padding in case insets were not available on first run
+        container.setPadding(0, statusBarHeight, 0, 0)
+
+        // Search Input Setup
+        if (searchInput == null) {
+            // Container for input and close button
+            val searchRow = LinearLayout(activity).apply {
+                orientation = LinearLayout.HORIZONTAL
+                gravity = Gravity.CENTER_VERTICAL // Center items vertically
+            }
+
+            val input = EditText(activity).apply {
+                hint = "Search"
+                setSingleLine(true)
+                setTextColor(labelColor)
+                setHintTextColor(secondaryLabelColor)
+                // Remove EditText default background/border for a flat look
+                setBackgroundColor(Color.TRANSPARENT)
+
+                // Fine-tune padding inside the EditText for text alignment
+                setPadding(
+                    NativeUiUtils.dp(activity, 0f),
+                    NativeUiUtils.dp(activity, 10f),
+                    NativeUiUtils.dp(activity, 0f),
+                    NativeUiUtils.dp(activity, 10f)
+                )
+
+                // Layout params to make EditText take most of the horizontal space
+                layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1.0f)
+
+                setOnKeyListener { _, keyCode, event ->
+                    if (event.action == KeyEvent.ACTION_DOWN) {
+                        when (keyCode) {
+                            KeyEvent.KEYCODE_DEL -> notifyListeners("keyboardHackKey", JSObject().put("key", "backspace"))
+                            KeyEvent.KEYCODE_ENTER -> notifyListeners("keyboardHackKey", JSObject().put("key", "enter"))
+                        }
+                    }
+                    false
+                }
+                addTextChangedListener(object : TextWatcher {
+                    override fun afterTextChanged(s: Editable?) {
+                        // Toggle close button visibility based on text
+                        val hasText = !s.isNullOrEmpty()
+                        closeButton?.visibility = if (hasText) View.VISIBLE else View.GONE
+                    }
+                    override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
+                    override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
+                        notifyListeners("searchChanged", JSObject().put("query", s?.toString() ?: ""))
+                    }
+                })
+            }
+
+            // Close Button
+            val button = TextView(activity).apply {
+                text = "X" // Close icon (using simple 'X')
+                setTextColor(secondaryLabelColor)
+                textSize = 18f
+                gravity = Gravity.CENTER
+                setPadding(
+                    NativeUiUtils.dp(activity, 8f),
+                    NativeUiUtils.dp(activity, 8f),
+                    NativeUiUtils.dp(activity, 8f),
+                    NativeUiUtils.dp(activity, 8f)
+                )
+                visibility = View.GONE // Initially hidden
+
+                setOnClickListener {
+                    input.setText("") // Clear the EditText
+                    // TextWatcher will handle notifying the web view and hiding the button
+                }
+                // Set layout params for the button
+                layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
+            }
+
+            // 1. Add EditText
+            searchRow.addView(input)
+            // 2. Add Close Button
+            searchRow.addView(button)
+
+            // inputContainer (was the old search container wrapper)
+            val inputContainer = LinearLayout(activity).apply {
+                // Add horizontal padding for the search box container
+                setPadding(
+                    NativeUiUtils.dp(activity, HORIZONTAL_PADDING_DP),
+                    NativeUiUtils.dp(activity, VERTICAL_PADDING_DP),
+                    NativeUiUtils.dp(activity, HORIZONTAL_PADDING_DP),
+                    NativeUiUtils.dp(activity, VERTICAL_PADDING_DP)
+                )
+                orientation = LinearLayout.VERTICAL
+                // Add the new searchRow (input + button)
+                addView(searchRow, 0, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
+
+                // Add a divider below the search box (optional visual polish)
+                val divider = View(activity).apply {
+                    layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, NativeUiUtils.dp(activity, 1f))
+                    setBackgroundColor(
+                        if (theme.isDark) Color.argb(40, 245, 247, 250) else Color.parseColor("#E0E0E0")
+                    )
+                }
+                addView(divider, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, NativeUiUtils.dp(activity, 1f)))
+            }
+
+            // Insert the inputContainer into the main searchContainer
+            container.addView(inputContainer, 0, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
+            searchInput = input
+            closeButton = button
+        }
+
+        // Search Results Setup
+        if (resultsContainer == null) {
+            val scroll = ScrollView(activity)
+            val inner = LinearLayout(activity).apply {
+                orientation = LinearLayout.VERTICAL
+                // Apply horizontal padding for the list of results
+                setPadding(
+                    NativeUiUtils.dp(activity, HORIZONTAL_PADDING_DP), // Left
+                    NativeUiUtils.dp(activity, 0f),  // Top
+                    NativeUiUtils.dp(activity, HORIZONTAL_PADDING_DP), // Right
+                    NativeUiUtils.dp(activity, 12f)  // Bottom
+                )
+            }
+            scroll.addView(inner, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
+            // The ScrollView should take up the rest of the vertical space
+            container.addView(scroll, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
+            resultsContainer = inner
+        }
+
+        container.visibility = View.VISIBLE
+    }
+
+    private fun hideSearchUi() {
+        searchContainer?.visibility = View.GONE
+    }
+
+    private fun makeResultRow(result: SearchResult): View {
+        val activity = activity ?: throw IllegalStateException("No activity")
+        val theme = LogseqTheme.current()
+        val labelColor = if (theme.isDark) theme.tint else Color.BLACK
+        val secondaryLabelColor =
+            if (theme.isDark) Color.argb(200, 245, 247, 250) else Color.DKGRAY
+        return LinearLayout(activity).apply {
+            orientation = LinearLayout.VERTICAL
+
+            // Apply vertical padding for the row item, using RESULT_ROW_VERTICAL_PADDING_DP (10f)
+            // for both top and bottom to ensure they are equal.
+            setPadding(0,
+                NativeUiUtils.dp(activity, RESULT_ROW_VERTICAL_PADDING_DP), // TOP: 10f
+                0,
+                NativeUiUtils.dp(activity, RESULT_ROW_VERTICAL_PADDING_DP)  // BOTTOM: 10f
+            )
+
+            val subtitleText = result.subtitle
+            if (subtitleText != null &&
+                !subtitleText.isNullOrBlank() &&
+                subtitleText.lowercase() != "null") {
+
+                val sub = TextView(activity).apply {
+                    text = subtitleText
+                    setTextColor(secondaryLabelColor)
+                    textSize = 13f
+                }
+                addView(sub)
+            }
+
+            val titleView = TextView(activity).apply {
+                text = result.title
+                setTextColor(labelColor)
+                textSize = 15f
+            }
+            addView(titleView)
+
+            setOnClickListener {
+                hideSearchUi()
+                notifyListeners("openSearchResultBlock", JSObject().put("id", result.id))
+            }
+        }
+    }
+
+    @Composable
+    private fun BottomNavBar(
+        tabs: List<TabSpec>,
+        currentId: String?,
+        onSelect: (TabSpec) -> Unit
+    ) {
+        val theme by LogseqTheme.colors.collectAsState()
+        val container = ComposeColor(theme.background)
+        val unselected = if (theme.isDark) ComposeColor(theme.tint).copy(alpha = 0.78f) else ComposeColor.Black.copy(alpha = 0.65f)
+        Box(
+            modifier = Modifier
+                .fillMaxWidth()
+                .background(container)
+                .padding(horizontal = TAB_BAR_HORIZONTAL_PADDING)
+        ) {
+            NavigationBar(
+                modifier = Modifier.fillMaxWidth(),
+                containerColor = container
+            ) {
+                tabs.forEach { tab ->
+                    val selected = tab.id == currentId
+                    val icon = remember(tab.systemImage, tab.id) {
+                        MaterialIconResolver.resolve(tab.systemImage) ?: MaterialIconResolver.resolve(tab.id)
+                    }
+                    val accent = ComposeColor(NativeUiUtils.parseColor(ACCENT_COLOR_HEX, Color.parseColor(ACCENT_COLOR_HEX)))
+
+                    NavigationBarItem(
+                        selected = selected,
+                        onClick = { onSelect(tab) },
+                        colors = NavigationBarItemDefaults.colors(
+                            selectedIconColor = accent,
+                            selectedTextColor = accent,
+                            unselectedIconColor = unselected,
+                            unselectedTextColor = unselected,
+                            indicatorColor = accent.copy(alpha = 0.12f)
+                        ),
+                        icon = {
+                            Icon(
+                                imageVector = icon ?: Icons.Filled.Circle,
+                                contentDescription = tab.title
+                            )
+                        },
+                        // Slightly reduce the default Material3 gap between icon and label.
+                        label = { Text(tab.title, modifier = Modifier.offset(y = (-4).dp)) }
+                    )
+                }
+            }
+        }
+    }
+
+    private fun parseTabs(array: JSArray?): List<TabSpec> {
+        if (array == null) return emptyList()
+        val result = mutableListOf<TabSpec>()
+        for (i in 0 until array.length()) {
+            val obj = array.optJSONObject(i) ?: continue
+            val id = obj.optString("id", "")
+            if (id.isBlank()) continue
+            val title = obj.optString("title", id)
+            val systemImage = obj.optString("systemImage", "")
+            val role = obj.optString("role", "normal")
+            result.add(TabSpec(id, title, systemImage, role))
+        }
+        return result
+    }
+
+    private fun parseResults(array: JSArray?): List<SearchResult> {
+        if (array == null) return emptyList()
+        val result = mutableListOf<SearchResult>()
+        for (i in 0 until array.length()) {
+            val obj = array.optJSONObject(i) ?: continue
+            val id = obj.optString("id", "")
+            if (id.isBlank()) continue
+            val title = obj.optString("title", "")
+            val subtitle = obj.optString("subtitle", null)
+            result.add(SearchResult(id, title, subtitle))
+        }
+        return result
+    }
+}
+
+data class TabSpec(
+    val id: String,
+    val title: String,
+    val systemImage: String,
+    val role: String
+)
+
+data class SearchResult(
+    val id: String,
+    val title: String,
+    val subtitle: String?
+)

+ 44 - 0
android/app/src/main/java/com/logseq/app/LogseqTheme.kt

@@ -0,0 +1,44 @@
+package com.logseq.app
+
+import android.content.Context
+import android.content.res.Configuration
+import android.graphics.Color
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+data class LogseqThemeColors(
+  val background: Int,
+  val tint: Int,
+  val isDark: Boolean,
+)
+
+object LogseqTheme {
+  private val _colors = MutableStateFlow(compute(isDark = false))
+  val colors: StateFlow<LogseqThemeColors> = _colors.asStateFlow()
+
+  fun update(context: Context) {
+    _colors.value = compute(context)
+  }
+
+  fun current(): LogseqThemeColors = _colors.value
+
+  fun isDark(context: Context): Boolean {
+    val mask = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
+    return mask == Configuration.UI_MODE_NIGHT_YES
+  }
+
+  private fun compute(context: Context): LogseqThemeColors = compute(isDark(context))
+
+  private fun compute(isDark: Boolean): LogseqThemeColors {
+    val background =
+      if (isDark) Color.parseColor("#002B36") else Color.parseColor("#FCFCFC")
+    val tint =
+      if (isDark) Color.parseColor("#F5F7FA") else Color.parseColor("#000000")
+    return LogseqThemeColors(
+      background = background,
+      tint = tint,
+      isDark = isDark,
+    )
+  }
+}

+ 129 - 3
android/app/src/main/java/com/logseq/app/MainActivity.java

@@ -1,16 +1,21 @@
 package com.logseq.app;
 
 import android.content.Intent;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.IntentFilter;
 import android.content.res.Configuration;
-import android.os.Build;
 import android.os.Bundle;
-import android.view.View;
-import android.view.Window;
 import android.webkit.ValueCallback;
 import android.webkit.WebView;
+import androidx.activity.EdgeToEdge;
+import androidx.core.view.WindowInsetsControllerCompat;
 import com.getcapacitor.PluginCall;
 import com.getcapacitor.JSObject;
 import com.getcapacitor.BridgeActivity;
+import androidx.activity.OnBackPressedDispatcher;
+import android.util.Log;
+import android.view.View;
 
 import java.util.Timer;
 import java.util.TimerTask;
@@ -18,17 +23,55 @@ import java.util.TimerTask;
 import ee.forgr.capacitor_navigation_bar.NavigationBarPlugin;
 
 public class MainActivity extends BridgeActivity {
+    private NavigationCoordinator navigationCoordinator = new NavigationCoordinator();
+    private BroadcastReceiver routeChangeReceiver;
+
     @Override
     public void onCreate(Bundle savedInstanceState) {
         registerPlugin(FolderPicker.class);
         registerPlugin(UILocal.class);
+        registerPlugin(NativeTopBarPlugin.class);
+        registerPlugin(NativeBottomSheetPlugin.class);
+        registerPlugin(NativeEditorToolbarPlugin.class);
+        registerPlugin(NativeSelectionActionBarPlugin.class);
+        registerPlugin(LiquidTabsPlugin.class);
+        registerPlugin(Utils.class);
 
         super.onCreate(savedInstanceState);
+        EdgeToEdge.enable(this);
         WebView webView = getBridge().getWebView();
         webView.setOverScrollMode(WebView.OVER_SCROLL_NEVER);
         webView.getSettings().setUseWideViewPort(true);
         webView.getSettings().setLoadWithOverviewMode(true);
 
+        applyLogseqTheme();
+
+        // Let Compose host the WebView with system bar padding for safe areas and handle back.
+        ComposeHost.INSTANCE.renderWithSystemInsets(this, webView, () -> {
+            sendJsBack(webView);
+            return null;
+        }, () -> {
+            finish();
+            return null;
+        });
+
+        routeChangeReceiver = new BroadcastReceiver() {
+            @Override
+            public void onReceive(Context context, Intent intent) {
+                if (!UILocal.ACTION_ROUTE_CHANGED.equals(intent.getAction())) return;
+                String stack = intent.getStringExtra("stack");
+                String navigationType = intent.getStringExtra("navigationType");
+                String path = intent.getStringExtra("path");
+                navigationCoordinator.onRouteChange(stack, navigationType, path);
+            }
+        };
+        IntentFilter filter = new IntentFilter(UILocal.ACTION_ROUTE_CHANGED);
+        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
+            registerReceiver(routeChangeReceiver, filter, Context.RECEIVER_NOT_EXPORTED);
+        } else {
+            registerReceiver(routeChangeReceiver, filter);
+        }
+
         // initNavigationBarBgColor();
 
         new Timer().schedule(new TimerTask() {
@@ -44,6 +87,58 @@ public class MainActivity extends BridgeActivity {
         }, 5000);
     }
 
+    @Override
+    public void onConfigurationChanged(Configuration newConfig) {
+        super.onConfigurationChanged(newConfig);
+        applyLogseqTheme();
+        dispatchSystemThemeToWeb();
+    }
+
+    private void applyLogseqTheme() {
+        LogseqTheme.INSTANCE.update(this);
+        LogseqThemeColors colors = LogseqTheme.INSTANCE.current();
+
+        int bg = colors.getBackground();
+        boolean isDark = colors.isDark();
+
+        View content = findViewById(android.R.id.content);
+        if (content != null) {
+            content.setBackgroundColor(bg);
+        }
+
+        WebView webView = getBridge() != null ? getBridge().getWebView() : null;
+        if (webView != null) {
+            webView.setBackgroundColor(bg);
+        }
+
+        getWindow().getDecorView().setBackgroundColor(bg);
+        getWindow().setStatusBarColor(bg);
+        getWindow().setNavigationBarColor(bg);
+
+        WindowInsetsControllerCompat controller = new WindowInsetsControllerCompat(getWindow(), getWindow().getDecorView());
+        controller.setAppearanceLightStatusBars(!isDark);
+        controller.setAppearanceLightNavigationBars(!isDark);
+
+        WebViewSnapshotManager.INSTANCE.setSnapshotBackgroundColor(bg);
+    }
+
+    public void applyLogseqThemeNow() {
+        applyLogseqTheme();
+    }
+
+    private void dispatchSystemThemeToWeb() {
+        try {
+            if (bridge == null) return;
+            boolean isDark = LogseqTheme.INSTANCE.isDark(this);
+            String js = "window.dispatchEvent(new CustomEvent('logseq:native-system-theme-changed', { detail: { isDark: "
+                + (isDark ? "true" : "false")
+                + " } }));";
+            bridge.eval(js, null);
+        } catch (Exception e) {
+            // ignore
+        }
+    }
+
     public void initNavigationBarBgColor() {
         NavigationBarPlugin navigationBarPlugin = new NavigationBarPlugin();
         JSObject data = new JSObject();
@@ -59,6 +154,20 @@ public class MainActivity extends BridgeActivity {
         super.onPause();
     }
 
+    @Override
+    public void onBackPressed() {
+        Log.d("onBackPressed", "Debug");
+
+        WebView webView = getBridge().getWebView();
+        if (webView != null) {
+            // Send "native back" into JS. JS will call your UILocal/route-change,
+            // which flows into ComposeHost.applyNavigation(...) and animates.
+            sendJsBack(webView);
+        } else {
+            // Fallback if for some reason there is no webview
+            super.onBackPressed();
+        }
+    }
     @Override
     protected void onNewIntent(Intent intent) {
         super.onNewIntent(intent);
@@ -74,4 +183,21 @@ public class MainActivity extends BridgeActivity {
             });
         }
     }
+
+    @Override
+    public void onDestroy() {
+        if (routeChangeReceiver != null) {
+            unregisterReceiver(routeChangeReceiver);
+            routeChangeReceiver = null;
+        }
+        super.onDestroy();
+    }
+
+    private void sendJsBack(WebView webView) {
+        if (webView == null) return;
+        webView.post(() -> webView.evaluateJavascript(
+            "window.LogseqNative && window.LogseqNative.onNativePop && window.LogseqNative.onNativePop();",
+            null
+        ));
+    }
 }

+ 85 - 0
android/app/src/main/java/com/logseq/app/MaterialIconResolver.kt

@@ -0,0 +1,85 @@
+package com.logseq.app
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Bookmarks
+import androidx.compose.material.icons.filled.Circle
+import androidx.compose.material.icons.filled.CloudUpload
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Home
+import androidx.compose.material.icons.filled.Layers
+import androidx.compose.material.icons.filled.Menu
+import androidx.compose.material.icons.filled.Send
+import androidx.compose.material.icons.filled.Star
+import androidx.compose.material.icons.outlined.CameraAlt
+import androidx.compose.material.icons.outlined.Add
+import androidx.compose.material.icons.outlined.AddCircle
+import androidx.compose.material.icons.outlined.DataArray
+import androidx.compose.material.icons.outlined.KeyboardCommandKey
+import androidx.compose.material.icons.outlined.ArrowBack
+import androidx.compose.material.icons.outlined.ArrowForward
+import androidx.compose.material.icons.outlined.BookmarkAdd
+import androidx.compose.material.icons.outlined.CalendarToday
+import androidx.compose.material.icons.outlined.CheckBox
+import androidx.compose.material.icons.outlined.Code
+import androidx.compose.material.icons.outlined.ContentCopy
+import androidx.compose.material.icons.outlined.Dashboard
+import androidx.compose.material.icons.outlined.Delete
+import androidx.compose.material.icons.outlined.Equalizer
+import androidx.compose.material.icons.outlined.Explore
+import androidx.compose.material.icons.outlined.GraphicEq
+import androidx.compose.material.icons.outlined.KeyboardHide
+import androidx.compose.material.icons.outlined.Link
+import androidx.compose.material.icons.outlined.LocalOffer
+import androidx.compose.material.icons.outlined.MoreVert
+import androidx.compose.material.icons.outlined.Redo
+import androidx.compose.material.icons.outlined.Search
+import androidx.compose.material.icons.outlined.StarBorder
+import androidx.compose.material.icons.outlined.Undo
+import androidx.compose.ui.graphics.vector.ImageVector
+
+object MaterialIconResolver {
+    fun resolve(name: String?): ImageVector? {
+        val key = name
+            ?.trim()
+            ?.lowercase()
+            ?.replace("_", "-")
+            ?.replace("\\s+".toRegex(), "-")
+            ?.replace(".", "-")
+            ?: return null
+
+        return when (key) {
+            "chevron-backward", "arrow-left", "back" -> Icons.Outlined.ArrowBack
+            "arrow-right" -> Icons.Outlined.ArrowForward
+            "arrow-uturn-backward" -> Icons.Outlined.Undo
+            "arrow-uturn-forward" -> Icons.Outlined.Redo
+            "calendar" -> Icons.Outlined.CalendarToday
+            "waveform", "audio", "equalizer" -> Icons.Outlined.GraphicEq
+            "ellipsis" -> Icons.Outlined.MoreVert
+            "star-fill" -> Icons.Filled.Star
+            "star" -> Icons.Outlined.StarBorder
+            "circle-fill" -> Icons.Filled.Circle
+            "plus", "add" -> Icons.Outlined.Add
+            "paperplane", "send" -> Icons.Filled.Send
+            "todo", "checkmark-square" -> Icons.Outlined.CheckBox
+            "number", "tag" -> Icons.Outlined.LocalOffer
+            "parentheses" -> Icons.Outlined.DataArray
+            "command", "slash" -> Icons.Outlined.KeyboardCommandKey
+            "camera" -> Icons.Outlined.CameraAlt
+            "keyboard-chevron-compact-down", "keyboard-hide" -> Icons.Outlined.KeyboardHide
+            "doc-on-doc", "copy" -> Icons.Outlined.ContentCopy
+            "trash", "delete" -> Icons.Outlined.Delete
+            "r-square", "bookmark-ref" -> Icons.Outlined.BookmarkAdd
+            "link" -> Icons.Outlined.Link
+            "xmark", "close" -> Icons.Filled.Close
+            "house", "home" -> Icons.Filled.Home
+            "app-background-dotted" -> Icons.Outlined.Dashboard
+            "tray", "add" -> Icons.Outlined.AddCircle
+            "square-stack-3d-down-right", "layers" -> Icons.Filled.Layers
+            "magnifyingglass", "search" -> Icons.Outlined.Search
+            "go-to", "goto" -> Icons.Outlined.Explore
+            "bookmark" -> Icons.Filled.Bookmarks
+            "sync" -> Icons.Outlined.Equalizer
+            else -> null
+        }
+    }
+}

+ 182 - 0
android/app/src/main/java/com/logseq/app/NativeBottomSheetPlugin.kt

@@ -0,0 +1,182 @@
+package com.logseq.app
+
+import android.graphics.Color
+import android.os.Handler
+import android.os.Looper
+import android.view.View
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import com.getcapacitor.JSObject
+import com.getcapacitor.Plugin
+import com.getcapacitor.PluginCall
+import com.getcapacitor.PluginMethod
+import com.getcapacitor.annotation.CapacitorPlugin
+import com.google.android.material.bottomsheet.BottomSheetBehavior
+import com.google.android.material.bottomsheet.BottomSheetDialog
+
+@CapacitorPlugin(name = "NativeBottomSheetPlugin")
+class NativeBottomSheetPlugin : Plugin() {
+    private val snapshotTag = "bottom-sheet"
+    private val mainHandler = Handler(Looper.getMainLooper())
+    private var dialog: BottomSheetDialog? = null
+    private var previousParent: ViewGroup? = null
+    private var previousIndex: Int = -1
+    private var previousLayoutParams: ViewGroup.LayoutParams? = null
+    private var placeholder: View? = null
+    private var container: FrameLayout? = null
+
+    @PluginMethod
+    fun present(call: PluginCall) {
+        val activity = activity ?: run {
+            call.reject("No activity")
+            return
+        }
+        val webView = bridge.webView ?: run {
+            call.reject("No webview")
+            return
+        }
+
+        activity.runOnUiThread {
+            WebViewSnapshotManager.registerWindow(activity.window)
+            if (dialog != null) {
+                call.resolve()
+                return@runOnUiThread
+            }
+
+            val ctx = activity
+            container = FrameLayout(ctx)
+            container!!.layoutParams = ViewGroup.LayoutParams(
+                ViewGroup.LayoutParams.MATCH_PARENT,
+                ViewGroup.LayoutParams.MATCH_PARENT
+            )
+
+            val sheet = BottomSheetDialog(ctx)
+            sheet.setContentView(container!!)
+
+            WebViewSnapshotManager.showSnapshot(snapshotTag, webView)
+
+            // Move the WebView into the BottomSheet container
+            detachWebView(webView, ctx)
+            container!!.addView(
+                webView,
+                FrameLayout.LayoutParams(
+                    ViewGroup.LayoutParams.MATCH_PARENT,
+                    ViewGroup.LayoutParams.MATCH_PARENT
+                )
+            )
+
+            val behavior = sheet.behavior
+            val defaultHeight = call.getInt("defaultHeight", null)
+            val allowFullHeight = call.getBoolean("allowFullHeight") ?: true
+            if (defaultHeight != null) {
+                val peek = NativeUiUtils.dp(ctx, defaultHeight.toFloat())
+                behavior.peekHeight = peek
+                behavior.state = BottomSheetBehavior.STATE_COLLAPSED
+            } else {
+                behavior.state = BottomSheetBehavior.STATE_COLLAPSED
+            }
+            behavior.skipCollapsed = !allowFullHeight
+
+            sheet.setOnDismissListener {
+                notifyListeners("state", JSObject().put("dismissing", true))
+
+                // Forcefully detach WebView before restoring
+                try {
+                    (webView.parent as? ViewGroup)?.removeView(webView)
+                    container?.removeAllViews()
+                } catch (_: Exception) {}
+
+                // Delay restoration slightly to let Android clean up window surfaces
+                mainHandler.post {
+                    restoreWebView(webView)
+                    webView.alpha = 0f
+
+                    webView.postDelayed({
+                        webView.alpha = 1f
+                        WebViewSnapshotManager.clearSnapshot(snapshotTag)
+                        notifyListeners(
+                            "state",
+                            JSObject()
+                                .put("presented", false)
+                                .put("dismissing", false)
+                        )
+                    }, 120)
+                }
+
+                dialog = null
+                container = null
+            }
+
+            notifyListeners("state", JSObject().put("presenting", true))
+            sheet.show()
+            notifyListeners("state", JSObject().put("presented", true))
+            dialog = sheet
+            call.resolve()
+        }
+    }
+
+    @PluginMethod
+    fun dismiss(call: PluginCall) {
+        activity?.runOnUiThread {
+            if (dialog == null) {
+                WebViewSnapshotManager.clearSnapshot(snapshotTag)
+                call.resolve()
+                return@runOnUiThread
+            }
+
+            dialog?.dismiss()
+            call.resolve()
+        } ?: call.resolve()
+    }
+
+    private fun detachWebView(webView: View, ctx: android.content.Context) {
+        val parent = webView.parent as? ViewGroup ?: return
+        previousParent = parent
+        previousIndex = parent.indexOfChild(webView)
+        previousLayoutParams = webView.layoutParams
+
+        parent.removeView(webView)
+        placeholder = View(ctx).apply {
+            setBackgroundColor(LogseqTheme.current().background)
+            layoutParams = previousLayoutParams ?: ViewGroup.LayoutParams(
+                ViewGroup.LayoutParams.MATCH_PARENT,
+                ViewGroup.LayoutParams.MATCH_PARENT
+            )
+        }
+        parent.addView(placeholder, previousIndex)
+    }
+
+    private fun restoreWebView(webView: View) {
+        val parent = previousParent ?: return
+        val lp = previousLayoutParams ?: ViewGroup.LayoutParams(
+            ViewGroup.LayoutParams.MATCH_PARENT,
+            ViewGroup.LayoutParams.MATCH_PARENT
+        )
+
+        // Fully detach from any container
+        (webView.parent as? ViewGroup)?.removeView(webView)
+        placeholder?.let { parent.removeView(it) }
+        placeholder = null
+
+        // Reattach WebView
+        if (previousIndex in 0..parent.childCount) {
+            parent.addView(webView, previousIndex, lp)
+        } else {
+            parent.addView(webView, lp)
+        }
+
+        // ✅ Force WebView to recreate its SurfaceView and redraw
+        webView.visibility = View.INVISIBLE
+        webView.post {
+            webView.visibility = View.VISIBLE
+            webView.requestLayout()
+            webView.invalidate()
+            webView.dispatchWindowVisibilityChanged(View.VISIBLE)
+        }
+
+        previousParent = null
+        previousLayoutParams = null
+        previousIndex = -1
+        container = null
+    }
+}

+ 263 - 0
android/app/src/main/java/com/logseq/app/NativeEditorToolbarPlugin.kt

@@ -0,0 +1,263 @@
+package com.logseq.app
+
+import android.graphics.Color
+import android.view.Gravity
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color as ComposeColor
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.ViewCompositionStrategy
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.getcapacitor.JSArray
+import com.getcapacitor.JSObject
+import com.getcapacitor.Plugin
+import com.getcapacitor.PluginCall
+import com.getcapacitor.PluginMethod
+import com.getcapacitor.annotation.CapacitorPlugin
+
+@CapacitorPlugin(name = "NativeEditorToolbarPlugin")
+class NativeEditorToolbarPlugin : Plugin() {
+    private var toolbarView: EditorToolbarView? = null
+
+    @PluginMethod
+    fun present(call: PluginCall) {
+        val activity = activity ?: run {
+            call.reject("No activity")
+            return
+        }
+
+        val actions = parseActions(call.getArray("actions"))
+        val trailing = call.getObject("trailingAction")?.let { EditorAction.from(it) }
+        val tintHex = call.getString("tintColor")
+        val bgHex = call.getString("backgroundColor")
+
+        activity.runOnUiThread {
+            if (actions.isEmpty() && trailing == null) {
+                dismissInternal()
+                call.resolve()
+                return@runOnUiThread
+            }
+
+            val view = toolbarView ?: EditorToolbarView(activity).also { v ->
+                v.onAction = { id ->
+                    notifyListeners("action", JSObject().put("id", id))
+                }
+                toolbarView = v
+            }
+
+            view.bind(actions, trailing, tintHex, bgHex)
+
+            val root = NativeUiUtils.contentRoot(activity)
+            if (view.parent !== root) {
+                NativeUiUtils.detachView(view)
+                val lp = FrameLayout.LayoutParams(
+                    ViewGroup.LayoutParams.MATCH_PARENT,
+                    ViewGroup.LayoutParams.WRAP_CONTENT,
+                    Gravity.BOTTOM
+                ).apply {
+                    val margin = NativeUiUtils.dp(activity, 12f)
+                    setMargins(margin, margin, margin, margin)
+                }
+                root.addView(view, lp)
+            }
+
+            call.resolve()
+        }
+    }
+
+    @PluginMethod
+    fun dismiss(call: PluginCall) {
+        activity?.runOnUiThread {
+            dismissInternal()
+            call.resolve()
+        } ?: call.resolve()
+    }
+
+    private fun dismissInternal() {
+        val root = activity?.let { NativeUiUtils.contentRoot(it) } ?: return
+        toolbarView?.let { root.removeView(it) }
+        toolbarView = null
+    }
+
+    private fun parseActions(array: JSArray?): List<EditorAction> {
+        if (array == null) return emptyList()
+        val result = mutableListOf<EditorAction>()
+        for (i in 0 until array.length()) {
+            val obj = array.optJSONObject(i) ?: continue
+            EditorAction.from(obj)?.let { result.add(it) }
+        }
+        return result
+    }
+}
+
+data class EditorAction(
+    val id: String,
+    val title: String,
+    val systemIcon: String?
+) {
+    companion object {
+        fun from(obj: org.json.JSONObject): EditorAction? {
+            val id = obj.optString("id", "")
+            if (id.isBlank()) return null
+            val title = obj.optString("title", id)
+            val icon = obj.optString("systemIcon", null)
+            return EditorAction(id, title, icon)
+        }
+    }
+}
+
+private class EditorToolbarView(context: android.content.Context) : FrameLayout(context) {
+    var onAction: ((String) -> Unit)? = null
+
+    private val composeView = ComposeView(context).apply {
+        setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
+        layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
+    }
+
+    private var actions: List<EditorAction> = emptyList()
+    private var trailing: EditorAction? = null
+    private var tint: Int = defaultTint()
+    private var backgroundColor: Int = defaultBackground()
+
+    init {
+        addView(composeView)
+    }
+
+    fun bind(
+        actions: List<EditorAction>,
+        trailing: EditorAction?,
+        tintHex: String?,
+        bgHex: String?
+    ) {
+        this.actions = actions
+        this.trailing = trailing
+        tint = NativeUiUtils.parseColor(tintHex, defaultTint())
+        backgroundColor = NativeUiUtils.parseColor(bgHex, defaultBackground())
+        render()
+    }
+
+    private fun defaultTint(): Int =
+        if (LogseqTheme.current().isDark) Color.WHITE else Color.BLACK
+
+    private fun defaultBackground(): Int = LogseqTheme.current().background
+
+    private fun render() {
+        val onActionFn = onAction
+        val actionsSnapshot = actions
+        val trailingSnapshot = trailing
+        val tintColor = tint
+        val bgColor = backgroundColor
+
+        composeView.setContent {
+            EditorToolbar(
+                actions = actionsSnapshot,
+                trailing = trailingSnapshot,
+                tint = ComposeColor(tintColor),
+                background = ComposeColor(bgColor),
+                onAction = { id -> onActionFn?.invoke(id) }
+            )
+        }
+    }
+}
+
+@Composable
+private fun EditorToolbar(
+    actions: List<EditorAction>,
+    trailing: EditorAction?,
+    tint: ComposeColor,
+    background: ComposeColor,
+    onAction: (String) -> Unit
+) {
+    Surface(
+        color = background,
+        shadowElevation = 6.dp,
+        shape = RoundedCornerShape(16.dp),
+        modifier = Modifier
+            .fillMaxWidth()
+            .wrapContentHeight()
+            .navigationBarsPadding()
+            .imePadding() // Lift toolbar above system nav/IME when the keyboard opens
+    ) {
+        Row(
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(horizontal = 8.dp, vertical = 6.dp),
+            verticalAlignment = Alignment.CenterVertically
+        ) {
+            Row(
+                modifier = Modifier
+                    .weight(1f)
+                    .horizontalScroll(rememberScrollState()),
+                verticalAlignment = Alignment.CenterVertically,
+                horizontalArrangement = Arrangement.spacedBy(4.dp)
+            ) {
+                actions.forEach { action ->
+                    ToolbarButton(action, tint, onAction)
+                }
+            }
+
+            trailing?.let { trailingAction ->
+                if (actions.isNotEmpty()) {
+                    Spacer(modifier = Modifier.width(8.dp))
+                }
+                ToolbarButton(trailingAction, tint, onAction)
+            }
+        }
+    }
+}
+
+@Composable
+private fun ToolbarButton(
+    action: EditorAction,
+    tint: ComposeColor,
+    onAction: (String) -> Unit
+) {
+    val icon = remember(action.systemIcon) { MaterialIconResolver.resolve(action.systemIcon) }
+    val contentTint = remember(tint) { tint.copy(alpha = 0.8f) }
+    Row(
+        verticalAlignment = Alignment.CenterVertically,
+        modifier = Modifier
+            .defaultMinSize(minWidth = 44.dp)
+            .clickable { onAction(action.id) }
+            .padding(horizontal = 10.dp, vertical = 8.dp)
+    ) {
+        if (icon != null) {
+            Icon(
+                imageVector = icon,
+                contentDescription = action.title.ifBlank { action.id },
+                tint = contentTint,
+                modifier = Modifier
+                    .defaultMinSize(minWidth = 20.dp)
+                    .padding(end = 2.dp)
+            )
+        } else {
+            Text(
+                text = action.title,
+                color = contentTint,
+                fontSize = 14.sp
+            )
+        }
+    }
+}

+ 279 - 0
android/app/src/main/java/com/logseq/app/NativeSelectionActionBarPlugin.kt

@@ -0,0 +1,279 @@
+package com.logseq.app
+
+import android.graphics.Color
+import android.view.Gravity
+import android.view.View
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Divider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color as ComposeColor
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.ViewCompositionStrategy
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.doOnNextLayout
+import com.getcapacitor.JSArray
+import com.getcapacitor.JSObject
+import com.getcapacitor.Plugin
+import com.getcapacitor.PluginCall
+import com.getcapacitor.PluginMethod
+import com.getcapacitor.annotation.CapacitorPlugin
+import kotlin.math.max
+
+@CapacitorPlugin(name = "NativeSelectionActionBarPlugin")
+class NativeSelectionActionBarPlugin : Plugin() {
+    private var barView: SelectionActionBarView? = null
+
+    @PluginMethod
+    fun present(call: PluginCall) {
+        val activity = activity ?: run {
+            call.reject("No activity")
+            return
+        }
+
+        val actionsArray = call.getArray("actions")
+        val actions = parseActions(actionsArray)
+        val tintHex = call.getString("tintColor")
+        val bgHex = call.getString("backgroundColor")
+
+        activity.runOnUiThread {
+            if (actions.isEmpty()) {
+                dismissInternal()
+                call.resolve()
+                return@runOnUiThread
+            }
+
+            val root = NativeUiUtils.contentRoot(activity)
+            val view = barView ?: SelectionActionBarView(activity).also { v ->
+                v.onAction = { id ->
+                    notifyListeners("action", JSObject().put("id", id))
+                }
+                barView = v
+            }
+
+            view.bind(actions, tintHex, bgHex)
+
+            if (view.parent !== root) {
+                NativeUiUtils.detachView(view)
+
+                val lp = FrameLayout.LayoutParams(
+                    FrameLayout.LayoutParams.MATCH_PARENT,
+                    FrameLayout.LayoutParams.WRAP_CONTENT,
+                    Gravity.BOTTOM
+                ).apply {
+                    val margin = NativeUiUtils.dp(activity, 12f)
+                    val bottomOffset = computeBottomOffset(activity, root)
+                    // top / left / right: margin
+                    // bottom: margin + bottom nav height + system/IME inset
+                    setMargins(margin, margin, margin, margin + bottomOffset)
+                }
+
+                root.addView(view, lp)
+            }
+
+            call.resolve()
+        }
+    }
+
+    @PluginMethod
+    fun dismiss(call: PluginCall) {
+        activity?.runOnUiThread {
+            dismissInternal()
+            call.resolve()
+        } ?: call.resolve()
+    }
+
+    private fun dismissInternal() {
+        val activity = activity ?: return
+        val root = NativeUiUtils.contentRoot(activity)
+
+        barView?.let { root.removeView(it) }
+        barView = null
+    }
+
+    private fun parseActions(array: JSArray?): List<SelectionAction> {
+        if (array == null) return emptyList()
+        val result = mutableListOf<SelectionAction>()
+        for (i in 0 until array.length()) {
+            val obj = array.optJSONObject(i) ?: continue
+            SelectionAction.from(obj)?.let { result.add(it) }
+        }
+        return result
+    }
+
+    /**
+     * Compute how far we must lift the bar from the bottom so that it
+     * sits above:
+     *  - the BottomNavigationView created by LiquidTabsPlugin
+     *  - system nav / gesture bar
+     *  - IME (when showing)
+     */
+    private fun computeBottomOffset(activity: android.app.Activity, root: ViewGroup): Int {
+        val insets = ViewCompat.getRootWindowInsets(root)
+        val systemBarsBottom = insets?.getInsets(WindowInsetsCompat.Type.systemBars())?.bottom ?: 0
+        val imeBottom = insets?.getInsets(WindowInsetsCompat.Type.ime())?.bottom ?: 0
+
+        // Find the bottom nav created by LiquidTabsPlugin (must have this ID set there)
+        val bottomNav = activity.findViewById<View>(R.id.liquid_tabs_bottom_nav)
+        // Fallback height if nav not measured yet
+        val navHeight = bottomNav?.height ?: NativeUiUtils.dp(activity, 56f)
+
+        val insetBottom = max(systemBarsBottom, imeBottom)
+        return navHeight + insetBottom
+    }
+}
+
+data class SelectionAction(
+    val id: String,
+    val title: String,
+    val systemIcon: String?
+) {
+    companion object {
+        fun from(obj: org.json.JSONObject): SelectionAction? {
+            val id = obj.optString("id", "")
+            if (id.isBlank()) return null
+            val title = obj.optString("title", id)
+            val icon = obj.optString("systemIcon", null)
+            return SelectionAction(id, title, icon)
+        }
+    }
+}
+
+private class SelectionActionBarView(context: android.content.Context) : FrameLayout(context) {
+    var onAction: ((String) -> Unit)? = null
+    private val composeView: ComposeView
+
+    init {
+        composeView = ComposeView(context).apply {
+            setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
+            layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
+        }
+        addView(composeView)
+    }
+
+    fun bind(actions: List<SelectionAction>, tintHex: String?, bgHex: String?) {
+        val defaultTint = if (LogseqTheme.current().isDark) Color.WHITE else Color.BLACK
+        val tint = ComposeColor(NativeUiUtils.parseColor(tintHex, defaultTint))
+        val backgroundColor = ComposeColor(NativeUiUtils.parseColor(bgHex, LogseqTheme.current().background))
+        val onActionFn = onAction
+
+        composeView.setContent {
+            SelectionActionBar(actions, tint, backgroundColor) { id ->
+                onActionFn?.invoke(id)
+            }
+        }
+        composeView.doOnNextLayout { requestLayout() }
+    }
+}
+
+@Composable
+private fun SelectionActionBar(
+    actions: List<SelectionAction>,
+    tint: ComposeColor,
+    background: ComposeColor,
+    onAction: (String) -> Unit
+) {
+    val (mainActions, trailingAction) = remember(actions) {
+        val primary = if (actions.size > 1) actions.dropLast(1) else emptyList()
+        Pair(primary, actions.lastOrNull())
+    }
+    val scrollState = rememberScrollState()
+
+    Surface(
+        color = background,
+        shadowElevation = 6.dp,
+        shape = RoundedCornerShape(16.dp)
+    ) {
+        Row(
+            modifier = Modifier
+                .padding(horizontal = 12.dp, vertical = 10.dp),
+            verticalAlignment = Alignment.CenterVertically
+        ) {
+            if (mainActions.isNotEmpty()) {
+                Row(
+                    modifier = Modifier
+                        .weight(1f)
+                        .horizontalScroll(scrollState),
+                    verticalAlignment = Alignment.CenterVertically,
+                    horizontalArrangement = Arrangement.spacedBy(10.dp)
+                ) {
+                    mainActions.forEach { action ->
+                        SelectionActionButton(action, tint, onAction)
+                    }
+                }
+            }
+
+            trailingAction?.let { action ->
+                if (mainActions.isNotEmpty()) {
+                    Spacer(modifier = Modifier.width(10.dp))
+                    Divider(
+                        modifier = Modifier
+                            .height(28.dp)
+                            .width(1.dp),
+                        color = tint.copy(alpha = 0.15f)
+                    )
+                    Spacer(modifier = Modifier.width(10.dp))
+                }
+                SelectionActionButton(action, tint, onAction)
+            }
+        }
+    }
+}
+
+@Composable
+private fun SelectionActionButton(
+    action: SelectionAction,
+    tint: ComposeColor,
+    onAction: (String) -> Unit
+) {
+    val icon = remember(action.systemIcon) { MaterialIconResolver.resolve(action.systemIcon) }
+    val contentTint = remember(tint) { tint.copy(alpha = 0.8f) }
+    Column(
+        horizontalAlignment = Alignment.CenterHorizontally,
+        verticalArrangement = Arrangement.spacedBy(6.dp),
+        modifier = Modifier
+            .defaultMinSize(minWidth = 56.dp)
+            .clickable { onAction(action.id) }
+            .padding(horizontal = 6.dp, vertical = 8.dp)
+    ) {
+        if (icon != null) {
+            Icon(
+                imageVector = icon,
+                contentDescription = action.title.ifBlank { action.id },
+                tint = contentTint,
+                modifier = Modifier.size(22.dp)
+            )
+        }
+        Text(
+            text = action.title,
+            color = contentTint,
+            fontSize = 12.sp,
+            fontWeight = FontWeight.SemiBold,
+            textAlign = TextAlign.Center
+        )
+    }
+}

+ 331 - 0
android/app/src/main/java/com/logseq/app/NativeTopBarPlugin.kt

@@ -0,0 +1,331 @@
+package com.logseq.app
+
+import android.graphics.Color
+import android.os.Build
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBars
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color as ComposeColor
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.ViewCompositionStrategy
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.core.view.doOnNextLayout
+import com.getcapacitor.JSArray
+import com.getcapacitor.JSObject
+import com.getcapacitor.Plugin
+import com.getcapacitor.PluginCall
+import com.getcapacitor.PluginMethod
+import com.getcapacitor.annotation.CapacitorPlugin
+
+@CapacitorPlugin(name = "NativeTopBarPlugin")
+class NativeTopBarPlugin : Plugin() {
+    private var topBarView: NativeTopBarView? = null
+    private var originalWebViewPaddingTop: Int? = null
+
+    @PluginMethod
+    fun configure(call: PluginCall) {
+        val activity = activity ?: run {
+            call.reject("No activity")
+            return
+        }
+
+        activity.runOnUiThread {
+            val hidden = call.getBoolean("hidden") ?: false
+            val title = call.getString("title") ?: ""
+            val leftButtons = parseButtons(call.getArray("leftButtons"))
+            val rightButtons = parseButtons(call.getArray("rightButtons"))
+            val titleClickable = call.getBoolean("titleClickable") ?: false
+            val tintHex = call.getString("tintColor")
+            val tintColorOverride =
+                tintHex?.takeIf { it.isNotBlank() }?.let { NativeUiUtils.parseColor(it, LogseqTheme.current().tint) }
+
+            val webView = bridge.webView
+
+            if (hidden) {
+                removeBar()
+                restorePadding(webView)
+                call.resolve()
+                return@runOnUiThread
+            }
+
+            val bar = topBarView ?: NativeTopBarView(activity).also { view ->
+                view.onTap = { id ->
+                    notifyListeners("buttonTapped", JSObject().put("id", id))
+                }
+                attachBar(view)
+                topBarView = view
+            }
+
+            bar.bind(title, titleClickable, leftButtons, rightButtons, tintColorOverride)
+            bar.post {
+                adjustWebViewPadding(webView, bar.height)
+            }
+            call.resolve()
+        }
+    }
+
+    private fun attachBar(bar: NativeTopBarView) {
+        val root = NativeUiUtils.contentRoot(activity)
+        if (bar.parent !== root) {
+            NativeUiUtils.detachView(bar)
+            val lp = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
+            root.addView(bar, lp)
+        }
+    }
+
+    private fun removeBar() {
+        val root = activity?.let { NativeUiUtils.contentRoot(it) } ?: return
+        topBarView?.let { view ->
+            root.removeView(view)
+        }
+        topBarView = null
+    }
+
+    private fun statusBarInset(webView: android.webkit.WebView?): Int {
+        if (webView == null) return 0
+        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+            webView.rootWindowInsets?.getInsets(android.view.WindowInsets.Type.statusBars())?.top ?: 0
+        } else {
+            @Suppress("DEPRECATION")
+            webView.rootWindowInsets?.stableInsetTop ?: 0
+        }
+    }
+
+    private fun adjustWebViewPadding(webView: android.webkit.WebView?, barHeight: Int) {
+        if (webView == null) return
+        if (originalWebViewPaddingTop == null) {
+            originalWebViewPaddingTop = webView.paddingTop
+        }
+        val insetTop = statusBarInset(webView)
+        val target = (barHeight - insetTop).coerceAtLeast(0)
+            .takeIf { it > 0 } ?: NativeUiUtils.dp(webView.context, 56f)
+        webView.setPadding(
+            webView.paddingLeft,
+            target,
+            webView.paddingRight,
+            webView.paddingBottom
+        )
+    }
+
+    private fun restorePadding(webView: android.webkit.WebView?) {
+        if (webView == null) return
+        val original = originalWebViewPaddingTop
+        if (original != null) {
+            webView.setPadding(webView.paddingLeft, original, webView.paddingRight, webView.paddingBottom)
+        }
+    }
+
+    private fun parseButtons(array: JSArray?): List<ButtonSpec> {
+        if (array == null) return emptyList()
+        val result = mutableListOf<ButtonSpec>()
+        for (i in 0 until array.length()) {
+            val obj = array.optJSONObject(i) ?: continue
+            val id = obj.optString("id", "")
+            if (id.isBlank()) continue
+            val systemIcon = obj.optString("systemIcon", "")
+            val title = obj.optString("title", id)
+            val tintHex = obj.optString("tintColor", obj.optString("color", ""))
+            val iconSize = if (id == "sync") {
+                "small"
+            } else {
+                "medium"
+            }
+
+            val size = obj.optString("size", iconSize)
+            result.add(
+                ButtonSpec(
+                    id = id,
+                    title = if (title.isNotBlank()) title else id,
+                    systemIcon = systemIcon.takeIf { it.isNotBlank() },
+                    tint = tintHex,
+                    size = size
+                )
+            )
+        }
+        return result
+    }
+}
+
+data class ButtonSpec(
+    val id: String,
+    val title: String,
+    val systemIcon: String?,
+    val tint: String?,
+    val size: String
+)
+
+private class NativeTopBarView(context: android.content.Context) : FrameLayout(context) {
+    var onTap: ((String) -> Unit)? = null
+    private val composeView = ComposeView(context).apply {
+        setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
+        layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
+    }
+
+    init {
+        addView(composeView)
+    }
+
+    fun bind(
+        title: String,
+        titleClickable: Boolean,
+        leftButtons: List<ButtonSpec>,
+        rightButtons: List<ButtonSpec>,
+        tintColorOverride: Int?
+    ) {
+        val onTapFn = onTap
+        composeView.setContent {
+            TopBarContent(
+                title = title,
+                titleClickable = titleClickable,
+                leftButtons = leftButtons,
+                rightButtons = rightButtons,
+                tintOverride = tintColorOverride,
+                onTap = { id -> onTapFn?.invoke(id) }
+            )
+        }
+        doOnNextLayout {
+            requestLayout()
+        }
+    }
+}
+
+@Composable
+private fun TopBarContent(
+    title: String,
+    titleClickable: Boolean,
+    leftButtons: List<ButtonSpec>,
+    rightButtons: List<ButtonSpec>,
+    tintOverride: Int?,
+    onTap: (String) -> Unit
+) {
+    val theme by LogseqTheme.colors.collectAsState()
+    val background = ComposeColor(theme.background)
+    val tint = tintOverride?.let { ComposeColor(it) } ?: ComposeColor(theme.tint)
+    val contentTint = tint.copy(alpha = 0.8f)
+
+    Surface(
+        color = background,
+        shadowElevation = 4.dp
+    ) {
+        Row(
+            modifier = Modifier
+                .fillMaxWidth()
+                .windowInsetsPadding(WindowInsets.statusBars)
+                .padding(horizontal = 12.dp, vertical = 12.dp),
+            verticalAlignment = Alignment.CenterVertically
+        ) {
+            Row(
+                modifier = Modifier.weight(1f),
+                horizontalArrangement = Arrangement.Start,
+                verticalAlignment = Alignment.CenterVertically
+            ) {
+                leftButtons.forEachIndexed { index, button ->
+                    if (index > 0) {
+                        Spacer(modifier = Modifier.width(8.dp))
+                    }
+                    TopBarButton(button, contentTint, onTap)
+                }
+            }
+
+            Text(
+                text = title,
+                color = contentTint,
+                fontSize = 17.sp,
+                fontWeight = FontWeight.Medium,
+                maxLines = 1,
+                overflow = TextOverflow.Ellipsis,
+                modifier = Modifier
+                    .padding(horizontal = 6.dp)
+                    .let { mod ->
+                        if (titleClickable) {
+                            mod.clickable { onTap("title") }
+                        } else {
+                            mod
+                        }
+                    }
+            )
+
+            Row(
+                modifier = Modifier.weight(1f),
+                horizontalArrangement = Arrangement.End,
+                verticalAlignment = Alignment.CenterVertically
+            ) {
+                rightButtons.forEachIndexed { index, button ->
+                    if (index > 0) {
+                        Spacer(modifier = Modifier.width(8.dp))
+                    }
+                    TopBarButton(button, contentTint, onTap)
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun TopBarButton(
+    spec: ButtonSpec,
+    fallbackTint: ComposeColor,
+    onTap: (String) -> Unit
+) {
+    val icon = remember(spec.systemIcon) { MaterialIconResolver.resolve(spec.systemIcon) }
+    val baseTint = remember(spec.tint, fallbackTint) {
+        spec.tint?.let { ComposeColor(NativeUiUtils.parseColor(it, fallbackTint.toArgb())) } ?: fallbackTint
+    }
+    val tint = remember(baseTint) { baseTint.copy(alpha = 0.8f) }
+    val fontSize = when (spec.size.lowercase()) {
+        "small" -> 13.sp
+        "large" -> 17.sp
+        else -> 15.sp
+    }
+    Row(
+        verticalAlignment = Alignment.CenterVertically,
+        modifier = Modifier
+            .clickable { onTap(spec.id) }
+            .padding(horizontal = 8.dp, vertical = 6.dp)
+    ) {
+        if (icon != null) {
+            val iconSize = if (spec.size.lowercase() == "small") {
+                12.dp
+            } else {
+                22.dp
+            }
+            Icon(
+                imageVector = icon,
+                contentDescription = spec.title.ifBlank { spec.id },
+                tint = tint,
+                modifier = Modifier.size(iconSize)
+            )
+        } else {
+            Text(
+                text = spec.title,
+                color = tint,
+                fontSize = fontSize,
+                maxLines = 1,
+                overflow = TextOverflow.Ellipsis
+            )
+        }
+    }
+}

+ 31 - 0
android/app/src/main/java/com/logseq/app/NativeUiUtils.kt

@@ -0,0 +1,31 @@
+package com.logseq.app
+
+import android.app.Activity
+import android.content.Context
+import android.graphics.Color
+import android.view.View
+import android.view.ViewGroup
+import android.widget.FrameLayout
+
+object NativeUiUtils {
+    fun dp(context: Context, value: Float): Int =
+        (value * context.resources.displayMetrics.density).toInt()
+
+    fun parseColor(hex: String?, defaultColor: Int): Int {
+        if (hex.isNullOrBlank()) return defaultColor
+        return try {
+            Color.parseColor(hex)
+        } catch (_: IllegalArgumentException) {
+            defaultColor
+        }
+    }
+
+    fun contentRoot(activity: Activity): FrameLayout =
+        activity.findViewById(android.R.id.content)
+
+    fun detachView(view: View?): ViewGroup? {
+        val parent = view?.parent as? ViewGroup ?: return null
+        parent.removeView(view)
+        return parent
+    }
+}

+ 74 - 0
android/app/src/main/java/com/logseq/app/NavigationCoordinator.kt

@@ -0,0 +1,74 @@
+package com.logseq.app
+
+class NavigationCoordinator {
+    private val primaryStack = "home"
+    private val stackPaths: MutableMap<String, MutableList<String>> = mutableMapOf(
+        primaryStack to mutableListOf("/")
+    )
+    var activeStackId: String = primaryStack
+        private set
+
+    private fun defaultPath(stack: String): String =
+        if (stack == primaryStack) "/" else "/__stack__/$stack"
+
+    private fun normalizedPath(raw: String?): String =
+        raw?.takeIf { it.isNotBlank() } ?: "/"
+
+    fun onRouteChange(stack: String?, navigationType: String?, path: String?) {
+        val stackId = stack?.takeIf { it.isNotBlank() } ?: primaryStack
+        val navType = navigationType?.lowercase() ?: "push"
+        val resolvedPath = normalizedPath(path)
+
+        val paths = stackPaths.getOrPut(stackId) { mutableListOf(defaultPath(stackId)) }
+
+        when (navType) {
+            "reset" -> {
+                paths.clear()
+                paths.add(resolvedPath)
+            }
+
+            "replace" -> {
+                if (paths.isEmpty()) {
+                    paths.add(resolvedPath)
+                } else {
+                    paths[paths.lastIndex] = resolvedPath
+                }
+            }
+
+            "pop" -> {
+                if (paths.size > 1) {
+                    paths.removeAt(paths.lastIndex)
+                }
+            }
+
+            else -> { // push (default)
+                if (paths.isEmpty()) {
+                    paths.add(resolvedPath)
+                } else if (paths.last() != resolvedPath) {
+                    paths.add(resolvedPath)
+                }
+            }
+        }
+
+        // Special case: reset home stack to root when path is "/"
+        if (stackId == primaryStack && resolvedPath == "/") {
+            paths.clear()
+            paths.add("/")
+        }
+
+        activeStackId = stackId
+        stackPaths[stackId] = paths
+    }
+
+    fun canPop(): Boolean {
+        val paths = stackPaths[activeStackId] ?: return false
+        return paths.size > 1
+    }
+
+    fun pop(): String? {
+        val paths = stackPaths[activeStackId] ?: return null
+        if (paths.size <= 1) return null
+        paths.removeAt(paths.lastIndex)
+        return paths.lastOrNull()
+    }
+}

+ 61 - 6
android/app/src/main/java/com/logseq/app/UILocal.kt

@@ -1,13 +1,12 @@
 package com.logseq.app
 
-import android.app.AlertDialog
 import android.app.DatePickerDialog
-import android.os.Build
+import android.content.Intent
 import android.view.View
 import android.view.ViewGroup
 import android.widget.DatePicker
 import android.widget.FrameLayout
-import androidx.annotation.RequiresApi
+import android.widget.Toast
 import com.getcapacitor.JSObject
 import com.getcapacitor.Plugin
 import com.getcapacitor.PluginCall
@@ -19,14 +18,17 @@ import java.util.Locale
 
 @CapacitorPlugin(name = "UILocal")
 class UILocal : Plugin() {
+  private var toast: Toast? = null
+  companion object {
+    const val ACTION_ROUTE_CHANGED = "com.logseq.app.ROUTE_DID_CHANGE"
+  }
 
-  @RequiresApi(Build.VERSION_CODES.O)
   @PluginMethod
   fun showDatePicker(call: PluginCall) {
     val defaultDate = call.getString("defaultDate")
     val calendar = Calendar.getInstance()
 
-    if (defaultDate.isNullOrEmpty()) {
+    if (!defaultDate.isNullOrEmpty()) {
       try {
         val sdf = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
         val date = defaultDate?.let { sdf.parse(it) }
@@ -85,4 +87,57 @@ class UILocal : Plugin() {
       call.reject("Error showing date picker", e)
     }
   }
-}
+
+  @PluginMethod
+  fun alert(call: PluginCall) {
+    val message = call.getString("title") ?: call.getString("message")
+    val duration = if ((call.getDouble("duration") ?: 0.0) > 3.5) Toast.LENGTH_LONG else Toast.LENGTH_SHORT
+    val ctx = context ?: run {
+      call.reject("No context")
+      return
+    }
+    if (message.isNullOrBlank()) {
+      call.reject("title or message is required")
+      return
+    }
+    toast?.cancel()
+    toast = Toast.makeText(ctx, message, duration).also { it.show() }
+    call.resolve()
+  }
+
+  @PluginMethod
+  fun hideAlert(call: PluginCall) {
+    toast?.cancel()
+    toast = null
+    call.resolve()
+  }
+
+  @PluginMethod
+  fun routeDidChange(call: PluginCall) {
+    val navigationType = call.getString("navigationType") ?: "push"
+    val push = call.getBoolean("push") ?: (navigationType == "push")
+    val path = call.getString("path") ?: "/"
+    val stack = call.getString("stack") ?: "home"
+
+    // Drive Compose Nav for native animations/back handling.
+    ComposeHost.applyNavigation(navigationType, path)
+
+    val ctx = context
+    if (ctx != null) {
+      val intent = Intent(ACTION_ROUTE_CHANGED).apply {
+        putExtra("navigationType", navigationType)
+        putExtra("push", push)
+        putExtra("stack", stack)
+        putExtra("path", path)
+      }
+      ctx.sendBroadcast(intent)
+    }
+
+    call.resolve()
+  }
+
+  @PluginMethod
+  fun transcribeAudio2Text(call: PluginCall) {
+    call.reject("transcription not supported on Android")
+  }
+}

+ 38 - 0
android/app/src/main/java/com/logseq/app/Utils.kt

@@ -0,0 +1,38 @@
+package com.logseq.app
+
+import androidx.appcompat.app.AppCompatDelegate
+import com.getcapacitor.Plugin
+import com.getcapacitor.PluginCall
+import com.getcapacitor.PluginMethod
+import com.getcapacitor.annotation.CapacitorPlugin
+
+@CapacitorPlugin(name = "Utils")
+class Utils : Plugin() {
+  @PluginMethod
+  fun setInterfaceStyle(call: PluginCall) {
+    val mode = (call.getString("mode") ?: "system").lowercase()
+    val system = call.getBoolean("system") ?: (mode == "system")
+
+    val nightMode =
+      if (system || mode == "system") {
+        AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
+      } else if (mode == "dark") {
+        AppCompatDelegate.MODE_NIGHT_YES
+      } else {
+        AppCompatDelegate.MODE_NIGHT_NO
+      }
+
+    val activity = activity
+    if (activity == null) {
+      call.reject("No activity")
+      return
+    }
+
+    activity.runOnUiThread {
+      AppCompatDelegate.setDefaultNightMode(nightMode)
+      (activity as? MainActivity)?.applyLogseqThemeNow()
+      call.resolve()
+    }
+  }
+}
+

+ 185 - 0
android/app/src/main/java/com/logseq/app/WebViewSnapshotManager.kt

@@ -0,0 +1,185 @@
+package com.logseq.app
+
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Rect
+import android.os.Build
+import android.os.Handler
+import android.os.Looper
+import android.view.PixelCopy
+import android.view.View
+import android.view.Window
+import android.widget.FrameLayout
+import android.widget.ImageView
+import java.lang.ref.WeakReference
+
+/**
+ * Utility to capture lightweight WebView snapshots and show them in the overlay container.
+ * Used to keep the background stable while the shared WebView is being reparented
+ * (navigation transitions, bottom sheet presentation/dismiss).
+ */
+object WebViewSnapshotManager {
+    private var overlayRef: WeakReference<FrameLayout>? = null
+    private var windowRef: WeakReference<Window>? = null
+    private val snapshotRefs: MutableMap<String, WeakReference<View>> = mutableMapOf()
+    private val containerRefs: MutableMap<String, WeakReference<FrameLayout>> = mutableMapOf()
+    private var snapshotBackgroundColor: Int = LogseqTheme.current().background
+    private val mainHandler = Handler(Looper.getMainLooper())
+
+    fun setSnapshotBackgroundColor(color: Int) {
+        snapshotBackgroundColor = color
+        overlayRef?.get()?.setBackgroundColor(Color.TRANSPARENT)
+    }
+
+    fun registerWindow(window: Window?) {
+        windowRef = window?.let { WeakReference(it) }
+    }
+
+    fun registerOverlay(overlay: FrameLayout?) {
+        overlayRef = overlay?.let { WeakReference(it) }
+    }
+
+    fun showSnapshot(tag: String, webView: View): View? {
+        val overlay = ensureOverlay(webView) ?: return null
+        clearSnapshot(tag)
+        overlay.visibility = View.VISIBLE
+
+        val snapshotView = makeSnapshotView(webView)
+
+        overlay.addView(
+            snapshotView,
+            FrameLayout.LayoutParams(
+                FrameLayout.LayoutParams.MATCH_PARENT,
+                FrameLayout.LayoutParams.MATCH_PARENT
+            )
+        )
+        overlay.bringChildToFront(snapshotView)
+
+        snapshotRefs[tag] = WeakReference(snapshotView)
+        containerRefs[tag] = WeakReference(overlay)
+
+        return snapshotView
+    }
+
+    fun clearSnapshot(tag: String) {
+        val view = snapshotRefs.remove(tag)?.get()
+        val container = containerRefs.remove(tag)?.get()
+        if (view != null && container != null) {
+            container.removeView(view)
+            if (container.childCount == 0) {
+                container.visibility = View.GONE
+            }
+        }
+    }
+
+    fun clearAll() {
+        snapshotRefs.keys.toList().forEach { clearSnapshot(it) }
+    }
+
+    private fun ensureOverlay(webView: View): FrameLayout? {
+        overlayRef?.get()?.let { return it }
+        val root = webView.rootView
+        val overlay = root.findViewById<FrameLayout>(R.id.webview_overlay_container)
+        if (overlay != null) {
+            overlayRef = WeakReference(overlay)
+        }
+        return overlay
+    }
+
+    private fun makeSnapshotView(webView: View): View {
+        val width = webView.width
+        val height = webView.height
+        if (width <= 0 || height <= 0) {
+            return View(webView.context).apply {
+                layoutParams = FrameLayout.LayoutParams(
+                    FrameLayout.LayoutParams.MATCH_PARENT,
+                    FrameLayout.LayoutParams.MATCH_PARENT
+                )
+                setBackgroundColor(snapshotBackgroundColor)
+                isClickable = false
+                isFocusable = false
+            }
+        }
+
+        // Limit bitmap size to avoid OOM (e.g., max 4096x4096 or 16MB)
+        val MAX_BITMAP_DIMENSION = 4096
+        val MAX_BITMAP_SIZE = 16 * 1024 * 1024 // 16MB
+        val safeWidth = width.coerceAtMost(MAX_BITMAP_DIMENSION)
+        val safeHeight = height.coerceAtMost(MAX_BITMAP_DIMENSION)
+        val estimatedSize = safeWidth * safeHeight * 4
+        if (estimatedSize > MAX_BITMAP_SIZE) {
+            // Fallback: show plain color view if too large
+            return View(webView.context).apply {
+                layoutParams = FrameLayout.LayoutParams(
+                    FrameLayout.LayoutParams.MATCH_PARENT,
+                    FrameLayout.LayoutParams.MATCH_PARENT
+                )
+                setBackgroundColor(snapshotBackgroundColor)
+                isClickable = false
+                isFocusable = false
+            }
+        }
+
+        val bitmap = try {
+            Bitmap.createBitmap(safeWidth, safeHeight, Bitmap.Config.ARGB_8888)
+        } catch (e: OutOfMemoryError) {
+            // Fallback: show plain color view if OOM
+            return View(webView.context).apply {
+                layoutParams = FrameLayout.LayoutParams(
+                    FrameLayout.LayoutParams.MATCH_PARENT,
+                    FrameLayout.LayoutParams.MATCH_PARENT
+                )
+                setBackgroundColor(snapshotBackgroundColor)
+                isClickable = false
+                isFocusable = false
+            }
+        }
+        // Fast fallback: capture via View#draw (can be imperfect for WebView on some devices).
+        try {
+            val canvas = Canvas(bitmap)
+            canvas.drawColor(snapshotBackgroundColor)
+            // If we had to scale down, scale the canvas
+            if (safeWidth != width || safeHeight != height) {
+                val scaleX = safeWidth.toFloat() / width
+                val scaleY = safeHeight.toFloat() / height
+                canvas.scale(scaleX, scaleY)
+            }
+            webView.draw(canvas)
+        } catch (_: Exception) {
+            // Keep bitmap with background color only.
+        }
+
+        val imageView = ImageView(webView.context).apply {
+            setImageBitmap(bitmap)
+            scaleType = ImageView.ScaleType.FIT_XY
+            layoutParams = FrameLayout.LayoutParams(
+                FrameLayout.LayoutParams.MATCH_PARENT,
+                FrameLayout.LayoutParams.MATCH_PARENT
+            )
+            setBackgroundColor(snapshotBackgroundColor)
+            isClickable = false
+            isFocusable = false
+        }
+
+        // Higher-fidelity capture: PixelCopy from the Window buffer.
+        val window = windowRef?.get()
+        if (window != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+            try {
+                val loc = IntArray(2)
+                webView.getLocationInWindow(loc)
+                val rect = Rect(loc[0], loc[1], loc[0] + width, loc[1] + height)
+
+                PixelCopy.request(window, rect, bitmap, { result ->
+                    if (result == PixelCopy.SUCCESS) {
+                        imageView.invalidate()
+                    }
+                }, mainHandler)
+            } catch (_: Exception) {
+                // Ignore; fallback bitmap already set.
+            }
+        }
+
+        return imageView
+    }
+}

+ 3 - 1
android/app/src/main/res/values/colors.xml

@@ -1,6 +1,8 @@
 <?xml version="1.0" encoding="utf-8"?>
 <resources>
     <color name="logoPrimary">#0f262e</color>
-    <color name="colorPrimary">#f7f7f7</color>
+    <!-- Main brand color (HSL 200 97% 37%) -->
+    <color name="colorPrimary">#037DBA</color>
+    <color name="colorAccent">#6097c7</color>
     <color name="colorPrimaryDark">#0f262e</color>
 </resources>

+ 5 - 0
android/app/src/main/res/values/ids.xml

@@ -0,0 +1,5 @@
+<resources>
+    <item name="webview_container" type="id"/>
+    <item name="webview_overlay_container" type="id"/>
+    <item name="liquid_tabs_bottom_nav" type="id"/>
+</resources>

+ 1 - 0
android/app/src/main/res/values/styles.xml

@@ -14,6 +14,7 @@
         <item name="windowNoTitle">true</item>
         <item name="android:background">@null</item>
         <item name="android:windowIsTranslucent">true</item>
+        <item name="colorAccent">@color/colorAccent</item>
         <item name="android:navigationBarColor">@color/colorPrimary</item>
         <item name="android:statusBarColor">@color/colorPrimary</item>
     </style>

+ 5 - 3
android/build.gradle

@@ -1,9 +1,11 @@
 // Top-level build file where you can add configuration options common to all sub-projects/modules.
 
 buildscript {
-    
+
     ext {
-        kotlin_version = '2.1.21'
+        kotlin_version = '2.0.21'
+        composeCompilerVersion = '2.0.1'
+        androidxActivityVersion = '1.9.2'
     }
     repositories {
         google()
@@ -14,6 +16,7 @@ buildscript {
         classpath 'com.android.tools.build:gradle:8.7.2'
         classpath 'com.google.gms:google-services:4.4.2'
         classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
+        classpath "org.jetbrains.kotlin:compose-compiler-gradle-plugin:$composeCompilerVersion"
 
         // NOTE: Do not place your application dependencies here; they belong
         // in the individual module build.gradle files
@@ -33,4 +36,3 @@ allprojects {
 task clean(type: Delete) {
     delete rootProject.buildDir
 }
-

+ 4 - 0
android/variables.gradle

@@ -6,6 +6,7 @@ ext {
     androidxAppCompatVersion = '1.7.0'
     androidxCoordinatorLayoutVersion = '1.2.0'
     androidxCoreVersion = '1.15.0'
+    androidxNavigationVersion = '2.9.6'
     androidxFragmentVersion = '1.8.4'
     junitVersion = '4.13.2'
     androidxJunitVersion = '1.2.1'
@@ -13,4 +14,7 @@ ext {
     cordovaAndroidVersion = '10.1.1'
     coreSplashScreenVersion = '1.0.1'
     androidxWebkitVersion = '1.12.1'
+    materialVersion = '1.12.0'
+    composeBomVersion = '2024.09.02'
+    composeCompilerVersion = '1.7.0'
 }

+ 36 - 13
src/main/frontend/mobile/util.cljs

@@ -22,19 +22,27 @@
 (defn convert-file-src [path-str]
   (.convertFileSrc Capacitor path-str))
 
+(defn plugin-available?
+  "Check if a native plugin is available from Capacitor.Plugins."
+  [name]
+  (boolean (aget (.-Plugins js/Capacitor) name)))
+
 (defonce folder-picker (registerPlugin "FolderPicker"))
 (defonce ui-local (registerPlugin "UILocal"))
-(defonce native-top-bar nil)
-(defonce native-bottom-sheet nil)
-(defonce native-editor-toolbar nil)
-(defonce native-selection-action-bar nil)
-(defonce ios-utils nil)
-(when (native-ios?)
-  (set! native-top-bar (registerPlugin "NativeTopBarPlugin"))
-  (set! native-bottom-sheet (registerPlugin "NativeBottomSheetPlugin"))
-  (set! native-editor-toolbar (registerPlugin "NativeEditorToolbarPlugin"))
-  (set! native-selection-action-bar (registerPlugin "NativeSelectionActionBarPlugin"))
-  (set! ios-utils (registerPlugin "Utils")))
+(defonce native-top-bar (when (and (native-platform?)
+                                   (plugin-available? "NativeTopBarPlugin"))
+                          (registerPlugin "NativeTopBarPlugin")))
+(defonce native-bottom-sheet (when (and (native-platform?)
+                                        (plugin-available? "NativeBottomSheetPlugin"))
+                               (registerPlugin "NativeBottomSheetPlugin")))
+(defonce native-editor-toolbar (when (and (native-platform?)
+                                          (plugin-available? "NativeEditorToolbarPlugin"))
+                                 (registerPlugin "NativeEditorToolbarPlugin")))
+(defonce native-selection-action-bar (when (and (native-platform?)
+                                                (plugin-available? "NativeSelectionActionBarPlugin"))
+                                       (registerPlugin "NativeSelectionActionBarPlugin")))
+(defonce ios-utils (when (native-ios?) (registerPlugin "Utils")))
+(defonce android-utils (when (native-android?) (registerPlugin "Utils")))
 
 (defonce ios-content-size-listener nil)
 
@@ -71,6 +79,21 @@
      (.setInterfaceStyle ^js ios-utils (clj->js {:mode mode
                                                  :system system?})))))
 
+(defn set-android-interface-style!
+  [mode system?]
+  (when (native-android?)
+    (p/do!
+     (.setInterfaceStyle ^js android-utils (clj->js {:mode mode
+                                                     :system system?})))))
+
+(defn set-native-interface-style!
+  "Sync native light/dark/system appearance with Logseq theme mode."
+  [mode system?]
+  (cond
+    (native-ios?) (set-ios-interface-style! mode system?)
+    (native-android?) (set-android-interface-style! mode system?)
+    :else nil))
+
 (defn get-idevice-model
   []
   (when (native-ios?)
@@ -158,11 +181,11 @@
                   accessibility (assoc :accessibility accessibility))]
     (cond
       (not title) (p/rejected (js/Error. "title is required"))
-      (native-ios?) (.alert ^js ui-local (clj->js payload))
+      (native-platform?) (.alert ^js ui-local (clj->js payload))
       :else (p/resolved nil))))
 
 (defn hide-alert []
-  (if (native-ios?)
+  (if (native-platform?)
     (.hideAlert ^js ui-local)
     (p/resolved nil)))
 

+ 4 - 4
src/main/frontend/state.cljs

@@ -1430,8 +1430,8 @@ Similar to re-frame subscriptions"
      (if (= mode "light")
        (util/set-theme-light)
        (util/set-theme-dark)))
-   (when (mobile-util/native-ios?)
-     (mobile-util/set-ios-interface-style! mode system-theme?))
+   (when (mobile-util/native-platform?)
+     (mobile-util/set-native-interface-style! mode system-theme?))
    (set-state! :ui/theme mode)
    (storage/set :ui/theme mode)))
 
@@ -1475,8 +1475,8 @@ Similar to re-frame subscriptions"
   []
   (let [mode (or (storage/get :ui/theme) "light")
         system-theme? (storage/get :ui/system-theme?)]
-    (when (mobile-util/native-ios?)
-      (mobile-util/set-ios-interface-style! mode system-theme?))
+    (when (mobile-util/native-platform?)
+      (mobile-util/set-native-interface-style! mode system-theme?))
     (when (and (not system-theme?)
                (mobile-util/native-platform?))
       (if (= mode "light")

+ 93 - 67
src/main/mobile/bottom_tabs.cljs

@@ -1,17 +1,18 @@
 (ns mobile.bottom-tabs
-  "iOS bottom tabs"
+  "Native bottom tabs"
   (:require [cljs-bean.core :as bean]
             [clojure.string :as string]
             [frontend.handler.editor :as editor-handler]
             [frontend.handler.route :as route-handler]
+            [frontend.mobile.util :as mobile-util]
             [frontend.state :as state]
             [frontend.util :as util]
+            [mobile.navigation :as mobile-nav]
             [mobile.search :as mobile-search]
             [mobile.state :as mobile-state]
             [promesa.core :as p]))
 
-;; Capacitor plugin instance:
-;; Make sure the plugin is registered as `LiquidTabs` on the native side.
+;; Capacitor plugin instance (nil if native side hasn't shipped it yet).
 (def ^js liquid-tabs
   (.. js/Capacitor -Plugins -LiquidTabsPlugin))
 
@@ -22,17 +23,19 @@
    [{:id \"home\"   :title \"Home\"   :system-image \"house\"   :role \"normal\"}
     {:id \"search\" :title \"Search\" :system-image \"magnifyingglass\" :role \"search\"}]"
   [tabs]
-  ;; Returns the underlying JS Promise from Capacitor
-  (.configureTabs
-   liquid-tabs
-   (bean/->js {:tabs tabs})))
+  (when liquid-tabs
+    ;; Returns the underlying JS Promise from Capacitor
+    (.configureTabs
+     liquid-tabs
+     (bean/->js {:tabs tabs}))))
 
 (defn select!
   "Programmatically select a tab by id. Returns a JS Promise."
   [id]
-  (.selectTab
-   liquid-tabs
-   #js {:id id}))
+  (when liquid-tabs
+    (.selectTab
+     liquid-tabs
+     #js {:id id})))
 
 (defn update-native-search-results!
   "Send native search result list to the iOS plugin."
@@ -43,17 +46,17 @@
 (defn add-tab-selected-listener!
   "Listen to native tab selection.
 
-   `f` receives the tab id string.
+   `f` receives the tab id string and a boolean indicating reselect.
    Returns the Capacitor listener handle; call `(.remove handle)` to unsubscribe."
   [f]
   (when (and (util/capacitor?) liquid-tabs)
     (.addListener
      liquid-tabs
      "tabSelected"
-     (fn [data]
-      ;; data is like { id: string }
+     (fn [^js data]
+      ;; data is like { id: string, reselected?: boolean }
        (when-let [id (.-id data)]
-         (f id))))))
+         (f id (boolean (.-reselected data))))))))
 
 (defn add-search-listener!
   "Listen to native search query changes from the SwiftUI search tab.
@@ -61,54 +64,71 @@
    `f` receives a query string.
    Returns the Capacitor listener handle; call `(.remove handle)` to unsubscribe."
   [f]
-  (.addListener
-   liquid-tabs
-   "searchChanged"
-   (fn [data]
-       ;; data is like { query: string }
-     (f (.-query data)))))
+  (when (and (util/capacitor?) liquid-tabs)
+    (.addListener
+     liquid-tabs
+     "searchChanged"
+     (fn [data]
+         ;; data is like { query: string }
+       (f (.-query data))))))
 
 (defn add-search-result-item-listener!
   []
-  (.addListener
-   liquid-tabs
-   "openSearchResultBlock"
-   (fn [data]
-     (when-let [id (.-id data)]
-       (when-not (string/blank? id)
-         (route-handler/redirect-to-page! id {:push false}))))))
+  (when (and (util/capacitor?) liquid-tabs)
+    (.addListener
+     liquid-tabs
+     "openSearchResultBlock"
+     (fn [data]
+       (when-let [id (.-id data)]
+         (when-not (string/blank? id)
+           (route-handler/redirect-to-page! id {:push (mobile-util/native-android?)})))))))
 
 (defn add-keyboard-hack-listener!
   "Listen for Backspace or Enter while the invisible keyboard field is focused."
   []
-  (.addListener
-   liquid-tabs
-   "keyboardHackKey"
-   (fn [data]
-     ;; data is like { key: string }
-     (when-let [k (.-key data)]
-       (case k
-         "backspace"
-         (editor-handler/delete-block-when-zero-pos! nil)
-         "enter"
-         (when-let [input (state/get-input)]
-           (let [value (.-value input)]
-             (when (string/blank? value)
-               (editor-handler/keydown-new-block-handler nil))))
-         nil)))))
+  (when (and (util/capacitor?) liquid-tabs)
+    (.addListener
+     liquid-tabs
+     "keyboardHackKey"
+     (fn [data]
+       ;; data is like { key: string }
+       (when-let [k (.-key data)]
+         (case k
+           "backspace"
+           (editor-handler/delete-block-when-zero-pos! nil)
+           "enter"
+           (when-let [input (state/get-input)]
+             (let [value (.-value input)]
+               (when (string/blank? value)
+                 (editor-handler/keydown-new-block-handler nil))))
+           nil))))))
 
 (defonce add-tab-listeners!
-  (do
-    (add-tab-selected-listener!
-     (fn [tab]
-       (mobile-state/set-tab! tab)
+  (when (and (util/capacitor?) liquid-tabs)
+    (let [*current-tab (atom nil)]
+      (add-tab-selected-listener!
+       (fn [tab reselected?]
+         (cond
+           reselected?
+           (do
+             (mobile-nav/pop-to-root! tab)
+             (mobile-state/set-tab! tab)
+             (when (= "home" tab)
+               (util/scroll-to-top false)))
+
+           (not= @*current-tab tab)
+           (do
+             (reset! *current-tab tab)
+             (mobile-state/set-tab! tab)
+             (when (= "home" tab)
+               (util/scroll-to-top false))))))
 
-       (when (= "home" tab)
-         (util/scroll-to-top false))))
+      (add-watch mobile-state/*tab ::select-tab
+                 (fn [_ _ _old new]
+                   (when (and new (not= @*current-tab new))
+                     (reset! *current-tab new)
+                     (select! new)))))
 
-    (add-watch mobile-state/*tab ::select-tab
-               (fn [_ _ _old new]
-                 (when new (select! new))))
     (add-search-listener!
      (fn [q]
        ;; wire up search handler
@@ -122,19 +142,25 @@
 (defn configure
   []
   (configure-tabs
-   [{:id "home"
-     :title "Home"
-     :systemImage "house"
-     :role "normal"}
-    {:id "graphs"
-     :title "Graphs"
-     :systemImage "app.background.dotted"
-     :role "normal"}
-    {:id "capture"
-     :title "Capture"
-     :systemImage "tray"
-     :role "normal"}
-    {:id "go to"
-     :title "Go To"
-     :systemImage "square.stack.3d.down.right"
-     :role "normal"}]))
+   (cond->
+    [{:id "home"
+      :title "Home"
+      :systemImage "house"
+      :role "normal"}
+     {:id "graphs"
+      :title "Graphs"
+      :systemImage "app.background.dotted"
+      :role "normal"}
+     {:id "capture"
+      :title "Capture"
+      :systemImage "tray"
+      :role "normal"}
+     {:id "go to"
+      :title "Go To"
+      :systemImage "square.stack.3d.down.right"
+      :role "normal"}]
+     (mobile-util/native-android?)
+     (conj {:id "search"
+            :title "Search"
+            :systemImage "search"
+            :role "search"}))))

+ 12 - 4
src/main/mobile/components/app.cljs

@@ -53,7 +53,13 @@
   (hooks/use-effect!
    (fn []
      (state/sync-system-theme!)
-     (ui/setup-system-theme-effect!))
+     (ui/setup-system-theme-effect!)
+     (let [handler (fn [^js e]
+                     (when (:ui/system-theme? @state/state)
+                       (let [is-dark? (boolean (some-> e .-detail .-isDark))]
+                         (state/set-theme-mode! (if is-dark? "dark" "light") true))))]
+       (.addEventListener js/window "logseq:native-system-theme-changed" handler)
+       #(.removeEventListener js/window "logseq:native-system-theme-changed" handler)))
    [])
   (hooks/use-effect!
    #(let [^js doc js/document.documentElement
@@ -89,7 +95,9 @@
   {:did-mount (fn [state]
                 (p/do!
                  (editor-handler/quick-add-ensure-new-block-exists!)
-                 (editor-handler/quick-add-open-last-block!))
+                 (when (mobile-util/native-ios?)
+                   ;; FIXME: android doesn't open keyboard automatically
+                   (editor-handler/quick-add-open-last-block!)))
                 state)}
   []
   (quick-add/quick-add))
@@ -118,7 +126,7 @@
     ;; Both are absolutely positioned and stacked; we toggle visibility.
     [:div.w-full.relative
         ;; Journals scroll container (keep-alive)
-     [:div#app-main-home.pl-3.pr-2.absolute.inset-0
+     [:div#app-main-home.pl-4.pr-3.absolute.inset-0
       {:class (when-not home? "invisible pointer-events-none")}
       (home)]
 
@@ -134,7 +142,7 @@
     (use-theme-effects! current-repo theme)
     (hooks/use-effect!
      (fn []
-       (when (mobile-util/native-ios?)
+       (when (mobile-util/native-platform?)
          (bottom-tabs/configure))
        (when-let [element (util/app-scroll-container-node)]
          (common-handler/listen-to-scroll! element)))

+ 6 - 0
src/main/mobile/components/app.css

@@ -23,6 +23,12 @@ html.is-ios {
     padding-bottom: env(safe-area-inset-bottom);
 }
 
+html.is-native-android {
+  #app-container-wrapper {
+    padding-top: 56px;
+    padding-bottom: 0;
+  }
+}
 html.is-native-ios body,
 html.is-native-ios textarea,
 html.is-native-ios input,

+ 7 - 9
src/main/mobile/components/editor_toolbar.cljs

@@ -1,7 +1,6 @@
 (ns mobile.components.editor-toolbar
   "Mobile editor toolbar"
-  (:require [frontend.colors :as colors]
-            [frontend.commands :as commands]
+  (:require [frontend.commands :as commands]
             [frontend.handler.editor :as editor-handler]
             [frontend.handler.history :as history]
             [frontend.mobile.camera :as mobile-camera]
@@ -179,12 +178,12 @@
         native-actions (mapv action->native main)
         trailing-native (some-> trailing action->native)
         plugin ^js mobile-util/native-editor-toolbar
-        should-show? (and show? (mobile-util/native-ios?) (some? plugin))]
+        should-show? (and show? (mobile-util/native-platform?) (some? plugin))]
     (set! (.-current handlers-ref) (action-handlers main trailing))
 
     (hooks/use-effect!
      (fn []
-       (when (and (mobile-util/native-ios?) plugin)
+       (when (and (mobile-util/native-platform?) plugin)
          (let [listener (.addListener plugin "action"
                                       (fn [^js e]
                                         (when-let [id (.-id e)]
@@ -199,12 +198,11 @@
 
     (hooks/use-effect!
      (fn []
-       (when (mobile-util/native-ios?)
+       (when (mobile-util/native-platform?)
          (when should-show?
            (.present plugin (clj->js {:actions native-actions
-                                      :trailingAction trailing-native
-                                      :tintColor (colors/get-accent-color)}))))
-       #(when (and (mobile-util/native-ios?) should-show?)
+                                      :trailingAction trailing-native}))))
+       #(when (and (mobile-util/native-platform?) should-show?)
           (.dismiss plugin)))
      [should-show? native-actions trailing-native])
 
@@ -217,5 +215,5 @@
         show? (and (not code-block?)
                    editing?)
         actions (toolbar-actions)]
-    (when (mobile-util/native-ios?)
+    (when (mobile-util/native-platform?)
       (native-toolbar show? actions))))

+ 12 - 4
src/main/mobile/components/header.cljs

@@ -108,11 +108,13 @@
    {:default-height false}))
 
 (defn- register-native-top-bar-events! []
-  (when (and (mobile-util/native-ios?)
+  (when (and (mobile-util/native-platform?)
+             mobile-util/native-top-bar
              (not @native-top-bar-listener?))
     (.addListener ^js mobile-util/native-top-bar "buttonTapped"
                   (fn [^js e]
                     (case (.-id e)
+                      "back" (js/history.back)
                       "title" (open-graph-switch!)
                       "calendar" (open-journal-calendar!)
                       "capture" (do
@@ -142,8 +144,9 @@
 
 (defn- configure-native-top-bar!
   [repo {:keys [tab title route-name route-view sync-color favorited?]}]
-  (when (mobile-util/native-ios?)
-    (let [hidden? (= tab "search")
+  (when (and (mobile-util/native-platform?)
+             mobile-util/native-top-bar)
+    (let [hidden? (and (mobile-util/native-ios?) (= tab "search"))
           rtc-indicator? (and repo
                               (ldb/get-graph-rtc-uuid (db/get-db))
                               (user-handler/logged-in?))
@@ -153,6 +156,7 @@
                  (assoc :title title))
           page? (= route-name :page)
           left-buttons (cond
+                         page? [{:id "back" :systemIcon "chevron.backward"}]
                          (and (= tab "home") (nil? route-view))
                          [(conj {:id "calendar" :systemIcon "calendar"})]
                          (and (= tab "capture") (nil? route-view))
@@ -178,6 +182,9 @@
                           [{:id "capture" :systemIcon "paperplane"}]
 
                           :else nil)
+          [left-buttons right-buttons] (if (mobile-util/native-android?)
+                                         [(reverse left-buttons) (reverse right-buttons)]
+                                         [left-buttons right-buttons])
           header (cond-> base
                    left-buttons (assoc :leftButtons left-buttons)
                    right-buttons (assoc :rightButtons right-buttons)
@@ -208,7 +215,8 @@
                      "#CA8A04")]
     (hooks/use-effect!
      (fn []
-       (when (mobile-util/native-ios?)
+       (when (and (mobile-util/native-platform?)
+                  mobile-util/native-top-bar)
          (register-native-top-bar-events!)
          (p/let [block (when (= route-name :page)
                          (let [id (get-in route-match [:parameters :path :name])]

+ 7 - 9
src/main/mobile/components/popup.cljs

@@ -41,8 +41,7 @@
 
 (defn- handle-native-sheet-state!
   [^js data]
-  (let [;; presenting? (.-presenting data)
-        dismissing? (.-dismissing data)]
+  (let [dismissing? (.-dismissing data)]
     (cond
       dismissing?
       (when (some? @mobile-state/*popup-data)
@@ -50,17 +49,16 @@
          (state/pub-event! [:mobile/clear-edit])
          (mobile-state/set-popup! nil)
          (reset! *last-popup-data nil)
-         (when (mobile-util/native-ios?)
-           (let [plugin ^js mobile-util/native-editor-toolbar]
-             (.dismiss plugin)))))
+         (when-let [plugin ^js mobile-util/native-editor-toolbar]
+           (.dismiss plugin))))
 
       :else
       nil)))
 
 (defonce native-sheet-listener
-  (when (mobile-util/native-ios?)
-    (when-let [^js plugin mobile-util/native-bottom-sheet]
-      (.addListener plugin "state" handle-native-sheet-state!))))
+  (when-let [^js plugin (when (mobile-util/native-platform?)
+                          mobile-util/native-bottom-sheet)]
+    (.addListener plugin "state" handle-native-sheet-state!)))
 
 (defn- wrap-calc-commands-popup-side
   [pos opts]
@@ -98,7 +96,7 @@
     :else
     (when content-fn
       (reset! *last-popup? true)
-      (when (mobile-util/native-ios?)
+      (when-let [_plugin ^js mobile-util/native-bottom-sheet]
         (let [data {:open? true
                     :content-fn content-fn
                     :opts opts}]

+ 3 - 2
src/main/mobile/components/selection_toolbar.cljs

@@ -10,7 +10,8 @@
 
 (defn- dismiss-action-bar!
   []
-  (.dismiss ^js mobile-util/native-selection-action-bar))
+  (when-let [plugin ^js mobile-util/native-selection-action-bar]
+    (.dismiss plugin)))
 
 (defn close-selection-bar!
   []
@@ -81,7 +82,7 @@
 
     (hooks/use-effect!
      (fn []
-       (when (and (mobile-util/native-ios?)
+       (when (and (mobile-util/native-platform?)
                   mobile-util/native-selection-action-bar)
          (let [listener (.addListener ^js mobile-util/native-selection-action-bar
                                       "action"

+ 0 - 3
src/main/mobile/init.cljs

@@ -3,7 +3,6 @@
   (:require ["@capacitor/app" :refer [^js App]]
             ["@capacitor/keyboard" :refer [^js Keyboard]]
             ["@capacitor/network" :refer [^js Network]]
-            ["@capgo/capacitor-navigation-bar" :refer [^js NavigationBar]]
             [clojure.string :as string]
             [frontend.handler.editor :as editor-handler]
             [frontend.mobile.flows :as mobile-flows]
@@ -46,8 +45,6 @@
 (defn- android-init!
   "Initialize Android-specified event listeners"
   []
-  (js/setTimeout
-   #(.setNavigationBarColor NavigationBar #js {:color "transparent"}) 128)
   (.addListener App "backButton"
                 (fn []
                   (when (false?

+ 35 - 2
src/main/mobile/navigation.cljs

@@ -1,5 +1,5 @@
 (ns mobile.navigation
-  "Native navigation bridge for mobile (iOS)."
+  "Native navigation bridge for mobile."
   (:require [clojure.string :as string]
             [frontend.handler.route :as route-handler]
             [frontend.mobile.util :as mobile-util]
@@ -186,7 +186,7 @@
         path (if (string/blank? path) "/" path)]
     (set-current-stack! stack)
     (remember-route! stack navigation-type route path route-match)
-    (when (and (mobile-util/native-ios?)
+    (when (and (mobile-util/native-platform?)
                mobile-util/ui-local)
       (let [payload (cond-> {:navigationType navigation-type
                              :push push?
@@ -238,6 +238,12 @@
   (let [stack (current-stack)
         {:keys [history]} (get @stack-history stack)
         history (vec history)]
+    ;; back to search root
+    (when (and
+           (mobile-util/native-android?)
+           (= stack "search")
+           (= (count history) 2))
+      (.showSearchUiNative ^js (.. js/Capacitor -Plugins -LiquidTabsPlugin)))
     (when (>= (count history) 1)
       (let [root-history? (= (count history) 1)
             new-history (if root-history?
@@ -259,6 +265,33 @@
 
         (route-handler/set-route-match! route-match)))))
 
+(defn pop-to-root!
+  "Pop current or given stack back to its root entry and notify navigation."
+  ([] (pop-to-root! (current-stack)))
+  ([stack]
+   (when stack
+     (let [{:keys [history]} (get @stack-history stack)
+           root (or (first history) (stack-defaults stack))
+           {:keys [route route-match path]} root
+           route-match (or route-match (:route-match (stack-defaults stack)))
+           path (or path (current-path))
+           route (or route {:to (get-in route-match [:data :name])
+                            :path-params (get-in route-match [:parameters :path])
+                            :query-params (get-in route-match [:parameters :query])})]
+       (swap! stack-history assoc stack {:history [root]})
+       (set-current-stack! stack)
+       ;; Use original replace-state to avoid recording a push intent.
+       (orig-replace-state (get-in route-match [:data :name])
+                           (get-in route-match [:parameters :path])
+                           (get-in route-match [:parameters :query]))
+       (route-handler/set-route-match! route-match)
+       (notify-route-change!
+        {:route route
+         :route-match route-match
+         :path path
+         :stack stack
+         :push false})))))
+
 (defn ^:export install-native-bridge!
   []
   (set! (.-LogseqNative js/window)