Skip to content

Commit

Permalink
Reddit VPN workaround to prod (#4591)
Browse files Browse the repository at this point in the history
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
aitorvs authored Jun 4, 2024
1 parent 4ce0b1e commit 8791181
Show file tree
Hide file tree
Showing 4 changed files with 293 additions and 59 deletions.
1 change: 1 addition & 0 deletions network-protection/network-protection-impl/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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()
}
}
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;"
}
}
}
}

This file was deleted.

0 comments on commit 8791181

Please sign in to comment.