Skip to content

Commit 3a7d592

Browse files
CDRussellkarlenDimla
authored andcommitted
Extract ad campaign from Play Store referrer (#4818)
Task/Issue URL: https://app.asana.com/0/488551667048375/1207902032895525/f ### Description Add ability to extract an ad campaign from Play Store referrer URLs. ### Steps to test this PR ℹ️ Logcat filter: `Pixel sent: m_android_install` ℹ️ This requires the Play Store to be available and active on your test device / emulator. ⚠️ You need to remove the **production** app from your device during this test **Local changes for testing** it's a nightmare to E2E test this because it involves first having released these changes in an app version on the Play Store, so that the app you install from the Play Store referral link has the changes already in it, but we can't release until we've tested the changes 🐔/🥚 . there is a neat hack to simulate this workflow though - [x] delete the line `applicationIdSuffix ".debug"` from your `build.gradle` - [x] Uninstall the prod app if already installed **Send a Play Store app install link containing referrer to your test device / emulator** - [x] Send a URL to your device so that you can click on it (e.g., via email). The format should be like this: - ```https://play.google.com/store/apps/details?id=com.duckduckgo.mobile.android&referrer=origin%3Dfunnel_playstore_whatever``` - You can change the value to test different things, including adding extra parts afterwards but it must start `origin%3D` - [x] Click on the link to launch the app's listing in the Play Store app. **Don't install it** **Install the APK from this branch (Play variant)** - [x] Now you can install the APK from this branch `./gradlew installPlayDebug` - [x] Launch the app - [x] Verify in logcat that: install pixel contains ad campaign you specified in the URL above - [x] Verify pixel params look right, including `reinstall` value - [x] Close the app and relaunch; verify not sent again **Testing what happens if referrer does not contain origin (when not installed from Play Store)** - [x] Delete the app - [x] Install from this branch `./gradlew installPlayDebug` (without visiting the referral link) and launch the app - [x] Verify in logcat that: install pixel still fires but that there is no origin attached **Testing what happens if referrer does not contain origin (when installed from Play Store)** - [x] Requires local hack: hardcode `VerificationCheckPlayStoreInstallImpl.installedFromPlayStore()` to return `true` - [x] Delete the app - [x] Install from this branch `./gradlew installPlayDebug` (without visiting the referral link) and launch the app - [x] Verify in logcat that: install pixel still fires and that origin has been set to default, `origin=funnel_playstore` **Testing reinstall flag picked up correctly** - [x] Requires local hack: hardcode `ReinstallAtbListener.beforeAtbInit()` to detect returning user - [x] Delete the app - [x] `./gradlew installPlayDebug` (don't need to visit the referral link) and launch the app - [x] Verify in logcat that: install pixel fires and that `reinstall=true` - [x] Repeat the above but hardcoding returning user to false, and verify `reinstall=false` **Testing non-Play variant** - [x] Delete app - [x] `./gradlew installInternalDebug` and launch app - [x] Verify in logcat that: install pixel **not** fired
1 parent 123c747 commit 3a7d592

File tree

12 files changed

+460
-12
lines changed

12 files changed

+460
-12
lines changed

app/src/main/java/com/duckduckgo/app/global/api/PixelParamRemovalInterceptor.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ object PixelInterceptorPixelsRequiringDataCleaning : PixelParamRemovalPlugin {
8383
StatisticsPixelName.BROWSER_DAILY_ACTIVE_FEATURE_STATE.pixelName to PixelParameter.removeAll(),
8484
WebViewPixelName.WEB_PAGE_LOADED.pixelName to PixelParameter.removeAll(),
8585
WebViewPixelName.WEB_PAGE_PAINTED.pixelName to PixelParameter.removeAll(),
86+
AppPixelName.REFERRAL_INSTALL_UTM_CAMPAIGN.pixelName to PixelParameter.removeAtb(),
8687
)
8788
}
8889
}

app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,4 +347,6 @@ enum class AppPixelName(override val pixelName: String) : Pixel.PixelName {
347347
TAB_MANAGER_REARRANGE_BANNER_DISPLAYED("m_tab_manager_rearrange_banner_displayed"),
348348

349349
ADD_BOOKMARK_CONFIRM_EDITED("m_add_bookmark_confirm_edit"),
350+
351+
REFERRAL_INSTALL_UTM_CAMPAIGN("m_android_install"),
350352
}

