Skip to content

Commit e1890ba

Browse files
authored
Add Pixels when app restarts in foreground by automatic data clearer (#858)
* Register when app is opened with a intent with params and Fire pixels when app restarts due to data clearing onAppForeground
1 parent ae038dc commit e1890ba

File tree

9 files changed

+248
-9
lines changed

9 files changed

+248
-9
lines changed

app/src/androidTest/java/com/duckduckgo/app/fire/AutomaticDataClearerTest.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,13 @@
1919
package com.duckduckgo.app.fire
2020

2121
import androidx.test.annotation.UiThreadTest
22+
import androidx.test.platform.app.InstrumentationRegistry
2223
import androidx.work.WorkManager
2324
import com.duckduckgo.app.global.view.ClearDataAction
2425
import com.duckduckgo.app.settings.clear.ClearWhatOption
2526
import com.duckduckgo.app.settings.clear.ClearWhenOption
2627
import com.duckduckgo.app.settings.db.SettingsDataStore
28+
import com.duckduckgo.app.statistics.pixels.Pixel
2729
import com.nhaarman.mockitokotlin2.*
2830
import kotlinx.coroutines.Dispatchers
2931
import kotlinx.coroutines.runBlocking
@@ -39,12 +41,14 @@ class AutomaticDataClearerTest {
3941
private val mockClearAction: ClearDataAction = mock()
4042
private val mockTimeKeeper: BackgroundTimeKeeper = mock()
4143
private val mockWorkManager: WorkManager = mock()
44+
private val pixel: Pixel = mock()
45+
private val dataClearerForegroundAppRestartPixel = DataClearerForegroundAppRestartPixel(InstrumentationRegistry.getInstrumentation().targetContext, pixel)
4246

4347
@UiThreadTest
4448
@Before
4549
fun setup() {
4650
whenever(mockSettingsDataStore.hasBackgroundTimestampRecorded()).thenReturn(true)
47-
testee = AutomaticDataClearer(mockWorkManager, mockSettingsDataStore, mockClearAction, mockTimeKeeper)
51+
testee = AutomaticDataClearer(mockWorkManager, mockSettingsDataStore, mockClearAction, mockTimeKeeper, dataClearerForegroundAppRestartPixel)
4852
}
4953

5054
private suspend fun simulateLifecycle(isFreshAppLaunch: Boolean) {
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/*
2+
* Copyright (c) 2020 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.app.fire
18+
19+
import android.content.Intent
20+
import android.net.Uri
21+
import androidx.test.platform.app.InstrumentationRegistry
22+
import com.duckduckgo.app.browser.BrowserActivity
23+
import com.duckduckgo.app.statistics.pixels.Pixel
24+
import com.duckduckgo.app.systemsearch.SystemSearchActivity
25+
import com.nhaarman.mockitokotlin2.mock
26+
import com.nhaarman.mockitokotlin2.verify
27+
import org.junit.Test
28+
29+
class DataClearerForegroundAppRestartPixelTest {
30+
31+
private val context = InstrumentationRegistry.getInstrumentation().targetContext
32+
private val pixel = mock<Pixel>()
33+
private val testee = DataClearerForegroundAppRestartPixel(context, pixel)
34+
35+
@Test
36+
fun whenAppRestartsAfterOpenSearchWidgetThenPixelWithIntentIsSent() {
37+
val intent = SystemSearchActivity.fromWidget(context)
38+
testee.registerIntent(intent)
39+
testee.incrementCount()
40+
41+
testee.firePendingPixels()
42+
43+
verify(pixel).fire(Pixel.PixelName.FORGET_ALL_AUTO_RESTART_WITH_INTENT)
44+
}
45+
46+
@Test
47+
fun whenAppRestartsAfterOpenExternalLinkThenPixelWithIntentIsSent() {
48+
val i = givenIntentWithData("https://example.com")
49+
testee.registerIntent(i)
50+
testee.incrementCount()
51+
52+
testee.firePendingPixels()
53+
54+
verify(pixel).fire(Pixel.PixelName.FORGET_ALL_AUTO_RESTART_WITH_INTENT)
55+
}
56+
57+
@Test
58+
fun whenAppRestartsAfterOpenAnEmptyIntentThenPixelIsSent() {
59+
val intent = givenEmptyIntent()
60+
testee.registerIntent(intent)
61+
testee.incrementCount()
62+
63+
testee.firePendingPixels()
64+
65+
verify(pixel).fire(Pixel.PixelName.FORGET_ALL_AUTO_RESTART)
66+
}
67+
68+
@Test
69+
fun whenAllUnsentPixelsAreFiredThenResetCounter() {
70+
val intent = givenEmptyIntent()
71+
testee.registerIntent(intent)
72+
testee.incrementCount()
73+
74+
testee.firePendingPixels()
75+
testee.firePendingPixels()
76+
77+
verify(pixel).fire(Pixel.PixelName.FORGET_ALL_AUTO_RESTART)
78+
}
79+
80+
@Test
81+
fun whenAppRestartedAfterGoingBackFromBackgroundThenPixelIsSent() {
82+
val intent = SystemSearchActivity.fromWidget(context)
83+
testee.registerIntent(intent)
84+
testee.onAppBackgrounded()
85+
testee.incrementCount()
86+
87+
testee.firePendingPixels()
88+
89+
verify(pixel).fire(Pixel.PixelName.FORGET_ALL_AUTO_RESTART)
90+
}
91+
92+
private fun givenEmptyIntent(): Intent = Intent(context, BrowserActivity::class.java)
93+
94+
private fun givenIntentWithData(url: String) = Intent(Intent.ACTION_VIEW).apply {
95+
data = Uri.parse(url)
96+
}
97+
}

app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import com.duckduckgo.app.browser.rating.ui.GiveFeedbackDialogFragment
3434
import com.duckduckgo.app.browser.rating.ui.RateAppDialogFragment
3535
import com.duckduckgo.app.feedback.ui.common.FeedbackActivity
3636
import com.duckduckgo.app.fire.DataClearer
37+
import com.duckduckgo.app.fire.DataClearerForegroundAppRestartPixel
3738
import com.duckduckgo.app.global.ApplicationClearDataState
3839
import com.duckduckgo.app.global.DuckDuckGoActivity
3940
import com.duckduckgo.app.global.intentText
@@ -64,6 +65,9 @@ class BrowserActivity : DuckDuckGoActivity(), CoroutineScope by MainScope() {
6465
@Inject
6566
lateinit var playStoreUtils: PlayStoreUtils
6667

68+
@Inject
69+
lateinit var dataClearerForegroundAppRestartPixel: DataClearerForegroundAppRestartPixel
70+
6771
private var currentTab: BrowserTabFragment? = null
6872

6973
private val viewModel: BrowserViewModel by bindViewModel()
@@ -81,11 +85,9 @@ class BrowserActivity : DuckDuckGoActivity(), CoroutineScope by MainScope() {
8185
@SuppressLint("MissingSuperCall")
8286
override fun onCreate(savedInstanceState: Bundle?) {
8387
super.daggerInject()
84-
85-
renderer = BrowserStateRenderer()
86-
8788
Timber.i("onCreate called. freshAppLaunch: ${dataClearer.isFreshAppLaunch}, savedInstanceState: $savedInstanceState")
88-
89+
dataClearerForegroundAppRestartPixel.registerIntent(intent)
90+
renderer = BrowserStateRenderer()
8991
val newInstanceState = if (dataClearer.isFreshAppLaunch) null else savedInstanceState
9092
instanceStateBundles = CombinedInstanceState(originalInstanceState = savedInstanceState, newInstanceState = newInstanceState)
9193

@@ -110,6 +112,7 @@ class BrowserActivity : DuckDuckGoActivity(), CoroutineScope by MainScope() {
110112
override fun onNewIntent(intent: Intent?) {
111113
super.onNewIntent(intent)
112114
Timber.i("onNewIntent: $intent")
115+
dataClearerForegroundAppRestartPixel.registerIntent(intent)
113116

114117
if (dataClearer.dataClearerState.value == ApplicationClearDataState.FINISHED) {
115118
Timber.i("Automatic data clearer has finished, so processing intent now")

app/src/main/java/com/duckduckgo/app/di/PrivacyModule.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,10 @@ class PrivacyModule {
7373
workManager: WorkManager,
7474
settingsDataStore: SettingsDataStore,
7575
clearDataAction: ClearDataAction,
76-
dataClearerTimeKeeper: BackgroundTimeKeeper
76+
dataClearerTimeKeeper: BackgroundTimeKeeper,
77+
dataClearerForegroundAppRestartPixel: DataClearerForegroundAppRestartPixel
7778
): DataClearer {
78-
return AutomaticDataClearer(workManager, settingsDataStore, clearDataAction, dataClearerTimeKeeper)
79+
return AutomaticDataClearer(workManager, settingsDataStore, clearDataAction, dataClearerTimeKeeper, dataClearerForegroundAppRestartPixel)
7980
}
8081

8182
@Provides

app/src/main/java/com/duckduckgo/app/fire/AutomaticDataClearer.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ class AutomaticDataClearer(
4444
private val workManager: WorkManager,
4545
private val settingsDataStore: SettingsDataStore,
4646
private val clearDataAction: ClearDataAction,
47-
private val dataClearerTimeKeeper: BackgroundTimeKeeper
47+
private val dataClearerTimeKeeper: BackgroundTimeKeeper,
48+
private val dataClearerForegroundAppRestartPixel: DataClearerForegroundAppRestartPixel
4849
) : DataClearer, LifecycleObserver, CoroutineScope {
4950

5051
private val clearJob: Job = Job()
@@ -159,7 +160,7 @@ class AutomaticDataClearer(
159160
Timber.i("All data now cleared, will restart process? $processNeedsRestarted")
160161
if (processNeedsRestarted) {
161162
clearDataAction.setAppUsedSinceLastClearFlag(false)
162-
163+
dataClearerForegroundAppRestartPixel.incrementCount()
163164
// need a moment to draw background color (reduces flickering UX)
164165
Handler().postDelayed(100) {
165166
Timber.i("Will now restart process")
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/*
2+
* Copyright (c) 2020 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.app.fire
18+
19+
import android.content.Context
20+
import android.content.Intent
21+
import android.content.SharedPreferences
22+
import androidx.annotation.UiThread
23+
import androidx.annotation.VisibleForTesting
24+
import androidx.core.content.edit
25+
import androidx.lifecycle.Lifecycle
26+
import androidx.lifecycle.LifecycleObserver
27+
import androidx.lifecycle.OnLifecycleEvent
28+
import com.duckduckgo.app.global.intentText
29+
import com.duckduckgo.app.statistics.pixels.Pixel
30+
import com.duckduckgo.app.systemsearch.SystemSearchActivity
31+
import timber.log.Timber
32+
import javax.inject.Inject
33+
import javax.inject.Singleton
34+
35+
/**
36+
* Stores information about unsent automatic data clearer restart Pixels, detecting if user started the app from an external Intent.
37+
* Contains logic to send unsent pixels.
38+
*
39+
* When writing values here to SharedPreferences, it is crucial to use `commit = true`. As otherwise the change can be lost in the process restart.
40+
*/
41+
@Singleton
42+
class DataClearerForegroundAppRestartPixel @Inject constructor(
43+
private val context: Context,
44+
private val pixel: Pixel
45+
) : LifecycleObserver {
46+
private var detectedUserIntent: Boolean = false
47+
48+
private val pendingAppForegroundRestart: Int
49+
get() = preferences.getInt(KEY_UNSENT_CLEAR_APP_RESTARTED_PIXELS, 0)
50+
51+
private val pendingAppForegroundRestartWithIntent: Int
52+
get() = preferences.getInt(KEY_UNSENT_CLEAR_APP_RESTARTED_WITH_INTENT_PIXELS, 0)
53+
54+
@UiThread
55+
@OnLifecycleEvent(Lifecycle.Event.ON_STOP)
56+
fun onAppBackgrounded() {
57+
Timber.i("Registered App on_stop")
58+
detectedUserIntent = false
59+
}
60+
61+
fun registerIntent(intent: Intent?) {
62+
detectedUserIntent = widgetActivity(intent) || !intent?.intentText.isNullOrEmpty()
63+
}
64+
65+
fun incrementCount() {
66+
if (detectedUserIntent) {
67+
Timber.i("Registered restart with intent")
68+
incrementCount(pendingAppForegroundRestart, KEY_UNSENT_CLEAR_APP_RESTARTED_WITH_INTENT_PIXELS)
69+
} else {
70+
Timber.i("Registered restart without intent")
71+
incrementCount(pendingAppForegroundRestartWithIntent, KEY_UNSENT_CLEAR_APP_RESTARTED_PIXELS)
72+
}
73+
}
74+
75+
fun firePendingPixels() {
76+
firePendingPixels(pendingAppForegroundRestart, Pixel.PixelName.FORGET_ALL_AUTO_RESTART)
77+
firePendingPixels(pendingAppForegroundRestartWithIntent, Pixel.PixelName.FORGET_ALL_AUTO_RESTART_WITH_INTENT)
78+
resetCount()
79+
}
80+
81+
private fun incrementCount(counter: Int, sharedPrefKey: String) {
82+
val updated = counter + 1
83+
preferences.edit(commit = true) {
84+
putInt(sharedPrefKey, updated)
85+
}
86+
}
87+
88+
private fun firePendingPixels(counter: Int, pixelName: Pixel.PixelName) {
89+
if (counter > 0) {
90+
for (i in 1..counter) {
91+
Timber.i("Fired pixel: ${pixelName.pixelName}/$counter")
92+
pixel.fire(pixelName)
93+
}
94+
}
95+
}
96+
97+
private fun resetCount() {
98+
preferences.edit(commit = true) {
99+
putInt(KEY_UNSENT_CLEAR_APP_RESTARTED_PIXELS, 0)
100+
putInt(KEY_UNSENT_CLEAR_APP_RESTARTED_WITH_INTENT_PIXELS, 0)
101+
}
102+
Timber.i("counter reset")
103+
}
104+
105+
private fun widgetActivity(intent: Intent?): Boolean =
106+
intent?.component?.className?.contains(SystemSearchActivity::class.java.canonicalName.orEmpty()) == true
107+
108+
private val preferences: SharedPreferences by lazy {
109+
context.getSharedPreferences(FILENAME, Context.MODE_PRIVATE)
110+
}
111+
112+
companion object {
113+
@VisibleForTesting
114+
const val FILENAME = "com.duckduckgo.app.fire.unsentpixels.settings"
115+
const val KEY_UNSENT_CLEAR_APP_RESTARTED_PIXELS = "KEY_UNSENT_CLEAR_APP_RESTARTED_PIXELS"
116+
const val KEY_UNSENT_CLEAR_APP_RESTARTED_WITH_INTENT_PIXELS = "KEY_UNSENT_CLEAR_APP_RESTARTED_WITH_INTENT_PIXELS"
117+
}
118+
}

app/src/main/java/com/duckduckgo/app/global/DuckDuckGoApplication.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import com.duckduckgo.app.di.DaggerAppComponent
3030
import com.duckduckgo.app.fire.DataClearer
3131
import com.duckduckgo.app.fire.FireActivity
3232
import com.duckduckgo.app.fire.UnsentForgetAllPixelStore
33+
import com.duckduckgo.app.fire.DataClearerForegroundAppRestartPixel
3334
import com.duckduckgo.app.global.Theming.initializeTheme
3435
import com.duckduckgo.app.global.initialization.AppDataLoader
3536
import com.duckduckgo.app.global.install.AppInstallStore
@@ -110,6 +111,9 @@ open class DuckDuckGoApplication : HasAndroidInjector, Application(), LifecycleO
110111
@Inject
111112
lateinit var unsentForgetAllPixelStore: UnsentForgetAllPixelStore
112113

114+
@Inject
115+
lateinit var dataClearerForegroundAppRestartPixel: DataClearerForegroundAppRestartPixel
116+
113117
@Inject
114118
lateinit var offlinePixelScheduler: OfflinePixelScheduler
115119

@@ -170,6 +174,7 @@ open class DuckDuckGoApplication : HasAndroidInjector, Application(), LifecycleO
170174
it.addObserver(appDaysUsedRecorder)
171175
it.addObserver(defaultBrowserObserver)
172176
it.addObserver(appEnjoymentLifecycleObserver)
177+
it.addObserver(dataClearerForegroundAppRestartPixel)
173178
}
174179

175180
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
@@ -254,6 +259,8 @@ open class DuckDuckGoApplication : HasAndroidInjector, Application(), LifecycleO
254259
}
255260
unsentForgetAllPixelStore.resetCount()
256261
}
262+
263+
dataClearerForegroundAppRestartPixel.firePendingPixels()
257264
}
258265

