Skip to content

Commit

Permalink
Enable SERP promo for PPro
Browse files Browse the repository at this point in the history
  • Loading branch information
aitorvs committed Nov 6, 2024
1 parent 21d7919 commit 2a32cc6
Show file tree
Hide file tree
Showing 9 changed files with 303 additions and 20 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,9 @@ interface PrivacyProFeature {

@Toggle.DefaultValue(false)
fun useUnifiedFeedback(): Toggle

@Toggle.DefaultValue(true)
fun serpPromoCookie(): Toggle
}

@ContributesBinding(AppScope::class)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package com.duckduckgo.subscriptions.impl.repository

import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.data.store.api.SharedPreferencesProvider
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.subscriptions.api.Product
import com.duckduckgo.subscriptions.api.SubscriptionStatus
Expand All @@ -27,17 +28,21 @@ import com.duckduckgo.subscriptions.api.SubscriptionStatus.INACTIVE
import com.duckduckgo.subscriptions.api.SubscriptionStatus.NOT_AUTO_RENEWABLE
import com.duckduckgo.subscriptions.api.SubscriptionStatus.UNKNOWN
import com.duckduckgo.subscriptions.api.SubscriptionStatus.WAITING
import com.duckduckgo.subscriptions.impl.serp_promo.SerpPromo
import com.duckduckgo.subscriptions.impl.store.SubscriptionsDataStore
import com.duckduckgo.subscriptions.impl.store.SubscriptionsEncryptedDataStore
import com.duckduckgo.subscriptions.impl.toStatus
import com.squareup.anvil.annotations.ContributesBinding
import com.squareup.anvil.annotations.ContributesTo
import com.squareup.moshi.Moshi
import com.squareup.moshi.Moshi.Builder
import com.squareup.moshi.Types
import dagger.Module
import dagger.Provides
import dagger.SingleInstanceIn
import javax.inject.Inject
import kotlinx.coroutines.withContext