app/src/main/java/com/duckduckgo/app/referral/AppInstallationReferrerParser.kt

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,18 @@ interface AppInstallationReferrerParser {
3232

3333
@Suppress("SameParameterValue")
3434
@ContributesBinding(AppScope::class)
35-
class QueryParamReferrerParser @Inject constructor() : AppInstallationReferrerParser {
35+
class QueryParamReferrerParser @Inject constructor(
36+
private val originAttributeHandler: ReferrerOriginAttributeHandler,
37+
) : AppInstallationReferrerParser {
3638

3739
override fun parse(referrer: String): ParsedReferrerResult {
40+
Timber.v("Full referrer string: %s", referrer)
41+
3842
val referrerParts = splitIntoConstituentParts(referrer)
39-
if (referrerParts.isNullOrEmpty()) return ReferrerNotFound(fromCache = false)
43+
if (referrerParts.isEmpty()) return ReferrerNotFound(fromCache = false)
44+
45+
// processing this doesn't change anything with the ATB-based campaign referrer or EU search/ballot logic
46+
originAttributeHandler.process(referrerParts)
4047

4148
val auctionReferrer = extractEuAuctionReferrer(referrerParts)
4249
if (auctionReferrer is EuAuctionSearchChoiceReferrerFound || auctionReferrer is EuAuctionBrowserChoiceReferrerFound) {
@@ -60,7 +67,7 @@ class QueryParamReferrerParser @Inject constructor() : AppInstallationReferrerPa
6067
}
6168
}
6269

63-
Timber.d("App not installed as a result of EU auction")
70+
Timber.d("No EU referrer data found; app not installed as a result of EU auction or choice screen")
6471
return ReferrerNotFound()
6572
}
6673

@@ -101,8 +108,8 @@ class QueryParamReferrerParser @Inject constructor() : AppInstallationReferrerPa
101108
return fullCampaignName.substringAfter(prefix, "")
102109
}
103110

104-
private fun splitIntoConstituentParts(referrer: String?): List<String>? {
105-
return referrer?.split("&")
111+
private fun splitIntoConstituentParts(referrer: String?): List<String> {
112+
return referrer?.split("&") ?: emptyList()
106113
}
107114

108115
companion object {
@@ -124,7 +131,7 @@ sealed class ParsedReferrerResult(open val fromCache: Boolean = false) {
124131

125132
data class ReferrerNotFound(override val fromCache: Boolean = false) : ParsedReferrerResult(fromCache)
126133
data class ParseFailure(val reason: ParseFailureReason) : ParsedReferrerResult()
127-
object ReferrerInitialising : ParsedReferrerResult()
134+
data object ReferrerInitialising : ParsedReferrerResult()
128135
}
129136

130137
sealed class ParseFailureReason {

app/src/main/java/com/duckduckgo/app/referral/AppReferrerDataStore.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ interface AppReferrerDataStore {
2828
var referrerCheckedPreviously: Boolean
2929
var campaignSuffix: String?
3030
var installedFromEuAuction: Boolean
31+
var utmOriginAttributeCampaign: String?
3132
}
3233

3334
@ContributesBinding(AppScope::class)
@@ -37,6 +38,10 @@ class AppReferenceSharePreferences @Inject constructor(private val context: Cont
3738
get() = preferences.getString(KEY_CAMPAIGN_SUFFIX, null)
3839
set(value) = preferences.edit(true) { putString(KEY_CAMPAIGN_SUFFIX, value) }
3940

41+
override var utmOriginAttributeCampaign: String?
42+
get() = preferences.getString(KEY_ORIGIN_ATTRIBUTE_CAMPAIGN, null)
43+
set(value) = preferences.edit(true) { putString(KEY_ORIGIN_ATTRIBUTE_CAMPAIGN, value) }
44+
4045
override var referrerCheckedPreviously: Boolean
4146
get() = preferences.getBoolean(KEY_CHECKED_PREVIOUSLY, false)
4247
set(value) = preferences.edit(true) { putBoolean(KEY_CHECKED_PREVIOUSLY, value) }
@@ -50,6 +55,7 @@ class AppReferenceSharePreferences @Inject constructor(private val context: Cont
5055
companion object {
5156
const val FILENAME = "com.duckduckgo.app.referral"
5257
private const val KEY_CAMPAIGN_SUFFIX = "KEY_CAMPAIGN_SUFFIX"
58+
private const val KEY_ORIGIN_ATTRIBUTE_CAMPAIGN = "KEY_ORIGIN_ATTRIBUTE_CAMPAIGN"
5359
private const val KEY_CHECKED_PREVIOUSLY = "KEY_CHECKED_PREVIOUSLY"
5460
private const val KEY_INSTALLED_FROM_EU_AUCTION = "KEY_INSTALLED_FROM_EU_AUCTION"
5561
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/*
2+
* Copyright (c) 2024 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.referral
18+
19+
import com.duckduckgo.di.scopes.AppScope
20+
import com.duckduckgo.verifiedinstallation.installsource.VerificationCheckPlayStoreInstall
21+
import com.squareup.anvil.annotations.ContributesBinding
22+
import javax.inject.Inject
23+
import timber.log.Timber
24+
25+
interface ReferrerOriginAttributeHandler {
26+
fun process(referrerParts: List<String>)
27+
}
28+
29+
@ContributesBinding(AppScope::class)
30+
class ReferrerOriginAttributeHandlerImpl @Inject constructor(
31+
private val appReferrerDataStore: AppReferrerDataStore,
32+
private val playStoreInstallChecker: VerificationCheckPlayStoreInstall,
33+
) : ReferrerOriginAttributeHandler {
34+
35+
override fun process(referrerParts: List<String>) {
36+
runCatching {
37+
Timber.v("Looking for origin attribute referrer data")
38+
var originAttributePart = extractOriginAttribute(referrerParts)
39+
40+
if (originAttributePart == null && playStoreInstallChecker.installedFromPlayStore()) {
41+
Timber.v("No origin attribute referrer data available; assigning one")
42+
originAttributePart = DEFAULT_ATTRIBUTION_FOR_PLAY_STORE_INSTALLS
43+
}
44+
45+
persistOriginAttribute(originAttributePart)
46+
}
47+
}
48+
49+
private fun extractOriginAttribute(referrerParts: List<String>): String? {
50+
val originAttributePart = referrerParts.find { it.startsWith("$ORIGIN_ATTRIBUTE_KEY=") }
51+
if (originAttributePart == null) {
52+
Timber.v("Did not find referrer origin attribute key")
53+
return null
54+
}
55+
56+
Timber.v("Found referrer origin attribute: %s", originAttributePart)
57+
58+
return originAttributePart.removePrefix("$ORIGIN_ATTRIBUTE_KEY=").also {
59+
Timber.i("Found referrer origin attribute value: %s", it)
60+
}
61+
}
62+
63+
private fun persistOriginAttribute(originAttributePart: String?) {
64+
appReferrerDataStore.utmOriginAttributeCampaign = originAttributePart
65+
}
66+
67+
companion object {
68+
const val ORIGIN_ATTRIBUTE_KEY = "origin"
69+
const val DEFAULT_ATTRIBUTION_FOR_PLAY_STORE_INSTALLS = "funnel_playstore"
70+
}
71+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/*
2+
* Copyright (c) 2024 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.referral
18+
19+
import com.duckduckgo.app.di.AppCoroutineScope
20+
import com.duckduckgo.app.pixels.AppPixelName
21+
import com.duckduckgo.app.referral.AppReferrerDataStore
22+
import com.duckduckgo.app.statistics.api.AtbLifecyclePlugin
23+
import com.duckduckgo.app.statistics.pixels.Pixel
24+
import com.duckduckgo.app.statistics.store.StatisticsDataStore
25+
import com.duckduckgo.appbuildconfig.api.AppBuildConfig
26+
import com.duckduckgo.common.utils.DispatcherProvider
27+
import com.duckduckgo.di.scopes.AppScope
28+
import com.squareup.anvil.annotations.ContributesMultibinding
29+
import java.util.concurrent.atomic.AtomicBoolean
30+
import javax.inject.Inject
31+
import kotlinx.coroutines.CoroutineScope
32+
import kotlinx.coroutines.launch
33+
import timber.log.Timber
34+
35+
@ContributesMultibinding(scope = AppScope::class)
36+
class AppReferrerInstallPixelSender @Inject constructor(
37+
private val appReferrerDataStore: AppReferrerDataStore,
38+
private val pixel: Pixel,
39+
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
40+
private val dispatchers: DispatcherProvider,
41+
private val appBuildConfig: AppBuildConfig,
42+
private val statisticsDataStore: StatisticsDataStore,
43+
) : AtbLifecyclePlugin {
44+
45+
private val pixelSent = AtomicBoolean(false)
46+
47+
override fun onAppAtbInitialized() {
48+
Timber.v("AppReferrerInstallPixelSender: onAppAtbInitialized")
49+
sendPixelIfUnsent()
50+
}
51+
52+
private fun sendPixelIfUnsent() {
53+
if (pixelSent.compareAndSet(false, true)) {
54+
appCoroutineScope.launch(dispatchers.io()) {
55+
sendOriginAttribute(appReferrerDataStore.utmOriginAttributeCampaign)
56+
}
57+
}
58+
}
59+
60+
private fun sendOriginAttribute(originAttribute: String?) {
61+
val returningUser = statisticsDataStore.variant == RETURNING_USER_VARIANT
62+
63+
val params = mutableMapOf(
64+
PIXEL_PARAM_LOCALE to appBuildConfig.deviceLocale.toLanguageTag(),
65+
PIXEL_PARAM_RETURNING_USER to returningUser.toString(),
66+
)
67+
68+
// if origin is null, pixel is sent with origin omitted
69+
if (originAttribute != null) {
70+
params[PIXEL_PARAM_ORIGIN] = originAttribute
71+
}
72+
73+
pixel.fire(pixel = AppPixelName.REFERRAL_INSTALL_UTM_CAMPAIGN, type = Pixel.PixelType.UNIQUE, parameters = params)
74+
}
75+
76+
companion object {
77+
private const val RETURNING_USER_VARIANT = "ru"
78+
79+
const val PIXEL_PARAM_ORIGIN = "origin"
80+
const val PIXEL_PARAM_LOCALE = "locale"
81+
const val PIXEL_PARAM_RETURNING_USER = "reinstall"
82+
}
83+
}

app/src/play/java/com/duckduckgo/referral/PlayStoreAppReferrerStateListener.kt

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,30 @@ import android.content.pm.PackageManager
2323
import android.content.pm.ResolveInfo
2424
import android.os.Build
2525
import com.android.installreferrer.api.InstallReferrerClient
26-
import com.android.installreferrer.api.InstallReferrerClient.InstallReferrerResponse.*
26+
import com.android.installreferrer.api.InstallReferrerClient.InstallReferrerResponse.DEVELOPER_ERROR
27+
import com.android.installreferrer.api.InstallReferrerClient.InstallReferrerResponse.FEATURE_NOT_SUPPORTED
28+
import com.android.installreferrer.api.InstallReferrerClient.InstallReferrerResponse.OK
29+
import com.android.installreferrer.api.InstallReferrerClient.InstallReferrerResponse.SERVICE_DISCONNECTED
30+
import com.android.installreferrer.api.InstallReferrerClient.InstallReferrerResponse.SERVICE_UNAVAILABLE
2731
import com.android.installreferrer.api.InstallReferrerStateListener
28-
import com.duckduckgo.app.referral.*
32+
import com.duckduckgo.app.referral.AppInstallationReferrerParser
33+
import com.duckduckgo.app.referral.AppInstallationReferrerStateListener
2934
import com.duckduckgo.app.referral.AppInstallationReferrerStateListener.Companion.MAX_REFERRER_WAIT_TIME_MS
30-
import com.duckduckgo.app.referral.ParseFailureReason.*
31-
import com.duckduckgo.app.referral.ParsedReferrerResult.*
35+
import com.duckduckgo.app.referral.AppReferrerDataStore
36+
import com.duckduckgo.app.referral.ParseFailureReason
37+
import com.duckduckgo.app.referral.ParseFailureReason.DeveloperError
38+
import com.duckduckgo.app.referral.ParseFailureReason.FeatureNotSupported
39+
import com.duckduckgo.app.referral.ParseFailureReason.ReferralServiceUnavailable
40+
import com.duckduckgo.app.referral.ParseFailureReason.ServiceDisconnected
41+
import com.duckduckgo.app.referral.ParseFailureReason.ServiceUnavailable
42+
import com.duckduckgo.app.referral.ParseFailureReason.UnknownError
43+
import com.duckduckgo.app.referral.ParsedReferrerResult
44+
import com.duckduckgo.app.referral.ParsedReferrerResult.CampaignReferrerFound
45+
import com.duckduckgo.app.referral.ParsedReferrerResult.EuAuctionBrowserChoiceReferrerFound
46+
import com.duckduckgo.app.referral.ParsedReferrerResult.EuAuctionSearchChoiceReferrerFound
47+
import com.duckduckgo.app.referral.ParsedReferrerResult.ParseFailure
48+
import com.duckduckgo.app.referral.ParsedReferrerResult.ReferrerInitialising
49+
import com.duckduckgo.app.referral.ParsedReferrerResult.ReferrerNotFound
3250
import com.duckduckgo.app.statistics.AtbInitializerListener
3351
import com.duckduckgo.common.utils.playstore.PlayStoreAndroidUtils.Companion.PLAY_STORE_PACKAGE
3452
import com.duckduckgo.common.utils.playstore.PlayStoreAndroidUtils.Companion.PLAY_STORE_REFERRAL_SERVICE

app/src/test/java/com/duckduckgo/app/referral/QueryParamReferrerParserTest.kt

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,17 @@ import com.duckduckgo.app.referral.ParsedReferrerResult.EuAuctionSearchChoiceRef
2222
import org.junit.Assert.assertEquals
2323
import org.junit.Assert.assertTrue
2424
import org.junit.Test
25+
import org.mockito.kotlin.any
26+
import org.mockito.kotlin.mock
27+
import org.mockito.kotlin.verify
2528

2629
class QueryParamReferrerParserTest {
2730

28-
private val testee: QueryParamReferrerParser = QueryParamReferrerParser()
31+
private val originAttributeHandler: ReferrerOriginAttributeHandler = mock()
32+
33+
private val testee: QueryParamReferrerParser = QueryParamReferrerParser(
34+
originAttributeHandler = originAttributeHandler,
35+
)
2936

3037
@Test
3138
fun whenReferrerDoesNotContainTargetThenNoReferrerFound() {
@@ -73,6 +80,12 @@ class QueryParamReferrerParserTest {
7380
verifyCampaignReferrerFound("AB", result)
7481
}
7582

83+
@Test
84+
fun whenReferrerContainsTargetAndUtmCampaignThenReferrerFound() {
85+
val result = testee.parse("key1=foo&key2=bar&key3=DDGRAAB&origin=funnel_playstore_whatever")
86+
verifyCampaignReferrerFound("AB", result)
87+
}
88+
7689
@Test
7790
fun whenReferrerContainsTargetWithDifferentCaseThenNoReferrerFound() {
7891
verifyReferrerNotFound(testee.parse("ddgraAB"))
@@ -108,6 +121,19 @@ class QueryParamReferrerParserTest {
108121
assertTrue(result is EuAuctionBrowserChoiceReferrerFound)
109122
}
110123

124+
@Test
125+
fun whenReferrerDoesNotContainEuAuctionDataThenUtmCampaignProcessorCalled() {
126+
testee.parse("origin=funnel_playstore_whatever")
127+
verify(originAttributeHandler).process(any())
128+
}
129+
130+
@Test
131+
fun whenReferrerDoesContainEuAuctionDataThenUtmCampaignProcessorStillCalled() {
132+
val result = testee.parse("$INSTALLATION_SOURCE_KEY=$INSTALLATION_SOURCE_EU_BROWSER_CHOICE_AUCTION_VALUE")
133+
verify(originAttributeHandler).process(any())
134+
assertTrue(result is EuAuctionBrowserChoiceReferrerFound)
135+
}
136+
111137
@Test
112138
fun whenReferrerContainsBothEuAuctionBrowserChoiceAndCampaignReferrerDataThenEuActionReferrerFound() {
113139
val result = testee.parse("key1=DDGRAAB&key2=foo&key3=bar&$INSTALLATION_SOURCE_KEY=$INSTALLATION_SOURCE_EU_BROWSER_CHOICE_AUCTION_VALUE")

0 commit comments

Comments
 (0)