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>
2
android/app/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/build/*
|
||||
!/build/.npmkeep
|
||||
79
android/app/build.gradle
Normal 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")
|
||||
}
|
||||
36
android/app/capacitor.build.gradle
Normal 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
@@ -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
|
||||
69
android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
74
android/app/src/main/assets/capacitor.plugins.json
Normal 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"
|
||||
}
|
||||
]
|
||||
375
android/app/src/main/java/com/logseq/app/ComposeHost.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
214
android/app/src/main/java/com/logseq/app/FileUtil.java
Normal 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());
|
||||
}
|
||||
}
|
||||
91
android/app/src/main/java/com/logseq/app/FolderPicker.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
307
android/app/src/main/java/com/logseq/app/FsWatcher.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
663
android/app/src/main/java/com/logseq/app/LiquidTabsPlugin.kt
Normal 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?
|
||||
)
|
||||
44
android/app/src/main/java/com/logseq/app/LogseqTheme.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
345
android/app/src/main/java/com/logseq/app/MainActivity.java
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
300
android/app/src/main/java/com/logseq/app/NativeTopBarPlugin.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
31
android/app/src/main/java/com/logseq/app/NativeUiUtils.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
146
android/app/src/main/java/com/logseq/app/UILocal.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
38
android/app/src/main/java/com/logseq/app/Utils.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
12
android/app/src/main/res/anim/byebye.xml
Normal 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>
|
||||
BIN
android/app/src/main/res/drawable-land-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
android/app/src/main/res/drawable-land-ldpi/splash.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
android/app/src/main/res/drawable-land-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
android/app/src/main/res/drawable-land-night-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
android/app/src/main/res/drawable-land-night-ldpi/splash.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
android/app/src/main/res/drawable-land-night-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
android/app/src/main/res/drawable-land-night-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
android/app/src/main/res/drawable-land-night-xxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
android/app/src/main/res/drawable-land-night-xxxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
android/app/src/main/res/drawable-land-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
android/app/src/main/res/drawable-land-xxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
android/app/src/main/res/drawable-land-xxxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
android/app/src/main/res/drawable-night/splash.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
android/app/src/main/res/drawable-port-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
android/app/src/main/res/drawable-port-ldpi/splash.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
android/app/src/main/res/drawable-port-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
android/app/src/main/res/drawable-port-night-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
android/app/src/main/res/drawable-port-night-ldpi/splash.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
android/app/src/main/res/drawable-port-night-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
android/app/src/main/res/drawable-port-night-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
android/app/src/main/res/drawable-port-night-xxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
android/app/src/main/res/drawable-port-night-xxxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
android/app/src/main/res/drawable-port-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
android/app/src/main/res/drawable-port-xxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
android/app/src/main/res/drawable-port-xxxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 141 KiB |
@@ -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>
|
||||
170
android/app/src/main/res/drawable/ic_launcher_background.xml
Normal 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>
|
||||
15
android/app/src/main/res/drawable/ic_search_clear.xml
Normal 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>
|
||||
|
||||
|
||||
14
android/app/src/main/res/drawable/ic_tab_flashcards.xml
Normal 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>
|
||||
|
||||
|
||||
BIN
android/app/src/main/res/drawable/splash.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
6
android/app/src/main/res/drawable/splash_centered.xml
Normal 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>
|
||||
|
||||
12
android/app/src/main/res/layout/activity_main.xml
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
android/app/src/main/res/mipmap-ldpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 691 B |
BIN
android/app/src/main/res/mipmap-ldpi/ic_launcher_background.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
android/app/src/main/res/mipmap-ldpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
android/app/src/main/res/mipmap-ldpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 10 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
8
android/app/src/main/res/values/colors.xml
Normal 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>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
5
android/app/src/main/res/values/ids.xml
Normal 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>
|
||||
7
android/app/src/main/res/values/strings.xml
Normal 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>
|
||||
30
android/app/src/main/res/values/styles.xml
Normal 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>
|
||||
6
android/app/src/main/res/xml/config.xml
Normal 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>
|
||||
5
android/app/src/main/res/xml/file_paths.xml
Normal 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>
|
||||
16
android/app/src/main/res/xml/network_security_config.xml
Normal 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>
|
||||