Initial import of LogDarioSeq baseline

Establishes version control for the personal Logseq DB fork before
starting the server-client architecture migration and domain features
described in docs/agent-guide and the approved implementation plan.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 15:32:15 +02:00
commit 40631362b3
2016 changed files with 543898 additions and 0 deletions

2
android/app/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/build/*
!/build/.npmkeep

79
android/app/build.gradle Normal file
View File

@@ -0,0 +1,79 @@
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"
compileSdkVersion rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "com.logseq.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 88
versionName "2.0.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion rootProject.ext.composeCompilerVersion
}
kotlinOptions {
jvmTarget = '21'
}
}
repositories {
flatDir {
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
}
dependencies {
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
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"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch (Exception e) {
logger.warn("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}

View File

@@ -0,0 +1,36 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':aparajita-capacitor-secure-storage')
implementation project(':capacitor-community-safe-area')
implementation project(':capacitor-action-sheet')
implementation project(':capacitor-app')
implementation project(':capacitor-camera')
implementation project(':capacitor-clipboard')
implementation project(':capacitor-device')
implementation project(':capacitor-dialog')
implementation project(':capacitor-filesystem')
implementation project(':capacitor-haptics')
implementation project(':capacitor-keyboard')
implementation project(':capacitor-network')
implementation project(':capacitor-share')
implementation project(':capacitor-splash-screen')
implementation project(':capacitor-status-bar')
implementation project(':capgo-capacitor-navigation-bar')
implementation project(':send-intent')
implementation project(':jcesarmobile-ssl-skip')
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}

21
android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="utf-8" ?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<application
android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
tools:targetApi="tiramisu"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:name="com.logseq.app.MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
android:exported="true"
android:windowSoftInputMode="adjustResize"
android:label="@string/title_activity_main"
android:launchMode="singleTask"
android:theme="@style/AppTheme.NoActionBarLaunch">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
<data android:mimeType="image/*" />
<data android:mimeType="application/*" />
<data android:mimeType="video/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="logseq" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View File

@@ -0,0 +1,74 @@
[
{
"pkg": "@aparajita/capacitor-secure-storage",
"classpath": "com.aparajita.capacitor.securestorage.SecureStorage"
},
{
"pkg": "@capacitor-community/safe-area",
"classpath": "com.getcapacitor.community.safearea.SafeAreaPlugin"
},
{
"pkg": "@capacitor/action-sheet",
"classpath": "com.capacitorjs.plugins.actionsheet.ActionSheetPlugin"
},
{
"pkg": "@capacitor/app",
"classpath": "com.capacitorjs.plugins.app.AppPlugin"
},
{
"pkg": "@capacitor/camera",
"classpath": "com.capacitorjs.plugins.camera.CameraPlugin"
},
{
"pkg": "@capacitor/clipboard",
"classpath": "com.capacitorjs.plugins.clipboard.ClipboardPlugin"
},
{
"pkg": "@capacitor/device",
"classpath": "com.capacitorjs.plugins.device.DevicePlugin"
},
{
"pkg": "@capacitor/dialog",
"classpath": "com.capacitorjs.plugins.dialog.DialogPlugin"
},
{
"pkg": "@capacitor/filesystem",
"classpath": "com.capacitorjs.plugins.filesystem.FilesystemPlugin"
},
{
"pkg": "@capacitor/haptics",
"classpath": "com.capacitorjs.plugins.haptics.HapticsPlugin"
},
{
"pkg": "@capacitor/keyboard",
"classpath": "com.capacitorjs.plugins.keyboard.KeyboardPlugin"
},
{
"pkg": "@capacitor/network",
"classpath": "com.capacitorjs.plugins.network.NetworkPlugin"
},
{
"pkg": "@capacitor/share",
"classpath": "com.capacitorjs.plugins.share.SharePlugin"
},
{
"pkg": "@capacitor/splash-screen",
"classpath": "com.capacitorjs.plugins.splashscreen.SplashScreenPlugin"
},
{
"pkg": "@capacitor/status-bar",
"classpath": "com.capacitorjs.plugins.statusbar.StatusBarPlugin"
},
{
"pkg": "@capgo/capacitor-navigation-bar",
"classpath": "ee.forgr.capacitor_navigation_bar.CapgoNavigationBarPlugin"
},
{
"pkg": "send-intent",
"classpath": "de.mindlib.sendIntent.SendIntent"
},
{
"pkg": "@jcesarmobile/ssl-skip",
"classpath": "com.jcesarmobile.sslskip.SslSkipPlugin"
}
]

View File

@@ -0,0 +1,375 @@
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.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
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.DisposableEffect
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.delay
import kotlinx.coroutines.launch
private const val ROOT_ROUTE = "web/{encodedPath}"
private const val DEBUG_NAV_STACK_PREFIX = "[DEBUG-navstack]"
/**
* 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<NavigationRenderState>(extraBufferCapacity = 64)
val navigationEvents: SharedFlow<NavigationRenderState> = navEvents
@JvmStatic
fun applyNavigation(state: NavigationRenderState) {
Log.d(
"NavStack",
"$DEBUG_NAV_STACK_PREFIX compose.applyNavigation stack=${state.activeStackId} previous=${state.previousStackId} " +
"type=${state.navigationType} path=${state.path} paths=${state.paths} switched=${state.stackSwitched}"
)
if (!navEvents.tryEmit(state)) {
Log.w(
"ComposeHost",
"Dropped navigation state: stack=${state.activeStackId} type=${state.navigationType} path=${state.path}"
)
}
}
fun renderWithSystemInsets(
activity: Activity,
webView: WebView
) {
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(
webView = webView
)
}
}
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)}"
private fun decodePath(encodedPath: String?): String =
Uri.decode(encodedPath?.takeIf { it.isNotBlank() } ?: encodePath("/"))
private fun NavHostController.currentPath(): String? =
currentBackStackEntry?.arguments?.getString("encodedPath")?.let(::decodePath)
private fun NavHostController.rebuildBackStack(paths: List<String>) {
val normalizedPaths = paths.ifEmpty { listOf("/") }
val rootRoute = routeFor(normalizedPaths.first())
navigate(rootRoute) {
popUpTo(ROOT_ROUTE) {
inclusive = true
}
launchSingleTop = true
}
normalizedPaths.drop(1).forEach { path ->
navigate(routeFor(path)) {
launchSingleTop = true
}
}
}
private fun shouldAnimateNavigation(navigationType: String): Boolean =
when (navigationType.trim().lowercase()) {
"push",
"pop",
"back",
"forward",
"replace" -> true
else -> false
}
@OptIn(ExperimentalAnimationApi::class)
@Composable
private fun ComposeNavigationHost(
webView: WebView
) {
val navController = rememberNavController()
DisposableEffect(navController) {
navController.enableOnBackPressed(false)
Log.d("NavStack", "$DEBUG_NAV_STACK_PREFIX compose.navController.backHandling disabled")
onDispose {
navController.enableOnBackPressed(true)
}
}
// Track the last navigation type so we can change slide direction.
val lastNavTypeState = remember { mutableStateOf("push") }
HandleNavigationEvents(
navController = navController,
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 = {
val navType = lastNavTypeState.value
if (!shouldAnimateNavigation(navType)) {
EnterTransition.None
} else if (navType == "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 = {
val navType = lastNavTypeState.value
if (!shouldAnimateNavigation(navType)) {
ExitTransition.None
} else if (navType == "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 = {
if (!shouldAnimateNavigation(lastNavTypeState.value)) {
EnterTransition.None
} else {
slideInHorizontally(
initialOffsetX = { fullWidth -> -fullWidth / 4 },
animationSpec = tween(200)
) + fadeIn(animationSpec = tween(160))
}
},
popExitTransition = {
if (!shouldAnimateNavigation(lastNavTypeState.value)) {
ExitTransition.None
} else {
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,
webView: WebView,
onNavType: (String) -> Unit
) {
LaunchedEffect(navController) {
var snapshotVersion = 0
ComposeHost.navigationEvents.collect { state ->
snapshotVersion += 1
val currentSnapshotVersion = snapshotVersion
val paths = state.paths.ifEmpty { listOf(state.path) }
val targetPath = paths.lastOrNull() ?: state.path
val route = routeFor(targetPath)
val animateNavigation = shouldAnimateNavigation(state.navigationType) && !state.stackSwitched
Log.d(
"NavStack",
"$DEBUG_NAV_STACK_PREFIX compose.event.before stack=${state.activeStackId} previous=${state.previousStackId} " +
"type=${state.navigationType} path=$targetPath paths=$paths switched=${state.stackSwitched} targetRoute=$route " +
"currentRoute=${navController.currentBackStackEntry?.destination?.route} " +
"currentArgs=${navController.currentBackStackEntry?.arguments} " +
"previousRoute=${navController.previousBackStackEntry?.destination?.route}"
)
if (animateNavigation) {
WebViewSnapshotManager.showSnapshot("navigation", webView)
} else {
WebViewSnapshotManager.clearSnapshot("navigation")
}
onNavType(state.navigationType)
when {
state.stackSwitched || state.navigationType == "reset" -> {
navController.rebuildBackStack(paths)
}
state.navigationType == "push" -> {
if (navController.currentPath() != targetPath) {
navController.navigate(route)
}
}
state.navigationType == "replace" -> {
navController.popBackStack()
navController.navigate(route) {
launchSingleTop = true
}
}
state.navigationType == "pop" -> {
if (!navController.popBackStack(route, inclusive = false)) {
navController.rebuildBackStack(paths)
}
}
else -> navController.navigate(route)
}
Log.d(
"NavStack",
"$DEBUG_NAV_STACK_PREFIX compose.event.after stack=${state.activeStackId} type=${state.navigationType} path=$targetPath targetRoute=$route " +
"currentRoute=${navController.currentBackStackEntry?.destination?.route} " +
"currentArgs=${navController.currentBackStackEntry?.arguments} " +
"previousRoute=${navController.previousBackStackEntry?.destination?.route}"
)
if (animateNavigation) {
launch {
delay(260)
if (currentSnapshotVersion == snapshotVersion) {
WebViewSnapshotManager.clearSnapshot("navigation")
}
}
}
}
}
}

View File

@@ -0,0 +1,214 @@
package com.logseq.app;
import android.annotation.TargetApi;
import android.content.ContentUris;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.provider.DocumentsContract;
import android.provider.MediaStore;
import android.text.TextUtils;
import android.util.Log;
import java.io.File;
// The following refs
// https://stackoverflow.com/questions/29713587/how-to-get-the-real-path-with-action-open-document-tree-intent
// https://gist.github.com/asifmujteba/d89ba9074bc941de1eaa#file-asfurihelper
// with bug fixes and patches.
public class FileUtil {
@TargetApi(Build.VERSION_CODES.KITKAT)
public static String getPath(final Context context, final Uri uri) {
final boolean isKitKat = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT;
// DocumentProvider
if (isKitKat && DocumentsContract.isDocumentUri(context, uri)) {
// ExternalStorageProvider
if (isExternalStorageDocument(uri)) {
final String docId = DocumentsContract.getDocumentId(uri);
final String[] split = docId.split(":");
final String type = split[0];
// NOTE: It's not a good idea to use storage root as Graph root.
String remain = "";
if (split.length == 2) {
remain = split[1];
}
if ("primary".equalsIgnoreCase(type)) {
return Environment.getExternalStorageDirectory() + "/" + remain;
} else if ("home".equalsIgnoreCase(type)) {
return Environment.getExternalStorageDirectory() + "/Documents/" + remain;
} else if ("downloads".equalsIgnoreCase(type)) {
return Environment.getExternalStorageDirectory() + "/Download/" + remain; // No 's' here
}
File dir = null;
File[] mdirs = context.getExternalMediaDirs();
for (File mdir : mdirs) {
String extPath = mdir.getAbsolutePath();
if (extPath.contains("/" + type + "/")) {
dir = new File(extPath.substring(0, extPath.indexOf("/Android")) + "/" + remain);
if (dir.exists()) {
return dir.getAbsolutePath();
}
}
}
// FIXME: The following attempt cannot handle same directory name on different devices!
// attempt 1
dir = new File("/storage/" + type + "/" + remain);
if (dir.exists()) {
return dir.getAbsolutePath();
}
// attempt 3
dir = new File("/mnt/media_rw/" + type + "/" + remain);
if (dir.exists()) {
return dir.getAbsolutePath();
}
// attempt 3
dir = new File("/mnt/" + type + "/" + remain);
if (dir.exists()) {
return dir.getAbsolutePath();
}
// TODO: other cases
} else if (isDownloadsDocument(uri)) {
final String id = DocumentsContract.getDocumentId(uri);
if (!TextUtils.isEmpty(id)) {
if (id.startsWith("raw:")) {
return id.replaceFirst("raw:", "");
}
try {
final Uri contentUri = ContentUris.withAppendedId(Uri.parse("content://downloads/public_downloads"), Long.valueOf(id));
return getDataColumn(context, contentUri, null, null);
} catch (NumberFormatException e) {
return null;
}
}
} else if (isMediaDocument(uri)) {
final String docId = DocumentsContract.getDocumentId(uri);
final String[] split = docId.split(":");
final String type = split[0];
Uri contentUri = null;
if ("image".equals(type)) {
contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
} else if ("video".equals(type)) {
contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI;
} else if ("audio".equals(type)) {
contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
}
final String selection = "_id=?";
final String[] selectionArgs = new String[]{
split[1]
};
return getDataColumn(context, contentUri, selection, selectionArgs);
} else if (isTermuxDocument(uri)) {
String docId = DocumentsContract.getDocumentId(uri);
// Ref: https://github.com/termux/termux-app/blob/master/app/src/main/java/com/termux/app/TermuxInstaller.java
if (docId.startsWith("/")) {
if (docId.contains("/com.termux/files/home/storage/")) {
String remain = docId.replaceFirst("^.*?com\\.termux/files/home/storage/[^/]+/", "");
if (docId.contains("/storage/external-1")) { // TODO: Support external-2 or more
File[] dirs = context.getExternalFilesDirs(remain);
if (dirs != null && dirs.length >= 2) {
docId = dirs[1].getAbsolutePath();
}
} else if (docId.contains("/storage/media-1")) {
File[] dirs = context.getExternalMediaDirs();
if (dirs != null && dirs.length >= 2) {
docId = dirs[1].getAbsolutePath() + "/" + remain;
}
} else if (docId.contains("/storage/downloads")) {
docId = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + "/" + remain;
} else if (docId.contains("/storage/shared")) {
docId = Environment.getExternalStorageDirectory() + "/" + remain;
}
}
File dir = new File(docId);
if (dir.exists()) {
return dir.getAbsolutePath();
}
Log.e("Logseq/FileUtil", "Handle termux content url failed: " + docId);
}
// FIXME: Are there any other cases?
}
} else if ("content".equalsIgnoreCase(uri.getScheme())) {
// Return the remote address
if (isGooglePhotosUri(uri))
return uri.getLastPathSegment();
return getDataColumn(context, uri, null, null);
} else if ("file".equalsIgnoreCase(uri.getScheme())) {
return uri.getPath();
}
return null;
}
public static String getDataColumn(Context context, Uri uri, String selection,
String[] selectionArgs) {
Cursor cursor = null;
final String column = "_data";
final String[] projection = {
column
};
try {
cursor = context.getContentResolver().query(uri, projection, selection, selectionArgs,
null);
if (cursor != null && cursor.moveToFirst()) {
final int index = cursor.getColumnIndexOrThrow(column);
return cursor.getString(index);
}
} finally {
if (cursor != null)
cursor.close();
}
return null;
}
/**
* @param uri The Uri to check.
* @return Whether the Uri authority is ExternalStorageProvider.
*/
public static boolean isExternalStorageDocument(Uri uri) {
return "com.android.externalstorage.documents".equals(uri.getAuthority());
}
/**
* @param uri The Uri to check.
* @return Whether the Uri authority is DownloadsProvider.
*/
public static boolean isDownloadsDocument(Uri uri) {
return "com.android.providers.downloads.documents".equals(uri.getAuthority());
}
/**
* @param uri The Uri to check.
* @return Whether the Uri authority is MediaProvider.
*/
public static boolean isMediaDocument(Uri uri) {
return "com.android.providers.media.documents".equals(uri.getAuthority());
}
/**
* @param uri The Uri to check.
* @return Whether the Uri authority is Google Photos.
*/
public static boolean isGooglePhotosUri(Uri uri) {
return "com.google.android.apps.photos.content".equals(uri.getAuthority());
}
public static boolean isTermuxDocument(Uri uri) {
return "com.termux.documents".equals(uri.getAuthority());
}
}

