From 290434c346491d7e1ea954bc2e2d748571828b46 Mon Sep 17 00:00:00 2001 From: Youssef Keyrouz Date: Sun, 16 Aug 2026 03:44:46 -0700 Subject: [PATCH 1/4] Add the m_app_return pixel, fired on every foreground return --- .../pixels/definitions/ntp_after_idle.json5 | 54 +++++ .../duckduckgo/app/browser/BrowserActivity.kt | 21 ++ .../dispatchers/IntentDispatcherActivity.kt | 7 + .../app/launch/LaunchBridgeActivity.kt | 8 + .../com/duckduckgo/app/pixels/AppPixelName.kt | 2 + .../AppReturnPixelParamRemovalPlugin.kt | 34 +++ .../app/pixels/AppReturnPixelSender.kt | 174 ++++++++++++++ .../app/pixels/EnqueuedPixelWorker.kt | 13 +- .../app/systemsearch/SystemSearchActivity.kt | 8 + .../widget/FavoritesWidgetItemFactory.kt | 2 + .../app/pixels/AppReturnPixelSenderTest.kt | 227 ++++++++++++++++++ .../app/pixels/EnqueuedPixelWorkerTest.kt | 6 +- 12 files changed, 543 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/com/duckduckgo/app/pixels/AppReturnPixelParamRemovalPlugin.kt create mode 100644 app/src/main/java/com/duckduckgo/app/pixels/AppReturnPixelSender.kt create mode 100644 app/src/test/java/com/duckduckgo/app/pixels/AppReturnPixelSenderTest.kt diff --git a/PixelDefinitions/pixels/definitions/ntp_after_idle.json5 b/PixelDefinitions/pixels/definitions/ntp_after_idle.json5 index 8fc96742c3d8..71a917261a45 100644 --- a/PixelDefinitions/pixels/definitions/ntp_after_idle.json5 +++ b/PixelDefinitions/pixels/definitions/ntp_after_idle.json5 @@ -402,5 +402,59 @@ "triggers": ["other"], "suffixes": ["form_factor"], "parameters": ["appVersion"] + }, + + // App-return pixel: fired on every foreground transition, ungated by any feature flag + "m_app_return": { + "description": "Fires on every foreground transition, ungated and launch-action-independent. Reports how long the user was away, the resolved idle threshold, and which Duck.ai capabilities are available. This is the denominator for every \"% of app opens\" panel.", + "owners": ["YoussefKeyrouz"], + "triggers": ["other"], + "suffixes": ["first_daily_count", "form_factor"], + "parameters": [ + "appVersion", + { + "key": "time_away_bucket", + "type": "string", + "description": "Time away before reopening", + "enum": ["cold_start", "lt_1m", "1_5m", "5_15m", "15_30m", "30_60m", "gt_60m"] + }, + { + "key": "exceeded_idle_threshold", + "type": "boolean", + "description": "Whether the time away exceeded the resolved idle threshold, reported even when the after-inactivity feature is ineligible" + }, + { + "key": "idle_threshold_seconds", + "type": "integer", + "description": "The resolved idle threshold in seconds (user preference, remote-config default, or hardcoded default), e.g. 1800" + }, + { + "key": "after_inactivity_option", + "type": "string", + "description": "The effective after-inactivity opening setting", + "enum": ["new_tab_page", "last_opened_tab", "specific_page"] + }, + { + "key": "feature_eligible", + "type": "boolean", + "description": "Whether the NTP-after-idle feature is available (remote flag on)" + }, + { + "key": "unified_input_available", + "type": "boolean", + "description": "Whether the native input field capability is enabled for this user" + }, + { + "key": "toggle_visible", + "type": "boolean", + "description": "Whether the resolved input-mode capability currently offers the Search/Duck.ai toggle" + }, + { + "key": "launch_source", + "type": "string", + "description": "What triggered this foreground transition", + "enum": ["standard", "url", "shortcut", "widget", "other"] + } + ] } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt index 9becd7dcedbf..2b2637b2e964 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt @@ -85,7 +85,10 @@ import com.duckduckgo.app.onboarding.ui.OnboardingActivity import com.duckduckgo.app.onboarding.ui.page.DefaultBrowserPage import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.pixels.AppPixelName.FIRE_DIALOG_CANCEL +import com.duckduckgo.app.pixels.AppReturnPixelSender import com.duckduckgo.app.pixels.BrowserModeSwitchSource +import com.duckduckgo.app.pixels.LaunchSourceValues +import com.duckduckgo.app.pixels.toPixelLaunchSourceValue import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter @@ -172,6 +175,9 @@ open class BrowserActivity : DuckDuckGoActivity() { @Inject lateinit var dataClearerForegroundAppRestartPixel: DataClearerForegroundAppRestartPixel + @Inject + lateinit var appReturnPixelSender: AppReturnPixelSender + @Inject lateinit var serviceWorkerClientCompat: ServiceWorkerClientCompat @@ -577,6 +583,13 @@ open class BrowserActivity : DuckDuckGoActivity() { } } + override fun onResume() { + super.onResume() + appReturnPixelSender.fireIfNeeded(intent?.getStringExtra(LAUNCH_SOURCE_PIXEL_VALUE) ?: LaunchSourceValues.STANDARD) + // Consume launch extra so a later resume with no new Intent doesn't replay this. + intent?.removeExtra(LAUNCH_SOURCE_PIXEL_VALUE) + } + override fun onStop() { openMessageInNewTabJob?.cancel() @@ -604,6 +617,7 @@ open class BrowserActivity : DuckDuckGoActivity() { logcat(INFO) { "onNewIntent: $intent" } intent.sanitize() + setIntent(intent) intent.getStringExtra(LAUNCH_FROM_NOTIFICATION_PIXEL_NAME)?.let { viewModel.onLaunchedFromNotification(it) @@ -1230,6 +1244,7 @@ open class BrowserActivity : DuckDuckGoActivity() { intent.putExtra(DUCK_CHAT_SESSION_ACTIVE, duckChatSessionActive) intent.putExtra(DELETED_TAB_COUNT_EXTRA, deletedTabCount) intent.putExtra(LAUNCH_REQUIRES_REGULAR_MODE, launchSource.requiresRegularMode) + intent.putExtra(LAUNCH_SOURCE_PIXEL_VALUE, launchSource.toPixelLaunchSourceValue()) return intent } @@ -1252,6 +1267,12 @@ open class BrowserActivity : DuckDuckGoActivity() { */ const val LAUNCH_REQUIRES_REGULAR_MODE = "LAUNCH_REQUIRES_REGULAR_MODE" + /** + * The [BrowserLaunchSource], pre-mapped to its [LaunchSourceValues] pixel string. Read by + * [AppReturnPixelSender] for the `m_app_return` pixel's `launch_source` param. + */ + const val LAUNCH_SOURCE_PIXEL_VALUE = "LAUNCH_SOURCE_PIXEL_VALUE" + private const val OPEN_DUCK_CHAT = "OPEN_DUCK_CHAT_EXTRA" private const val CLOSE_DUCK_CHAT = "CLOSE_DUCK_CHAT_EXTRA" private const val DUCK_CHAT_URL = "DUCK_CHAT_URL" diff --git a/app/src/main/java/com/duckduckgo/app/dispatchers/IntentDispatcherActivity.kt b/app/src/main/java/com/duckduckgo/app/dispatchers/IntentDispatcherActivity.kt index d9bc6245e86a..c3038390cbce 100644 --- a/app/src/main/java/com/duckduckgo/app/dispatchers/IntentDispatcherActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/dispatchers/IntentDispatcherActivity.kt @@ -31,6 +31,8 @@ import com.duckduckgo.app.browser.mode.ExternalUrl import com.duckduckgo.app.browser.mode.InAppNavigation import com.duckduckgo.app.dispatchers.IntentDispatcherViewModel.ViewState import com.duckduckgo.app.global.sanitize +import com.duckduckgo.app.pixels.AppReturnPixelSender +import com.duckduckgo.app.pixels.toPixelLaunchSourceValue import com.duckduckgo.common.ui.DuckDuckGoActivity import com.duckduckgo.customtabs.api.CustomTabsSessionRegistry import com.duckduckgo.di.scopes.ActivityScope @@ -51,12 +53,17 @@ class IntentDispatcherActivity : DuckDuckGoActivity() { @Inject lateinit var customTabsSessionRegistry: CustomTabsSessionRegistry + @Inject + lateinit var appReturnPixelSender: AppReturnPixelSender + override fun onCreate(savedInstanceState: Bundle?) { // Sanitize before super.onCreate so lifecycle callbacks dispatched from there don't trip over // Parcelable extras whose classes are absent from our classpath. intent?.sanitize() super.onCreate(savedInstanceState) + appReturnPixelSender.fireIfNeeded(ExternalUrl.toPixelLaunchSourceValue()) + logcat { "onCreate called with intent $intent" } lifecycleScope.launch { diff --git a/app/src/main/java/com/duckduckgo/app/launch/LaunchBridgeActivity.kt b/app/src/main/java/com/duckduckgo/app/launch/LaunchBridgeActivity.kt index 5e8afdca3298..23cf25acff80 100644 --- a/app/src/main/java/com/duckduckgo/app/launch/LaunchBridgeActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/launch/LaunchBridgeActivity.kt @@ -23,19 +23,27 @@ import com.duckduckgo.app.browser.BrowserActivity import com.duckduckgo.app.browser.R import com.duckduckgo.app.browser.mode.AppLauncher import com.duckduckgo.app.onboarding.ui.OnboardingActivity +import com.duckduckgo.app.pixels.AppReturnPixelSender +import com.duckduckgo.app.pixels.toPixelLaunchSourceValue import com.duckduckgo.common.ui.DuckDuckGoActivity import com.duckduckgo.di.scopes.ActivityScope +import javax.inject.Inject @InjectWith(ActivityScope::class) class LaunchBridgeActivity : DuckDuckGoActivity() { private val viewModel: LaunchViewModel by bindViewModel() + @Inject + lateinit var appReturnPixelSender: AppReturnPixelSender + override fun onCreate(savedInstanceState: Bundle?) { val splashScreen = installSplashScreen() super.onCreate(savedInstanceState) splashScreen.setKeepOnScreenCondition { true } + appReturnPixelSender.fireIfNeeded(AppLauncher.toPixelLaunchSourceValue()) + setContentView(R.layout.activity_launch) configureObservers() diff --git a/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt b/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt index fdeb61e3a1c3..378e89246a39 100644 --- a/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt +++ b/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt @@ -20,6 +20,8 @@ import com.duckduckgo.app.statistics.pixels.Pixel enum class AppPixelName(override val pixelName: String) : Pixel.PixelName { APP_LAUNCH("ml"), + APP_RETURN_COUNT("m_app_return_count"), + APP_RETURN_DAILY("m_app_return_daily"), PROCESS_CREATED_MAIN("m_process_created_main"), PROCESS_CREATED_VPN("m_process_created_vpn"), diff --git a/app/src/main/java/com/duckduckgo/app/pixels/AppReturnPixelParamRemovalPlugin.kt b/app/src/main/java/com/duckduckgo/app/pixels/AppReturnPixelParamRemovalPlugin.kt new file mode 100644 index 000000000000..72ba743d06b8 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/pixels/AppReturnPixelParamRemovalPlugin.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.pixels + +import com.duckduckgo.common.utils.plugins.pixel.PixelParamRemovalPlugin +import com.duckduckgo.common.utils.plugins.pixel.PixelParamRemovalPlugin.PixelParameter +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesMultibinding +import javax.inject.Inject + +@ContributesMultibinding(AppScope::class) +class AppReturnPixelParamRemovalPlugin @Inject constructor() : PixelParamRemovalPlugin { + + override fun names(): List>> { + return listOf( + AppPixelName.APP_RETURN_COUNT.pixelName to PixelParameter.removeAtb(), + AppPixelName.APP_RETURN_DAILY.pixelName to PixelParameter.removeAtb(), + ) + } +} diff --git a/app/src/main/java/com/duckduckgo/app/pixels/AppReturnPixelSender.kt b/app/src/main/java/com/duckduckgo/app/pixels/AppReturnPixelSender.kt new file mode 100644 index 000000000000..babee55a093c --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/pixels/AppReturnPixelSender.kt @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.pixels + +import androidx.lifecycle.LifecycleOwner +import com.duckduckgo.app.browser.mode.AppLauncher +import com.duckduckgo.app.browser.mode.AppShortcutBookmarks +import com.duckduckgo.app.browser.mode.AppShortcutDuckAi +import com.duckduckgo.app.browser.mode.AppShortcutNewTab +import com.duckduckgo.app.browser.mode.BrowserLaunchSource +import com.duckduckgo.app.browser.mode.DuckAiPinShortcut +import com.duckduckgo.app.browser.mode.ExternalUrl +import com.duckduckgo.app.browser.mode.FavoritesWidget +import com.duckduckgo.app.browser.mode.FireRestart +import com.duckduckgo.app.browser.mode.InAppNavigation +import com.duckduckgo.app.browser.mode.Onboarding +import com.duckduckgo.app.browser.mode.PinnedPageShortcut +import com.duckduckgo.app.browser.mode.PrivacyNotification +import com.duckduckgo.app.browser.mode.SearchWidgetDuckAi +import com.duckduckgo.app.browser.mode.SelectedTextSearch +import com.duckduckgo.app.browser.mode.SystemSearchExternal +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.app.fire.UnsentForgetAllPixelStore +import com.duckduckgo.app.generalsettings.showonapplaunch.IdleThresholdResolver +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption +import com.duckduckgo.app.generalsettings.showonapplaunch.store.ShowOnAppLaunchOptionDataStore +import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver +import com.duckduckgo.app.settings.db.SettingsDataStore +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.browser.feature.toggles.AndroidBrowserConfigFeature +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.duckchat.api.DuckAiFeatureState +import com.duckduckgo.duckchat.api.DuckChatInputModeState +import com.duckduckgo.duckchat.api.nativeinput.NativeInputState +import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.SingleInstanceIn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Provider + +/** + * Fires `m_app_return` on every foreground return, given the [LaunchSourceValues] classification + * of whatever triggered it. + */ +interface AppReturnPixelSender { + fun fireIfNeeded(launchSource: String) + + /** Whether the current process launch was caused by the Fire Button's automatic restart. */ + fun isLaunchByFireAction(): Boolean +} + +@SingleInstanceIn(AppScope::class) +@ContributesBinding(AppScope::class, boundType = AppReturnPixelSender::class) +@ContributesMultibinding(AppScope::class, boundType = MainProcessLifecycleObserver::class) +class RealAppReturnPixelSender @Inject constructor( + private val pixel: Provider, + private val settingsDataStore: SettingsDataStore, + private val idleThresholdResolver: IdleThresholdResolver, + private val showOnAppLaunchOptionDataStore: ShowOnAppLaunchOptionDataStore, + private val duckChatInputModeState: DuckChatInputModeState, + private val duckAiFeatureState: DuckAiFeatureState, + private val androidBrowserConfigFeature: AndroidBrowserConfigFeature, + private val unsentForgetAllPixelStore: UnsentForgetAllPixelStore, + private val dispatchers: DispatcherProvider, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, +) : AppReturnPixelSender, MainProcessLifecycleObserver { + + private var fired = false + private var fireButtonRestartHandled = false + + override fun onStop(owner: LifecycleOwner) { + fired = false + } + + override fun fireIfNeeded(launchSource: String) { + if (!fireButtonRestartHandled && isLaunchByFireAction()) { + // Only the very first resume after a fire-triggered process restart is skipped. + // Once handled, later resumes must be evaluated as real returns even if still inside the grace window. + fireButtonRestartHandled = true + fired = true + return + } + if (fired) return + fired = true + + appCoroutineScope.launch(dispatchers.io()) { + val lastBackgrounded = settingsDataStore.lastSessionBackgroundTimestamp + val elapsedMs = System.currentTimeMillis() - lastBackgrounded + val idleThresholdSeconds = idleThresholdResolver.effectiveThresholdSeconds(settingsDataStore.userSelectedIdleThresholdSeconds) + val exceededIdleThreshold = lastBackgrounded != 0L && elapsedMs >= idleThresholdSeconds * 1000 + val afterInactivityOption = showOnAppLaunchOptionDataStore.optionFlow.firstOrNull() + ?.let { ShowOnAppLaunchOption.getDailyPixelValue(it) } + val toggleVisible = duckChatInputModeState.inputModeCapability.value == NativeInputState.InputMode.SEARCH_AND_DUCK_AI + + val params = buildMap { + put(AppReturnPixelParameters.TIME_AWAY_BUCKET, timeAwayBucket(lastBackgrounded, elapsedMs)) + put(AppReturnPixelParameters.EXCEEDED_IDLE_THRESHOLD, exceededIdleThreshold.toString()) + put(AppReturnPixelParameters.IDLE_THRESHOLD_SECONDS, idleThresholdSeconds.toString()) + afterInactivityOption?.let { put(AppReturnPixelParameters.AFTER_INACTIVITY_OPTION, it) } + put(AppReturnPixelParameters.FEATURE_ELIGIBLE, androidBrowserConfigFeature.showNTPAfterIdleReturn().isEnabled().toString()) + put(AppReturnPixelParameters.UNIFIED_INPUT_AVAILABLE, duckAiFeatureState.nativeInputFieldEnabled.value.toString()) + put(AppReturnPixelParameters.TOGGLE_VISIBLE, toggleVisible.toString()) + put(AppReturnPixelParameters.LAUNCH_SOURCE, launchSource) + } + + pixel.get().fire(pixel = AppPixelName.APP_RETURN_COUNT, parameters = params) + pixel.get().fire(pixel = AppPixelName.APP_RETURN_DAILY, parameters = params, type = Pixel.PixelType.Daily()) + } + } + + private fun timeAwayBucket(lastBackgrounded: Long, elapsedMs: Long): String = when { + lastBackgrounded == 0L -> "cold_start" + elapsedMs < 60_000L -> "lt_1m" + elapsedMs < 300_000L -> "1_5m" + elapsedMs < 900_000L -> "5_15m" + elapsedMs < 1_800_000L -> "15_30m" + elapsedMs < 3_600_000L -> "30_60m" + else -> "gt_60m" + } + + override fun isLaunchByFireAction(): Boolean { + val timeDifferenceMillis = System.currentTimeMillis() - unsentForgetAllPixelStore.lastClearTimestamp + return timeDifferenceMillis <= APP_RESTART_CAUSED_BY_FIRE_GRACE_PERIOD + } + + private companion object { + const val APP_RESTART_CAUSED_BY_FIRE_GRACE_PERIOD: Long = 10_000L + } +} + +object AppReturnPixelParameters { + const val TIME_AWAY_BUCKET = "time_away_bucket" + const val EXCEEDED_IDLE_THRESHOLD = "exceeded_idle_threshold" + const val IDLE_THRESHOLD_SECONDS = "idle_threshold_seconds" + const val AFTER_INACTIVITY_OPTION = "after_inactivity_option" + const val FEATURE_ELIGIBLE = "feature_eligible" + const val UNIFIED_INPUT_AVAILABLE = "unified_input_available" + const val TOGGLE_VISIBLE = "toggle_visible" + const val LAUNCH_SOURCE = "launch_source" +} + +object LaunchSourceValues { + const val STANDARD = "standard" + const val URL = "url" + const val SHORTCUT = "shortcut" + const val WIDGET = "widget" + const val OTHER = "other" +} + +fun BrowserLaunchSource.toPixelLaunchSourceValue(): String = when (this) { + AppLauncher -> LaunchSourceValues.STANDARD + ExternalUrl -> LaunchSourceValues.URL + AppShortcutNewTab, AppShortcutBookmarks, AppShortcutDuckAi, PinnedPageShortcut, DuckAiPinShortcut -> LaunchSourceValues.SHORTCUT + FavoritesWidget, SearchWidgetDuckAi -> LaunchSourceValues.WIDGET + SelectedTextSearch, PrivacyNotification, SystemSearchExternal, Onboarding, FireRestart, InAppNavigation -> LaunchSourceValues.OTHER +} diff --git a/app/src/main/java/com/duckduckgo/app/pixels/EnqueuedPixelWorker.kt b/app/src/main/java/com/duckduckgo/app/pixels/EnqueuedPixelWorker.kt index 5f975d8cc3b7..926ac5d2a3d9 100644 --- a/app/src/main/java/com/duckduckgo/app/pixels/EnqueuedPixelWorker.kt +++ b/app/src/main/java/com/duckduckgo/app/pixels/EnqueuedPixelWorker.kt @@ -58,6 +58,7 @@ class EnqueuedPixelWorker @Inject constructor( private val workManager: WorkManager, private val pixel: Provider, private val unsentForgetAllPixelStore: UnsentForgetAllPixelStore, + private val appReturnPixelSender: AppReturnPixelSender, private val webViewVersionProvider: WebViewVersionProvider, private val defaultBrowserDetector: DefaultBrowserDetector, private val customTabDetector: CustomTabDetector, @@ -72,7 +73,7 @@ class EnqueuedPixelWorker @Inject constructor( override fun onCreate(owner: LifecycleOwner) { scheduleWorker(workManager) - launchedByFireAction = isLaunchByFireAction() + launchedByFireAction = appReturnPixelSender.isLaunchByFireAction() } override fun onStart(owner: LifecycleOwner) { @@ -130,15 +131,6 @@ class EnqueuedPixelWorker @Inject constructor( return (applicationId == "com.duckduckgo.mobile.android" || applicationId == "com.duckduckgo.mobile.android.debug").toString() } - private fun isLaunchByFireAction(): Boolean { - val timeDifferenceMillis = System.currentTimeMillis() - unsentForgetAllPixelStore.lastClearTimestamp - if (timeDifferenceMillis <= APP_RESTART_CAUSED_BY_FIRE_GRACE_PERIOD) { - logcat(INFO) { "The app was re-launched as a result of the fire action being triggered (happened ${timeDifferenceMillis}ms ago)" } - return true - } - return false - } - suspend fun submitUnsentFirePixels() { withContext(dispatchers.io()) { val pendingPixelCounts = unsentForgetAllPixelStore.pendingPixelCountsClearData @@ -166,7 +158,6 @@ class EnqueuedPixelWorker @Inject constructor( } companion object { - private const val APP_RESTART_CAUSED_BY_FIRE_GRACE_PERIOD: Long = 10_000L private const val WORKER_SEND_ENQUEUED_PIXELS = "com.duckduckgo.pixels.enqueued.worker" private fun scheduleWorker(workManager: WorkManager) { diff --git a/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchActivity.kt b/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchActivity.kt index 79094c10a84e..fbe24f6834ef 100644 --- a/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchActivity.kt @@ -53,6 +53,8 @@ import com.duckduckgo.app.browser.newtab.FavoritesQuickAccessAdapter.Companion.Q import com.duckduckgo.app.browser.newtab.QuickAccessDragTouchItemListener import com.duckduckgo.app.fire.DataClearerForegroundAppRestartPixel import com.duckduckgo.app.pixels.AppPixelName +import com.duckduckgo.app.pixels.AppReturnPixelSender +import com.duckduckgo.app.pixels.LaunchSourceValues import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.systemsearch.SystemSearchViewModel.Command.AutocompleteItemRemoved @@ -111,6 +113,9 @@ class SystemSearchActivity : DuckDuckGoActivity() { @Inject lateinit var dataClearerForegroundAppRestartPixel: DataClearerForegroundAppRestartPixel + @Inject + lateinit var appReturnPixelSender: AppReturnPixelSender + @Inject lateinit var faviconManager: FaviconManager @@ -247,6 +252,8 @@ class SystemSearchActivity : DuckDuckGoActivity() { override fun onResume() { super.onResume() + appReturnPixelSender.fireIfNeeded(if (launchedFromAnyWidget(intent)) LaunchSourceValues.WIDGET else LaunchSourceValues.OTHER) + if (viewModel.hasOmnibarTypeChanged) { recreate() } @@ -254,6 +261,7 @@ class SystemSearchActivity : DuckDuckGoActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) + setIntent(intent) dataClearerForegroundAppRestartPixel.registerIntent(intent) viewModel.resetViewState() viewModel.setLaunchedFromSearchOnlyWidget(launchedFromSearchOnlyWidget(intent)) diff --git a/app/src/main/java/com/duckduckgo/widget/FavoritesWidgetItemFactory.kt b/app/src/main/java/com/duckduckgo/widget/FavoritesWidgetItemFactory.kt index 2f21a4ec9e79..d1154357227d 100644 --- a/app/src/main/java/com/duckduckgo/widget/FavoritesWidgetItemFactory.kt +++ b/app/src/main/java/com/duckduckgo/widget/FavoritesWidgetItemFactory.kt @@ -31,6 +31,7 @@ import com.duckduckgo.app.browser.BrowserActivity import com.duckduckgo.app.browser.R import com.duckduckgo.app.browser.mode.FavoritesWidget import com.duckduckgo.app.global.DuckDuckGoApplication +import com.duckduckgo.app.pixels.toPixelLaunchSourceValue import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.baseHost import com.duckduckgo.savedsites.api.SavedSitesRepository @@ -196,6 +197,7 @@ class FavoritesWidgetItemFactory( BrowserActivity.LAUNCH_REQUIRES_REGULAR_MODE, FavoritesWidget.requiresRegularMode, ) + bundle.putString(BrowserActivity.LAUNCH_SOURCE_PIXEL_VALUE, FavoritesWidget.toPixelLaunchSourceValue()) val intent = Intent() intent.putExtras(bundle) remoteViews.setOnClickFillInIntent(R.id.quickAccessFaviconContainer, intent) diff --git a/app/src/test/java/com/duckduckgo/app/pixels/AppReturnPixelSenderTest.kt b/app/src/test/java/com/duckduckgo/app/pixels/AppReturnPixelSenderTest.kt new file mode 100644 index 000000000000..134016348da8 --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/pixels/AppReturnPixelSenderTest.kt @@ -0,0 +1,227 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.pixels + +import com.duckduckgo.app.fire.UnsentForgetAllPixelStore +import com.duckduckgo.app.generalsettings.showonapplaunch.IdleThresholdResolver +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption +import com.duckduckgo.app.generalsettings.showonapplaunch.store.ShowOnAppLaunchOptionDataStore +import com.duckduckgo.app.settings.db.SettingsDataStore +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.browser.feature.toggles.AndroidBrowserConfigFeature +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.duckchat.api.DuckAiFeatureState +import com.duckduckgo.duckchat.api.DuckChatInputModeState +import com.duckduckgo.duckchat.api.nativeinput.NativeInputState +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle.State +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argThat +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class AppReturnPixelSenderTest { + + @get:Rule + var coroutineRule = CoroutineTestRule() + + private val pixel: Pixel = mock() + private val settingsDataStore: SettingsDataStore = mock() + private val idleThresholdResolver: IdleThresholdResolver = mock() + private val showOnAppLaunchOptionDataStore: ShowOnAppLaunchOptionDataStore = mock() + private val duckChatInputModeState: DuckChatInputModeState = mock() + private val duckAiFeatureState: DuckAiFeatureState = mock() + private val androidBrowserConfigFeature = FakeFeatureToggleFactory.create(AndroidBrowserConfigFeature::class.java) + private val unsentForgetAllPixelStore: UnsentForgetAllPixelStore = mock() + + private lateinit var testee: RealAppReturnPixelSender + + @Before + fun setup() { + whenever(idleThresholdResolver.effectiveThresholdSeconds(anyOrNull())).thenReturn(1800L) + whenever(showOnAppLaunchOptionDataStore.optionFlow).thenReturn(flowOf(ShowOnAppLaunchOption.NewTabPage)) + whenever(duckChatInputModeState.inputModeCapability).thenReturn(MutableStateFlow(NativeInputState.InputMode.SEARCH_ONLY)) + whenever(duckAiFeatureState.nativeInputFieldEnabled).thenReturn(MutableStateFlow(false)) + whenever(unsentForgetAllPixelStore.lastClearTimestamp).thenReturn(0L) + androidBrowserConfigFeature.showNTPAfterIdleReturn().setRawStoredState(State(enable = false)) + + testee = RealAppReturnPixelSender( + pixel = { pixel }, + settingsDataStore = settingsDataStore, + idleThresholdResolver = idleThresholdResolver, + showOnAppLaunchOptionDataStore = showOnAppLaunchOptionDataStore, + duckChatInputModeState = duckChatInputModeState, + duckAiFeatureState = duckAiFeatureState, + androidBrowserConfigFeature = androidBrowserConfigFeature, + unsentForgetAllPixelStore = unsentForgetAllPixelStore, + dispatchers = coroutineRule.testDispatcherProvider, + appCoroutineScope = coroutineRule.testScope, + ) + } + + @Test + fun whenColdStartThenParamsCarryColdStartBucket() = runTest { + whenever(settingsDataStore.lastSessionBackgroundTimestamp).thenReturn(0L) + + testee.fireIfNeeded("standard") + advanceUntilIdle() + + val params = mapOf( + AppReturnPixelParameters.TIME_AWAY_BUCKET to "cold_start", + AppReturnPixelParameters.EXCEEDED_IDLE_THRESHOLD to "false", + AppReturnPixelParameters.IDLE_THRESHOLD_SECONDS to "1800", + AppReturnPixelParameters.AFTER_INACTIVITY_OPTION to "new_tab_page", + AppReturnPixelParameters.FEATURE_ELIGIBLE to "false", + AppReturnPixelParameters.UNIFIED_INPUT_AVAILABLE to "false", + AppReturnPixelParameters.TOGGLE_VISIBLE to "false", + AppReturnPixelParameters.LAUNCH_SOURCE to "standard", + ) + verify(pixel).fire(pixel = AppPixelName.APP_RETURN_COUNT, parameters = params) + verify(pixel).fire(pixel = AppPixelName.APP_RETURN_DAILY, parameters = params, type = Pixel.PixelType.Daily()) + } + + @Test + fun whenBackgroundedUnderAMinuteAgoThenParamsCarryLt1mBucket() = runTest { + whenever(settingsDataStore.lastSessionBackgroundTimestamp).thenReturn(System.currentTimeMillis() - 1_000L) + + testee.fireIfNeeded("standard") + advanceUntilIdle() + + verify(pixel).fire( + pixel = eq(AppPixelName.APP_RETURN_COUNT), + parameters = argThat { this[AppReturnPixelParameters.TIME_AWAY_BUCKET] == "lt_1m" }, + encodedParameters = any(), + type = any(), + ) + } + + @Test + fun whenBackgroundedOverAnHourAgoThenParamsCarryGt60mBucketAndExceededThreshold() = runTest { + whenever(settingsDataStore.lastSessionBackgroundTimestamp).thenReturn(System.currentTimeMillis() - 3_700_000L) + + testee.fireIfNeeded("standard") + advanceUntilIdle() + + verify(pixel).fire( + pixel = eq(AppPixelName.APP_RETURN_COUNT), + parameters = argThat { + this[AppReturnPixelParameters.TIME_AWAY_BUCKET] == "gt_60m" && + this[AppReturnPixelParameters.EXCEEDED_IDLE_THRESHOLD] == "true" + }, + encodedParameters = any(), + type = any(), + ) + } + + @Test + fun whenToggleCapabilityIsSearchAndDuckAiThenParamsCarryToggleVisibleTrue() = runTest { + whenever(settingsDataStore.lastSessionBackgroundTimestamp).thenReturn(0L) + whenever(duckChatInputModeState.inputModeCapability) + .thenReturn(MutableStateFlow(NativeInputState.InputMode.SEARCH_AND_DUCK_AI)) + whenever(duckAiFeatureState.nativeInputFieldEnabled).thenReturn(MutableStateFlow(true)) + + testee.fireIfNeeded("standard") + advanceUntilIdle() + + verify(pixel).fire( + pixel = eq(AppPixelName.APP_RETURN_COUNT), + parameters = argThat { + this[AppReturnPixelParameters.TOGGLE_VISIBLE] == "true" && + this[AppReturnPixelParameters.UNIFIED_INPUT_AVAILABLE] == "true" + }, + encodedParameters = any(), + type = any(), + ) + } + + @Test + fun whenLaunchSourceGivenThenParamsCarryIt() = runTest { + whenever(settingsDataStore.lastSessionBackgroundTimestamp).thenReturn(0L) + + testee.fireIfNeeded("widget") + advanceUntilIdle() + + verify(pixel).fire( + pixel = eq(AppPixelName.APP_RETURN_COUNT), + parameters = argThat { this[AppReturnPixelParameters.LAUNCH_SOURCE] == "widget" }, + encodedParameters = any(), + type = any(), + ) + } + + @Test + fun whenLaunchedByFireActionThenDoNotFire() = runTest { + whenever(unsentForgetAllPixelStore.lastClearTimestamp).thenReturn(System.currentTimeMillis()) + + testee.fireIfNeeded("standard") + advanceUntilIdle() + + verify(pixel, never()).fire(eq(AppPixelName.APP_RETURN_COUNT), any>(), any(), any()) + verify(pixel, never()).fire(eq(AppPixelName.APP_RETURN_DAILY), any>(), any(), eq(Pixel.PixelType.Daily())) + } + + @Test + fun whenLaunchedByFireActionThenLaterFireIfNeededInSameSessionIsSuppressedEvenAfterGraceWindow() = runTest { + whenever(settingsDataStore.lastSessionBackgroundTimestamp).thenReturn(0L) + whenever(unsentForgetAllPixelStore.lastClearTimestamp).thenReturn(System.currentTimeMillis()) + + testee.fireIfNeeded("standard") + advanceUntilIdle() + + // grace window has since lapsed, but this is still the same foreground session + whenever(unsentForgetAllPixelStore.lastClearTimestamp).thenReturn(0L) + testee.fireIfNeeded("standard") + advanceUntilIdle() + + verify(pixel, never()).fire(eq(AppPixelName.APP_RETURN_COUNT), any>(), any(), any()) + } + + @Test + fun whenFireIfNeededCalledTwiceThenOnlyFiresOnce() = runTest { + whenever(settingsDataStore.lastSessionBackgroundTimestamp).thenReturn(0L) + + testee.fireIfNeeded("standard") + testee.fireIfNeeded("widget") + advanceUntilIdle() + + verify(pixel).fire(pixel = eq(AppPixelName.APP_RETURN_COUNT), parameters = any(), encodedParameters = any(), type = any()) + } + + @Test + fun whenStoppedThenNextFireIfNeededFiresAgain() = runTest { + whenever(settingsDataStore.lastSessionBackgroundTimestamp).thenReturn(0L) + + testee.fireIfNeeded("standard") + testee.onStop(mock()) + testee.fireIfNeeded("widget") + advanceUntilIdle() + + verify(pixel, times(2)).fire(pixel = eq(AppPixelName.APP_RETURN_COUNT), parameters = any(), encodedParameters = any(), type = any()) + } +} diff --git a/app/src/test/java/com/duckduckgo/app/pixels/EnqueuedPixelWorkerTest.kt b/app/src/test/java/com/duckduckgo/app/pixels/EnqueuedPixelWorkerTest.kt index 4b5d5660e9f8..84da22bc4643 100644 --- a/app/src/test/java/com/duckduckgo/app/pixels/EnqueuedPixelWorkerTest.kt +++ b/app/src/test/java/com/duckduckgo/app/pixels/EnqueuedPixelWorkerTest.kt @@ -53,6 +53,7 @@ class EnqueuedPixelWorkerTest { private val androidBrowserConfigFeature = FakeFeatureToggleFactory.create(AndroidBrowserConfigFeature::class.java) private val isVerifiedPlayStoreInstall: IsVerifiedPlayStoreInstall = mock() private val appBuildConfig: AppBuildConfig = mock() + private val appReturnPixelSender: AppReturnPixelSender = mock() private lateinit var enqueuedPixelWorker: EnqueuedPixelWorker @@ -62,6 +63,7 @@ class EnqueuedPixelWorkerTest { workManager, { pixel }, unsentForgetAllPixelStore, + appReturnPixelSender, webViewVersionProvider, defaultBrowserDetector, customTabDetector, @@ -97,7 +99,7 @@ class EnqueuedPixelWorkerTest { @Test fun whenOnStartAndLaunchByFireActionThenDoNotSendAppLaunchPixel() { whenever(unsentForgetAllPixelStore.pendingPixelCountClearData).thenReturn(1) - whenever(unsentForgetAllPixelStore.lastClearTimestamp).thenReturn(System.currentTimeMillis()) + whenever(appReturnPixelSender.isLaunchByFireAction()).thenReturn(true) enqueuedPixelWorker.onCreate(lifecycleOwner) enqueuedPixelWorker.onStart(lifecycleOwner) @@ -257,7 +259,7 @@ class EnqueuedPixelWorkerTest { @Test fun whenOnStartAndLaunchByFireActionFollowedByAppLaunchThenSendOneAppLaunchPixel() { whenever(unsentForgetAllPixelStore.pendingPixelCountClearData).thenReturn(1) - whenever(unsentForgetAllPixelStore.lastClearTimestamp).thenReturn(System.currentTimeMillis()) + whenever(appReturnPixelSender.isLaunchByFireAction()).thenReturn(true) whenever(webViewVersionProvider.getMajorVersion()).thenReturn("91") whenever(defaultBrowserDetector.isDefaultBrowser()).thenReturn(false) From a770375dd6bd915210f8587c23e525338ab960f1 Mon Sep 17 00:00:00 2001 From: Youssef Keyrouz Date: Mon, 17 Aug 2026 09:00:00 -0700 Subject: [PATCH 2/4] Add enum values to idle_threshold_seconds --- PixelDefinitions/pixels/definitions/ntp_after_idle.json5 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/PixelDefinitions/pixels/definitions/ntp_after_idle.json5 b/PixelDefinitions/pixels/definitions/ntp_after_idle.json5 index 71a917261a45..039e4fb92d08 100644 --- a/PixelDefinitions/pixels/definitions/ntp_after_idle.json5 +++ b/PixelDefinitions/pixels/definitions/ntp_after_idle.json5 @@ -426,7 +426,8 @@ { "key": "idle_threshold_seconds", "type": "integer", - "description": "The resolved idle threshold in seconds (user preference, remote-config default, or hardcoded default), e.g. 1800" + "description": "The resolved idle threshold in seconds (user preference, remote-config default, or hardcoded default)", + "enum": [0, 60, 300, 600, 1800, 3600, 43200, 86400] }, { "key": "after_inactivity_option", From dc6408198ebf19e2cbb74634c4d6a5e08b02cecd Mon Sep 17 00:00:00 2001 From: Youssef Keyrouz Date: Thu, 20 Aug 2026 02:46:34 -0700 Subject: [PATCH 3/4] Address PR review comments --- .../duckduckgo/app/browser/BrowserActivity.kt | 17 ++++++++++++++--- .../app/systemsearch/SystemSearchActivity.kt | 16 +++++++++++++++- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt index 2b2637b2e964..add2c516a220 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt @@ -252,6 +252,14 @@ open class BrowserActivity : DuckDuckGoActivity() { private var instanceStateBundles: CombinedInstanceState? = null + /** + * The launch source extra from a genuinely new [Intent] delivery (fresh launch or [onNewIntent]), + * consumed by the next [onResume]. Not read off [getIntent] directly, since after process death the + * system replays the original Intent extras on recreation and a mutated (extra-removed) copy is never + * persisted for that replay. + */ + private var pendingLaunchSource: String? = null + /** * Holds an [Intent] that arrived in [onNewIntent] while [dataClearer] was still clearing, * deferred until it finishes. Read once by [BrowserStateRenderer.showWebContent] and cleared. @@ -362,6 +370,9 @@ open class BrowserActivity : DuckDuckGoActivity() { intent?.sanitize() logcat(INFO) { "onCreate called. freshAppLaunch: ${dataClearer.isFreshAppLaunch}, savedInstanceState: $savedInstanceState" } + if (savedInstanceState == null) { + pendingLaunchSource = intent?.getStringExtra(LAUNCH_SOURCE_PIXEL_VALUE) + } dataClearerForegroundAppRestartPixel.registerIntent(intent) renderer = BrowserStateRenderer() val newInstanceState = if (dataClearer.isFreshAppLaunch) null else savedInstanceState @@ -585,9 +596,8 @@ open class BrowserActivity : DuckDuckGoActivity() { override fun onResume() { super.onResume() - appReturnPixelSender.fireIfNeeded(intent?.getStringExtra(LAUNCH_SOURCE_PIXEL_VALUE) ?: LaunchSourceValues.STANDARD) - // Consume launch extra so a later resume with no new Intent doesn't replay this. - intent?.removeExtra(LAUNCH_SOURCE_PIXEL_VALUE) + appReturnPixelSender.fireIfNeeded(pendingLaunchSource ?: LaunchSourceValues.STANDARD) + pendingLaunchSource = null } override fun onStop() { @@ -618,6 +628,7 @@ open class BrowserActivity : DuckDuckGoActivity() { intent.sanitize() setIntent(intent) + pendingLaunchSource = intent.getStringExtra(LAUNCH_SOURCE_PIXEL_VALUE) intent.getStringExtra(LAUNCH_FROM_NOTIFICATION_PIXEL_NAME)?.let { viewModel.onLaunchedFromNotification(it) diff --git a/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchActivity.kt b/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchActivity.kt index fbe24f6834ef..22d98c06a563 100644 --- a/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchActivity.kt @@ -146,6 +146,14 @@ class SystemSearchActivity : DuckDuckGoActivity() { private var nestedScrollViewPosition: Int = 0 private var nestedScrollViewRestorePosition: Int = 0 + /** + * The launch source resolved from a genuinely new [Intent] delivery (fresh launch or [onNewIntent]), + * consumed by the next [onResume]. Not re-derived from [getIntent] on every resume, since the widget + * extras it carries are sticky and would otherwise be misread as a fresh widget open on a plain + * Recents return. + */ + private var pendingLaunchSource: String? = null + private val systemSearchOnboarding get() = binding.includeSystemSearchOnboarding @@ -227,6 +235,7 @@ class SystemSearchActivity : DuckDuckGoActivity() { if (savedInstanceState == null) { intent?.let { + pendingLaunchSource = resolveLaunchSource(it) sendLaunchPixels(it) if (launchedFromAssist(it)) { handleDigitalAssistIntent() @@ -252,16 +261,21 @@ class SystemSearchActivity : DuckDuckGoActivity() { override fun onResume() { super.onResume() - appReturnPixelSender.fireIfNeeded(if (launchedFromAnyWidget(intent)) LaunchSourceValues.WIDGET else LaunchSourceValues.OTHER) + appReturnPixelSender.fireIfNeeded(pendingLaunchSource ?: LaunchSourceValues.OTHER) + pendingLaunchSource = null if (viewModel.hasOmnibarTypeChanged) { recreate() } } + private fun resolveLaunchSource(intent: Intent): String = + if (launchedFromAnyWidget(intent)) LaunchSourceValues.WIDGET else LaunchSourceValues.OTHER + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) + pendingLaunchSource = resolveLaunchSource(intent) dataClearerForegroundAppRestartPixel.registerIntent(intent) viewModel.resetViewState() viewModel.setLaunchedFromSearchOnlyWidget(launchedFromSearchOnlyWidget(intent)) From 5d9cbc00058b63c1a7e5f324b2f9f55988005673 Mon Sep 17 00:00:00 2001 From: Youssef Keyrouz Date: Thu, 20 Aug 2026 03:01:23 -0700 Subject: [PATCH 4/4] removing enum values from idle_threshold_seconds because it could be a server config driven --- PixelDefinitions/pixels/definitions/ntp_after_idle.json5 | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/PixelDefinitions/pixels/definitions/ntp_after_idle.json5 b/PixelDefinitions/pixels/definitions/ntp_after_idle.json5 index 039e4fb92d08..ecc3cc16e481 100644 --- a/PixelDefinitions/pixels/definitions/ntp_after_idle.json5 +++ b/PixelDefinitions/pixels/definitions/ntp_after_idle.json5 @@ -426,8 +426,7 @@ { "key": "idle_threshold_seconds", "type": "integer", - "description": "The resolved idle threshold in seconds (user preference, remote-config default, or hardcoded default)", - "enum": [0, 60, 300, 600, 1800, 3600, 43200, 86400] + "description": "The resolved idle threshold in seconds (user preference, remote-config default, or hardcoded default)" }, { "key": "after_inactivity_option",