feat(core): improve mobile perf (#15317)

#### PR Dependency Tree


* **PR #15317** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Virtualized mobile navigation with shell navigation and interactive
swipe menus; coordinated mobile back handling with interactive
phases/state restoration.
* Added shared auth request proxy and message-port based token handling
across mobile and worker flows.
* **Bug Fixes**
  * Hydrated remote worker error stacks for calls and observable errors.
* Improved SQLite FTS/indexer and nbstore optional text handling;
refined docs-search ref parsing and notification loading/retry.
* **Refactor / UX**
* Modal focus-preservation and pointer behavior updates; improved mobile
menu controls and back gesture plugins.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-07-23 00:23:21 +08:00
committed by GitHub
parent 02e75862cc
commit 1d36e2e4b2
160 changed files with 6660 additions and 1890 deletions
@@ -20,35 +20,42 @@ def nativeAbi = nativeTarget == 'x86_64' ? 'x86_64' : 'arm64-v8a'
apply from: 'capacitor.build.gradle'
android {
namespace "app.affine.pro"
compileSdk rootProject.ext.compileSdkVersion
namespace = "app.affine.pro"
compileSdk = rootProject.ext.compileSdkVersion
ndkVersion = new File(sdkDirectory, "ndk").listFiles().sort().last().name
defaultConfig {
applicationId "app.affine.pro"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName System.getenv('VERSION_NAME') ?: 'v1.0.0-local'
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
applicationId = "app.affine.pro"
minSdkVersion = rootProject.ext.minSdkVersion
targetSdkVersion = rootProject.ext.targetSdkVersion
versionCode = 1
versionName = System.getenv('VERSION_NAME') ?: 'v1.0.0-local'
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
manifestPlaceholders = [usesCleartextTraffic: "false"]
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:!*~'
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
ndk {
abiFilters nativeAbi
}
}
buildFeatures {
compose true
buildConfig true
viewBinding true
compose = true
buildConfig = true
viewBinding = true
}
buildTypes {
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
profile {
initWith release
profileable true
signingConfig signingConfigs.debug
matchingFallbacks = ['release']
}
debug {
minifyEnabled false
@@ -112,6 +119,7 @@ dependencies {
implementation libs.androidx.datastore.preferences
implementation libs.androidx.navigation.fragment
implementation libs.androidx.navigation.ui.ktx
implementation libs.androidx.webkit
implementation libs.apollo.runtime
implementation (libs.jna) {
@@ -159,8 +167,8 @@ cargo {
kotlin {
compilerOptions {
apiVersion = KotlinVersion.KOTLIN_2_1
languageVersion = KotlinVersion.KOTLIN_2_1
apiVersion = KotlinVersion.KOTLIN_2_2
languageVersion = KotlinVersion.KOTLIN_2_2
jvmTarget = JvmTarget.JVM_21
}
}
@@ -0,0 +1,77 @@
package app.affine.pro
import androidx.activity.BackEventCompat
import androidx.activity.OnBackPressedCallback
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class MobileBackDispatcherTest {
@Test
fun disabledAppCallbackPassesTheSameBackToTheNextCallback() {
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
scenario.onActivity { activity ->
var fallbackCalled = false
activity.onBackPressedDispatcher.addCallback(
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
fallbackCalled = true
}
},
)
val appCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() = Unit
}
activity.onBackPressedDispatcher.addCallback(appCallback)
appCallback.isEnabled = false
activity.onBackPressedDispatcher.onBackPressed()
assertTrue(fallbackCalled)
}
}
}
@Test
fun predictiveLifecycleTargetsOneEnabledCallback() {
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
scenario.onActivity { activity ->
val phases = mutableListOf<String>()
activity.onBackPressedDispatcher.addCallback(
object : OnBackPressedCallback(true) {
override fun handleOnBackStarted(backEvent: BackEventCompat) {
phases.add("begin")
}
override fun handleOnBackProgressed(backEvent: BackEventCompat) {
phases.add("progress")
}
override fun handleOnBackCancelled() {
phases.add("cancel")
}
override fun handleOnBackPressed() {
phases.add("commit")
}
},
)
val event = BackEventCompat(0f, 0f, 0.5f, BackEventCompat.EDGE_LEFT)
activity.onBackPressedDispatcher.dispatchOnBackStarted(event)
activity.onBackPressedDispatcher.dispatchOnBackProgressed(event)
activity.onBackPressedDispatcher.dispatchOnBackCancelled()
activity.onBackPressedDispatcher.dispatchOnBackStarted(event)
activity.onBackPressedDispatcher.onBackPressed()
assertEquals(
listOf("begin", "progress", "cancel", "begin", "commit"),
phases,
)
}
}
}
}
@@ -20,7 +20,7 @@
<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
android:exported="true"
android:hardwareAccelerated="true"
android:label="@string/title_activity_main"
@@ -3,6 +3,12 @@ package app.affine.pro
import android.annotation.SuppressLint
import android.app.Application
import android.content.Context
import android.os.SystemClock
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewOutcomeReceiver
import androidx.webkit.WebViewStartUpConfig
import androidx.webkit.WebViewStartUpResult
import androidx.webkit.WebViewStartupException
import app.affine.pro.utils.logger.AffineDebugTree
import app.affine.pro.utils.logger.CrashlyticsTree
import app.affine.pro.utils.logger.FileTree
@@ -11,12 +17,14 @@ import com.google.firebase.crashlytics.setCustomKeys
import com.google.firebase.ktx.Firebase
import dagger.hilt.android.HiltAndroidApp
import timber.log.Timber
import java.util.concurrent.Executors
@HiltAndroidApp
class AFFiNEApp : Application() {
override fun onCreate() {
super.onCreate()
startWebView()
_context = applicationContext
// init logger
if (BuildConfig.DEBUG) {
@@ -33,6 +41,30 @@ class AFFiNEApp : Application() {
}
}
private fun startWebView() {
val startedAt = SystemClock.elapsedRealtime()
val executor = Executors.newSingleThreadExecutor()
val config = WebViewStartUpConfig.Builder(executor).build()
WebViewCompat.startUpWebView(
this,
config,
object : WebViewOutcomeReceiver<WebViewStartUpResult, WebViewStartupException> {
override fun onResult(result: WebViewStartUpResult) {
executor.shutdown()
Timber.i(
"WebView startup completed asynchronously in %d ms.",
SystemClock.elapsedRealtime() - startedAt,
)
}
override fun onError(error: WebViewStartupException) {
executor.shutdown()
Timber.w(error, "WebView asynchronous startup failed.")
}
},
)
}
override fun onTerminate() {
_context = null
super.onTerminate()
@@ -44,4 +76,4 @@ class AFFiNEApp : Application() {
fun context() = requireNotNull(_context)
}
}
}
@@ -16,7 +16,7 @@ object AuthInitializer {
fun initialize(bridge: Bridge) {
bridge.addWebViewListener(object : WebViewListener() {
override fun onPageLoaded(webView: WebView?) {
bridge.removeWebViewListener(this)
webView?.post { bridge.removeWebViewListener(this) }
MainScope().launch(Dispatchers.IO) {
try {
FileTree.get()?.checkAndUploadOldLogs(
@@ -1,13 +1,16 @@
package app.affine.pro
import android.content.res.ColorStateList
import android.content.ComponentCallbacks2
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.webkit.WebSettings
import androidx.activity.enableEdgeToEdge
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateMargins
@@ -19,6 +22,7 @@ import app.affine.pro.plugin.AFFiNEThemePlugin
import app.affine.pro.plugin.AuthPlugin
import app.affine.pro.plugin.HashCashPlugin
import app.affine.pro.plugin.NbStorePlugin
import app.affine.pro.plugin.MobileBackPlugin
import app.affine.pro.plugin.PreviewPlugin
import app.affine.pro.service.GraphQLService
import app.affine.pro.service.SSEService
@@ -53,6 +57,7 @@ class MainActivity : BridgeActivity(), AIButtonPlugin.Callback, AFFiNEThemePlugi
AuthPlugin::class.java,
HashCashPlugin::class.java,
NbStorePlugin::class.java,
MobileBackPlugin::class.java,
PreviewPlugin::class.java,
)
)
@@ -83,6 +88,8 @@ class MainActivity : BridgeActivity(), AIButtonPlugin.Callback, AFFiNEThemePlugi
private var navHeight = 0
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
enableEdgeToEdge()
super.onCreate(savedInstanceState)
ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { v, insets ->
navHeight = px2dp(insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom)
@@ -96,6 +103,16 @@ class MainActivity : BridgeActivity(), AIButtonPlugin.Callback, AFFiNEThemePlugi
configureEditorWebView()
}
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
if (level >= ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN) {
bridge.webView.evaluateJavascript(
"window.dispatchEvent(new Event('affine:memory-pressure'))",
null,
)
}
}
private fun configureEditorWebView() {
bridge.webView.apply {
overScrollMode = View.OVER_SCROLL_NEVER
@@ -0,0 +1,52 @@
package app.affine.pro.plugin
import androidx.activity.BackEventCompat
import androidx.activity.OnBackPressedCallback
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
@CapacitorPlugin(name = "MobileBack")
class MobileBackPlugin : Plugin() {
private val callback = object : OnBackPressedCallback(false) {
override fun handleOnBackStarted(backEvent: BackEventCompat) {
emit("begin", backEvent.progress)
}
override fun handleOnBackProgressed(backEvent: BackEventCompat) {
emit("progress", backEvent.progress)
}
override fun handleOnBackCancelled() {
emit("cancel")
}
override fun handleOnBackPressed() {
emit("commit")
}
}
override fun load() {
activity.onBackPressedDispatcher.addCallback(activity, callback)
}
@PluginMethod
fun setEnabled(call: PluginCall) {
callback.isEnabled = call.getBoolean("enabled", false) ?: false
call.resolve()
}
private fun emit(phase: String, progress: Float? = null) {
notifyListeners("back", JSObject().apply {
put("phase", phase)
progress?.let { put("progress", it) }
})
}
override fun handleOnDestroy() {
callback.remove()
super.handleOnDestroy()
}
}
@@ -7,11 +7,14 @@ import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import kotlinx.coroutines.Dispatchers
import org.json.JSONObject
import timber.log.Timber
import uniffi.affine_mobile_native.DocRecord
import uniffi.affine_mobile_native.SetBlob
import uniffi.affine_mobile_native.newDocStoragePool
private const val ANDROID_INDEXER_VERSION_OFFSET = 1u
@CapacitorPlugin(name = "NbStoreDocStorage")
class NbStorePlugin : Plugin() {
@@ -598,10 +601,10 @@ class NbStorePlugin : Plugin() {
JSObject()
.put("blockId", block.blockId)
.put("flavour", block.flavour)
.put("content", block.content)
.put("blob", block.blob)
.put("refDocId", block.refDocId)
.put("refInfo", block.refInfo)
.put("content", block.content?.let(::JSArray))
.put("blob", block.blob?.let(::JSArray))
.put("refDocId", block.refDocId?.let(::JSArray))
.put("refInfo", block.refInfo?.let(::JSArray))
.put("parentFlavour", block.parentFlavour)
.put("parentBlockId", block.parentBlockId)
.put("additional", block.additional)
@@ -683,7 +686,7 @@ class NbStorePlugin : Plugin() {
val indexName = call.getStringEnsure("indexName")
val docId = call.getStringEnsure("docId")
val text = docStoragePool.ftsGetDocument(id, indexName, docId)
call.resolve(JSObject().put("text", text))
call.resolve(JSObject().put("text", text ?: JSONObject.NULL))
} catch (e: Exception) {
call.reject("Failed to get fts document: ${e.message}", null, e)
}
@@ -728,7 +731,7 @@ class NbStorePlugin : Plugin() {
fun ftsIndexVersion(call: PluginCall) {
launch(Dispatchers.IO) {
try {
val version = docStoragePool.ftsIndexVersion()
val version = docStoragePool.ftsIndexVersion() + ANDROID_INDEXER_VERSION_OFFSET
call.resolve(JSObject().put("indexVersion", version))
} catch (e: Exception) {
call.reject("Failed to get fts index version: ${e.message}", null, e)
@@ -17,6 +17,7 @@
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">#FFFFFF</item>
<item name="postSplashScreenTheme">@style/AppTheme.NoActionBar</item>
</style>
</resources>
</resources>