View File

@@ -0,0 +1,91 @@
package com.logseq.app;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Environment;
import android.os.Build;
import android.provider.DocumentsContract;
import android.provider.Settings;
import android.util.Log;
import androidx.activity.result.ActivityResult;
import androidx.core.content.FileProvider;
import androidx.documentfile.provider.DocumentFile;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.annotation.ActivityCallback;
import com.getcapacitor.annotation.CapacitorPlugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import java.io.File;
@CapacitorPlugin(name = "FolderPicker")
public class FolderPicker extends Plugin {
public static boolean FileAccessAllowed()
{
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
return true;
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
return (Environment.isExternalStorageManager());
}
return false;
}
@PluginMethod()
public void pickFolder(PluginCall call) {
if (FileAccessAllowed()) {
Intent i = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
i.addCategory(Intent.CATEGORY_DEFAULT);
startActivityForResult(call, i, "folderPickerResult");
} else {
Intent intent = new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION);
Uri uri = Uri.fromParts("package", this.getContext().getPackageName(), null);
intent.setData(uri);
startActivityForResult(call, intent, 20);
}
}
@PluginMethod()
public void openFile(PluginCall call) {
Uri uri = Uri.parse(call.getString("uri"));
File file = new File(uri.getPath());
// Get URI and MIME type of file
String appId = getAppId();
uri = FileProvider.getUriForFile(getActivity(), appId + ".fileprovider", file);
String mime = getContext().getContentResolver().getType(uri);
Intent intent = new Intent();
intent.setAction(Intent.ACTION_VIEW);
intent.setDataAndType(uri, mime);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
getContext().startActivity(intent);
}
@ActivityCallback
private void folderPickerResult(PluginCall call, ActivityResult result) {
if (call == null) {
return;
}
JSObject ret = new JSObject();
Context context = this.getContext();
Uri treeUri = result.getData().getData();
Uri docUri = DocumentsContract.buildDocumentUriUsingTree(treeUri,
DocumentsContract.getTreeDocumentId(treeUri));
Log.i("Logseq/FolderPicker", "Got uri " + docUri);
String path = FileUtil.getPath(context, docUri);
Log.i("Logseq/FolderPicker", "Convert to path " + FileUtil.getPath(context, docUri));
if (path == null || path.isEmpty()) {
call.reject("Cannot support this directory type: " + docUri);
} else {
Uri folderUri = Uri.fromFile(new File(path));
ret.put("path", folderUri.toString());
call.resolve(ret);
}
}
}

