-
Notifications
You must be signed in to change notification settings - Fork 138
perf: [startup optimization] defer CookieManager initialization to background thread #3093
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1c21bf4
b851b37
74d790a
8de62e0
4133144
46a95c8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| ## 2026-05-28 - Defer CookieManager Initialization **Learning:** `CookieManager.getInstance()` performs synchronous file I/O on the main thread and initializes the Chromium WebView engine, blocking Time to Initial Display (TTID). **Action:** Defer initialization to a background coroutine via `ProcessLifecycleOwner.get().lifecycleScope.launch(Dispatchers.Default)` and wrap any UI fallback (e.g., Toasts) with `withContext(Dispatchers.Main)` to avoid `CalledFromWrongThreadException`. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -45,6 +45,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged | |
| import kotlinx.coroutines.flow.launchIn | ||
| import kotlinx.coroutines.flow.onEach | ||
| import kotlinx.coroutines.launch | ||
| import kotlinx.coroutines.withContext | ||
| import org.conscrypt.Conscrypt | ||
| import org.nekomanga.core.network.NetworkPreferences | ||
| import org.nekomanga.core.security.SecurityPreferences | ||
|
|
@@ -74,17 +75,6 @@ open class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.F | |
|
|
||
| GlobalExceptionHandler.initialize(applicationContext, CrashActivity::class.java) | ||
|
|
||
| kotlin | ||
| .runCatching { CookieManager.getInstance() } | ||
| .onFailure { | ||
| Toast.makeText( | ||
| applicationContext, | ||
| "Error! App requires WebView to be installed", | ||
| Toast.LENGTH_LONG, | ||
| ) | ||
| .show() | ||
| } | ||
|
|
||
| // TLS 1.3 support for Android < 10 | ||
| if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { | ||
| Security.insertProviderAt(Conscrypt.newProvider(), 1) | ||
|
|
@@ -94,7 +84,7 @@ open class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.F | |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { | ||
| val process = getProcessName() | ||
| if (packageName != process) { | ||
| kotlin.runCatching { WebView.setDataDirectorySuffix(process) } | ||
| runCatching { WebView.setDataDirectorySuffix(process) } | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -112,6 +102,18 @@ open class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.F | |
| ProcessLifecycleOwner.get().lifecycle.addObserver(this) | ||
|
|
||
| ProcessLifecycleOwner.get().lifecycleScope.launch(Dispatchers.Default) { | ||
| runCatching { CookieManager.getInstance() } | ||
| .onFailure { | ||
| withContext(Dispatchers.Main) { | ||
| Toast.makeText( | ||
| applicationContext, | ||
| getString(R.string.information_webview_required), | ||
| Toast.LENGTH_LONG, | ||
| ) | ||
| .show() | ||
| } | ||
| } | ||
|
Comment on lines
104
to
+115
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Initializing WebView/CookieManager on a background thread ( To safely defer the initialization without blocking the critical startup path (maintaining the TTID optimization), you can launch the ProcessLifecycleOwner.get().lifecycleScope.launch(Dispatchers.Default) {
launch(Dispatchers.Main) {
runCatching { CookieManager.getInstance() }
.onFailure {
Toast.makeText(
applicationContext,
getString(R.string.information_webview_required),
Toast.LENGTH_LONG,
).show()
}
}References
|
||
|
|
||
| setupNotificationChannels() | ||
| MangaCoverMetadata.load() | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,287 @@ | ||
| package org.nekomanga | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| import android.Manifest | ||
| import android.annotation.SuppressLint | ||
| import android.app.Application | ||
| import android.app.PendingIntent | ||
| import android.content.BroadcastReceiver | ||
| import android.content.Context | ||
| import android.content.Intent | ||
| import android.content.IntentFilter | ||
| import android.content.pm.PackageManager | ||
| import android.os.Build | ||
| import android.os.Looper | ||
| import android.webkit.CookieManager | ||
| import android.webkit.WebView | ||
| import android.widget.Toast | ||
| import androidx.appcompat.app.AppCompatDelegate | ||
| import androidx.core.app.ActivityCompat | ||
| import androidx.core.app.NotificationCompat | ||
| import androidx.core.app.NotificationManagerCompat | ||
| import androidx.core.content.ContextCompat | ||
| import androidx.lifecycle.DefaultLifecycleObserver | ||
| import androidx.lifecycle.LifecycleOwner | ||
| import androidx.lifecycle.ProcessLifecycleOwner | ||
| import androidx.lifecycle.lifecycleScope | ||
| import androidx.multidex.MultiDex | ||
| import coil3.SingletonImageLoader | ||
| import eu.kanade.tachiyomi.AppModule | ||
| import eu.kanade.tachiyomi.PreferenceModule | ||
| import eu.kanade.tachiyomi.crash.CrashActivity | ||
| import eu.kanade.tachiyomi.crash.GlobalExceptionHandler | ||
| import eu.kanade.tachiyomi.data.coil.coilImageLoader | ||
| import eu.kanade.tachiyomi.data.notification.Notifications | ||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||
| import eu.kanade.tachiyomi.ui.library.LibraryViewModel | ||
| import eu.kanade.tachiyomi.ui.main.DeepLinks | ||
| import eu.kanade.tachiyomi.ui.main.MainActivity | ||
| import eu.kanade.tachiyomi.ui.security.SecureActivityDelegate | ||
| import eu.kanade.tachiyomi.util.manga.MangaCoverMetadata | ||
| import eu.kanade.tachiyomi.util.system.AuthenticatorUtil | ||
| import eu.kanade.tachiyomi.util.system.notification | ||
| import java.security.Security | ||
| import kotlinx.coroutines.Dispatchers | ||
| import kotlinx.coroutines.flow.distinctUntilChanged | ||
| import kotlinx.coroutines.flow.launchIn | ||
| import kotlinx.coroutines.flow.onEach | ||
| import kotlinx.coroutines.launch | ||
| import kotlinx.coroutines.withContext | ||
| import org.conscrypt.Conscrypt | ||
| import org.nekomanga.core.network.NetworkPreferences | ||
| import org.nekomanga.core.security.SecurityPreferences | ||
| import org.nekomanga.domain.site.MangaDexPreferences | ||
| import org.nekomanga.logging.CrashReportingTree | ||
| import org.nekomanga.logging.DebugReportingTree | ||
| import org.nekomanga.logging.TimberKt | ||
| import org.nekomanga.presentation.screens.feed.FeedViewModel | ||
| import tachiyomi.core.util.system.WebViewUtil | ||
| import uy.kohesive.injekt.Injekt | ||
| import uy.kohesive.injekt.injectLazy | ||
|
|
||
| private const val ACTION_DISABLE_INCOGNITO_MODE = "tachi.action.DISABLE_INCOGNITO_MODE" | ||
|
|
||
| open class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factory { | ||
|
|
||
| val preferences: PreferencesHelper by injectLazy() | ||
| val networkPreferences: NetworkPreferences by injectLazy() | ||
| val securityPreferences: SecurityPreferences by injectLazy() | ||
| val mangaDexPreferences: MangaDexPreferences by injectLazy() | ||
|
|
||
| private val disableIncognitoReceiver = DisableIncognitoReceiver() | ||
|
|
||
| @SuppressLint("LaunchActivityFromNotification") | ||
| override fun onCreate() { | ||
| super<Application>.onCreate() | ||
|
|
||
| GlobalExceptionHandler.initialize(applicationContext, CrashActivity::class.java) | ||
|
|
||
| // TLS 1.3 support for Android < 10 | ||
| if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { | ||
| Security.insertProviderAt(Conscrypt.newProvider(), 1) | ||
| } | ||
|
|
||
| // Avoid potential crashes | ||
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { | ||
| val process = getProcessName() | ||
| if (packageName != process) { | ||
| runCatching { WebView.setDataDirectorySuffix(process) } | ||
| } | ||
| } | ||
|
|
||
| Injekt.importModule(PreferenceModule(this)) | ||
| Injekt.importModule(AppModule(this)) | ||
|
|
||
| if (!BuildConfig.DEBUG) { | ||
| TimberKt.plant(CrashReportingTree()) | ||
| } | ||
| // also plant a debug tree in prod if enabled | ||
| if (BuildConfig.DEBUG || networkPreferences.verboseLogging().get()) { | ||
| TimberKt.plant(DebugReportingTree()) | ||
| } | ||
|
|
||
| ProcessLifecycleOwner.get().lifecycle.addObserver(this) | ||
|
|
||
| ProcessLifecycleOwner.get().lifecycleScope.launch(Dispatchers.Default) { | ||
| setupNotificationChannels() | ||
| MangaCoverMetadata.load() | ||
|
|
||
| runCatching { CookieManager.getInstance() } | ||
| .onFailure { | ||
| withContext(Dispatchers.Main) { | ||
| Toast.makeText( | ||
| applicationContext, | ||
| getString(R.string.information_webview_required), | ||
| Toast.LENGTH_LONG, | ||
| ) | ||
| .show() | ||
| } | ||
| } | ||
| } | ||
|
|
||
| preferences | ||
| .nightMode() | ||
| .changes() | ||
| .onEach { AppCompatDelegate.setDefaultNightMode(it) } | ||
| .launchIn((ProcessLifecycleOwner.get().lifecycleScope)) | ||
|
|
||
| // Show notification to disable Incognito Mode when it's enabled | ||
| securityPreferences | ||
| .incognitoMode() | ||
| .changes() | ||
| .onEach { enabled -> | ||
| val notificationManager = NotificationManagerCompat.from(this) | ||
| if (enabled) { | ||
| disableIncognitoReceiver.register() | ||
| val notification = | ||
| notification(Notifications.CHANNEL_INCOGNITO_MODE) { | ||
| val incogText = getString(R.string.incognito_mode) | ||
| setContentTitle(incogText) | ||
| setContentText(getString(R.string.turn_off_, incogText)) | ||
| setSmallIcon(R.drawable.ic_incognito_24dp) | ||
| setOngoing(true) | ||
|
|
||
| val pendingIntent = | ||
| PendingIntent.getBroadcast( | ||
| this@App, | ||
| 0, | ||
| Intent(ACTION_DISABLE_INCOGNITO_MODE), | ||
| PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE, | ||
| ) | ||
| setContentIntent(pendingIntent) | ||
| } | ||
| if ( | ||
| ActivityCompat.checkSelfPermission( | ||
| this, | ||
| Manifest.permission.POST_NOTIFICATIONS, | ||
| ) != PackageManager.PERMISSION_GRANTED | ||
| ) { | ||
| return@onEach | ||
| } | ||
| notificationManager.notify(Notifications.ID_INCOGNITO_MODE, notification) | ||
| } else { | ||
| disableIncognitoReceiver.unregister() | ||
| notificationManager.cancel(Notifications.ID_INCOGNITO_MODE) | ||
| } | ||
| } | ||
| .launchIn(ProcessLifecycleOwner.get().lifecycleScope) | ||
|
|
||
| // Show notification when the user has been unexpectedly signed out of MangaDex | ||
| // (auth refresh rejected). The flag is set in MangaDexLoginHelper and cleared on | ||
| // successful login or manual logout. | ||
| mangaDexPreferences | ||
| .unexpectedLogout() | ||
| .changes() | ||
| .distinctUntilChanged() | ||
| .onEach { unexpected -> | ||
| val notificationManager = NotificationManagerCompat.from(this) | ||
| if (unexpected) { | ||
| if ( | ||
| ActivityCompat.checkSelfPermission( | ||
| this, | ||
| Manifest.permission.POST_NOTIFICATIONS, | ||
| ) != PackageManager.PERMISSION_GRANTED | ||
| ) { | ||
| return@onEach | ||
| } | ||
| val tapIntent = | ||
| Intent(this, MainActivity::class.java).apply { | ||
| action = DeepLinks.Actions.MangaDexSettings | ||
| flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP | ||
| } | ||
| val pendingIntent = | ||
| PendingIntent.getActivity( | ||
| this, | ||
| Notifications.Id.Authentication.SessionExpired, | ||
| tapIntent, | ||
| PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, | ||
| ) | ||
| val body = getString(R.string.mangadex_session_expired_body) | ||
| val notification = | ||
| notification(Notifications.Channel.Authentication) { | ||
| setContentTitle(getString(R.string.mangadex_session_expired_title)) | ||
| setContentText(body) | ||
| setStyle(NotificationCompat.BigTextStyle().bigText(body)) | ||
| setSmallIcon(R.drawable.ic_neko_notification) | ||
| setContentIntent(pendingIntent) | ||
| setAutoCancel(true) | ||
| priority = NotificationCompat.PRIORITY_HIGH | ||
| } | ||
| notificationManager.notify( | ||
| Notifications.Id.Authentication.SessionExpired, | ||
| notification, | ||
| ) | ||
| } else { | ||
| notificationManager.cancel(Notifications.Id.Authentication.SessionExpired) | ||
| } | ||
| } | ||
| .launchIn(ProcessLifecycleOwner.get().lifecycleScope) | ||
| } | ||
|
|
||
| override fun onPause(owner: LifecycleOwner) { | ||
| if (!AuthenticatorUtil.isAuthenticating && securityPreferences.lockAfter().get() >= 0) { | ||
| SecureActivityDelegate.locked = true | ||
| } | ||
| } | ||
|
|
||
| override fun attachBaseContext(base: Context) { | ||
| super.attachBaseContext(base) | ||
| MultiDex.install(this) | ||
| } | ||
|
|
||
| override fun onLowMemory() { | ||
| super.onLowMemory() | ||
| LibraryViewModel.onLowMemory() | ||
| FeedViewModel.onLowMemory() | ||
| } | ||
|
|
||
| override fun getPackageName(): String { | ||
| try { | ||
| // Override the value passed as X-Requested-With in WebView requests | ||
| val stackTrace = Looper.getMainLooper().thread.stackTrace | ||
| val isChromiumCall = stackTrace.any { trace -> | ||
| trace.className.lowercase() in | ||
| setOf("org.chromium.base.buildinfo", "org.chromium.base.apkinfo") && | ||
| trace.methodName.lowercase() in setOf("getall", "getpackagename", "<init>") | ||
| } | ||
|
|
||
| if (isChromiumCall) return WebViewUtil.spoofedPackageName(applicationContext) | ||
| } catch (e: Exception) { | ||
| TimberKt.e(e) { "Failed to spoof package name" } | ||
| } | ||
|
|
||
| return super.getPackageName() | ||
| } | ||
|
|
||
| override fun newImageLoader(context: Context) = coilImageLoader(context) | ||
|
|
||
| protected open fun setupNotificationChannels() { | ||
| Notifications.createChannels(this) | ||
| } | ||
|
|
||
| private inner class DisableIncognitoReceiver : BroadcastReceiver() { | ||
| private var registered = false | ||
|
|
||
| override fun onReceive(context: Context, intent: Intent) { | ||
| securityPreferences.incognitoMode().set(false) | ||
| } | ||
|
|
||
| fun register() { | ||
| if (!registered) { | ||
| ContextCompat.registerReceiver( | ||
| this@App, | ||
| this, | ||
| IntentFilter(ACTION_DISABLE_INCOGNITO_MODE), | ||
| ContextCompat.RECEIVER_EXPORTED, | ||
| ) | ||
| registered = true | ||
| } | ||
| } | ||
|
|
||
| fun unregister() { | ||
| if (registered) { | ||
| unregisterReceiver(this) | ||
| registered = false | ||
| } | ||
| } | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.