Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions PixelDefinitions/pixels/definitions/ntp_after_idle.json5
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment thread
YoussefKeyrouz marked this conversation as resolved.
"type": "integer",
"description": "The resolved idle threshold in seconds (user preference, remote-config default, or hardcoded default)"
},
{
"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"]
}
]
}
}
32 changes: 32 additions & 0 deletions app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -172,6 +175,9 @@ open class BrowserActivity : DuckDuckGoActivity() {
@Inject
lateinit var dataClearerForegroundAppRestartPixel: DataClearerForegroundAppRestartPixel

@Inject
lateinit var appReturnPixelSender: AppReturnPixelSender

@Inject
lateinit var serviceWorkerClientCompat: ServiceWorkerClientCompat

Expand Down Expand Up @@ -246,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.
Expand Down Expand Up @@ -356,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
Expand Down Expand Up @@ -577,6 +594,12 @@ open class BrowserActivity : DuckDuckGoActivity() {
}
}

override fun onResume() {
super.onResume()
appReturnPixelSender.fireIfNeeded(pendingLaunchSource ?: LaunchSourceValues.STANDARD)
pendingLaunchSource = null
}
Comment thread
cursor[bot] marked this conversation as resolved.

override fun onStop() {
openMessageInNewTabJob?.cancel()

Expand Down Expand Up @@ -604,6 +627,8 @@ open class BrowserActivity : DuckDuckGoActivity() {
logcat(INFO) { "onNewIntent: $intent" }

intent.sanitize()
setIntent(intent)
pendingLaunchSource = intent.getStringExtra(LAUNCH_SOURCE_PIXEL_VALUE)

intent.getStringExtra(LAUNCH_FROM_NOTIFICATION_PIXEL_NAME)?.let {
viewModel.onLaunchedFromNotification(it)
Expand Down Expand Up @@ -1230,6 +1255,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
}

Expand All @@ -1252,6 +1278,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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"),

Expand Down
Original file line number Diff line number Diff line change
@@ -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<Pair<String, Set<PixelParameter>>> {
return listOf(
AppPixelName.APP_RETURN_COUNT.pixelName to PixelParameter.removeAtb(),
AppPixelName.APP_RETURN_DAILY.pixelName to PixelParameter.removeAtb(),
)
}
}
Loading
Loading