Skip to content

Commit

Permalink
Extract ad campaign from Play Store referrer (#4818)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
CDRussell authored Aug 5, 2024
1 parent f2c3509 commit fb3b5e3
Show file tree
Hide file tree
Showing 12 changed files with 460 additions and 12 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ object PixelInterceptorPixelsRequiringDataCleaning : PixelParamRemovalPlugin {
StatisticsPixelName.BROWSER_DAILY_ACTIVE_FEATURE_STATE.pixelName to PixelParameter.removeAll(),
WebViewPixelName.WEB_PAGE_LOADED.pixelName to PixelParameter.removeAll(),
WebViewPixelName.WEB_PAGE_PAINTED.pixelName to PixelParameter.removeAll(),
AppPixelName.REFERRAL_INSTALL_UTM_CAMPAIGN.pixelName to PixelParameter.removeAtb(),
)
}
}
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 @@ -347,4 +347,6 @@ enum class AppPixelName(override val pixelName: String) : Pixel.PixelName {
TAB_MANAGER_REARRANGE_BANNER_DISPLAYED("m_tab_manager_rearrange_banner_displayed"),

ADD_BOOKMARK_CONFIRM_EDITED("m_add_bookmark_confirm_edit"),

REFERRAL_INSTALL_UTM_CAMPAIGN("m_android_install"),
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,18 @@ interface AppInstallationReferrerParser {

@Suppress("SameParameterValue")
@ContributesBinding(AppScope::class)
class QueryParamReferrerParser @Inject constructor() : AppInstallationReferrerParser {
class QueryParamReferrerParser @Inject constructor(
private val originAttributeHandler: ReferrerOriginAttributeHandler,
) : AppInstallationReferrerParser {

override fun parse(referrer: String): ParsedReferrerResult {
Timber.v("Full referrer string: %s", referrer)

val referrerParts = splitIntoConstituentParts(referrer)
if (referrerParts.isNullOrEmpty()) return ReferrerNotFound(fromCache = false)
if (referrerParts.isEmpty()) return ReferrerNotFound(fromCache = false)

// processing this doesn't change anything with the ATB-based campaign referrer or EU search/ballot logic
originAttributeHandler.process(referrerParts)

val auctionReferrer = extractEuAuctionReferrer(referrerParts)
if (auctionReferrer is EuAuctionSearchChoiceReferrerFound || auctionReferrer is EuAuctionBrowserChoiceReferrerFound) {
Expand All @@ -60,7 +67,7 @@ class QueryParamReferrerParser @Inject constructor() : AppInstallationReferrerPa
}
}

Timber.d("App not installed as a result of EU auction")
Timber.d("No EU referrer data found; app not installed as a result of EU auction or choice screen")
return ReferrerNotFound()
}

Expand Down Expand Up @@ -101,8 +108,8 @@ class QueryParamReferrerParser @Inject constructor() : AppInstallationReferrerPa
return fullCampaignName.substringAfter(prefix, "")
}

private fun splitIntoConstituentParts(referrer: String?): List<String>? {
return referrer?.split("&")
private fun splitIntoConstituentParts(referrer: String?): List<String> {
return referrer?.split("&") ?: emptyList()
}

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

data class ReferrerNotFound(override val fromCache: Boolean = false) : ParsedReferrerResult(fromCache)
data class ParseFailure(val reason: ParseFailureReason) : ParsedReferrerResult()
object ReferrerInitialising : ParsedReferrerResult()
data object ReferrerInitialising : ParsedReferrerResult()
}

sealed class ParseFailureReason {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ interface AppReferrerDataStore {
var referrerCheckedPreviously: Boolean
var campaignSuffix: String?
var installedFromEuAuction: Boolean
var utmOriginAttributeCampaign: String?
}

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

override var utmOriginAttributeCampaign: String?
get() = preferences.getString(KEY_ORIGIN_ATTRIBUTE_CAMPAIGN, null)
set(value) = preferences.edit(true) { putString(KEY_ORIGIN_ATTRIBUTE_CAMPAIGN, value) }

override var referrerCheckedPreviously: Boolean
get() = preferences.getBoolean(KEY_CHECKED_PREVIOUSLY, false)
set(value) = preferences.edit(true) { putBoolean(KEY_CHECKED_PREVIOUSLY, value) }
Expand All @@ -50,6 +55,7 @@ class AppReferenceSharePreferences @Inject constructor(private val context: Cont
companion object {
const val FILENAME = "com.duckduckgo.app.referral"
private const val KEY_CAMPAIGN_SUFFIX = "KEY_CAMPAIGN_SUFFIX"
private const val KEY_ORIGIN_ATTRIBUTE_CAMPAIGN = "KEY_ORIGIN_ATTRIBUTE_CAMPAIGN"
private const val KEY_CHECKED_PREVIOUSLY = "KEY_CHECKED_PREVIOUSLY"
private const val KEY_INSTALLED_FROM_EU_AUCTION = "KEY_INSTALLED_FROM_EU_AUCTION"
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* Copyright (c) 2024 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.referral

import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.verifiedinstallation.installsource.VerificationCheckPlayStoreInstall
import com.squareup.anvil.annotations.ContributesBinding
import javax.inject.Inject
import timber.log.Timber

interface ReferrerOriginAttributeHandler {
fun process(referrerParts: List<String>)
}

@ContributesBinding(AppScope::class)
class ReferrerOriginAttributeHandlerImpl @Inject constructor(
private val appReferrerDataStore: AppReferrerDataStore,
private val playStoreInstallChecker: VerificationCheckPlayStoreInstall,
) : ReferrerOriginAttributeHandler {

override fun process(referrerParts: List<String>) {
runCatching {
Timber.v("Looking for origin attribute referrer data")
var originAttributePart = extractOriginAttribute(referrerParts)

if (originAttributePart == null && playStoreInstallChecker.installedFromPlayStore()) {
Timber.v("No origin attribute referrer data available; assigning one")
originAttributePart = DEFAULT_ATTRIBUTION_FOR_PLAY_STORE_INSTALLS
}

persistOriginAttribute(originAttributePart)
}
}

private fun extractOriginAttribute(referrerParts: List<String>): String? {
val originAttributePart = referrerParts.find { it.startsWith("$ORIGIN_ATTRIBUTE_KEY=") }
if (originAttributePart == null) {
Timber.v("Did not find referrer origin attribute key")
return null
}

Timber.v("Found referrer origin attribute: %s", originAttributePart)

return originAttributePart.removePrefix("$ORIGIN_ATTRIBUTE_KEY=").also {
Timber.i("Found referrer origin attribute value: %s", it)
}
}

private fun persistOriginAttribute(originAttributePart: String?) {
appReferrerDataStore.utmOriginAttributeCampaign = originAttributePart
}

companion object {
const val ORIGIN_ATTRIBUTE_KEY = "origin"
const val DEFAULT_ATTRIBUTION_FOR_PLAY_STORE_INSTALLS = "funnel_playstore"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
* Copyright (c) 2024 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.referral

import com.duckduckgo.app.di.AppCoroutineScope
import com.duckduckgo.app.pixels.AppPixelName
import com.duckduckgo.app.referral.AppReferrerDataStore
import com.duckduckgo.app.statistics.api.AtbLifecyclePlugin
import com.duckduckgo.app.statistics.pixels.Pixel
import com.duckduckgo.app.statistics.store.StatisticsDataStore
import com.duckduckgo.appbuildconfig.api.AppBuildConfig
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesMultibinding
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber

@ContributesMultibinding(scope = AppScope::class)
class AppReferrerInstallPixelSender @Inject constructor(
private val appReferrerDataStore: AppReferrerDataStore,
private val pixel: Pixel,
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
private val dispatchers: DispatcherProvider,
private val appBuildConfig: AppBuildConfig,
private val statisticsDataStore: StatisticsDataStore,
) : AtbLifecyclePlugin {

private val pixelSent = AtomicBoolean(false)

override fun onAppAtbInitialized() {
Timber.v("AppReferrerInstallPixelSender: onAppAtbInitialized")
sendPixelIfUnsent()
}

private fun sendPixelIfUnsent() {
if (pixelSent.compareAndSet(false, true)) {
appCoroutineScope.launch(dispatchers.io()) {
sendOriginAttribute(appReferrerDataStore.utmOriginAttributeCampaign)
}
}
}

private fun sendOriginAttribute(originAttribute: String?) {
val returningUser = statisticsDataStore.variant == RETURNING_USER_VARIANT

val params = mutableMapOf(
PIXEL_PARAM_LOCALE to appBuildConfig.deviceLocale.toLanguageTag(),
PIXEL_PARAM_RETURNING_USER to returningUser.toString(),
)

// if origin is null, pixel is sent with origin omitted
if (originAttribute != null) {
params[PIXEL_PARAM_ORIGIN] = originAttribute
}

pixel.fire(pixel = AppPixelName.REFERRAL_INSTALL_UTM_CAMPAIGN, type = Pixel.PixelType.UNIQUE, parameters = params)
}

companion object {
private const val RETURNING_USER_VARIANT = "ru"

const val PIXEL_PARAM_ORIGIN = "origin"
const val PIXEL_PARAM_LOCALE = "locale"
const val PIXEL_PARAM_RETURNING_USER = "reinstall"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,30 @@ import android.content.pm.PackageManager
import android.content.pm.ResolveInfo
import android.os.Build
import com.android.installreferrer.api.InstallReferrerClient
import com.android.installreferrer.api.InstallReferrerClient.InstallReferrerResponse.*
import com.android.installreferrer.api.InstallReferrerClient.InstallReferrerResponse.DEVELOPER_ERROR
import com.android.installreferrer.api.InstallReferrerClient.InstallReferrerResponse.FEATURE_NOT_SUPPORTED
import com.android.installreferrer.api.InstallReferrerClient.InstallReferrerResponse.OK
import com.android.installreferrer.api.InstallReferrerClient.InstallReferrerResponse.SERVICE_DISCONNECTED
import com.android.installreferrer.api.InstallReferrerClient.InstallReferrerResponse.SERVICE_UNAVAILABLE
import com.android.installreferrer.api.InstallReferrerStateListener
import com.duckduckgo.app.referral.*
import com.duckduckgo.app.referral.AppInstallationReferrerParser
import com.duckduckgo.app.referral.AppInstallationReferrerStateListener
import com.duckduckgo.app.referral.AppInstallationReferrerStateListener.Companion.MAX_REFERRER_WAIT_TIME_MS
import com.duckduckgo.app.referral.ParseFailureReason.*
import com.duckduckgo.app.referral.ParsedReferrerResult.*
import com.duckduckgo.app.referral.AppReferrerDataStore
import com.duckduckgo.app.referral.ParseFailureReason
import com.duckduckgo.app.referral.ParseFailureReason.DeveloperError
import com.duckduckgo.app.referral.ParseFailureReason.FeatureNotSupported
import com.duckduckgo.app.referral.ParseFailureReason.ReferralServiceUnavailable
import com.duckduckgo.app.referral.ParseFailureReason.ServiceDisconnected
import com.duckduckgo.app.referral.ParseFailureReason.ServiceUnavailable
import com.duckduckgo.app.referral.ParseFailureReason.UnknownError
import com.duckduckgo.app.referral.ParsedReferrerResult
import com.duckduckgo.app.referral.ParsedReferrerResult.CampaignReferrerFound
import com.duckduckgo.app.referral.ParsedReferrerResult.EuAuctionBrowserChoiceReferrerFound
import com.duckduckgo.app.referral.ParsedReferrerResult.EuAuctionSearchChoiceReferrerFound
import com.duckduckgo.app.referral.ParsedReferrerResult.ParseFailure
import com.duckduckgo.app.referral.ParsedReferrerResult.ReferrerInitialising
import com.duckduckgo.app.referral.ParsedReferrerResult.ReferrerNotFound
import com.duckduckgo.app.statistics.AtbInitializerListener
import com.duckduckgo.common.utils.playstore.PlayStoreAndroidUtils.Companion.PLAY_STORE_PACKAGE
import com.duckduckgo.common.utils.playstore.PlayStoreAndroidUtils.Companion.PLAY_STORE_REFERRAL_SERVICE
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,17 @@ import com.duckduckgo.app.referral.ParsedReferrerResult.EuAuctionSearchChoiceRef
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.mockito.kotlin.any
import org.mockito.kotlin.mock
import org.mockito.kotlin.verify

class QueryParamReferrerParserTest {

private val testee: QueryParamReferrerParser = QueryParamReferrerParser()
private val originAttributeHandler: ReferrerOriginAttributeHandler = mock()

private val testee: QueryParamReferrerParser = QueryParamReferrerParser(
originAttributeHandler = originAttributeHandler,
)

@Test
fun whenReferrerDoesNotContainTargetThenNoReferrerFound() {
Expand Down Expand Up @@ -73,6 +80,12 @@ class QueryParamReferrerParserTest {
verifyCampaignReferrerFound("AB", result)
}

@Test
fun whenReferrerContainsTargetAndUtmCampaignThenReferrerFound() {
val result = testee.parse("key1=foo&key2=bar&key3=DDGRAAB&origin=funnel_playstore_whatever")
verifyCampaignReferrerFound("AB", result)
}

@Test
fun whenReferrerContainsTargetWithDifferentCaseThenNoReferrerFound() {
verifyReferrerNotFound(testee.parse("ddgraAB"))
Expand Down Expand Up @@ -108,6 +121,19 @@ class QueryParamReferrerParserTest {
assertTrue(result is EuAuctionBrowserChoiceReferrerFound)
}

@Test
fun whenReferrerDoesNotContainEuAuctionDataThenUtmCampaignProcessorCalled() {
testee.parse("origin=funnel_playstore_whatever")
verify(originAttributeHandler).process(any())
}

@Test
fun whenReferrerDoesContainEuAuctionDataThenUtmCampaignProcessorStillCalled() {
val result = testee.parse("$INSTALLATION_SOURCE_KEY=$INSTALLATION_SOURCE_EU_BROWSER_CHOICE_AUCTION_VALUE")
verify(originAttributeHandler).process(any())
assertTrue(result is EuAuctionBrowserChoiceReferrerFound)
}

@Test
fun whenReferrerContainsBothEuAuctionBrowserChoiceAndCampaignReferrerDataThenEuActionReferrerFound() {
val result = testee.parse("key1=DDGRAAB&key2=foo&key3=bar&$INSTALLATION_SOURCE_KEY=$INSTALLATION_SOURCE_EU_BROWSER_CHOICE_AUCTION_VALUE")
Expand Down
Loading

0 comments on commit fb3b5e3

Please sign in to comment.