View File

@@ -0,0 +1,307 @@
package com.logseq.app;
import android.system.ErrnoException;
import android.system.Os;
import android.system.StructStat;
import android.util.Log;
import android.os.FileObserver;
import android.net.Uri;
import java.io.*;
import java.net.URI;
import java.text.Normalizer;
import java.util.HashMap;
import java.util.Map;
import java.util.Stack;
import java.util.regex.Pattern;
import java.io.File;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.annotation.CapacitorPlugin;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.PluginCall;
@CapacitorPlugin(name = "FsWatcher")
public class FsWatcher extends Plugin {
private String mPath;
private PollingFsWatcher mWatcher;
private Thread mThread;
@Override
public void load() {
Log.i("FsWatcher", "Android fs-watcher loaded!");
}
@PluginMethod()
public void watch(PluginCall call) {
String pathParam = call.getString("path");
// check file:// or no scheme uris
Uri u = Uri.parse(pathParam);
Log.i("FsWatcher", "watching " + u);
// TODO: handle context:// uri
if (u.getScheme() == null || u.getScheme().equals("file")) {
File pathObj;
try {
pathObj = new File(u.getPath());
} catch (Exception e) {
call.reject("invalid watch path: " + pathParam);
return;
}
mPath = pathObj.getAbsolutePath();
if (mWatcher != null) {
call.reject("already watching");
return;
}
mWatcher = new PollingFsWatcher(mPath);
mThread = new Thread(mWatcher);
mThread.start();
call.resolve();
} else {
call.reject(u.getScheme() + " scheme not supported");
}
}
@PluginMethod()
public void unwatch(PluginCall call) {
Log.i("FsWatcher", "unwatch all...");
if (mWatcher != null) {
mThread.interrupt();
mWatcher = null;
}
call.resolve();
}
// add, change, unlink events
public void onObserverEvent(int event, String path, SimpleFileMetadata metadata) {
JSObject obj = new JSObject();
String content = null;
File f = new File(path);
boolean shouldRead = false;
if (Pattern.matches("(?i)[^.].*?\\.(md|org|css|edn|js|markdown|excalidraw)$", f.getName())) {
shouldRead = true;
}
Uri dir = Uri.fromFile(new File(mPath));
Uri fpath = Uri.fromFile(f);
String relpath = null;
if (fpath.getPath().startsWith(dir.getPath())) {
relpath = fpath.getPath().substring(dir.getPath().length());
if (relpath.startsWith("/")) {
relpath = relpath.substring(1);
}
} else {
Log.e("FsWatcher", "file path not under watch path");
return;
}
obj.put("path", Normalizer.normalize(relpath, Normalizer.Form.NFC));
obj.put("dir", Uri.fromFile(new File(mPath))); // Uri is for Android. URI is for RFC compatible
JSObject stat;
switch (event) {
case FileObserver.MODIFY:
obj.put("event", "change");
stat = new JSObject();
stat.put("mtime", metadata.mtime);
stat.put("ctime", metadata.ctime);
stat.put("size", metadata.size);
obj.put("stat", stat);
if (shouldRead) {
try {
content = getFileContents(f);
} catch (IOException e) {
Log.e("FsWatcher", "error reading modified file");
e.printStackTrace();
}
}
Log.i("FsWatcher", "prepare event " + obj);
obj.put("content", content);
break;
case FileObserver.CREATE:
obj.put("event", "add");
stat = new JSObject();
stat.put("mtime", metadata.mtime);
stat.put("ctime", metadata.ctime);
stat.put("size", metadata.size);
obj.put("stat", stat);
if (shouldRead) {
try {
content = getFileContents(f);
} catch (IOException e) {
Log.e("FsWatcher", "error reading new file");
e.printStackTrace();
}
}
obj.put("content", content);
break;
case FileObserver.DELETE:
if (f.exists()) {
Log.i("FsWatcher", "abandon delete notification due to file exists");
return;
} else {
obj.put("event", "unlink");
}
Log.i("FsWatcher", "prepare event " + obj);
break;
default:
// unreachable?
obj.put("event", "unknown");
break;
}
notifyListeners("watcher", obj);
}
public static String getFileContents(final File file) throws IOException {
InputStream inputStream = new FileInputStream(file);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int length;
while ((length = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, length);
}
inputStream.close();
return outputStream.toString("utf-8");
}
public class SimpleFileMetadata {
public long mtime;
public long ctime;
public long size;
public long ino;
public SimpleFileMetadata(File file) throws ErrnoException {
StructStat stat = Os.stat(file.getPath());
mtime = stat.st_mtime;
ctime = stat.st_ctime;
size = stat.st_size;
ino = stat.st_ino;
}
public boolean equals(SimpleFileMetadata other) {
return mtime == other.mtime && ctime == other.ctime && size == other.size && ino == other.ino;
}
}
public class PollingFsWatcher implements Runnable {
private String mPath;
private Map<String, SimpleFileMetadata> metaDb;
public PollingFsWatcher(String path) {
metaDb = new HashMap();
File dir = new File(path);
try {
mPath = dir.getCanonicalPath();
} catch (IOException e) {
e.printStackTrace();
}
}
@Override
public void run() {
this.tick(false); // skip initial notification
while (!Thread.currentThread().isInterrupted()) {
try {
this.tick(true);
Thread.sleep(2000); // The same as iOS fswatcher, 2s interval
} catch (InterruptedException e) {
// e.printStackTrace();
Log.i("FsWatcher", "interrupted, unwatch");
break;
}
}
}
private void tick(boolean shouldNotify) {
Map<String, SimpleFileMetadata> newMetaDb = new HashMap();
Stack<String> paths = new Stack();
paths.push(mPath);
while (!paths.isEmpty()) {
String dir = paths.pop();
File curr = new File(dir);
File[] files = curr.listFiles();
if (files != null) {
for (File file : files) {
String filename = file.getName();
if (file.isDirectory()) {
if (!filename.startsWith(".") && !filename.equals("bak") && !filename.equals("version-files") && !filename.equals("node_modules")) {
paths.push(file.getAbsolutePath());
}
} else if (file.isFile() && !filename.equals("graphs-txid.edn") && !filename.equals("broken-config.edn")) {
try {
SimpleFileMetadata metadata = new SimpleFileMetadata(file);
newMetaDb.put(file.getAbsolutePath(), metadata);
} catch (ErrnoException e) {
}
}
}
}
}
if (shouldNotify) {
this.updateMetaDb(newMetaDb);
} else {
this.metaDb = newMetaDb;
}
}
private void updateMetaDb(Map<String, SimpleFileMetadata> newMetaDb) {
for (Map.Entry<String, SimpleFileMetadata> entry : newMetaDb.entrySet()) {
String path = entry.getKey();
SimpleFileMetadata newMeta = entry.getValue();
SimpleFileMetadata oldMeta = metaDb.remove(path);
if (oldMeta == null) {
// new file
onObserverEvent(FileObserver.CREATE, path, newMeta);
Log.d("FsWatcher", "create " + path);
} else if (!oldMeta.equals(newMeta)) {
// file changed
onObserverEvent(FileObserver.MODIFY, path, newMeta);
Log.d("FsWatcher", "changed " + path);
}
}
for (String path : metaDb.keySet()) {
// file deleted
Thread timer = new Thread() {
@Override
public void run() {
try {
// delay 500ms then send, enough for most syncing net disks
Thread.sleep(500);
onObserverEvent(FileObserver.DELETE, path, null);
Log.d("FsWatcher", "deleted " + path);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
timer.start();
}
this.metaDb = newMetaDb;
}
}
}

View File

@@ -0,0 +1,663 @@
package com.logseq.app
import android.content.Context
import android.os.Build
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.view.WindowInsets
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import androidx.appcompat.content.res.AppCompatResources
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.res.painterResource
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: ImageButton? = 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"
private fun tabIconResId(tabId: String): Int? = when (tabId) {
"flashcards" -> R.drawable.ic_tab_flashcards
else -> null
}
@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, notify = 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, notify = false)
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()
}
fun handleNativeBackPressed(): Boolean {
val container = searchContainer ?: return false
if (container.visibility != View.VISIBLE) return false
val input = searchInput ?: return false
val imeVisible = ViewCompat.getRootWindowInsets(input)
?.isVisible(WindowInsetsCompat.Type.ime()) == true
val hasFocus = input.hasFocus()
if (imeVisible || hasFocus) {
moveFocusAwayFromSearchInput(input)
hideIme(input)
return true
}
dismissSearchUiFromBack()
return true
}
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, notify = true)
}
)
}
nav.doOnNextLayout { adjustWebViewPadding() }
return nav
}
private fun handleSelection(tab: TabSpec, reselected: Boolean, notify: Boolean) {
currentTabId = tab.id
if (tab.role == "search") {
showSearchUi()
} else {
clearSearchUi()
hideSearchUi()
}
if (notify) {
notifyListeners("tabSelected", JSObject().put("id", tab.id).put("reselected", reselected))
}
adjustWebViewPadding()
}
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)
imeOptions = EditorInfo.IME_ACTION_SEARCH
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)
setOnEditorActionListener { _, actionId, event ->
val isEnter =
actionId == EditorInfo.IME_ACTION_SEARCH ||
actionId == EditorInfo.IME_ACTION_DONE ||
actionId == EditorInfo.IME_ACTION_GO ||
(event?.keyCode == KeyEvent.KEYCODE_ENTER)
if (isEnter) {
notifyListeners("keyboardHackKey", JSObject().put("key", "enter"))
true
} else {
false
}
}
setOnKeyListener { _, keyCode, event ->
when (keyCode) {
KeyEvent.KEYCODE_ENTER -> {
if (event.action == KeyEvent.ACTION_DOWN) {
notifyListeners("keyboardHackKey", JSObject().put("key", "enter"))
}
true
}
KeyEvent.KEYCODE_DEL -> {
if (text.isNullOrEmpty() && event.action == KeyEvent.ACTION_DOWN) {
notifyListeners("keyboardHackKey", JSObject().put("key", "backspace"))
true
} else {
false
}
}
else -> 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 = ImageButton(activity).apply {
setImageDrawable(AppCompatResources.getDrawable(activity, R.drawable.ic_search_clear))
background = null
scaleType = ImageView.ScaleType.CENTER_INSIDE
contentDescription = "Clear search"
alpha = if (theme.isDark) 0.92f else 0.8f
setPadding(
NativeUiUtils.dp(activity, 4f),
NativeUiUtils.dp(activity, 4f),
NativeUiUtils.dp(activity, 4f),
NativeUiUtils.dp(activity, 4f)
)
minimumWidth = NativeUiUtils.dp(activity, 28f)
minimumHeight = NativeUiUtils.dp(activity, 28f)
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
).apply {
marginStart = NativeUiUtils.dp(activity, 8f)
}
}
// 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
adjustWebViewPadding()
}
private fun clearSearchUi() {
searchInput?.setText("")
resultsContainer?.removeAllViews()
}
private fun hideSearchUi() {
dismissActiveSearchInput()
searchContainer?.visibility = View.GONE
adjustWebViewPadding()
}
private fun dismissSearchUiFromBack() {
hideSearchUi()
}
private fun dismissActiveSearchInput() {
searchInput?.let { input ->
moveFocusAwayFromSearchInput(input)
hideIme(input)
}
}
private fun moveFocusAwayFromSearchInput(input: EditText) {
val container = searchContainer
if (container != null) {
container.isFocusableInTouchMode = true
container.isFocusable = true
container.requestFocus()
}
input.clearFocus()
activity?.currentFocus?.clearFocus()
}
private fun hideIme(target: View) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
target.windowInsetsController?.hide(WindowInsets.Type.ime())
}
val imm = activity?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
val windowToken = target.windowToken
?: activity?.currentFocus?.windowToken
?: activity?.window?.decorView?.windowToken
?: searchContainer?.windowToken
if (windowToken != null) {
imm?.hideSoftInputFromWindow(windowToken, 0)
}
target.post {
val postedToken = activity?.window?.decorView?.windowToken ?: target.windowToken
if (postedToken != null) {
imm?.hideSoftInputFromWindow(postedToken, 0)
}
}
}
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 iconResId = remember(tab.id) { tabIconResId(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 = {
if (iconResId != null) {
Icon(
painter = painterResource(id = iconResId),
contentDescription = tab.title
)
} else {
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 = if (obj.has("subtitle") && !obj.isNull("subtitle")) obj.optString("subtitle") else 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?
)

View File

@@ -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,
)
}
}

