Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,27 @@ def start = new Date(2015, 1, 1).getTime()
def now = System.currentTimeMillis()
def code = (int)((now - start) / 1000)

def dartDefines = [:]
if (project.hasProperty('dart-defines')) {
project.property('dart-defines').toString().split(',').each { encoded ->
if (encoded?.trim()) {
def decoded = new String(encoded.decodeBase64(), 'UTF-8')
def separator = decoded.indexOf('=')
if (separator > 0) {
dartDefines[decoded.substring(0, separator)] = decoded.substring(separator + 1)
}
}
}
}

def buildConfigBoolean = { name, defaultValue = false ->
def raw = dartDefines[name] ?: System.getenv(name) ?: project.findProperty(name)
if (raw == null) {
return defaultValue.toString()
}
return (raw.toString().trim().toLowerCase() in ['1', 'true', 'yes', 'on']) ? 'true' : 'false'
}

android {
namespace = "org.getlantern.lantern"
compileSdk = 36
Expand Down Expand Up @@ -78,6 +99,8 @@ android {
buildFeatures {
buildConfig true
}
buildConfigField "boolean", "STEALTH_DIRECT_CONNECTION_APPS",
buildConfigBoolean("STEALTH_DIRECT_CONNECTION_APPS")
}

signingConfigs {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import lantern.io.mobile.Mobile
import org.getlantern.lantern.BuildConfig
import org.getlantern.lantern.MainActivity
import org.getlantern.lantern.apps.AppFilters
import org.getlantern.lantern.constant.VPNStatus
import org.getlantern.lantern.stealth.DirectConnectionAppExclusionStore
import org.getlantern.lantern.utils.AppLogger
import org.getlantern.lantern.utils.PrivateServerListener
import org.getlantern.lantern.utils.VpnStatusManager
Expand Down Expand Up @@ -293,7 +295,12 @@ class MethodHandler : FlutterPlugin,
val filterType =
call.argument<String>("filterType") ?: error("Missing filterType")
val value = call.argument<String>("value") ?: error("Missing value")
Mobile.addSplitTunnelItem(filterType, value)
if (useDirectConnectionApps(filterType)) {
DirectConnectionAppExclusionStore(appContext).addPackage(value)
noteDirectConnectionAppsReconnect()
} else {
Mobile.addSplitTunnelItem(filterType, value)
}
success("Item added")
}.onFailure { e ->
result.error(
Expand All @@ -311,7 +318,12 @@ class MethodHandler : FlutterPlugin,
val filterType =
call.argument<String>("filterType") ?: error("Missing filterType")
val value = call.argument<String>("value") ?: error("Missing value")
Mobile.removeSplitTunnelItem(filterType, value)
if (useDirectConnectionApps(filterType)) {
DirectConnectionAppExclusionStore(appContext).removePackage(value)
noteDirectConnectionAppsReconnect()
} else {
Mobile.removeSplitTunnelItem(filterType, value)
}
success("Item removed")
}.onFailure { e ->
result.error(
Expand All @@ -326,8 +338,16 @@ class MethodHandler : FlutterPlugin,
Methods.AddAllItems.method -> {
scope.launch {
result.runCatching {
val filterType =
call.argument<String>("filterType") ?: error("Missing filterType")
val items = call.argument<String>("value")
Mobile.addSplitTunnelItems(items)
if (useDirectConnectionApps(filterType)) {
DirectConnectionAppExclusionStore(appContext)
.addPackages(splitCsvClean(items))
noteDirectConnectionAppsReconnect()
} else {
Mobile.addSplitTunnelItems(items)
}
Comment thread
reflog marked this conversation as resolved.
success("All items added")
}.onFailure { e ->
result.error(
Expand All @@ -342,8 +362,16 @@ class MethodHandler : FlutterPlugin,
Methods.RemoveAllItems.method -> {
scope.launch {
result.runCatching {
val filterType =
call.argument<String>("filterType") ?: error("Missing filterType")
val items = call.argument<String>("value")
Mobile.removeSplitTunnelItems(items)
if (useDirectConnectionApps(filterType)) {
DirectConnectionAppExclusionStore(appContext)
.removePackages(splitCsvClean(items))
noteDirectConnectionAppsReconnect()
} else {
Mobile.removeSplitTunnelItems(items)
}
Comment thread
reflog marked this conversation as resolved.
success("All items removed")
}.onFailure { e ->
result.error(
Expand Down Expand Up @@ -1055,7 +1083,11 @@ class MethodHandler : FlutterPlugin,
scope.launch {
result.runCatching {
val filterType = call.argument<String>("filterType") ?: error("Missing filterType")
val json = Mobile.getSplitTunnelItems(filterType)
val json = if (useDirectConnectionApps(filterType)) {
DirectConnectionAppExclusionStore(appContext).effectivePackageNamesJson()
} else {
Mobile.getSplitTunnelItems(filterType)
}
withContext(Dispatchers.Main) { success(json) }
}.onFailure { e ->
result.error("GET_SPLIT_TUNNEL_ITEMS_ERROR", e.localizedMessage ?: "Failed to get split tunnel items", e)
Expand Down Expand Up @@ -1349,6 +1381,23 @@ class MethodHandler : FlutterPlugin,
}
return false
}

private fun useDirectConnectionApps(filterType: String): Boolean {
return BuildConfig.STEALTH_DIRECT_CONNECTION_APPS && filterType == "packageName"
}

private fun noteDirectConnectionAppsReconnect() {
if (runCatching { Mobile.isVPNConnected() }.getOrDefault(false)) {
AppLogger.i(TAG, "Direct-connection app changes apply on next reconnect")
Comment thread
reflog marked this conversation as resolved.
}
}
}

private fun splitCsvClean(raw: String?): List<String> {
return raw.orEmpty()
.split(',')
.map { it.trim() }
.filter { it.isNotEmpty() }
}

private suspend fun MethodChannel.Result.mainSuccess(value: Any? = "ok") =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import org.getlantern.lantern.MainActivity
import org.getlantern.lantern.constant.VPNStatus
import org.getlantern.lantern.notification.NotificationHelper
import org.getlantern.lantern.service.LanternVpnService.Companion.ACTION_STOP_VPN
import org.getlantern.lantern.stealth.DirectConnectionAppExclusionStore
import org.getlantern.lantern.utils.AppLogger
import org.getlantern.lantern.utils.DeviceUtil
import org.getlantern.lantern.utils.FlutterEventListener
Expand Down Expand Up @@ -514,6 +515,7 @@ class LanternVpnService :

// Disallow traffic from our own app to the VPN.
builder.addDisallowedApplication(BuildConfig.APPLICATION_ID)
DirectConnectionAppExclusionStore.applyToBuilder(builder, this)

if (options.autoRoute) {
builder.addDnsServer(options.dnsServerAddress.value)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package org.getlantern.lantern.stealth

import android.content.Context
import android.content.pm.PackageManager
import android.net.VpnService
import org.getlantern.lantern.BuildConfig
import org.getlantern.lantern.utils.AppLogger
import org.json.JSONArray

class DirectConnectionAppExclusionStore(
private val context: Context,
) {
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)

private val defaultExclusions: List<DirectConnectionAppExclusion> by lazy {
cachedDefaultExclusions(context.applicationContext)
}

fun defaultPackageNames(): Set<String> {
return defaultExclusions.mapTo(LinkedHashSet()) { it.packageName }
Comment thread
reflog marked this conversation as resolved.
}

fun effectivePackageNames(): Set<String> {
return DirectConnectionAppExclusions.effectivePackageNames(
defaultPackages = defaultPackageNames(),
userAddedPackages = stringSet(KEY_USER_ADDED),
userRemovedDefaultPackages = stringSet(KEY_USER_REMOVED_DEFAULTS),
)
}

fun effectivePackageNamesJson(): String {
return JSONArray(effectivePackageNames()).toString()
}

fun addPackage(rawPackageName: String) {
updatePackages(listOf(rawPackageName), adding = true)
}

fun addPackages(rawPackageNames: Iterable<String>) {
updatePackages(rawPackageNames, adding = true)
}

fun removePackage(rawPackageName: String) {
updatePackages(listOf(rawPackageName), adding = false)
}

fun removePackages(rawPackageNames: Iterable<String>) {
updatePackages(rawPackageNames, adding = false)
}

private fun updatePackages(rawPackageNames: Iterable<String>, adding: Boolean) {
val packageNames = rawPackageNames.map(::requirePackageName)
if (packageNames.isEmpty()) {
return
}
val defaults = defaultPackageNames()
val added = stringSet(KEY_USER_ADDED).toMutableSet()
val removedDefaults = stringSet(KEY_USER_REMOVED_DEFAULTS).toMutableSet()

for (packageName in packageNames) {
if (adding) {
if (packageName in defaults) {
removedDefaults -= packageName
} else {
added += packageName
}
} else {
if (packageName in defaults) {
removedDefaults += packageName
} else {
added -= packageName
}
}
}

prefs.edit()
.putStringSet(KEY_USER_ADDED, added)
.putStringSet(KEY_USER_REMOVED_DEFAULTS, removedDefaults)
.apply()
}

private fun requirePackageName(rawPackageName: String): String {
val packageName = DirectConnectionAppExclusions.normalizePackageName(rawPackageName)
require(DirectConnectionAppExclusions.isValidPackageName(packageName)) {
"Invalid package name: raw='$rawPackageName', normalized='$packageName'"
}
return packageName
}

private fun stringSet(key: String): Set<String> {
return prefs.getStringSet(key, emptySet()).orEmpty()
.map(DirectConnectionAppExclusions::normalizePackageName)
.filter(DirectConnectionAppExclusions::isValidPackageName)
.toSet()
}

companion object {
private const val TAG = "DirectAppExclusions"
private const val PREFS_NAME = "direct_connection_app_exclusions"
private const val KEY_USER_ADDED = "user_added_packages"
private const val KEY_USER_REMOVED_DEFAULTS = "user_removed_default_packages"

private val assetCandidates = listOf(
DirectConnectionAppExclusions.DEFAULT_ASSET_PATH,
"assets/stealth/default_exclusions.json",
"stealth/default_exclusions.json",
)
@Volatile
private var defaultExclusionsCache: List<DirectConnectionAppExclusion>? = null

fun enabled(): Boolean = BuildConfig.STEALTH_DIRECT_CONNECTION_APPS

private fun cachedDefaultExclusions(
context: Context,
): List<DirectConnectionAppExclusion> {
defaultExclusionsCache?.let { return it }
return synchronized(this) {
defaultExclusionsCache ?: loadDefaultExclusions(context).also {
defaultExclusionsCache = it
}
}
}

fun loadDefaultExclusions(context: Context): List<DirectConnectionAppExclusion> {
for (path in assetCandidates) {
val json = runCatching {
context.assets.open(path).bufferedReader().use { it.readText() }
}.getOrNull()

if (json.isNullOrBlank()) {
continue
}

val parsed = runCatching {
DirectConnectionAppExclusions.parseDefaults(json)
}.onFailure { e ->
AppLogger.w(TAG, "Failed to parse $path", e)
}.getOrNull()
if (parsed != null) {
return parsed
Comment thread
reflog marked this conversation as resolved.
}
}

AppLogger.w(TAG, "No default direct-connection app exclusions asset found")
return emptyList()
}

fun applyToBuilder(
builder: VpnService.Builder,
context: Context,
): Int {
if (!enabled()) {
return 0
}

var applied = 0
val packages = DirectConnectionAppExclusionStore(context).effectivePackageNames()
for (packageName in packages) {
try {
builder.addDisallowedApplication(packageName)
applied += 1
} catch (e: PackageManager.NameNotFoundException) {
AppLogger.d(TAG, "Skipping direct-connection app not installed: $packageName")
} catch (e: Exception) {
AppLogger.w(TAG, "Skipping direct-connection app: $packageName", e)
}
}

AppLogger.i(
TAG,
"Applied $applied direct-connection app exclusions (${packages.size} configured)"
)
return applied
}
}
}
Loading
Loading