From 87911814808f836c6af2b703d67018b0e401eac4 Mon Sep 17 00:00:00 2001 From: Aitor Viana Date: Tue, 4 Jun 2024 12:26:46 +0100 Subject: [PATCH] Reddit VPN workaround to prod (#4591) Task/Issue URL: https://app.asana.com/0/488551667048375/1207360759125228/f ### Description Move the Reddit vpn workaround to prod users ### Steps to test this PR _Test_ - [x] install from this branch - [x] enable VPN and close re-open app - [x] go to reddit.com (do not be signed-in) - [x] verify reddit.com loads normally - [x] In chrome, launch `chrome://inspect` - [x] go to app settings -> dev settings -> enable web content debugging - [x] In chrome, inspect reddit.com - [x] home the app (do not close) - [x] verify the `reddit_session` cookie is not present - [x] re-open the app and reload reddit.com - [x] verify reddit.com loads normally - [x] disable VPN - [x] reload reddit.com - [x] verify reddit.com loads normally - [x] home app and re-open - [x] verify reddit.com loads normally - [x] close app and re-open - [x] verify reddit.com loads normally - [x] log into reddit with your account - [x] verify reddit.com loads normally - [x] enable the VPN - [x] verify reddit.com loads normally - [x] sign-out of reddit - [x] verify reddit.com loads normally - [x] sign-in into reddit.com - [x] verify reddit.com loads normally --- .../network-protection-impl/build.gradle | 1 + .../impl/reddit/RedditBlockWorkaround.kt | 122 +++++++++++++ .../impl/reddit/RedditBlockWorkaroundTest.kt | 170 ++++++++++++++++++ .../internal/reddit/RedditBlockWorkaround.kt | 59 ------ 4 files changed, 293 insertions(+), 59 deletions(-) create mode 100644 network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/reddit/RedditBlockWorkaround.kt create mode 100644 network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/reddit/RedditBlockWorkaroundTest.kt delete mode 100644 network-protection/network-protection-internal/src/main/java/com/duckduckgo/networkprotection/internal/reddit/RedditBlockWorkaround.kt diff --git a/network-protection/network-protection-impl/build.gradle b/network-protection/network-protection-impl/build.gradle index 1869e63531ee..7e54f8187167 100644 --- a/network-protection/network-protection-impl/build.gradle +++ b/network-protection/network-protection-impl/build.gradle @@ -71,6 +71,7 @@ dependencies { testImplementation AndroidX.test.ext.junit testImplementation "org.mockito.kotlin:mockito-kotlin:_" testImplementation Testing.robolectric + testImplementation AndroidX.lifecycle.runtime.testing // WorkManager implementation AndroidX.work.runtimeKtx diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/reddit/RedditBlockWorkaround.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/reddit/RedditBlockWorkaround.kt new file mode 100644 index 000000000000..0ea81e2bc975 --- /dev/null +++ b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/reddit/RedditBlockWorkaround.kt @@ -0,0 +1,122 @@ +/* + * 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.networkprotection.impl.reddit + +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.networkprotection.api.NetworkProtectionState +import com.squareup.anvil.annotations.ContributesMultibinding +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides +import javax.inject.Inject +import javax.inject.Qualifier +import kotlinx.coroutines.launch +import logcat.asLog +import logcat.logcat + +private const val HTTPS_WWW_REDDIT_COM = ".reddit.com" +private const val REDDIT_SESSION_ = "reddit_session=;" +private const val REDDIT_SESSION_EXPIRED_ = "reddit_session=; Expires=Wed, 21 Oct 2015 07:28:00 GMT" // random date + +@ContributesMultibinding(AppScope::class) +class RedditBlockWorkaround @Inject constructor( + private val networkProtectionState: NetworkProtectionState, + private val dispatcherProvider: DispatcherProvider, + @InternalApi private val cookieManager: CookieManagerWrapper, +) : MainProcessLifecycleObserver { + + override fun onResume(owner: LifecycleOwner) { + owner.lifecycleScope.launch(dispatcherProvider.io()) { + addRedditEmptyCookie() + } + } + override fun onPause(owner: LifecycleOwner) { + removeRedditEmptyCookie() + } + + private suspend fun addRedditEmptyCookie() { + runCatching { + val redditCookies = cookieManager.getCookie(HTTPS_WWW_REDDIT_COM) ?: "" + val redditSessionCookies = redditCookies.split(";").filter { it.contains("reddit_session") } + if (networkProtectionState.isEnabled() && redditSessionCookies.isEmpty()) { + // if the VPN is enabled and there's no reddit_session cookie we just add a fake one + // when the user logs into reddit, the fake reddit_session cookie is replaced automatically by the correct one + // when the user logs out, the reddit_session cookie is cleared + cookieManager.setCookie(HTTPS_WWW_REDDIT_COM, REDDIT_SESSION_) + } else { + removeRedditEmptyCookie() + } + }.onFailure { + logcat { "Reddit workaround error: ${it.asLog()}" } + } + } + + private fun removeRedditEmptyCookie() { + runCatching { + if (cookieManager.containsRedditDummyCookie()) { + cookieManager.setCookie(HTTPS_WWW_REDDIT_COM, REDDIT_SESSION_EXPIRED_) + } + } + } + + private fun CookieManagerWrapper.containsRedditDummyCookie(): Boolean { + val redditCookies = this.getCookie(HTTPS_WWW_REDDIT_COM) ?: "" + val redditSessionCookies = redditCookies.split(";").filter { it.contains("reddit_session") } + return redditSessionCookies.firstOrNull()?.split("=")?.lastOrNull()?.isEmpty() == true + } +} + +// 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) { + cookieManager.setCookie(domain, cookie) + cookieManager.flush() + } +} diff --git a/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/reddit/RedditBlockWorkaroundTest.kt b/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/reddit/RedditBlockWorkaroundTest.kt new file mode 100644 index 000000000000..4665bc1a1fe9 --- /dev/null +++ b/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/reddit/RedditBlockWorkaroundTest.kt @@ -0,0 +1,170 @@ +/* + * 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.networkprotection.impl.reddit + +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.testing.TestLifecycleOwner +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.networkprotection.api.NetworkProtectionState +import java.net.URI +import java.net.URISyntaxException +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class RedditBlockWorkaroundTest { + @get:Rule var coroutineRule = CoroutineTestRule() + private val networkProtectionState: NetworkProtectionState = mock() + private val lifecycleOwner: LifecycleOwner = TestLifecycleOwner() + + private val cookieManager = FakeCookieManagerWrapper() + + private val redditBlockWorkaround = RedditBlockWorkaround(networkProtectionState, coroutineRule.testDispatcherProvider, cookieManager) + + @Test + fun `on resume with netp disabled removes the reddit_session dummy`() = runTest { + whenever(networkProtectionState.isEnabled()).thenReturn(true) + redditBlockWorkaround.onResume(lifecycleOwner) + + whenever(networkProtectionState.isEnabled()).thenReturn(false) + redditBlockWorkaround.onResume(lifecycleOwner) + + redditBlockWorkaround.onResume(lifecycleOwner) + assertEquals("reddit_session=; Expires=Wed, 21 Oct 2015 07:28:00 GMT", cookieManager.getCookie(".reddit.com")) + } + + @Test + fun `on resume with netp enabled adds reddit_session dummy if not present`() = runTest { + whenever(networkProtectionState.isEnabled()).thenReturn(true) + + redditBlockWorkaround.onResume(lifecycleOwner) + assertEquals("reddit_session=;", cookieManager.getCookie(".reddit.com")) + } + + @Test + fun `on resume with netp disabled noops if reddit_session not present`() = runTest { + whenever(networkProtectionState.isEnabled()).thenReturn(false) + + redditBlockWorkaround.onResume(lifecycleOwner) + assertNull(cookieManager.getCookie(".reddit.com")) + } + + @Test + fun `on resume with netp enabled noops if reddit_session present`() = runTest { + whenever(networkProtectionState.isEnabled()).thenReturn(true) + cookieManager.setCookie(".reddit.com", "reddit_session=value;") + + redditBlockWorkaround.onResume(lifecycleOwner) + assertEquals("reddit_session=value;", cookieManager.getCookie(".reddit.com")) + } + + @Test + fun `on resume with netp disabled noops if reddit_session present`() = runTest { + whenever(networkProtectionState.isEnabled()).thenReturn(false) + cookieManager.setCookie(".reddit.com", "reddit_session=value;") + + redditBlockWorkaround.onResume(lifecycleOwner) + assertEquals("reddit_session=value;", cookieManager.getCookie(".reddit.com")) + } + + @Test + fun `on pause expires the reddit_session dummy if present`() = runTest { + whenever(networkProtectionState.isEnabled()).thenReturn(false) + cookieManager.setCookie(".reddit.com", "reddit_session=;") + redditBlockWorkaround.onPause(lifecycleOwner) + assertEquals("reddit_session=; Expires=Wed, 21 Oct 2015 07:28:00 GMT", cookieManager.getCookie(".reddit.com")) + } + + @Test + fun `on pause noops if reddit_session dummy if not present`() = runTest { + whenever(networkProtectionState.isEnabled()).thenReturn(false) + redditBlockWorkaround.onPause(lifecycleOwner) + assertNull(cookieManager.getCookie(".reddit.com")) + } +} + +class FakeCookieManagerWrapper() : CookieManagerWrapper { + + val cookieStore: MutableMap> = mutableMapOf() + // private val cookieStore: MutableMap> = mutableMapOf() + + // Function to get cookies for a specific URL + override fun getCookie(url: String): String? { + val host = getCookieHost(url) + val cookies = cookieStore[host] ?: return null + return cookies.joinToString(";") { it.toString() } + } + + // Function to set cookies for a specific URL or domain + override fun setCookie(url: String, cookieString: String) { + val uri = URI(url) + val domain = runCatching { if (uri.host.startsWith(".")) uri.host.substring(1) else uri.host }.getOrElse { url } + val cookie = Cookie.fromString(cookieString) + + // Add cookie to the specific domain + val cookies = cookieStore.computeIfAbsent(domain) { mutableListOf() }.filter { it.name != cookie.name }.toMutableList().apply { + add(cookie) + } + + cookieStore[domain] = cookies + } + + private fun getCookieHost(url: String): String? { + if (url.startsWith(".")) return url + + var url = url + if (!(url.startsWith("http") || url.startsWith("https"))) { + url = "http" + url + } + return try { + URI(url).host + } catch (e: URISyntaxException) { + throw IllegalArgumentException("wrong URL : $url", e) + } + } + + data class Cookie( + val name: String, + val value: String, + val expires: String, + ) { + companion object { + fun fromString(cookieString: String): Cookie { + val name = cookieString.substringBefore((";")).substringBefore("=") + val value = cookieString.substringBefore((";")).substringAfter("=") + val expires = if (cookieString.contains("Expires=")) { + cookieString.substringAfter("Expires=") + } else { + "" + } + return Cookie(name, value, expires) + } + } + + override fun toString(): String { + return if (expires.isNotEmpty()) { + "$name=$value; Expires=$expires" + } else { + "$name=$value;" + } + } + } +} diff --git a/network-protection/network-protection-internal/src/main/java/com/duckduckgo/networkprotection/internal/reddit/RedditBlockWorkaround.kt b/network-protection/network-protection-internal/src/main/java/com/duckduckgo/networkprotection/internal/reddit/RedditBlockWorkaround.kt deleted file mode 100644 index 24e0e1bd677f..000000000000 --- a/network-protection/network-protection-internal/src/main/java/com/duckduckgo/networkprotection/internal/reddit/RedditBlockWorkaround.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - * 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.networkprotection.internal.reddit - -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.networkprotection.api.NetworkProtectionState -import com.squareup.anvil.annotations.ContributesMultibinding -import javax.inject.Inject -import kotlinx.coroutines.launch -import logcat.asLog -import logcat.logcat - -private const val HTTPS_WWW_REDDIT_COM = ".reddit.com" -private const val REDDIT_SESSION_ = "reddit_session=;" - -@ContributesMultibinding(AppScope::class) -class RedditBlockWorkaround @Inject constructor( - private val networkProtectionState: NetworkProtectionState, - private val dispatcherProvider: DispatcherProvider, -) : MainProcessLifecycleObserver { - override fun onResume(owner: LifecycleOwner) { - owner.lifecycleScope.launch(dispatcherProvider.io()) { - runCatching { - runCatching { CookieManager.getInstance() }.getOrNull()?.let { cookieManager -> - val redditCookies = cookieManager.getCookie(HTTPS_WWW_REDDIT_COM) ?: "" - val redditSessionCookies = redditCookies.split(";").filter { it.contains("reddit_session") } - if (networkProtectionState.isEnabled() && redditSessionCookies.isEmpty()) { - // if the VPN is enabled and there's no reddit_session cookie we just add a fake one - // when the user logs into reddit, the fake reddit_session cookie is replaced automatically by the correct one - // when the user logs out, the reddit_session cookie is cleared - cookieManager.setCookie(HTTPS_WWW_REDDIT_COM, REDDIT_SESSION_) - cookieManager.flush() - } - } - }.onFailure { - logcat { "Reddit workaround error: ${it.asLog()}" } - } - } - } -}