View File

@@ -0,0 +1,345 @@
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.Bundle;
import android.os.Build;
import android.webkit.WebView;
import androidx.activity.OnBackPressedCallback;
import androidx.activity.BackEventCompat;
import androidx.activity.EdgeToEdge;
import androidx.core.content.ContextCompat;
import androidx.core.view.WindowInsetsControllerCompat;
import com.getcapacitor.BridgeActivity;
import com.getcapacitor.PluginHandle;
import com.getcapacitor.Plugin;
import android.util.Log;
import android.view.View;
import android.window.OnBackInvokedCallback;
import android.window.OnBackInvokedDispatcher;
import java.lang.reflect.Field;
import java.util.Timer;
import java.util.TimerTask;
public class MainActivity extends BridgeActivity {
private static final String NAV_STACK_DEBUG_PREFIX = "[DEBUG-navstack]";
private final NavigationCoordinator navigationCoordinator = new NavigationCoordinator();
private BroadcastReceiver routeChangeReceiver;
private OnBackInvokedCallback overlayBackInvokedCallback;
@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);
// @capacitor/app installs its own OnBackPressedCallback during plugin load (inside
// super.onCreate above). Its callback consumes the first system/edge back press by
// calling webView.goBack(), which both makes the WebView flash and prevents our
// MainActivity-level handler (below) from ever running. We route back through JS
// instead, so disable that plugin's back handler defensively even if the config
// entry "App.disableBackButtonHandler" hasn't been synced yet.
disableCapacitorAppBackHandler();
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.
// Android back is still delegated to JS from the Activity back dispatcher.
ComposeHost.INSTANCE.renderWithSystemInsets(this, webView);
WebViewKeyboardRecovery.install(this, webView);
// On API 33+ (Android 13+), predictive back routes through the platform-level
// OnBackInvokedDispatcher. AndroidX's OnBackPressedDispatcher bridges into it at
// PRIORITY_DEFAULT, so plugin / Compose / WebView callbacks registered through
// AndroidX can pre-empt ours. Register OUR callback directly on the platform
// dispatcher at PRIORITY_OVERLAY to guarantee the first edge-back gesture is
// delivered to handleNativeBack().
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
overlayBackInvokedCallback = () -> {
Log.d("NavStack", NAV_STACK_DEBUG_PREFIX + " activity.onBackInvoked source=window-overlay");
handleNativeBack();
};
getOnBackInvokedDispatcher().registerOnBackInvokedCallback(
OnBackInvokedDispatcher.PRIORITY_OVERLAY,
overlayBackInvokedCallback
);
Log.d("NavStack", NAV_STACK_DEBUG_PREFIX + " activity.onBackInvoked.registered priority=OVERLAY");
}
// Fallback for API < 33 and as defense in depth. Predictive-back lifecycle methods
// log so we can see whether the system is starting/cancelling vs committing.
getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) {
@Override
public void handleOnBackStarted(BackEventCompat backEvent) {
Log.d("NavStack", NAV_STACK_DEBUG_PREFIX + " activity.onBackStarted progress=" + backEvent.getProgress() + " edge=" + backEvent.getSwipeEdge());
}
@Override
public void handleOnBackProgressed(BackEventCompat backEvent) {
// Intentionally not logging every progress tick; uncomment for tracing.
// Log.d("NavStack", NAV_STACK_DEBUG_PREFIX + " activity.onBackProgressed progress=" + backEvent.getProgress());
}
@Override
public void handleOnBackCancelled() {
Log.d("NavStack", NAV_STACK_DEBUG_PREFIX + " activity.onBackCancelled");
}
@Override
public void handleOnBackPressed() {
Log.d("NavStack", NAV_STACK_DEBUG_PREFIX + " activity.onBackPressed source=androidx-dispatcher");
handleNativeBack();
}
});
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");
Log.d(
"NavStack",
NAV_STACK_DEBUG_PREFIX + " activity.routeChange.received stack=" + stack
+ " type=" + navigationType
+ " path=" + path
+ " before=" + navigationCoordinator.debugState()
);
NavigationRenderState renderState = navigationCoordinator.onRouteChange(stack, navigationType, path);
if (renderState.getApplyToCompose()) {
ComposeHost.applyNavigation(renderState);
} else {
Log.d(
"NavStack",
NAV_STACK_DEBUG_PREFIX + " activity.routeChange.skippedCompose stack="
+ renderState.getActiveStackId()
+ " type=" + renderState.getNavigationType()
+ " path=" + renderState.getPath()
);
}
Log.d(
"NavStack",
NAV_STACK_DEBUG_PREFIX + " activity.routeChange.applied after="
+ navigationCoordinator.debugState()
+ " renderStack=" + renderState.getActiveStackId()
+ " renderPaths=" + renderState.getPaths()
+ " stackSwitched=" + renderState.getStackSwitched()
+ " applyToCompose=" + renderState.getApplyToCompose()
);
}
};
IntentFilter filter = new IntentFilter(UILocal.ACTION_ROUTE_CHANGED);
ContextCompat.registerReceiver(this, routeChangeReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED);
new Timer().schedule(new TimerTask() {
@Override
public void run() {
bridge.eval("window.dispatchEvent(new Event('sendIntentReceived'))", s -> {});
}
}, 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
}
}
@Override
public void onPause() {
overridePendingTransition(0, R.anim.byebye);
super.onPause();
}
private void handleNativeBack() {
Log.d("onBackPressed", "Debug");
Log.d(
"NavStack",
NAV_STACK_DEBUG_PREFIX + " activity.nativeBack source=system-or-edge before="
+ navigationCoordinator.debugState()
);
if (consumeNativeOverlayBack()) {
Log.d("NavStack", NAV_STACK_DEBUG_PREFIX + " activity.nativeBack consumed=native-overlay");
return;
}
WebView webView = getBridge().getWebView();
if (webView != null) {
NavigationRenderState nativePopState = navigationCoordinator.prepareNativePop();
if (nativePopState != null) {
Log.d(
"NavStack",
NAV_STACK_DEBUG_PREFIX + " activity.nativeBack consumed=native-stack-pop target="
+ nativePopState.getPath()
+ " paths=" + nativePopState.getPaths()
);
ComposeHost.applyNavigation(nativePopState);
}
// 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
finish();
}
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
String action = intent.getAction();
String type = intent.getType();
if (Intent.ACTION_SEND.equals(action) && type != null) {
bridge.getActivity().setIntent(intent);
bridge.eval("window.dispatchEvent(new Event('sendIntentReceived'))", s -> {});
}
}
@Override
public void onDestroy() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && overlayBackInvokedCallback != null) {
try {
getOnBackInvokedDispatcher().unregisterOnBackInvokedCallback(overlayBackInvokedCallback);
} catch (Exception ignored) {
}
overlayBackInvokedCallback = null;
}
if (routeChangeReceiver != null) {
unregisterReceiver(routeChangeReceiver);
routeChangeReceiver = null;
}
WebViewKeyboardRecovery.uninstall(this);
super.onDestroy();
}
private void sendJsBack(WebView webView) {
if (webView == null) return;
Log.d("NavStack", NAV_STACK_DEBUG_PREFIX + " activity.sendJsBack dispatching onNativePop");
webView.post(() -> webView.evaluateJavascript(
"window.LogseqNative && window.LogseqNative.onNativePop && window.LogseqNative.onNativePop();",
null
));
}
private boolean consumeNativeOverlayBack() {
try {
if (getBridge() == null) {
return false;
}
PluginHandle handle = getBridge().getPlugin("LiquidTabsPlugin");
if (handle == null) {
return false;
}
Plugin instance = handle.getInstance();
if (!(instance instanceof LiquidTabsPlugin)) {
return false;
}
return ((LiquidTabsPlugin) instance).handleNativeBackPressed();
} catch (Exception e) {
Log.d(
"NavStack",
NAV_STACK_DEBUG_PREFIX + " activity.nativeOverlayBack error="
+ e.getClass().getSimpleName() + ":" + e.getMessage()
);
return false;
}
}
private void disableCapacitorAppBackHandler() {
try {
if (getBridge() == null) {
Log.d("NavStack", NAV_STACK_DEBUG_PREFIX + " activity.appPluginBack bridge=null skip");
return;
}
PluginHandle handle = getBridge().getPlugin("App");
if (handle == null) {
Log.d("NavStack", NAV_STACK_DEBUG_PREFIX + " activity.appPluginBack pluginHandle=null skip");
return;
}
Plugin instance = handle.getInstance();
if (instance == null) {
Log.d("NavStack", NAV_STACK_DEBUG_PREFIX + " activity.appPluginBack pluginInstance=null skip");
return;
}
Field field = instance.getClass().getDeclaredField("onBackPressedCallback");
field.setAccessible(true);
Object callback = field.get(instance);
if (callback instanceof OnBackPressedCallback) {
((OnBackPressedCallback) callback).setEnabled(false);
Log.d("NavStack", NAV_STACK_DEBUG_PREFIX + " activity.appPluginBack disabled");
} else {
Log.d("NavStack", NAV_STACK_DEBUG_PREFIX + " activity.appPluginBack unexpectedType=" + (callback == null ? "null" : callback.getClass().getName()));
}
} catch (NoSuchFieldException e) {
Log.d("NavStack", NAV_STACK_DEBUG_PREFIX + " activity.appPluginBack noField=" + e.getMessage());
} catch (Exception e) {
Log.d("NavStack", NAV_STACK_DEBUG_PREFIX + " activity.appPluginBack error=" + e.getClass().getSimpleName() + ":" + e.getMessage());
}
}
}