interface AuthRepository {
suspend fun getExternalID(): String?
suspend fun setAccessToken(accessToken: String?)
suspend fun getAccessToken(): String?
suspend fun setAuthToken(authToken: String?)
Expand All @@ -53,11 +58,24 @@ interface AuthRepository {
suspend fun canSupportEncryption(): Boolean
}

@ContributesBinding(AppScope::class)
@SingleInstanceIn(AppScope::class)
class RealAuthRepository @Inject constructor(
@Module
@ContributesTo(AppScope::class)
object AuthRepositoryModule {
@Provides
@SingleInstanceIn(AppScope::class)
fun provideAuthRepository(
dispatcherProvider: DispatcherProvider,
sharedPreferencesProvider: SharedPreferencesProvider,
serpPromo: SerpPromo,
): AuthRepository {
return RealAuthRepository(SubscriptionsEncryptedDataStore(sharedPreferencesProvider), dispatcherProvider, serpPromo)
}
}

internal class RealAuthRepository constructor(
private val subscriptionsDataStore: SubscriptionsDataStore,
private val dispatcherProvider: DispatcherProvider,
private val serpPromo: SerpPromo,
) : AuthRepository {

private val moshi = Builder().build()
Expand All @@ -77,8 +95,13 @@ class RealAuthRepository @Inject constructor(
return subscriptionsDataStore.entitlements?.let { moshi.parseList(it) } ?: emptyList()
}

override suspend fun getExternalID(): String? = withContext(dispatcherProvider.io()) {
return@withContext subscriptionsDataStore.externalId
}

override suspend fun setAccessToken(accessToken: String?) = withContext(dispatcherProvider.io()) {
subscriptionsDataStore.accessToken = accessToken
serpPromo.injectCookie(accessToken)
}

override suspend fun setAuthToken(authToken: String?) = withContext(dispatcherProvider.io()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/*
* 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.subscriptions.impl.serp_promo

import android.webkit.CookieManager
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.subscriptions.impl.PrivacyProFeature
import com.squareup.anvil.annotations.ContributesBinding
import com.squareup.anvil.annotations.ContributesMultibinding
import com.squareup.anvil.annotations.ContributesTo
import dagger.Lazy
import dagger.Module
import dagger.Provides
import javax.inject.Inject
import javax.inject.Qualifier
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import logcat.logcat

interface SerpPromo {
suspend fun injectCookie(cookieValue: String?)
}

private const val HTTPS_WWW_SUBSCRIPTION_DDG_COM = ".subscriptions.duckduckgo.com"
private const val SERP_PPRO_PROMO_COOKIE_NAME = "privacy_pro_access_token"

@ContributesBinding(
scope = AppScope::class,
boundType = SerpPromo::class,
)
@ContributesMultibinding(
scope = AppScope::class,
boundType = MainProcessLifecycleObserver::class,
)
class RealSerpPromo @Inject constructor(
@InternalApi private val cookieManager: CookieManagerWrapper,
private val dispatcherProvider: DispatcherProvider,
private val privacyProFeature: Lazy<PrivacyProFeature>,
) : SerpPromo, MainProcessLifecycleObserver {

override suspend fun injectCookie(cookieValue: String?) = withContext(dispatcherProvider.io()) {
if (privacyProFeature.get().serpPromoCookie().isEnabled()) {
synchronized(cookieManager) {
kotlin.runCatching {
val cookieString = "$SERP_PPRO_PROMO_COOKIE_NAME = ${cookieValue.orEmpty()}; HttpOnly; Path=/;"
cookieManager.setCookie(HTTPS_WWW_SUBSCRIPTION_DDG_COM, cookieString)
}
}
return@withContext
}
}

override fun onStart(owner: LifecycleOwner) {
owner.lifecycleScope.launch(dispatcherProvider.io()) {
if (privacyProFeature.get().serpPromoCookie().isEnabled()) {
kotlin.runCatching {
val cookies = cookieManager.getCookie(HTTPS_WWW_SUBSCRIPTION_DDG_COM) ?: ""
val pproCookies = cookies.split(";").filter { it.contains(SERP_PPRO_PROMO_COOKIE_NAME) }
if (pproCookies.isEmpty()) {
injectCookie("")
}
}
}
}
}
}

// This class is basically a convenience wrapper for easier testing
interface CookieManagerWrapper {
/**
* @return the cookie stored for the given [url] if any, null otherwise
*/
fun getCookie(url: String): String?

fun setCookie(url: String, cookieString: String)
}

@Retention(AnnotationRetention.BINARY)
@Qualifier
private annotation class InternalApi

@Module
@ContributesTo(AppScope::class)
object CookieManagerWrapperModule {
@Provides
@InternalApi
fun providesCookieManagerWrapper(): CookieManagerWrapper {
return CookieManagerWrapperImpl()
}
}
private class CookieManagerWrapperImpl constructor() : CookieManagerWrapper {

private val cookieManager: CookieManager by lazy { CookieManager.getInstance() }
override fun getCookie(url: String): String? {
return cookieManager.getCookie(url)
}

override fun setCookie(domain: String, cookie: String) {
logcat { "Setting cookie $cookie for domain $domain" }
cookieManager.setCookie(domain, cookie)
cookieManager.flush()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,7 @@ package com.duckduckgo.subscriptions.impl.store
import android.content.SharedPreferences
import androidx.core.content.edit
import com.duckduckgo.data.store.api.SharedPreferencesProvider
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import dagger.SingleInstanceIn
import javax.inject.Inject
import com.duckduckgo.subscriptions.impl.repository.AuthRepository

interface SubscriptionsDataStore {

Expand All @@ -43,9 +40,11 @@ interface SubscriptionsDataStore {
fun canUseEncryption(): Boolean
}

@ContributesBinding(AppScope::class)
@SingleInstanceIn(AppScope::class)
class SubscriptionsEncryptedDataStore @Inject constructor(
/**
* THINK TWICE before using this class directly.
* Usages of this class should all go through [AuthRepository]
*/
internal class SubscriptionsEncryptedDataStore constructor(
private val sharedPreferencesProvider: SharedPreferencesProvider,
) : SubscriptionsDataStore {
private val encryptedPreferences: SharedPreferences? by lazy { encryptedPreferences() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender
import com.duckduckgo.subscriptions.impl.repository.AuthRepository
import com.duckduckgo.subscriptions.impl.repository.FakeSubscriptionsDataStore
import com.duckduckgo.subscriptions.impl.repository.RealAuthRepository
import com.duckduckgo.subscriptions.impl.serp_promo.FakeSerpPromo
import com.duckduckgo.subscriptions.impl.services.AccessTokenResponse
import com.duckduckgo.subscriptions.impl.services.AccountResponse
import com.duckduckgo.subscriptions.impl.services.AuthService
Expand Down Expand Up @@ -66,7 +67,8 @@ class RealSubscriptionsManagerTest {
private val authService: AuthService = mock()
private val subscriptionsService: SubscriptionsService = mock()
private val authDataStore: SubscriptionsDataStore = FakeSubscriptionsDataStore()
private val authRepository = RealAuthRepository(authDataStore, coroutineRule.testDispatcherProvider)
private val serpPromo = FakeSerpPromo()
private val authRepository = RealAuthRepository(authDataStore, coroutineRule.testDispatcherProvider, serpPromo)
private val emailManager: EmailManager = mock()
private val playBillingManager: PlayBillingManager = mock()
private val context: Context = mock()
Expand Down Expand Up @@ -928,7 +930,7 @@ class RealSubscriptionsManagerTest {
@Test
fun whenCanSupportEncryptionIfCannotThenReturnFalse() = runTest {
val authDataStore: SubscriptionsDataStore = FakeSubscriptionsDataStore(supportEncryption = false)
val authRepository = RealAuthRepository(authDataStore, coroutineRule.testDispatcherProvider)
val authRepository = RealAuthRepository(authDataStore, coroutineRule.testDispatcherProvider, serpPromo)
subscriptionsManager = RealSubscriptionsManager(
authService,
subscriptionsService,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import com.duckduckgo.subscriptions.api.SubscriptionStatus.INACTIVE
import com.duckduckgo.subscriptions.api.SubscriptionStatus.NOT_AUTO_RENEWABLE
import com.duckduckgo.subscriptions.api.SubscriptionStatus.UNKNOWN
import com.duckduckgo.subscriptions.api.SubscriptionStatus.WAITING
import com.duckduckgo.subscriptions.impl.serp_promo.FakeSerpPromo
import kotlinx.coroutines.test.runTest
import org.junit.Assert.*
import org.junit.Rule
Expand All @@ -18,7 +19,8 @@ class RealAuthRepositoryTest {
val coroutineRule = CoroutineTestRule()

private val authStore = FakeSubscriptionsDataStore()
private val authRepository: AuthRepository = RealAuthRepository(authStore, coroutineRule.testDispatcherProvider)
private val serpPromo = FakeSerpPromo()
private val authRepository: AuthRepository = RealAuthRepository(authStore, coroutineRule.testDispatcherProvider, serpPromo)

@Test
fun whenClearAccountThenClearData() = runTest {
Expand All @@ -37,6 +39,13 @@ class RealAuthRepositoryTest {
assertNull(authStore.email)
}

@Test
fun whenSetAccessTokenThenInjectSerpPromoCookie() = runTest {
authRepository.setAccessToken("token")

assertEquals("token", serpPromo.cookie)
}

@Test
fun whenClearSubscriptionThenClearData() = runTest {
authStore.status = "expired"
Expand Down Expand Up @@ -113,6 +122,7 @@ class RealAuthRepositoryTest {
val repository: AuthRepository = RealAuthRepository(
FakeSubscriptionsDataStore(supportEncryption = false),
coroutineRule.testDispatcherProvider,
serpPromo,
)
assertFalse(repository.canSupportEncryption())
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/*
* 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.subscriptions.impl.serp_promo

class FakeSerpPromo : SerpPromo {
var cookie: String? = null
private set

override suspend fun injectCookie(cookieValue: String?) {
this.cookie = cookieValue
}
}
Loading

0 comments on commit 2a32cc6

Please sign in to comment.