tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit d3b2fe18b4226dd943828e74e046d2b9aa1d643d
parent 43612cbc8603f0ce86502f9075b0ab96aaebe48e
Author: kycn <35106533+kycn@users.noreply.github.com>
Date:   Thu,  9 Oct 2025 15:10:28 +0000

Bug 1992131 - Classify app link intents as WARM when the app process was started in the background without any activity (e.g. via WorkManager). r=android-reviewers,tcampbell

Previously, app link intents were classified as COLD even when the process
was already running in the background (e.g. started by WorkManager without
any activities). This change updates the classification logic to mark such
launches as WARM instead.

Differential Revision: https://phabricator.services.mozilla.com/D268066

Diffstat:
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/FenixApplication.kt | 3+--
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/IntentReceiverActivity.kt | 3+--
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/components/Components.kt | 3+++
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/perf/AppLinkIntentLaunchTypeProvider.kt | 60++++++++++++++++++++++++++++++++++++++++++++++--------------
Mmobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/AppLinkIntentLaunchTypeProviderTest.kt | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
5 files changed, 118 insertions(+), 34 deletions(-)

diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/FenixApplication.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/FenixApplication.kt @@ -99,7 +99,6 @@ import org.mozilla.fenix.lifecycle.StoreLifecycleObserver import org.mozilla.fenix.lifecycle.VisibilityLifecycleObserver import org.mozilla.fenix.nimbus.FxNimbus import org.mozilla.fenix.onboarding.MARKETING_CHANNEL_ID -import org.mozilla.fenix.perf.AppLinkIntentLaunchTypeProvider import org.mozilla.fenix.perf.ApplicationExitInfoMetrics import org.mozilla.fenix.perf.MarkersActivityLifecycleCallbacks import org.mozilla.fenix.perf.ProfilerMarkerFactProcessor @@ -310,9 +309,9 @@ open class FenixApplication : LocaleAwareApplication(), Provider { registerActivityLifecycleCallbacks(visibilityLifecycleCallback) registerActivityLifecycleCallbacks(MarkersActivityLifecycleCallbacks(components.core.engine)) - AppLinkIntentLaunchTypeProvider.register(this) components.appStartReasonProvider.registerInAppOnCreate(this) components.startupActivityLog.registerInAppOnCreate(this) + components.appLinkIntentLaunchTypeProvider.registerInAppOnCreate(this) initVisualCompletenessQueueAndQueueTasks() ProcessLifecycleOwner.get().lifecycle.addObservers( diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/IntentReceiverActivity.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/IntentReceiverActivity.kt @@ -27,7 +27,6 @@ import org.mozilla.fenix.components.getType import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.isIntentInternal import org.mozilla.fenix.ext.settings -import org.mozilla.fenix.perf.AppLinkIntentLaunchTypeProvider import org.mozilla.fenix.perf.MarkersActivityLifecycleCallbacks import org.mozilla.fenix.perf.StartupTimeline import org.mozilla.fenix.shortcut.NewTabShortcutIntentProcessor @@ -49,7 +48,7 @@ class IntentReceiverActivity : Activity() { // e.g. COLD launch is interpreted as WARM due to [Activity.onActivityCreated] being called // earlier. if (intent.dataString != null) { // data is null when there's no URI to load, e.g. Search widget. - val type = AppLinkIntentLaunchTypeProvider.getExternalIntentLaunchType(HomeActivity::class.java) + val type = components.appLinkIntentLaunchTypeProvider.getExternalIntentLaunchType(HomeActivity::class.java) intent.putExtra(EXTRA_APP_LINK_LAUNCH_TYPE, type) } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/components/Components.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/components/Components.kt @@ -64,6 +64,7 @@ import org.mozilla.fenix.home.setup.store.SetupChecklistTelemetryMiddleware import org.mozilla.fenix.messaging.state.MessagingMiddleware import org.mozilla.fenix.nimbus.FxNimbus import org.mozilla.fenix.onboarding.FenixOnboarding +import org.mozilla.fenix.perf.AppLinkIntentLaunchTypeProvider import org.mozilla.fenix.perf.AppStartReasonProvider import org.mozilla.fenix.perf.StartupActivityLog import org.mozilla.fenix.perf.StartupStateProvider @@ -239,6 +240,8 @@ class Components(private val context: Context) { val startupActivityLog by lazyMonitored { StartupActivityLog() } val startupStateProvider by lazyMonitored { StartupStateProvider(startupActivityLog, appStartReasonProvider) } + val appLinkIntentLaunchTypeProvider by lazyMonitored { AppLinkIntentLaunchTypeProvider(appStartReasonProvider) } + val appStore by lazyMonitored { val blocklistHandler = BlocklistHandler(settings) diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/perf/AppLinkIntentLaunchTypeProvider.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/perf/AppLinkIntentLaunchTypeProvider.kt @@ -18,11 +18,13 @@ import java.util.concurrent.atomic.AtomicInteger * Tracks and provides the type of the [Application] instance's launch. * See [EngineSession.LoadUrlFlags] for more details about the types. * - * [register] must be called for this object to work correctly. + * [registerInAppOnCreate] must be called for this object to work correctly. * * This class relies on specific lifecycle method call orders for the app process and the activities. */ -object AppLinkIntentLaunchTypeProvider { +class AppLinkIntentLaunchTypeProvider( + private val startReasonProvider: AppStartReasonProvider, +) { private val hasAnyActivityBeenCreated = AtomicBoolean(false) private val liveActivityCounts = ConcurrentHashMap<Class<out Activity>, AtomicInteger>() @@ -30,7 +32,7 @@ object AppLinkIntentLaunchTypeProvider { * Registers the handlers needed by this object: this is expected to be called from * [Application.onCreate]. */ - fun register(app: Application) { + fun registerInAppOnCreate(app: Application) { app.registerActivityLifecycleCallbacks( object : DefaultActivityLifecycleCallbacks { override fun onActivityCreated(activity: Activity, bundle: Bundle?) { @@ -47,25 +49,55 @@ object AppLinkIntentLaunchTypeProvider { } /** - * Classifies an external intent launch type at the moment it arrives. + * Classifies an external intent’s launch type at the moment it arrives. * - * COLD -> when the application and the activity are created from scratch. + * - COLD: The process and the first activity are created from scratch + * (no activity has ever been created in this process). * - * WARM -> when the application process is alive but the activity is created from scratch. - * - * HOT -> when the application process is alive and an existing instance of the target activity - * is reused (no new activity instance is created). + * - WARM: The process is alive but the target activity will be created fresh. + * Example: a WorkManager job started the process (NON_ACTIVITY) without + * creating any activity; later an app-link arrives. At this point, + * GeckoRuntime may already be initialized via + * [mozilla.components.concept.engine.Engine.warmUp] + * in [org.mozilla.fenix.FenixApplication.onCreate] and Gecko libraries may + * have begun loading. * + * - HOT: The process is alive and an existing instance of the target activity + * is reused (no new activity instance is created). */ - fun getExternalIntentLaunchType(target: Class<*>?): Int = when { - !hasAnyActivityBeenCreated.get() -> EngineSession.LoadUrlFlags.APP_LINK_LAUNCH_TYPE_COLD - (liveActivityCounts[target]?.get() ?: 0) > 0 -> - EngineSession.LoadUrlFlags.APP_LINK_LAUNCH_TYPE_HOT - else -> EngineSession.LoadUrlFlags.APP_LINK_LAUNCH_TYPE_WARM + fun getExternalIntentLaunchType(target: Class<out Activity>?): Int { + val headless = isHeadlessLaunch() + val anyActivityCreated = hasAnyActivityBeenCreated.get() + val targetLive = hasLiveTargetActivity(target) + + return when { + // Target instance already exists -> HOT + targetLive -> + EngineSession.LoadUrlFlags.APP_LINK_LAUNCH_TYPE_HOT + + // Headless (NON_ACTIVITY) start and we won't reuse the target -> WARM + headless -> + EngineSession.LoadUrlFlags.APP_LINK_LAUNCH_TYPE_WARM + + // No activity has ever been created in this process -> COLD + !anyActivityCreated -> + EngineSession.LoadUrlFlags.APP_LINK_LAUNCH_TYPE_COLD + + // Process alive, target not live -> WARM + else -> + EngineSession.LoadUrlFlags.APP_LINK_LAUNCH_TYPE_WARM + } } + private fun isHeadlessLaunch() = + startReasonProvider.reason == AppStartReasonProvider.StartReason.NON_ACTIVITY + + private fun hasLiveTargetActivity(target: Class<out Activity>?) = + (liveActivityCounts[target]?.get() ?: 0) > 0 + @VisibleForTesting internal fun resetForTests() { hasAnyActivityBeenCreated.set(false) + liveActivityCounts.clear() } } diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/AppLinkIntentLaunchTypeProviderTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/AppLinkIntentLaunchTypeProviderTest.kt @@ -7,7 +7,11 @@ package org.mozilla.fenix.perf import android.app.Activity import android.app.Application import androidx.test.core.app.ApplicationProvider +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk import mozilla.components.concept.engine.EngineSession +import org.junit.After import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test @@ -19,38 +23,85 @@ import org.robolectric.RobolectricTestRunner class AppLinkIntentLaunchTypeProviderTest { private lateinit var app: Application + private lateinit var startReasonProvider: AppStartReasonProvider + private lateinit var provider: AppLinkIntentLaunchTypeProvider @Before fun setUp() { app = ApplicationProvider.getApplicationContext() - AppLinkIntentLaunchTypeProvider.resetForTests() - AppLinkIntentLaunchTypeProvider.register(app) + startReasonProvider = mockk(relaxed = false) + every { startReasonProvider.reason } returns AppStartReasonProvider.StartReason.TO_BE_DETERMINED + provider = AppLinkIntentLaunchTypeProvider(startReasonProvider) + provider.registerInAppOnCreate(app) + provider.resetForTests() + } + + @After + fun tearDown() { + clearMocks(startReasonProvider) + provider.resetForTests() } @Test - fun `classify as COLD before any activity is created`() { - // No activities created yet - assertEquals(EngineSession.LoadUrlFlags.APP_LINK_LAUNCH_TYPE_COLD, AppLinkIntentLaunchTypeProvider.getExternalIntentLaunchType(Activity::class.java)) + fun `classify as COLD when process is fresh and no activity created yet - as long as the start reason is not non-activity`() { // the default StartReason.TO_BE_DETERMINED is being used + val type = provider.getExternalIntentLaunchType(TestActivity::class.java) + assertEquals(EngineSession.LoadUrlFlags.APP_LINK_LAUNCH_TYPE_COLD, type) } @Test - fun `classify as WARM when the app was created before but no activity exists`() { - // Create an activity and destroy it. - val controller = Robolectric.buildActivity(Activity::class.java) + fun `classify as WARM when process had an activity before but none is currently alive`() { + val controller = Robolectric.buildActivity(TestActivity::class.java) controller.create().start().resume() - controller.pause().stop().destroy() + controller.pause().stop().destroy() // no live activities remain; but the app had launched at least one + + val type = provider.getExternalIntentLaunchType(TestActivity::class.java) - // App is created, but no activity exists. It is no longer a cold start. - assertEquals(EngineSession.LoadUrlFlags.APP_LINK_LAUNCH_TYPE_WARM, AppLinkIntentLaunchTypeProvider.getExternalIntentLaunchType(Activity::class.java)) + // process is alive (not COLD), target not live (not HOT) -> WARM + assertEquals(EngineSession.LoadUrlFlags.APP_LINK_LAUNCH_TYPE_WARM, type) } @Test - fun `classify as HOT when the app was created before and the activity already exists`() { - // Create an activity. - val controller = Robolectric.buildActivity(Activity::class.java) + fun `classify as HOT when target activity instance already exists`() { + val controller = Robolectric.buildActivity(TestActivity::class.java) + controller.create().start().resume() + + val type = provider.getExternalIntentLaunchType(TestActivity::class.java) + assertEquals(EngineSession.LoadUrlFlags.APP_LINK_LAUNCH_TYPE_HOT, type) + } + + @Test + fun `classify as HOT when process started NON_ACTIVITY, user opened UI then backgrounded it, and target exists`() { + // 1) Process starts headlessly (NON_ACTIVITY) + every { startReasonProvider.reason } returns AppStartReasonProvider.StartReason.NON_ACTIVITY + + // 2) User opens the UI (target activity becomes live), then backgrounds it + val controller = Robolectric.buildActivity(TestActivity::class.java) controller.create().start().resume() + controller.pause().stop() // activity is still alive, i.e. not destroyed - // App is created, and the activity exists. It is no longer a warm start. - assertEquals(EngineSession.LoadUrlFlags.APP_LINK_LAUNCH_TYPE_HOT, AppLinkIntentLaunchTypeProvider.getExternalIntentLaunchType(Activity::class.java)) + // 3) App-link intent arrives: target already exists → HOT + val type = provider.getExternalIntentLaunchType(TestActivity::class.java) + assertEquals(EngineSession.LoadUrlFlags.APP_LINK_LAUNCH_TYPE_HOT, type) } + + @Test + fun `classify as WARM when process started headless and no activity exists yet`() { + // Headless (e.g., WorkManager) start. + every { startReasonProvider.reason } returns AppStartReasonProvider.StartReason.NON_ACTIVITY + + val type = provider.getExternalIntentLaunchType(TestActivity::class.java) + assertEquals(EngineSession.LoadUrlFlags.APP_LINK_LAUNCH_TYPE_WARM, type) + } + + @Test + fun `classify as WARM when a different activity exists but target does not`() { + val other = Robolectric.buildActivity(OtherActivity::class.java) + other.create().start().resume() + + val type = provider.getExternalIntentLaunchType(TestActivity::class.java) + assertEquals(EngineSession.LoadUrlFlags.APP_LINK_LAUNCH_TYPE_WARM, type) + } + + private class TestActivity : Activity() + private class OtherActivity : Activity() }