View File

@@ -0,0 +1,92 @@
package com.logseq.app
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.Comment
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.AddReaction
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.AttachFile
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
"text-bubble", "comment" -> Icons.AutoMirrored.Outlined.Comment
"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
"cloud", "cloud-upload", "icloud-and-arrow-up" -> Icons.Filled.CloudUpload
"paperclip" -> Icons.Outlined.AttachFile
"face-smiling" -> Icons.Outlined.AddReaction
else -> null
}
}
}

View File

@@ -0,0 +1,268 @@
package com.logseq.app
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
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 pendingPresentation: (() -> Unit)? = null
private var awaitingContentReady = false
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
private var pendingRestoreWebView: View? = null
private var dismissalFallback: Runnable? = 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 || pendingPresentation != null || awaitingContentReady) {
call.resolve()
return@runOnUiThread
}
val ctx = activity
container = FrameLayout(ctx)
container!!.layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
val cornerRadius = NativeUiUtils.dp(ctx, 16f).toFloat()
val roundedBackground = GradientDrawable().apply {
setColor(LogseqTheme.current().background)
cornerRadii = floatArrayOf(
cornerRadius, cornerRadius, // 左上角
cornerRadius, cornerRadius, // 右上角
0f, 0f, // 右下角
0f, 0f // 左下角
)
}
container!!.background = roundedBackground
container!!.clipToOutline = true
val sheet = BottomSheetDialog(ctx)
sheet.setContentView(container!!)
sheet.setOnShowListener {
val bottomSheet = sheet.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)
bottomSheet?.setBackgroundColor(Color.TRANSPARENT)
}
WebViewSnapshotManager.showSnapshot(snapshotTag, webView)
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 {
awaitingContentReady = 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
pendingRestoreWebView = webView
awaitingContentReady = true
scheduleDismissalFallback()
}
notifyListeners("state", JSObject().put("dismissing", true))
dialog = null
container = null
}
pendingPresentation = {
detachWebView(webView, ctx)
container!!.addView(
webView,
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
)
sheet.show()
notifyListeners("state", JSObject().put("presented", true))
dialog = sheet
call.resolve()
}
notifyListeners("state", JSObject().put("presenting", true))
}
}
@PluginMethod
fun dismiss(call: PluginCall) {
activity?.runOnUiThread {
if (pendingPresentation != null) {
pendingPresentation = null
container = null
WebViewSnapshotManager.clearSnapshot(snapshotTag)
notifyListeners("state", JSObject().put("dismissing", true))
notifyListeners(
"state",
JSObject()
.put("presented", false)
.put("dismissing", false)
)
call.resolve()
return@runOnUiThread
}
if (dialog == null) {
pendingRestoreWebView?.let { finishDismissal(it) }
?: WebViewSnapshotManager.clearSnapshot(snapshotTag)
call.resolve()
return@runOnUiThread
}
dialog?.dismiss()
call.resolve()
} ?: call.resolve()
}
@PluginMethod
fun contentReady(call: PluginCall) {
activity?.runOnUiThread {
pendingPresentation?.let { presentation ->
pendingPresentation = null
presentation()
call.resolve()
return@runOnUiThread
}
val webView = pendingRestoreWebView
if (!awaitingContentReady || webView == null) {
call.resolve()
return@runOnUiThread
}
finishDismissal(webView)
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
}
private fun finishDismissal(webView: View) {
dismissalFallback?.let(mainHandler::removeCallbacks)
dismissalFallback = null
awaitingContentReady = false
pendingRestoreWebView = null
webView.alpha = 1f
webView.requestLayout()
webView.invalidate()
WebViewSnapshotManager.clearSnapshot(snapshotTag)
notifyListeners(
"state",
JSObject()
.put("presented", false)
.put("dismissing", false)
)
}
private fun scheduleDismissalFallback() {
dismissalFallback?.let(mainHandler::removeCallbacks)
val runnable = Runnable {
val webView = pendingRestoreWebView
if (awaitingContentReady && webView != null) {
finishDismissal(webView)
}
}
dismissalFallback = runnable
mainHandler.postDelayed(runnable, 2_000)
}
}

