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>
@@ -1,6 +1,6 @@
ext {
androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.7.0'
cordovaAndroidVersion = project.hasProperty('cordovaAndroidVersion') ? rootProject.ext.cordovaAndroidVersion : '10.1.1'
androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.7.1'
cordovaAndroidVersion = project.hasProperty('cordovaAndroidVersion') ? rootProject.ext.cordovaAndroidVersion : '14.0.1'
}
buildscript {
@@ -9,21 +9,23 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.13.2'
classpath 'com.android.tools.build:gradle:8.13.0'
}
}
apply plugin: 'com.android.library'
android {
namespace "capacitor.cordova.android.plugins"
compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 36
namespace = "capacitor.cordova.android.plugins"
compileSdk = project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 36
defaultConfig {
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 35
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 24
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 36
versionCode 1
versionName "1.0"
}
lintOptions {
abortOnError false
abortOnError = false
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
@@ -54,4 +56,4 @@ apply from: "cordova.variables.gradle"
for (def func : cdvPluginPostBuildExtras) {
func()
}
}
@@ -1,6 +1,6 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
ext {
cdvMinSdkVersion = project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23
cdvMinSdkVersion = project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 24
// Plugin gradle extensions can append to this to have code run at the end.
cdvPluginPostBuildExtras = []
cordovaConfig = [:]
@@ -1,7 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:amazon="http://schemas.amazon.com/apk/res/android">
<application android:usesCleartextTraffic="true">
<application >
</application>
@@ -20,5 +20,6 @@ org.gradle.jvmargs=-Xmx1536m
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
android.r8.optimizedResourceShrinking=true
ksp.incremental.apt=true
ksp.useKSP2=true
@@ -1,15 +1,15 @@
[versions]
android-gradle-plugin = "8.13.2"
androidx-activity-compose = "1.10.1"
androidx-appcompat = "1.7.0"
androidx-browser = "1.8.0"
androidx-activity-compose = "1.13.0"
androidx-appcompat = "1.7.1"
androidx-browser = "1.9.0"
androidx-compose-bom = "2025.05.00"
androidx-coordinatorlayout = "1.3.0"
androidx-core-ktx = "1.16.0"
androidx-core-splashscreen = "1.0.1"
androidx-core-ktx = "1.17.0"
androidx-core-splashscreen = "1.2.0"
androidx-datastore-preferences = "1.2.0-alpha02"
androidx-espresso-core = "3.6.1"
androidx-junit = "1.2.1"
androidx-espresso-core = "3.7.0"
androidx-junit = "1.3.0"
androidx-lifecycle-compose = "2.9.0"
androidx-material3 = "1.3.1"
androidx-navigation = "2.9.0"
@@ -19,25 +19,26 @@ apollo-kotlin-adapters = "0.0.6"
compileSdk = "36"
firebase-bom = "33.13.0"
firebase-crashlytics = "3.0.3"
google-services = "4.4.2"
google-services = "4.4.4"
gradle-versions = "0.52.0"
hilt = "2.56.2"
hilt-ext = "1.2.0"
jna = "5.17.0"
junit = "4.13.2"
kotlin = "2.1.20"
kotlin = "2.2.20"
kotlinx-coroutines = "1.10.2"
kotlinx-datetime = "0.6.2"
kotlinx-serialization-json = "1.8.1"
ksp = "2.1.20-2.0.1"
ksp = "2.2.20-2.0.4"
# @keep
minSdk = "23"
minSdk = "24"
mozilla-rust-android = "0.9.6"
okhttp-bom = "5.0.0-alpha.14"
richtext = "1.0.0-alpha02"
# @keep
targetSdk = "35"
targetSdk = "36"
timber = "5.0.1"
webkit = "1.16.0"
version-catalog-update = "1.0.0"
[libraries]
@@ -67,6 +68,7 @@ androidx-lifecycle-viewModelCompose = { module = "androidx.lifecycle:lifecy
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" }
androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "androidx-navigation" }
androidx-navigation-ui-ktx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "androidx-navigation" }
androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "webkit" }
apollo-adapters-core = { module = "com.apollographql.adapters:apollo-adapters-core", version.ref = "apollo-kotlin-adapters" }
apollo-adapters-kotlinx-datetime = { module = "com.apollographql.adapters:apollo-adapters-kotlinx-datetime", version.ref = "apollo-kotlin-adapters" }
apollo-api = { module = "com.apollographql.apollo:apollo-api", version.ref = "apollo" }
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME