-
Notifications
You must be signed in to change notification settings - Fork 929
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
- Loading branch information
Showing
4 changed files
with
293 additions
and
59 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
122 changes: 122 additions & 0 deletions
122
...-impl/src/main/java/com/duckduckgo/networkprotection/impl/reddit/RedditBlockWorkaround.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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() | ||
} | ||
} |
170 changes: 170 additions & 0 deletions
170
...l/src/test/java/com/duckduckgo/networkprotection/impl/reddit/RedditBlockWorkaroundTest.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<String, MutableList<Cookie>> = mutableMapOf() | ||
// private val cookieStore: MutableMap<String, MutableList<String>> = 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;" | ||
} | ||
} | ||
} | ||
} |
59 changes: 0 additions & 59 deletions
59
...l/src/main/java/com/duckduckgo/networkprotection/internal/reddit/RedditBlockWorkaround.kt
This file was deleted.
Oops, something went wrong.