View File

@@ -0,0 +1,381 @@
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.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.compose.foundation.BorderStroke
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.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.graphics.luminance
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)
val margin = NativeUiUtils.dp(activity, 12f)
if (view.parent !== root) {
NativeUiUtils.detachView(view)
val lp = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
Gravity.BOTTOM
).apply {
setMargins(margin, margin, margin, margin)
}
root.addView(view, lp)
}
view.bindToKeyboardTop(root, margin)
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 {
it.clearKeyboardTopBinding()
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 = if (obj.has("systemIcon") && !obj.isNull("systemIcon")) {
obj.optString("systemIcon")
} else {
null
}
return EditorAction(id, title, icon)
}
}
}
private class EditorToolbarView(context: android.content.Context) : FrameLayout(context) {
var onAction: ((String) -> Unit)? = null
private val showAfterImeSettles = Runnable {
pendingShowAfterImeSettles = null
if (imeVisible && visibility != VISIBLE) {
visibility = VISIBLE
}
}
private var pendingShowAfterImeSettles: Runnable? = null
private var imeVisible: Boolean = false
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 {
visibility = GONE
addView(composeView)
}
fun bindToKeyboardTop(root: FrameLayout, margin: Int) {
val decorView = root.rootView ?: root
updateBottomMargin(margin)
ViewCompat.setOnApplyWindowInsetsListener(this) { _, insets ->
applyRootInsets(root, decorView, insets, margin)
insets
}
root.post {
ViewCompat.getRootWindowInsets(decorView)?.let { applyRootInsets(root, decorView, it, margin) }
?: ViewCompat.getRootWindowInsets(root)?.let { applyRootInsets(root, decorView, it, margin) }
ViewCompat.requestApplyInsets(this)
}
}
fun clearKeyboardTopBinding() {
cancelPendingShow()
imeVisible = false
visibility = GONE
ViewCompat.setOnApplyWindowInsetsListener(this, null)
}
private fun applyRootInsets(
root: FrameLayout,
decorView: View,
insets: WindowInsetsCompat,
margin: Int
) {
val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime())
val navInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars())
val imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime())
updateVisibilityForIme(imeVisible)
val coveredBySystemUi = if (imeVisible) {
bottomOverlap(root, decorView, imeInsets.bottom)
} else {
bottomOverlap(root, decorView, navInsets.bottom)
}
updateBottomMargin(coveredBySystemUi + margin)
}
private fun updateVisibilityForIme(imeVisible: Boolean) {
this.imeVisible = imeVisible
if (!imeVisible) {
cancelPendingShow()
if (visibility != GONE) {
visibility = GONE
}
return
}
if (visibility == VISIBLE) {
return
}
cancelPendingShow()
pendingShowAfterImeSettles = showAfterImeSettles
postDelayed(showAfterImeSettles, TOOLBAR_SHOW_DELAY_MS)
}
private fun cancelPendingShow() {
pendingShowAfterImeSettles?.let {
removeCallbacks(it)
pendingShowAfterImeSettles = null
}
}
companion object {
private const val TOOLBAR_SHOW_DELAY_MS = 160L
}
private fun bottomOverlap(root: View, decorView: View, bottomInset: Int): Int {
if (bottomInset <= 0 || root.height <= 0) return 0
val decorHeight = decorView.height.takeIf { it > 0 } ?: root.rootView.height
if (decorHeight <= 0) return bottomInset
val rootLocation = IntArray(2)
val decorLocation = IntArray(2)
root.getLocationInWindow(rootLocation)
decorView.getLocationInWindow(decorLocation)
val rootBottomInWindow = rootLocation[1] + root.height
val decorBottomInWindow = decorLocation[1] + decorHeight
val insetTopInWindow = decorBottomInWindow - bottomInset
return (rootBottomInWindow - insetTopInWindow).coerceAtLeast(0)
}
private fun updateBottomMargin(bottom: Int) {
val lp = layoutParams as? LayoutParams ?: return
if (lp.bottomMargin == bottom) return
lp.bottomMargin = bottom
layoutParams = lp
}
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
) {
val borderColor = ComposeColor.Gray.copy(
alpha = if (background.luminance() < 0.5f) 0.08f else 0.2f
)
Surface(
color = background,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, borderColor),
shape = RoundedCornerShape(16.dp),
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
) {
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
)
}
}
}