259266
/**

app/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ interface Pixel {
3636
FORGET_ALL_PRESSED_BROWSING("mf_bp"),
3737
FORGET_ALL_PRESSED_TABSWITCHING("mf_tp"),
3838
FORGET_ALL_EXECUTED("mf"),
39+
FORGET_ALL_AUTO_RESTART("m_f_r"),
40+
FORGET_ALL_AUTO_RESTART_WITH_INTENT("m_f_ri"),
3941

4042
APPLICATION_CRASH("m_d_ac"),
4143
APPLICATION_CRASH_GLOBAL("m_d_ac_g"),

app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchActivity.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import com.duckduckgo.app.browser.BrowserActivity
3434
import com.duckduckgo.app.browser.R
3535
import com.duckduckgo.app.browser.autocomplete.BrowserAutoCompleteSuggestionsAdapter
3636
import com.duckduckgo.app.browser.omnibar.OmnibarScrolling
37+
import com.duckduckgo.app.fire.DataClearerForegroundAppRestartPixel
3738
import com.duckduckgo.app.global.DuckDuckGoActivity
3839
import com.duckduckgo.app.global.view.TextChangedWatcher
3940
import com.duckduckgo.app.global.view.hideKeyboard
@@ -53,6 +54,9 @@ class SystemSearchActivity : DuckDuckGoActivity() {
5354
@Inject
5455
lateinit var omnibarScrolling: OmnibarScrolling
5556

57+
@Inject
58+
lateinit var dataClearerForegroundAppRestartPixel: DataClearerForegroundAppRestartPixel
59+
5660
private val viewModel: SystemSearchViewModel by bindViewModel()
5761
private lateinit var autocompleteSuggestionsAdapter: BrowserAutoCompleteSuggestionsAdapter
5862
private lateinit var deviceAppSuggestionsAdapter: DeviceAppSuggestionsAdapter
@@ -66,6 +70,7 @@ class SystemSearchActivity : DuckDuckGoActivity() {
6670

6771
override fun onCreate(savedInstanceState: Bundle?) {
6872
super.onCreate(savedInstanceState)
73+
dataClearerForegroundAppRestartPixel.registerIntent(intent)
6974
setContentView(R.layout.activity_system_search)
7075
configureObservers()
7176
configureOnboarding()
@@ -82,6 +87,7 @@ class SystemSearchActivity : DuckDuckGoActivity() {
8287

8388
override fun onNewIntent(newIntent: Intent?) {
8489
super.onNewIntent(newIntent)
90+
dataClearerForegroundAppRestartPixel.registerIntent(newIntent)
8591
viewModel.resetViewState()
8692
newIntent?.let { sendLaunchPixels(it) }
8793
}

0 commit comments

Comments
 (0)