View File

@@ -0,0 +1,280 @@
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.BorderStroke
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,
shape = RoundedCornerShape(16.dp),
border = BorderStroke(1.dp, tint.copy(alpha = 0.05f))
) {
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(4.dp)
) {
mainActions.forEach { action ->
SelectionActionButton(action, tint, onAction)
}
}
}
trailingAction?.let { action ->
if (mainActions.isNotEmpty()) {
Spacer(modifier = Modifier.width(6.dp))
Divider(
modifier = Modifier
.height(28.dp)
.width(1.dp),
color = tint.copy(alpha = 0.15f)
)
Spacer(modifier = Modifier.width(6.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 = 48.dp)
.clickable { onAction(action.id) }
.padding(horizontal = 4.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
)
}
}

View File

@@ -0,0 +1,300 @@
package com.logseq.app
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.size
import androidx.compose.material3.HorizontalDivider
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
@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) }
if (hidden) {
removeBar()
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)
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 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
) {
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 42.dp)
.padding(start = 12.dp, end = 8.dp, top = 16.dp, bottom = 16.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(10.dp))
}
TopBarButton(button, contentTint, onTap)
}
}
Text(
text = title,
color = contentTint,
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.padding(horizontal = 2.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(6.dp))
}
TopBarButton(button, contentTint, onTap)
}
}
}
// HorizontalDivider(
// thickness = 1.dp,
// color = contentTint.copy(alpha = 0.28f)
// )
}
}
}
@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 = 1.dp)
) {
if (icon != null) {
val iconSize = when (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
)
}
}
}

View File

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

View File

@@ -0,0 +1,203 @@
package com.logseq.app
import android.util.Log
data class NavigationRenderState(
val previousStackId: String,
val activeStackId: String,
val paths: List<String>,
val navigationType: String,
val path: String,
val stackSwitched: Boolean,
val applyToCompose: Boolean = true
)
private data class PendingNativePop(
val stackId: String,
val targetPath: String
)
class NavigationCoordinator {
companion object {
private const val TAG = "NavStack"
private const val DEBUG_PREFIX = "[DEBUG-navstack]"
}
private val primaryStack = "home"
private val stackPaths: MutableMap<String, MutableList<String>> = mutableMapOf(
primaryStack to mutableListOf("/")
)
private val pendingNativePops: ArrayDeque<PendingNativePop> = ArrayDeque()
var activeStackId: String = primaryStack
private set
private fun rootPath(stack: String): String =
if (stack == primaryStack) "/" else "/__stack__/$stack"
private fun normalizedPath(raw: String?): String =
raw?.takeIf { it.isNotBlank() } ?: "/"
fun debugState(): String =
"active=$activeStackId stacks=" + stackPaths.entries.joinToString(
prefix = "{",
postfix = "}"
) { (stack, paths) -> "$stack=${paths.joinToString(prefix = "[", postfix = "]")}" }
private fun logDebug(message: String) {
try {
Log.d(TAG, message)
} catch (_: RuntimeException) {
// Local JVM unit tests do not provide android.util.Log.
}
}
private fun snapshot(
previousStackId: String,
navigationType: String,
path: String,
stackSwitched: Boolean,
applyToCompose: Boolean = true
): NavigationRenderState {
val paths = stackPaths[activeStackId]?.toList() ?: listOf(rootPath(activeStackId))
return NavigationRenderState(
previousStackId = previousStackId,
activeStackId = activeStackId,
paths = paths,
navigationType = navigationType,
path = path,
stackSwitched = stackSwitched,
applyToCompose = applyToCompose
)
}
private fun consumePendingNativePop(stackId: String, path: String): Boolean {
val pending = pendingNativePops.firstOrNull() ?: return false
if (pending.stackId != stackId || pending.targetPath != path) {
return false
}
pendingNativePops.removeFirst()
logDebug(
"$DEBUG_PREFIX coordinator.consumePendingNativePop stack=$stackId path=$path remaining=${pendingNativePops.size} ${debugState()}"
)
return true
}
fun onRouteChange(stack: String?, navigationType: String?, path: String?): NavigationRenderState {
val stackId = stack?.takeIf { it.isNotBlank() } ?: primaryStack
val navType = navigationType?.lowercase() ?: "push"
val resolvedPath = normalizedPath(path)
val previousStackId = activeStackId
logDebug(
"$DEBUG_PREFIX coordinator.onRouteChange.before stack=$stackId type=$navType path=$resolvedPath ${debugState()}"
)
if (navType == "pop" && consumePendingNativePop(stackId, resolvedPath)) {
activeStackId = stackId
return snapshot(previousStackId, navType, resolvedPath, stackSwitched = false, applyToCompose = false)
}
val paths = stackPaths.getOrPut(stackId) { mutableListOf(rootPath(stackId)) }
if (stackId != activeStackId) {
val newPaths = paths.toMutableList()
when {
resolvedPath == rootPath(stackId) -> {
newPaths.clear()
newPaths.add(resolvedPath)
}
navType == "reset" -> {
newPaths.clear()
newPaths.add(resolvedPath)
}
stackId == primaryStack && resolvedPath == "/" -> {
newPaths.clear()
newPaths.add("/")
}
newPaths.isEmpty() -> newPaths.add(resolvedPath)
navType == "push" && newPaths.last() != resolvedPath -> newPaths.add(resolvedPath)
newPaths.last() != resolvedPath -> newPaths[newPaths.lastIndex] = resolvedPath
}
activeStackId = stackId
stackPaths[stackId] = newPaths
logDebug(
"$DEBUG_PREFIX coordinator.onRouteChange.after stack=$stackId type=$navType path=$resolvedPath ${debugState()} canPop=${canPop()} stackSwitched=true"
)
return snapshot(previousStackId, navType, resolvedPath, stackSwitched = true)
}
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)
}
}
}
if (navType != "push" && resolvedPath == rootPath(stackId)) {
paths.clear()
paths.add(resolvedPath)
}
activeStackId = stackId
stackPaths[stackId] = paths
logDebug(
"$DEBUG_PREFIX coordinator.onRouteChange.after stack=$stackId type=$navType path=$resolvedPath ${debugState()} canPop=${canPop()}"
)
return snapshot(previousStackId, navType, resolvedPath, stackSwitched = false)
}
fun canPop(): Boolean {
val paths = stackPaths[activeStackId] ?: return false
return paths.size > 1
}
fun prepareNativePop(): NavigationRenderState? {
val paths = stackPaths[activeStackId] ?: return null
if (paths.size <= 1) return null
paths.removeAt(paths.lastIndex)
val target = paths.lastOrNull() ?: return null
pendingNativePops.addLast(PendingNativePop(activeStackId, target))
logDebug(
"$DEBUG_PREFIX coordinator.prepareNativePop stack=$activeStackId target=$target pending=${pendingNativePops.size} ${debugState()} canPop=${canPop()}"
)
return snapshot(
previousStackId = activeStackId,
navigationType = "pop",
path = target,
stackSwitched = false,
applyToCompose = true
)
}
}

View File

@@ -0,0 +1,146 @@
package com.logseq.app
import android.app.DatePickerDialog
import android.content.Intent
import android.util.Log
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import java.text.SimpleDateFormat
import java.util.Calendar
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"
private const val DEBUG_PREFIX = "[DEBUG-navstack]"
}
@PluginMethod
fun showDatePicker(call: PluginCall) {
val defaultDate = call.getString("defaultDate")
val calendar = Calendar.getInstance()
if (!defaultDate.isNullOrEmpty()) {
try {
val sdf = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
val date = sdf.parse(defaultDate)
if (date != null) {
calendar.time = date
}
} catch (e: Exception) {
call.reject("Invalid default date format. Use YYYY-MM-DD", e)
return
}
}
val year = calendar.get(Calendar.YEAR)
val month = calendar.get(Calendar.MONTH)
val day = calendar.get(Calendar.DAY_OF_MONTH)
// create date picker dialog
val datePickerDialog = DatePickerDialog(
activity,
null,
year, month, day
)
datePickerDialog.datePicker.init(year, month, day) { _, selectedYear: Int, selectedMonth: Int, selectedDay: Int ->
// format selected date
val selectedDate = Calendar.getInstance().apply {
set(selectedYear, selectedMonth, selectedDay)
}
val sdf = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
val formattedDate = sdf.format(selectedDate.time)
// return to js
val res = JSObject()
res.put("value", formattedDate)
call.resolve(res)
// close dialog
datePickerDialog.dismiss()
}
datePickerDialog.setOnShowListener {
val parent = datePickerDialog.getButton(DatePickerDialog.BUTTON_POSITIVE).parent.parent
if (parent is ViewGroup) {
parent.visibility = View.GONE
}
}
if (call.getBoolean("restrictFuture", false) == true) {
datePickerDialog.datePicker.maxDate = System.currentTimeMillis()
}
try {
datePickerDialog.show()
} catch (e: Exception) {
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"
Log.d(
"NavStack",
"$DEBUG_PREFIX uiLocal.routeDidChange stack=$stack type=$navigationType push=$push path=$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")
}
}

View File

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

View File

@@ -0,0 +1,122 @@
package com.logseq.app
import android.app.Activity
import android.graphics.Rect
import android.view.View
import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.webkit.WebView
import android.widget.FrameLayout
import androidx.core.view.ViewCompat
import java.util.WeakHashMap
/**
* Defensive recovery for Android WebView/IME interactions.
*
* In this app the Capacitor WebView is re-parented into a Compose host. On some
* Android/WebView combinations, focusing an input while the IME is shown can
* leave the native WebView host chain panned/resized after the IME hides. The
* web content itself may remain scrolled, which is valid; this class only resets
* native view/container state that should always return to full-screen.
*/
object WebViewKeyboardRecovery {
private val listeners = WeakHashMap<Activity, ViewTreeObserver.OnGlobalLayoutListener>()
@JvmStatic
fun install(activity: Activity, webView: WebView) {
if (listeners.containsKey(activity)) return
val content = activity.findViewById<FrameLayout>(android.R.id.content) ?: return
val root = content.rootView ?: content
val keyboardThreshold = NativeUiUtils.dp(activity, 120f)
var wasKeyboardVisible = isKeyboardVisible(root, keyboardThreshold)
val listener = ViewTreeObserver.OnGlobalLayoutListener {
val keyboardVisible = isKeyboardVisible(root, keyboardThreshold)
if (wasKeyboardVisible && !keyboardVisible) {
recoverAfterKeyboardHidden(activity, content, webView)
}
wasKeyboardVisible = keyboardVisible
}
listeners[activity] = listener
content.viewTreeObserver.addOnGlobalLayoutListener(listener)
}
@JvmStatic
fun uninstall(activity: Activity) {
val listener = listeners.remove(activity) ?: return
val content = activity.findViewById<FrameLayout>(android.R.id.content) ?: return
if (content.viewTreeObserver.isAlive) {
content.viewTreeObserver.removeOnGlobalLayoutListener(listener)
}
}
private fun isKeyboardVisible(root: View, thresholdPx: Int): Boolean {
if (root.height <= 0) return false
val visibleFrame = Rect()
root.getWindowVisibleDisplayFrame(visibleFrame)
val coveredHeight = root.height - visibleFrame.bottom
return coveredHeight > thresholdPx
}
private fun recoverAfterKeyboardHidden(
activity: Activity,
content: FrameLayout,
webView: WebView
) {
longArrayOf(0L, 80L, 240L).forEach { delay ->
content.postDelayed({
resetNativeHostState(content, webView)
ViewCompat.requestApplyInsets(activity.window.decorView)
ViewCompat.requestApplyInsets(content)
ViewCompat.requestApplyInsets(webView)
}, delay)
}
}
private fun resetNativeHostState(content: FrameLayout, webView: WebView) {
content.translationY = 0f
content.scrollTo(0, 0)
var current: View? = webView
while (current != null) {
current.translationY = 0f
if (current !== webView && current is ViewGroup) {
current.scrollTo(0, 0)
}
resetLayoutParamsIfWebViewHost(current, webView)
current.requestLayout()
current.invalidate()
if (current === content) break
current = current.parent as? View
}
}
private fun resetLayoutParamsIfWebViewHost(view: View, webView: WebView) {
val shouldFillParent = view === webView ||
view.id == R.id.webview_container ||
view.tag == "compose-host-webview"
if (!shouldFillParent) return
val lp = view.layoutParams ?: return
var changed = false
if (lp.width != ViewGroup.LayoutParams.MATCH_PARENT) {
lp.width = ViewGroup.LayoutParams.MATCH_PARENT
changed = true
}
if (lp.height != ViewGroup.LayoutParams.MATCH_PARENT) {
lp.height = ViewGroup.LayoutParams.MATCH_PARENT
changed = true
}
if (changed) {
view.layoutParams = lp
}
}
}

View File

@@ -0,0 +1,187 @@
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.isClickable = true
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.isClickable = false
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
}
}

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:zAdjustment="top">
<translate android:fromYDelta="0" android:toYDelta="5%p"
android:duration="250"
android:interpolator="@android:interpolator/fast_out_slow_in"/>
<alpha android:fromAlpha="1.0" android:toAlpha="0.0"
android:fillEnabled="true" android:fillBefore="true" android:fillAfter="true"
android:startOffset="100" android:duration="150"
android:interpolator="@android:interpolator/fast_out_slow_in" />
</set>

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

View File

@@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>

View File

@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillColor="#26A69A"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
</vector>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="18dp"
android:height="18dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#A68E8E93"
android:pathData="M12,2C6.47,2 2,6.47 2,12s4.47,10 10,10 10,-4.47 10,-10S17.53,2 12,2z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M15.59,16.59 12,13 8.41,16.59 7,15.17 10.59,11.59 7,8 8.41,6.59 12,10.17 15.59,6.59 17,8 13.41,11.59 17,15.17z" />
</vector>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<group android:translateY="960">
<path
android:fillColor="#FF000000"
android:pathData="m608,-368l46,-166 -142,-98 -46,166 142,98ZM160,-207l-33,-16q-31,-13 -42,-44.5t3,-62.5l72,-156v279ZM320,-120q-33,0 -56.5,-24T240,-201v-239l107,294q3,7 5,13.5t7,12.5h-39ZM526,-125q-31,11 -62,-3t-42,-45L245,-662q-11,-31 3,-61.5t45,-41.5l301,-110q31,-11 61.5,3t41.5,45l178,489q11,31 -3,61.5T827,-235L526,-125ZM498,-200l302,-110 -179,-490 -301,110 178,490ZM560,-500Z" />
</group>
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/logoPrimary" />
<item android:drawable="@drawable/splash" android:gravity="center" />
</layer-list>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<WebView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background>
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
</background>
<foreground>
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
</foreground>
</adaptive-icon>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background>
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
</background>
<foreground>
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
</foreground>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 691 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

View File

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

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>

View File

@@ -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>

View File

@@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">Logseq</string>
<string name="title_activity_main">Logseq</string>
<string name="package_name">com.logseq.app</string>
<string name="custom_url_scheme">com.logseq.app</string>
</resources>

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<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>
<!-- App Starting -->
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:windowBackground">@drawable/splash_centered</item>
<item name="windowSplashScreenAnimationDuration">200</item>
<item name="windowSplashScreenIconBackgroundColor">@color/logoPrimary</item>
<item name="postSplashScreenTheme">@style/AppTheme</item>
</style>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version='1.0' encoding='utf-8'?>
<widget version="1.0.0" xmlns="http://www.w3.org/ns/widgets" xmlns:cdv="http://cordova.apache.org/ns/1.0">
<access origin="*" />
</widget>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">logseq.local</domain>
<trust-anchors>
<certificates src="@raw/logseq_local" />
</trust-anchors>
</domain-config>
<!-- 如果需要允许所有域名(仅用于调试,生产环境不推荐) -->
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>