diff --git a/android/app/cpp/ndk-stl-config.cmake b/android/app/cpp/ndk-stl-config.cmake index f9b176248d..dc7318e71d 100644 --- a/android/app/cpp/ndk-stl-config.cmake +++ b/android/app/cpp/ndk-stl-config.cmake @@ -1,18 +1,44 @@ -if(NOT ${ANDROID_STL} MATCHES "_shared") +if(NOT "${ANDROID_STL}" MATCHES "_shared") return() endif() -function(configure_shared_stl lib_path so_base) +set(NDK_PREBUILT_DIR "${ANDROID_NDK}/toolchains/llvm/prebuilt") + +if(DEFINED ANDROID_HOST_TAG AND EXISTS "${NDK_PREBUILT_DIR}/${ANDROID_HOST_TAG}") + set(NDK_PREBUILT_ROOT "${NDK_PREBUILT_DIR}/${ANDROID_HOST_TAG}") +elseif(CMAKE_HOST_SYSTEM_NAME STREQUAL "Linux" AND EXISTS "${NDK_PREBUILT_DIR}/linux-x86_64") + set(NDK_PREBUILT_ROOT "${NDK_PREBUILT_DIR}/linux-x86_64") +elseif(CMAKE_HOST_SYSTEM_NAME STREQUAL "Darwin") + if(CMAKE_HOST_SYSTEM_PROCESSOR MATCHES "^(arm64|aarch64)$" AND EXISTS "${NDK_PREBUILT_DIR}/darwin-arm64") + set(NDK_PREBUILT_ROOT "${NDK_PREBUILT_DIR}/darwin-arm64") + elseif(EXISTS "${NDK_PREBUILT_DIR}/darwin-x86_64") + set(NDK_PREBUILT_ROOT "${NDK_PREBUILT_DIR}/darwin-x86_64") + elseif(EXISTS "${NDK_PREBUILT_DIR}/darwin-arm64") + set(NDK_PREBUILT_ROOT "${NDK_PREBUILT_DIR}/darwin-arm64") + endif() +elseif(CMAKE_HOST_SYSTEM_NAME STREQUAL "Windows" AND EXISTS "${NDK_PREBUILT_DIR}/windows-x86_64") + set(NDK_PREBUILT_ROOT "${NDK_PREBUILT_DIR}/windows-x86_64") +endif() + +if(NOT NDK_PREBUILT_ROOT) + message(FATAL_ERROR "No Android NDK prebuilt toolchain found for ${CMAKE_HOST_SYSTEM_NAME}/${CMAKE_HOST_SYSTEM_PROCESSOR} under ${NDK_PREBUILT_DIR}; set ANDROID_HOST_TAG if needed") +endif() + +if(NOT EXISTS "${NDK_PREBUILT_ROOT}") + message(FATAL_ERROR "Android NDK prebuilt toolchain does not exist: ${NDK_PREBUILT_ROOT}") +endif() + +function(configure_shared_stl so_base) message("Configuring STL ${so_base} for ${ANDROID_ABI}") - if(${ANDROID_ABI} STREQUAL "arm64-v8a") - set(LIBCXX_PATH "${ANDROID_NDK}/toolchains/llvm/prebuilt/darwin-x86_64/sysroot/usr/lib/aarch64-linux-android/lib${so_base}.so") - elseif(${ANDROID_ABI} STREQUAL "armeabi-v7a") - set(LIBCXX_PATH "${ANDROID_NDK}/toolchains/llvm/prebuilt/darwin-x86_64/sysroot/usr/lib/arm-linux-androideabi/lib${so_base}.so") - elseif(${ANDROID_ABI} STREQUAL "x86") - set(LIBCXX_PATH "${ANDROID_NDK}/toolchains/llvm/prebuilt/darwin-x86_64/sysroot/usr/lib/i686-linux-android/lib${so_base}.so") - elseif(${ANDROID_ABI} STREQUAL "x86_64") - set(LIBCXX_PATH "${ANDROID_NDK}/toolchains/llvm/prebuilt/darwin-x86_64/sysroot/usr/lib/x86_64-linux-android/lib${so_base}.so") + if("${ANDROID_ABI}" STREQUAL "arm64-v8a") + set(LIBCXX_PATH "${NDK_PREBUILT_ROOT}/sysroot/usr/lib/aarch64-linux-android/lib${so_base}.so") + elseif("${ANDROID_ABI}" STREQUAL "armeabi-v7a") + set(LIBCXX_PATH "${NDK_PREBUILT_ROOT}/sysroot/usr/lib/arm-linux-androideabi/lib${so_base}.so") + elseif("${ANDROID_ABI}" STREQUAL "x86") + set(LIBCXX_PATH "${NDK_PREBUILT_ROOT}/sysroot/usr/lib/i686-linux-android/lib${so_base}.so") + elseif("${ANDROID_ABI}" STREQUAL "x86_64") + set(LIBCXX_PATH "${NDK_PREBUILT_ROOT}/sysroot/usr/lib/x86_64-linux-android/lib${so_base}.so") else() message(FATAL_ERROR "Unsupported ABI: ${ANDROID_ABI}") endif() @@ -33,13 +59,13 @@ elseif("${ANDROID_STL}" STREQUAL "gabi++_shared") message(FATAL_ERROR "gabi++_shared was not configured by ndk-stl package") elseif("${ANDROID_STL}" STREQUAL "stlport_shared") # The STLport runtime (shared). - configure_shared_stl("stlport" "stlport_shared") + configure_shared_stl("stlport_shared") elseif("${ANDROID_STL}" STREQUAL "gnustl_shared") # The GNU STL (shared). - configure_shared_stl("gnu-libstdc++/4.9" "gnustl_shared") + configure_shared_stl("gnustl_shared") elseif("${ANDROID_STL}" STREQUAL "c++_shared") - # The LLVM libc++ runtime (static). - configure_shared_stl("llvm-libc++" "c++_shared") + # The LLVM libc++ runtime (shared). + configure_shared_stl("c++_shared") else() message(FATAL_ERROR "STL configuration ANDROID_STL=${ANDROID_STL} is not supported") -endif() \ No newline at end of file +endif() diff --git a/android/app/proguard-stealth-novpn.pro b/android/app/proguard-stealth-novpn.pro new file mode 100644 index 0000000000..237749eae7 --- /dev/null +++ b/android/app/proguard-stealth-novpn.pro @@ -0,0 +1,2 @@ +-checkdiscard class org.getlantern.lantern.service.LanternVpnService +-checkdiscard class org.getlantern.lantern.service.QuickTileService diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/MainActivity.kt b/android/app/src/main/kotlin/org/getlantern/lantern/MainActivity.kt index 606b253232..b261ec34f3 100644 --- a/android/app/src/main/kotlin/org/getlantern/lantern/MainActivity.kt +++ b/android/app/src/main/kotlin/org/getlantern/lantern/MainActivity.kt @@ -1,6 +1,7 @@ package org.getlantern.lantern import android.Manifest +import android.app.Service import android.content.Intent import android.content.pm.PackageManager import android.net.VpnService @@ -20,6 +21,7 @@ import org.getlantern.lantern.constant.VPNStatus import org.getlantern.lantern.handler.EventHandler import org.getlantern.lantern.handler.MethodHandler import org.getlantern.lantern.service.LanternVpnService +import org.getlantern.lantern.service.NoVpnLanternService import org.getlantern.lantern.service.QuickTileService import org.getlantern.lantern.utils.AppLogger import org.getlantern.lantern.utils.VpnStatusManager @@ -29,7 +31,7 @@ import org.getlantern.lantern.utils.logDir import org.getlantern.lantern.utils.setupDirs -class MainActivity : FlutterFragmentActivity() { +open class MainActivity : FlutterFragmentActivity() { companion object { const val TAG = "A/MainActivity" lateinit var instance: MainActivity @@ -51,6 +53,16 @@ class MainActivity : FlutterFragmentActivity() { private val serviceStartHandler = Handler(Looper.getMainLooper()) + // NOTE: the stealth app uses its own MainActivity under the foundation.bridge + // source set; this main-source-set activity only ever runs in the regular + // (non-stealth) build where STEALTH_ENABLED is always false, so it references + // the regular Lantern service classes directly. + private val vpnServiceClass: Class + get() = LanternVpnService::class.java + + private val noVpnServiceClass: Class + get() = NoVpnLanternService::class.java + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) @@ -94,18 +106,25 @@ class MainActivity : FlutterFragmentActivity() { if (pendingServiceStart && retryCountResume < maxRetriesResume) { retryCountResume++ AppLogger.d(TAG, "Retrying pending service start") - startLanternService() + retryServiceStart() } } private fun startLanternService() { AppLogger.d(TAG, "Starting LanternService") - if (isServiceRunning(this, LanternVpnService::class.java)) { + if (BuildConfig.STEALTH_NO_VPN) { + AppLogger.d(TAG, "Stealth no-VPN build skips proxy autostart") + pendingServiceStart = false + retryCount = 0 + retryCountResume = 0 + return + } + if (isServiceRunning(this, vpnServiceClass)) { AppLogger.d(TAG, "LanternService is already running") return } try { - val radianceIntent = Intent(this, LanternVpnService::class.java).apply { + val radianceIntent = Intent(this, vpnServiceClass).apply { action = LanternVpnService.ACTION_START_RADIANCE } startService(radianceIntent) @@ -125,6 +144,27 @@ class MainActivity : FlutterFragmentActivity() { } } + private fun startNoVpnProxyService() { + if (isServiceRunning(this, noVpnServiceClass)) { + AppLogger.d(TAG, "NoVpnLanternService is already running; sending start action") + } + try { + ContextCompat.startForegroundService(this, Intent(this, noVpnServiceClass).apply { + action = NoVpnLanternService.ACTION_START_PROXY + }) + AppLogger.d(TAG, "NoVpnLanternService started") + pendingServiceStart = false + retryCount = 0 + retryCountResume = 0 + } catch (e: IllegalStateException) { + AppLogger.e(TAG, "Cannot start no-VPN proxy service in background: ${e.message}") + pendingServiceStart = true + } catch (e: Exception) { + AppLogger.e(TAG, "Error starting no-VPN proxy service", e) + handleImmediateRetry() + } + } + private fun handleImmediateRetry() { AppLogger.d(TAG, "Handling immediate retry for LanternService start") if (retryCount < maxRetries) { @@ -133,7 +173,7 @@ class MainActivity : FlutterFragmentActivity() { AppLogger.d(TAG, "Scheduling immediate retry #$retryCount in ${delay}ms") serviceStartHandler.postDelayed({ - startLanternService() + retryServiceStart() }, delay) } else { /* @@ -148,8 +188,20 @@ class MainActivity : FlutterFragmentActivity() { } } + private fun retryServiceStart() { + if (BuildConfig.STEALTH_NO_VPN) { + startNoVpnProxyService() + } else { + startLanternService() + } + } + fun startVPN() { + if (BuildConfig.STEALTH_NO_VPN) { + startNoVpnProxyService() + return + } if (!isVPNServiceReady()) { AppLogger.d(TAG, "VPN service not ready") return @@ -167,7 +219,7 @@ class MainActivity : FlutterFragmentActivity() { } try { - val vpnIntent = Intent(this, LanternVpnService::class.java).apply { + val vpnIntent = Intent(this, vpnServiceClass).apply { action = LanternVpnService.ACTION_START_VPN } ContextCompat.startForegroundService(this, vpnIntent) @@ -180,6 +232,13 @@ class MainActivity : FlutterFragmentActivity() { } fun connectToServer(tag: String) { + if (BuildConfig.STEALTH_NO_VPN) { + ContextCompat.startForegroundService(this, Intent(this, noVpnServiceClass).apply { + action = NoVpnLanternService.ACTION_CONNECT_TO_SERVER + putExtra("tag", tag) + }) + return + } if (!isVPNServiceReady()) { AppLogger.d(TAG, "VPN service not ready") return @@ -196,7 +255,7 @@ class MainActivity : FlutterFragmentActivity() { } try { - val vpnIntent = Intent(this, LanternVpnService::class.java).apply { + val vpnIntent = Intent(this, vpnServiceClass).apply { action = LanternVpnService.ACTION_CONNECT_TO_SERVER putExtra("tag", tag) } @@ -211,7 +270,20 @@ class MainActivity : FlutterFragmentActivity() { fun stopVPN() { - if (isServiceRunning(this, LanternVpnService::class.java)) { + if (BuildConfig.STEALTH_NO_VPN) { + if (isServiceRunning(this, noVpnServiceClass)) { + startService(Intent(this, noVpnServiceClass).apply { + action = NoVpnLanternService.ACTION_STOP_PROXY + }) + } else { + CoroutineScope(Dispatchers.Main).launch { + runCatching { Mobile.stopVPN() } + VpnStatusManager.postVPNStatus(VPNStatus.Disconnected) + } + } + return + } + if (isServiceRunning(this, vpnServiceClass)) { LanternApp.application.sendBroadcast( Intent(LanternVpnService.ACTION_STOP_VPN) .setPackage(LanternApp.application.packageName) diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/handler/EventHandler.kt b/android/app/src/main/kotlin/org/getlantern/lantern/handler/EventHandler.kt index 78924b2c81..f44daa07cf 100644 --- a/android/app/src/main/kotlin/org/getlantern/lantern/handler/EventHandler.kt +++ b/android/app/src/main/kotlin/org/getlantern/lantern/handler/EventHandler.kt @@ -11,10 +11,10 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import lantern.io.mobile.LogSubscription import lantern.io.mobile.Mobile -import lantern.io.utils.FlutterEvent import lantern.io.utils.LogListener import org.getlantern.lantern.apps.AppDataHandler import org.getlantern.lantern.constant.VPNStatus +import org.getlantern.lantern.utils.AppEvent import org.getlantern.lantern.utils.AppLogger import org.getlantern.lantern.utils.Event import org.getlantern.lantern.utils.FlutterEventStream @@ -41,7 +41,7 @@ class EventHandler : FlutterPlugin { private var appDataHandler: AppDataHandler? = null private var statusObserver: Observer>? = null - private var flutterEventObserver: Observer>? = null + private var flutterEventObserver: Observer>? = null var job: Job? = null private var logsSubscription: LogSubscription? = null private var logsListener: LogListener? = null diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/handler/MethodHandler.kt b/android/app/src/main/kotlin/org/getlantern/lantern/handler/MethodHandler.kt index 4c9ca8c362..0a63ec579d 100644 --- a/android/app/src/main/kotlin/org/getlantern/lantern/handler/MethodHandler.kt +++ b/android/app/src/main/kotlin/org/getlantern/lantern/handler/MethodHandler.kt @@ -20,11 +20,13 @@ 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.updater.AndroidSideloadInstaller import org.getlantern.lantern.updater.AndroidSideloadUpdateRequest +import org.getlantern.lantern.stealth.DirectConnectionAppExclusionStore import org.getlantern.lantern.utils.AppLogger import org.getlantern.lantern.utils.PrivateServerListener import org.getlantern.lantern.utils.VpnStatusManager @@ -87,6 +89,9 @@ enum class Methods(val method: String) { IsBlockAdsEnabled("isBlockAdsEnabled"), SetBlockAdsEnabled("setBlockAdsEnabled"), + // Stealth-novpn local proxy listen port + SetProxyListenPort("setProxyListenPort"), + //private server methods DigitalOcean("digitalOcean"), SelectAccount("selectAccount"), @@ -263,6 +268,10 @@ class MethodHandler : FlutterPlugin, Methods.SetSplitTunnelingEnabled.method -> { scope.launch { result.runCatching { + if (BuildConfig.STEALTH_NO_VPN) { + withContext(Dispatchers.Main) { success("disabled") } + return@runCatching + } val enabled = call.argument("enabled") ?: error("Missing enabled") Mobile.setSplitTunnelingEnabled(enabled) withContext(Dispatchers.Main) { success("ok") } @@ -279,6 +288,10 @@ class MethodHandler : FlutterPlugin, Methods.IsSplitTunnelingEnabled.method -> { scope.launch { runCatching { + if (BuildConfig.STEALTH_NO_VPN) { + withContext(Dispatchers.Main) { result.success(false) } + return@runCatching + } val on = Mobile.isSplitTunnelingEnabled() withContext(Dispatchers.Main) { result.success(on) } }.onFailure { e -> @@ -297,7 +310,12 @@ class MethodHandler : FlutterPlugin, val filterType = call.argument("filterType") ?: error("Missing filterType") val value = call.argument("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( @@ -315,7 +333,12 @@ class MethodHandler : FlutterPlugin, val filterType = call.argument("filterType") ?: error("Missing filterType") val value = call.argument("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( @@ -330,8 +353,16 @@ class MethodHandler : FlutterPlugin, Methods.AddAllItems.method -> { scope.launch { result.runCatching { + val filterType = + call.argument("filterType") ?: error("Missing filterType") val items = call.argument("value") - Mobile.addSplitTunnelItems(items) + if (useDirectConnectionApps(filterType)) { + DirectConnectionAppExclusionStore(appContext) + .addPackages(splitCsvClean(items)) + noteDirectConnectionAppsReconnect() + } else { + Mobile.addSplitTunnelItems(items) + } success("All items added") }.onFailure { e -> result.error( @@ -346,8 +377,16 @@ class MethodHandler : FlutterPlugin, Methods.RemoveAllItems.method -> { scope.launch { result.runCatching { + val filterType = + call.argument("filterType") ?: error("Missing filterType") val items = call.argument("value") - Mobile.removeSplitTunnelItems(items) + if (useDirectConnectionApps(filterType)) { + DirectConnectionAppExclusionStore(appContext) + .removePackages(splitCsvClean(items)) + noteDirectConnectionAppsReconnect() + } else { + Mobile.removeSplitTunnelItems(items) + } success("All items removed") }.onFailure { e -> result.error( @@ -838,6 +877,13 @@ class MethodHandler : FlutterPlugin, } } + Methods.SetProxyListenPort.method -> { + scope.handleResult(result, "set_proxy_listen_port") { + val port = call.argument("port") ?: error("Missing port") + Mobile.setProxyListenPort(port.toLong()) + } + } + Methods.IsBlockAdsEnabled.method -> { scope.handleValue(result, "is_block_ads_enabled") { Mobile.isBlockAdsEnabled() @@ -1060,7 +1106,11 @@ class MethodHandler : FlutterPlugin, result.runCatching { val filterType = call.argument("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( @@ -1254,6 +1304,10 @@ class MethodHandler : FlutterPlugin, Methods.CheckVpnConflict.method -> { scope.launch { runCatching { + if (BuildConfig.STEALTH_NO_VPN) { + withContext(Dispatchers.Main) { result.success(false) } + return@runCatching + } val hasConflict = isAnotherVpnActive(appContext) withContext(Dispatchers.Main) { result.success(hasConflict) @@ -1400,6 +1454,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") + } + } +} + +private fun splitCsvClean(raw: String?): List { + return raw.orEmpty() + .split(',') + .map { it.trim() } + .filter { it.isNotEmpty() } } private suspend fun MethodChannel.Result.mainSuccess(value: Any? = "ok") = diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/service/NoVpnLanternService.kt b/android/app/src/main/kotlin/org/getlantern/lantern/service/NoVpnLanternService.kt new file mode 100644 index 0000000000..73e1248e0d --- /dev/null +++ b/android/app/src/main/kotlin/org/getlantern/lantern/service/NoVpnLanternService.kt @@ -0,0 +1,344 @@ +package org.getlantern.lantern.service + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.os.Build +import android.os.IBinder +import android.system.Os +import androidx.core.app.NotificationCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import lantern.io.libbox.Notification +import lantern.io.libbox.StringIterator +import lantern.io.libbox.TunOptions +import lantern.io.mobile.Mobile +import lantern.io.utils.Opts +import org.getlantern.lantern.BuildConfig +import org.getlantern.lantern.MainActivity +import org.getlantern.lantern.R +import org.getlantern.lantern.constant.VPNStatus +import org.getlantern.lantern.utils.AppLogger +import org.getlantern.lantern.utils.DeviceUtil +import org.getlantern.lantern.utils.FlutterEventListener +import org.getlantern.lantern.utils.VpnStatusManager +import org.getlantern.lantern.utils.getRadianceEnv +import org.getlantern.lantern.utils.initConfigDir +import org.getlantern.lantern.utils.isTelemetryEnabled +import org.getlantern.lantern.utils.logDir +import java.util.concurrent.atomic.AtomicBoolean + +open class NoVpnLanternService : Service(), PlatformInterfaceWrapper { + companion object { + private const val TAG = "NoVpnLanternService" + const val ACTION_START_PROXY = "org.getlantern.START_LOCAL_PROXY" + const val ACTION_CONNECT_TO_SERVER = "org.getlantern.LOCAL_PROXY_CONNECT_TO_SERVER" + const val ACTION_STOP_PROXY = "org.getlantern.STOP_LOCAL_PROXY" + private const val PROXY_START_TIMEOUT_MS = 60_000L + private const val PROXY_NOTIFICATION_ID = 8787 + private const val PROXY_CHANNEL_ID = "local_connection" + private val connectInFlight = AtomicBoolean(false) + private val stopPending = AtomicBoolean(false) + } + + private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val flutterEventListener = FlutterEventListener() + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + return when (intent?.action) { + ACTION_STOP_PROXY -> { + serviceScope.launch { stopProxy() } + START_NOT_STICKY + } + + ACTION_CONNECT_TO_SERVER -> { + showProxyNotification("Starting local connection") + serviceScope.launch { connectToServer(intent.getStringExtra("tag") ?: "") } + START_STICKY + } + + else -> { + showProxyNotification("Starting local connection") + serviceScope.launch { startProxy() } + START_STICKY + } + } + } + + override fun onDestroy() { + runBlocking(Dispatchers.IO) { + cleanupProxy(stopService = false) + } + serviceScope.cancel() + super.onDestroy() + } + + private suspend fun startProxy() = withContext(Dispatchers.IO) { + VpnStatusManager.postVPNStatus(VPNStatus.Connecting) + val started = runBlockingMobileOperation( + errorCode = "start_proxy", + errorMessage = "Failed to start local proxy", + ) { + configureProxyEnv() + if (!Mobile.isRadianceConnected()) { + Mobile.startIPCServer(this@NoVpnLanternService, opts()) + Mobile.setupRadiance(opts(), flutterEventListener) + } + DefaultNetworkMonitor.start() + // Radiance exposes its no-VPN SOCKS/HTTP CONNECT listener through + // the existing connect path when RADIANCE_USE_SOCKS_PROXY is set. + Mobile.startVPN() + } + if (started) { + if (stopIfPending()) { + return@withContext + } + VpnStatusManager.postVPNStatus(VPNStatus.Connected) + showProxyNotification("Local connection active") + AppLogger.i(TAG, "Local proxy started at ${proxyAddress()}") + } + } + + private suspend fun stopProxy() = withContext(Dispatchers.IO) { + if (!connectInFlight.compareAndSet(false, true)) { + AppLogger.d(TAG, "Local proxy operation already in flight; deferring stop") + stopPending.set(true) + VpnStatusManager.postVPNStatus(VPNStatus.Disconnecting) + return@withContext + } + try { + cleanupProxy(stopService = true) + } finally { + connectInFlight.set(false) + } + } + + private suspend fun cleanupProxy(stopService: Boolean) = withContext(Dispatchers.IO) { + runCatching { + if (Mobile.isRadianceConnected()) { + Mobile.stopVPN() + } + }.onFailure { e -> + AppLogger.e(TAG, "Failed to stop local proxy", e) + } + runCatching { + DefaultNetworkMonitor.stop() + }.onFailure { e -> + AppLogger.e(TAG, "Failed to stop default network monitor", e) + } + VpnStatusManager.postVPNStatus(VPNStatus.Disconnected) + stopForeground(STOP_FOREGROUND_REMOVE) + if (stopService) { + stopSelf() + } + } + + private suspend fun connectToServer(tag: String) = withContext(Dispatchers.IO) { + VpnStatusManager.postVPNStatus(VPNStatus.Connecting) + if (!startProxyForConnect()) { + return@withContext + } + val connected = runBlockingMobileOperation( + errorCode = "connect_proxy_server", + errorMessage = "Failed to switch local proxy server", + ) { + Mobile.connectToServer(tag) + } + if (connected) { + if (stopIfPending()) { + return@withContext + } + VpnStatusManager.postVPNStatus(VPNStatus.Connected) + showProxyNotification("Local connection active") + } + } + + private suspend fun startProxyForConnect(): Boolean { + if (Mobile.isRadianceConnected()) { + return true + } + startProxy() + return Mobile.isRadianceConnected() + } + + private suspend fun runBlockingMobileOperation( + errorCode: String, + errorMessage: String, + block: suspend () -> Unit, + ): Boolean { + if (!connectInFlight.compareAndSet(false, true)) { + val error = IllegalStateException("previous local proxy operation still in flight") + AppLogger.e(TAG, errorMessage, error) + VpnStatusManager.postVPNError(errorCode, errorMessage, error) + if (Mobile.isRadianceConnected()) { + showProxyNotification("Local connection active") + } else { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + return false + } + + val connectScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val deferred = connectScope.async { block() } + deferred.invokeOnCompletion { + connectInFlight.set(false) + connectScope.cancel() + } + + return try { + withTimeout(PROXY_START_TIMEOUT_MS) { deferred.await() } + true + } catch (e: TimeoutCancellationException) { + AppLogger.e(TAG, "$errorMessage timed out after ${PROXY_START_TIMEOUT_MS}ms", e) + VpnStatusManager.postVPNError("${errorCode}_timeout", "$errorMessage timed out", e) + cleanupProxy(stopService = true) + false + } catch (e: Exception) { + AppLogger.e(TAG, errorMessage, e) + VpnStatusManager.postVPNError(errorCode, errorMessage, e) + cleanupProxy(stopService = true) + false + } + } + + private suspend fun stopIfPending(): Boolean { + if (!stopPending.getAndSet(false)) { + return false + } + cleanupProxy(stopService = true) + return true + } + + private fun configureProxyEnv() { + // Apply via Go (Mobile.setLocalProxy), NOT Os.setenv: radiance reads these + // through Go's os.LookupEnv, and the Go runtime's env cache does not + // observe libc setenv() calls made from Kotlin. Setting them from Kotlin + // leaves RADIANCE_USE_SOCKS_PROXY invisible to radiance, so sing-box binds + // the default bypass inbound instead of this SOCKS listener. See + // Mobile.SetLocalProxy. + Mobile.setLocalProxy(true, proxyAddress()) + } + + private fun proxyAddress(): String { + // The user-editable listen port arrives via RADIANCE_PROXY_LISTEN_PORT + // (Mobile.setProxyListenPort, driven by the Dart proxy-port setting); + // fall back to the build-time default when unset/invalid. + val port = Os.getenv("RADIANCE_PROXY_LISTEN_PORT") + ?.toIntOrNull() + ?.takeIf { it in 1..65535 } + ?: BuildConfig.STEALTH_NO_VPN_PROXY_PORT + return "${BuildConfig.STEALTH_NO_VPN_PROXY_HOST}:$port" + } + + override fun openTun(tunOptions: TunOptions): Int { + error("TUN is disabled in stealth no-VPN builds") + } + + // No-VPN builds run no TUN, so there is no tunnel to protect outbound sockets + // from. Returning false makes sing-box bind outbound sockets to the default + // interface through its own Go-side control (driven by the ConnectivityManager + // interface monitor) instead of invoking the per-socket platform + // AutoDetectInterfaceControl JNI callback on every dial. That per-socket + // callback crashes gomobile's seq layer ("Unknown reference" / + // go_seq_from_refnum SIGABRT) under the high call volume of direct dialing; + // the VPN build doesn't hit this because its traffic flows through the TUN. + override fun usePlatformAutoDetectInterfaceControl(): Boolean { + return false + } + + override fun autoDetectInterfaceControl(fd: Int) { + } + + override fun postServiceClose() { + } + + override fun restartService() { + AppLogger.i(TAG, "restartService called") + runBlocking(Dispatchers.IO) { + cleanupProxy(stopService = false) + startProxy() + if (!Mobile.isRadianceConnected()) { + val msg = "restartService failed: local proxy not connected after restart" + AppLogger.e(TAG, msg) + throw IllegalStateException(msg) + } + } + AppLogger.i(TAG, "restartService completed") + } + + override fun sendNotification(notification: Notification?) { + } + + private fun showProxyNotification(text: String) { + createProxyNotificationChannel() + startForeground(PROXY_NOTIFICATION_ID, buildProxyNotification(text)) + } + + private fun buildProxyNotification(text: String): android.app.Notification { + val pendingIntent = PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + return NotificationCompat.Builder(this, PROXY_CHANNEL_ID) + .setSmallIcon(R.drawable.lantern_notification_icon) + .setContentTitle(getString(R.string.app_name)) + .setContentText(text) + .setContentIntent(pendingIntent) + .setOngoing(true) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + } + + private fun createProxyNotificationChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return + } + val channel = NotificationChannel( + PROXY_CHANNEL_ID, + "Connection", + NotificationManager.IMPORTANCE_LOW, + ) + getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + } + + override fun systemCertificates(): StringIterator { + return object : StringIterator { + override fun hasNext(): Boolean = false + override fun len(): Int = 0 + override fun next(): String = "" + } + } + + override fun writeLog(message: String?) { + AppLogger.d(TAG, "writeLog: $message") + } + + fun opts(): Opts { + return Opts().apply { + dataDir = initConfigDir() + logDir = logDir() + logLevel = "trace" + deviceid = DeviceUtil.deviceId() + locale = DeviceUtil.getLanguageCode(this@NoVpnLanternService) + telemetryConsent = isTelemetryEnabled() + env = getRadianceEnv() + platform = this@NoVpnLanternService + } + } +} diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/stealth/DirectConnectionAppExclusionStore.kt b/android/app/src/main/kotlin/org/getlantern/lantern/stealth/DirectConnectionAppExclusionStore.kt new file mode 100644 index 0000000000..bf417ed71e --- /dev/null +++ b/android/app/src/main/kotlin/org/getlantern/lantern/stealth/DirectConnectionAppExclusionStore.kt @@ -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 by lazy { + cachedDefaultExclusions(context.applicationContext) + } + + fun defaultPackageNames(): Set { + return defaultExclusions.mapTo(LinkedHashSet()) { it.packageName } + } + + fun effectivePackageNames(): Set { + 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) { + updatePackages(rawPackageNames, adding = true) + } + + fun removePackage(rawPackageName: String) { + updatePackages(listOf(rawPackageName), adding = false) + } + + fun removePackages(rawPackageNames: Iterable) { + updatePackages(rawPackageNames, adding = false) + } + + private fun updatePackages(rawPackageNames: Iterable, 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 { + 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? = null + + fun enabled(): Boolean = BuildConfig.STEALTH_DIRECT_CONNECTION_APPS + + private fun cachedDefaultExclusions( + context: Context, + ): List { + defaultExclusionsCache?.let { return it } + return synchronized(this) { + defaultExclusionsCache ?: loadDefaultExclusions(context).also { + defaultExclusionsCache = it + } + } + } + + fun loadDefaultExclusions(context: Context): List { + 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 + } + } + + 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 + } + } +} diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/stealth/DirectConnectionAppExclusions.kt b/android/app/src/main/kotlin/org/getlantern/lantern/stealth/DirectConnectionAppExclusions.kt new file mode 100644 index 0000000000..eac5a58089 --- /dev/null +++ b/android/app/src/main/kotlin/org/getlantern/lantern/stealth/DirectConnectionAppExclusions.kt @@ -0,0 +1,100 @@ +package org.getlantern.lantern.stealth + +import org.json.JSONArray +import org.json.JSONObject +import java.util.Locale + +data class DirectConnectionAppExclusion( + val packageName: String, + val displayName: String, + val reasonFlags: Set, + val source: String, + val confidence: String, + val version: String, +) + +object DirectConnectionAppExclusions { + const val DEFAULT_ASSET_PATH = "flutter_assets/assets/stealth/default_exclusions.json" + + private const val SUPPORTED_SCHEMA_VERSION = 1 + + private val packageNamePattern = + Regex("^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)+$") + + fun normalizePackageName(raw: String?): String { + return raw.orEmpty().trim().lowercase(Locale.US) + } + + fun isValidPackageName(packageName: String): Boolean { + return packageNamePattern.matches(packageName) + } + + fun parseDefaults(json: String): List { + val root = JSONObject(json) + val schemaVersion = root.optInt("schema_version", -1) + if (schemaVersion != SUPPORTED_SCHEMA_VERSION) { + throw IllegalArgumentException( + "Unsupported direct-connection defaults schema_version=$schemaVersion", + ) + } + + val defaults = root.optJSONArray("defaults") ?: JSONArray() + val out = ArrayList(defaults.length()) + val seen = LinkedHashSet() + + for (i in 0 until defaults.length()) { + val item = defaults.optJSONObject(i) ?: continue + val packageName = normalizePackageName(item.optString("package_name")) + if (!isValidPackageName(packageName) || !seen.add(packageName)) { + continue + } + + out += DirectConnectionAppExclusion( + packageName = packageName, + displayName = item.optString("display_name").trim().ifEmpty { packageName }, + reasonFlags = item.optJSONArray("reason_flags").toStringSet(), + source = item.optString("source").trim(), + confidence = item.optString("confidence").trim(), + version = item.optString("version").trim(), + ) + } + + return out + } + + fun effectivePackageNames( + defaultPackages: Iterable, + userAddedPackages: Iterable, + userRemovedDefaultPackages: Iterable, + ): Set { + val effective = LinkedHashSet() + defaultPackages + .map(::normalizePackageName) + .filter(::isValidPackageName) + .forEach { effective += it } + + userAddedPackages + .map(::normalizePackageName) + .filter(::isValidPackageName) + .forEach { effective += it } + + userRemovedDefaultPackages + .map(::normalizePackageName) + .filter(::isValidPackageName) + .forEach { effective -= it } + + return effective.toSortedSet() + } +} + +private fun JSONArray?.toStringSet(): Set { + if (this == null) return emptySet() + val out = LinkedHashSet() + for (i in 0 until length()) { + val value = optString(i).trim() + if (value.isNotEmpty()) { + out += value + } + } + return out +} diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/utils/FlutterEventListener.kt b/android/app/src/main/kotlin/org/getlantern/lantern/utils/FlutterEventListener.kt index 05b10837cb..1a9a46e17f 100644 --- a/android/app/src/main/kotlin/org/getlantern/lantern/utils/FlutterEventListener.kt +++ b/android/app/src/main/kotlin/org/getlantern/lantern/utils/FlutterEventListener.kt @@ -1,25 +1,27 @@ package org.getlantern.lantern.utils import androidx.lifecycle.MutableLiveData -import lantern.io.utils.FlutterEvent import lantern.io.utils.FlutterEventEmitter +// Plain Kotlin event carried through the stream. The emitter receives the event +// fields by value as strings (see FlutterEventEmitter.SendEvent), so no gomobile +// Go object ever crosses into the LiveData stream. +data class AppEvent(val type: String, val message: String) + object FlutterEventStream { - private val _events = MutableLiveData>() - val events: MutableLiveData> = _events + private val _events = MutableLiveData>() + val events: MutableLiveData> = _events - fun emit(event: FlutterEvent) { + fun emit(event: AppEvent) { _events.postValue(Event(event)) } } class FlutterEventListener : FlutterEventEmitter { - override fun sendEvent(p0: FlutterEvent?) { - if (p0 != null) { - AppLogger.d("FlutterEventListener", "Sending Flutter event: $p0") - FlutterEventStream.emit(p0) - } + override fun sendEvent(p0: String?, p1: String?) { + AppLogger.d("FlutterEventListener", "Sending Flutter event: $p0") + FlutterEventStream.emit(AppEvent(p0.orEmpty(), p1.orEmpty())) } } \ No newline at end of file diff --git a/android/app/src/stealthNovpn/kotlin/foundation/bridge/HomeActivity.kt b/android/app/src/stealthNovpn/kotlin/foundation/bridge/HomeActivity.kt new file mode 100644 index 0000000000..47e7d53730 --- /dev/null +++ b/android/app/src/stealthNovpn/kotlin/foundation/bridge/HomeActivity.kt @@ -0,0 +1,13 @@ +package foundation.bridge + +class HomeActivity : BaseHomeActivity() { + override suspend fun connect() { + BridgeState.set("connecting") + startAction(SyncService::class.java, SyncService.ACTION_CONNECT) + } + + override suspend fun disconnect() { + BridgeState.set("disconnecting") + startAction(SyncService::class.java, SyncService.ACTION_DISCONNECT) + } +} diff --git a/android/app/src/stealthNovpn/kotlin/foundation/bridge/SyncService.kt b/android/app/src/stealthNovpn/kotlin/foundation/bridge/SyncService.kt new file mode 100644 index 0000000000..c0deb8af44 --- /dev/null +++ b/android/app/src/stealthNovpn/kotlin/foundation/bridge/SyncService.kt @@ -0,0 +1,96 @@ +package foundation.bridge + +import android.app.Service +import android.content.Intent +import android.os.IBinder +import android.util.Log +import foundation.engine.mobile.Mobile +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext + +class SyncService : Service() { + companion object { + private const val TAG = "SyncService" + const val ACTION_CONNECT = "foundation.bridge.CONNECT" + const val ACTION_DISCONNECT = "foundation.bridge.DISCONNECT" + } + + private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + return when (intent?.action) { + ACTION_DISCONNECT -> { + launchServiceWork { stopConnection() } + START_NOT_STICKY + } + else -> { + BridgeNotification.show(this, "Connecting") + launchServiceWork { startConnection() } + START_STICKY + } + } + } + + override fun onDestroy() { + runBlocking(Dispatchers.IO) { + runCatching { Mobile.stopProxy() } + } + serviceScope.cancel() + super.onDestroy() + } + + private fun launchServiceWork(block: suspend () -> Unit) { + serviceScope.launch { + try { + block() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (exception: Exception) { + Log.e(TAG, "Failed service operation", exception) + failConnection(exception) + } + } + } + + private suspend fun failConnection(cause: Throwable? = null) = withContext(Dispatchers.IO) { + runCatching { Mobile.stopProxy() } + if (cause != null) { + BridgeState.setError(cause.message ?: "Proxy operation failed") + } else { + BridgeState.set("disconnected") + } + BridgeNotification.clear(this@SyncService) + stopSelf() + } + + private suspend fun startConnection() = withContext(Dispatchers.IO) { + BridgeState.set("connecting") + Mobile.startProxy( + BridgePaths.dataDir(this@SyncService), + BridgePaths.logDir(this@SyncService), + BridgePaths.deviceId(this@SyncService), + BridgePaths.locale(), + false, + BuildConfig.LOCAL_PROXY_HOST, + BuildConfig.LOCAL_PROXY_PORT.toLong(), + ) + BridgeState.set("connected") + BridgeNotification.show(this@SyncService, "Connected") + } + + private suspend fun stopConnection() = withContext(Dispatchers.IO) { + BridgeState.set("disconnecting") + runCatching { Mobile.stopProxy() } + BridgeState.set("disconnected") + BridgeNotification.clear(this@SyncService) + stopSelf() + } +} diff --git a/android/app/src/stealthVpn/kotlin/foundation/bridge/HomeActivity.kt b/android/app/src/stealthVpn/kotlin/foundation/bridge/HomeActivity.kt new file mode 100644 index 0000000000..a40eb5d4bc --- /dev/null +++ b/android/app/src/stealthVpn/kotlin/foundation/bridge/HomeActivity.kt @@ -0,0 +1,34 @@ +package foundation.bridge + +import android.content.Intent +import android.net.VpnService + +class HomeActivity : BaseHomeActivity() { + companion object { + private const val PREPARE_REQUEST = 3917 + } + + override suspend fun connect() { + val prepareIntent = VpnService.prepare(this) + if (prepareIntent != null) { + startActivityForResult(prepareIntent, PREPARE_REQUEST) + return + } + BridgeState.set("connecting") + startAction(NetworkService::class.java, NetworkService.ACTION_CONNECT) + } + + override suspend fun disconnect() { + BridgeState.set("disconnecting") + startAction(NetworkService::class.java, NetworkService.ACTION_DISCONNECT) + } + + @Deprecated("Deprecated in Java") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == PREPARE_REQUEST && resultCode == RESULT_OK) { + BridgeState.set("connecting") + startAction(NetworkService::class.java, NetworkService.ACTION_CONNECT) + } + } +} diff --git a/android/app/src/stealthVpn/kotlin/foundation/bridge/NetworkService.kt b/android/app/src/stealthVpn/kotlin/foundation/bridge/NetworkService.kt new file mode 100644 index 0000000000..8efde64e6d --- /dev/null +++ b/android/app/src/stealthVpn/kotlin/foundation/bridge/NetworkService.kt @@ -0,0 +1,369 @@ +package foundation.bridge + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.net.VpnService +import android.net.wifi.WifiManager +import android.os.Build +import android.os.ParcelFileDescriptor +import android.os.Process +import android.system.OsConstants +import android.util.Log +import foundation.engine.libbox.InterfaceUpdateListener +import foundation.engine.libbox.Libbox +import foundation.engine.libbox.LocalDNSTransport +import foundation.engine.libbox.NetworkInterfaceIterator +import foundation.engine.libbox.Notification +import foundation.engine.libbox.StringIterator +import foundation.engine.libbox.TunOptions +import foundation.engine.libbox.WIFIState +import foundation.engine.mobile.Mobile +import foundation.engine.utils.FlutterEvent +import foundation.engine.utils.FlutterEventEmitter +import foundation.engine.utils.Opts +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import java.net.Inet6Address +import java.net.InetSocketAddress +import java.net.InterfaceAddress +import java.net.NetworkInterface + +class NetworkService : VpnService(), foundation.engine.utils.PlatformInterface { + companion object { + private const val TAG = "NetworkService" + const val ACTION_CONNECT = "foundation.bridge.CONNECT" + const val ACTION_DISCONNECT = "foundation.bridge.DISCONNECT" + } + + private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var tunFd: ParcelFileDescriptor? = null + private val eventEmitter = object : FlutterEventEmitter { + override fun sendEvent(event: FlutterEvent?) { + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + return when (intent?.action) { + ACTION_DISCONNECT -> { + launchServiceWork { stopConnection() } + START_NOT_STICKY + } + else -> { + BridgeNotification.show(this, "Connecting") + launchServiceWork { startConnection() } + START_STICKY + } + } + } + + override fun onDestroy() { + runBlocking(Dispatchers.IO) { + runCatching { Mobile.stopVPN() } + closeTun() + } + serviceScope.cancel() + super.onDestroy() + } + + private fun launchServiceWork(block: suspend () -> Unit) { + serviceScope.launch { + try { + block() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (exception: Exception) { + Log.e(TAG, "Failed service operation", exception) + failConnection(exception) + } + } + } + + private suspend fun failConnection(cause: Throwable? = null) = withContext(Dispatchers.IO) { + runCatching { Mobile.stopVPN() } + closeTun() + if (cause != null) { + BridgeState.setError(cause.message ?: "VPN operation failed") + } else { + BridgeState.set("disconnected") + } + BridgeNotification.clear(this@NetworkService) + stopSelf() + } + + private suspend fun startConnection() = withContext(Dispatchers.IO) { + BridgeState.set("connecting") + if (!Mobile.isRadianceConnected()) { + Mobile.startIPCServer(this@NetworkService, opts()) + Mobile.setupRadiance(opts(), eventEmitter) + } + Mobile.startVPN() + BridgeState.set("connected") + BridgeNotification.show(this@NetworkService, "Connected") + } + + private suspend fun stopConnection() = withContext(Dispatchers.IO) { + BridgeState.set("disconnecting") + runCatching { Mobile.stopVPN() } + closeTun() + BridgeState.set("disconnected") + BridgeNotification.clear(this@NetworkService) + stopSelf() + } + + override fun openTun(tunOptions: TunOptions): Int { + val fd = Builder() + .setSession(BuildConfig.SESSION_NAME) + .setMtu(tunOptions.mtu) + .apply { + val inet4Address = tunOptions.inet4Address + while (inet4Address.hasNext()) { + val address = inet4Address.next() + addAddress(address.address(), address.prefix()) + } + val inet6Address = tunOptions.inet6Address + while (inet6Address.hasNext()) { + val address = inet6Address.next() + addAddress(address.address(), address.prefix()) + } + if (tunOptions.autoRoute) { + addDnsServer(tunOptions.dnsServerAddress.value) + addRoute("0.0.0.0", 0) + addRoute("::", 0) + } + addDisallowedApplication(BuildConfig.APPLICATION_ID) + } + .establish() + ?: error("connection permission is not prepared") + tunFd = fd + return fd.fd + } + + override fun autoDetectInterfaceControl(fd: Int) { + protect(fd) + } + + override fun postServiceClose() { + closeTun() + } + + override fun restartService() { + runBlocking(Dispatchers.IO) { + stopConnection() + startConnection() + } + } + + override fun sendNotification(notification: Notification?) { + } + + override fun systemCertificates(): StringIterator { + return object : StringIterator { + override fun hasNext(): Boolean = false + override fun len(): Int = 0 + override fun next(): String = "" + } + } + + override fun writeLog(message: String?) { + } + + override fun clearDNSCache() { + } + + override fun underNetworkExtension(): Boolean { + return false + } + + override fun includeAllNetworks(): Boolean { + return false + } + + override fun usePlatformAutoDetectInterfaceControl(): Boolean { + return true + } + + override fun useProcFS(): Boolean { + return Build.VERSION.SDK_INT < Build.VERSION_CODES.Q + } + + // Intentionally returns null for DNS-leak prevention. The main app's LocalResolver + // uses Android's DnsResolver bound to the *physical* network — returning it here + // would bypass the tunnel for DNS, creating a DNS-leak vector. Returning null + // forces all DNS through the tunnel / sing-box DNS stack, which is the correct + // behaviour for a stealth VPN. Bootstrap is safe: radiance connects via its own + // dialing stack before startVPN() is called, so there is no resolve-before-tunnel + // deadlock. Do NOT wire in a local resolver here without a full DNS-leak audit. + override fun localDNSTransport(): LocalDNSTransport? { + return null + } + + // No-op by design (v1 limitation). Without a DefaultNetworkMonitor, sing-box does + // not receive interface-change callbacks when the physical network switches + // (e.g. Wi-Fi → cellular). As a result, existing connections stall after a + // network switch and require a manual VPN restart to reconnect. This is a known + // UX limitation tracked in the stealth-vpn follow-on issue. + // + // This is NOT a traffic or DNS leak: the tun is a full default route + // (addRoute 0.0.0.0/0 + ::/0, no allowBypass), so stalled traffic cannot + // spill onto the new physical interface — it simply stops until the tunnel + // is restarted. + override fun startDefaultInterfaceMonitor(listener: InterfaceUpdateListener) { + } + + override fun closeDefaultInterfaceMonitor(listener: InterfaceUpdateListener) { + } + + override fun readWIFIState(): WIFIState? { + @Suppress("DEPRECATION") + val wifiInfo = (getSystemService(Context.WIFI_SERVICE) as? WifiManager) + ?.connectionInfo ?: return null + var ssid = wifiInfo.ssid ?: return WIFIState("", "") + if (ssid == "") return WIFIState("", "") + if (ssid.startsWith("\"") && ssid.endsWith("\"")) { + ssid = ssid.substring(1, ssid.length - 1) + } + return WIFIState(ssid, wifiInfo.bssid ?: "") + } + + override fun packageNameByUid(uid: Int): String { + val packages = packageManager.getPackagesForUid(uid) + if (packages.isNullOrEmpty()) error("android: package not found") + return packages[0] + } + + @Suppress("DEPRECATION") + override fun uidByPackageName(packageName: String): Int { + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.getPackageUid(packageName, PackageManager.PackageInfoFlags.of(0)) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + packageManager.getPackageUid(packageName, 0) + } else { + packageManager.getApplicationInfo(packageName, 0).uid + } + } catch (e: PackageManager.NameNotFoundException) { + error("android: package not found") + } + } + + override fun findConnectionOwner( + ipProtocol: Int, + sourceAddress: String, + sourcePort: Int, + destinationAddress: String, + destinationPort: Int + ): Int { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) error("android: requires API 29") + val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val uid = cm.getConnectionOwnerUid( + ipProtocol, + InetSocketAddress(sourceAddress, sourcePort), + InetSocketAddress(destinationAddress, destinationPort) + ) + if (uid == Process.INVALID_UID) error("android: connection owner not found") + return uid + } + + override fun getInterfaces(): NetworkInterfaceIterator { + val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val networks = cm.allNetworks + val networkInterfaces = NetworkInterface.getNetworkInterfaces()?.toList() ?: emptyList() + val interfaces = mutableListOf() + for (network in networks) { + val linkProperties = cm.getLinkProperties(network) ?: continue + val networkCapabilities = cm.getNetworkCapabilities(network) ?: continue + val interfaceName = linkProperties.interfaceName ?: continue + val networkInterface = networkInterfaces.find { it.name == interfaceName } ?: continue + try { + val boxInterface = foundation.engine.libbox.NetworkInterface() + boxInterface.name = interfaceName + boxInterface.dnsServer = StringArray( + linkProperties.dnsServers.mapNotNull { it.hostAddress }.iterator() + ) + boxInterface.type = when { + networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> + Libbox.InterfaceTypeWIFI + networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> + Libbox.InterfaceTypeCellular + networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> + Libbox.InterfaceTypeEthernet + else -> Libbox.InterfaceTypeOther + } + boxInterface.index = networkInterface.index + boxInterface.mtu = networkInterface.mtu + boxInterface.addresses = StringArray( + networkInterface.interfaceAddresses + .mapTo(mutableListOf()) { it.toPrefix() } + .iterator() + ) + var dumpFlags = 0 + if (networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) { + dumpFlags = OsConstants.IFF_UP or OsConstants.IFF_RUNNING + } + if (networkInterface.isLoopback) dumpFlags = dumpFlags or OsConstants.IFF_LOOPBACK + if (networkInterface.isPointToPoint) dumpFlags = dumpFlags or OsConstants.IFF_POINTOPOINT + if (networkInterface.supportsMulticast()) dumpFlags = dumpFlags or OsConstants.IFF_MULTICAST + boxInterface.flags = dumpFlags + boxInterface.metered = !networkCapabilities.hasCapability( + NetworkCapabilities.NET_CAPABILITY_NOT_METERED + ) + interfaces.add(boxInterface) + } catch (e: Exception) { + Log.w(TAG, "Skipping interface $interfaceName: ${e.message}") + } + } + return InterfaceArray(interfaces.iterator()) + } + + private class InterfaceArray( + private val iterator: Iterator + ) : NetworkInterfaceIterator { + override fun hasNext(): Boolean = iterator.hasNext() + override fun next(): foundation.engine.libbox.NetworkInterface = iterator.next() + } + + private class StringArray(private val iterator: Iterator) : StringIterator { + override fun len(): Int = 0 + override fun hasNext(): Boolean = iterator.hasNext() + override fun next(): String = iterator.next() + } + + private fun InterfaceAddress.toPrefix(): String { + return if (address is Inet6Address) { + "${Inet6Address.getByAddress(address.address).hostAddress}/$networkPrefixLength" + } else { + "${address.hostAddress}/$networkPrefixLength" + } + } + + private fun opts(): Opts { + return Opts().apply { + dataDir = BridgePaths.dataDir(this@NetworkService) + logDir = BridgePaths.logDir(this@NetworkService) + logLevel = "warn" + deviceid = BridgePaths.deviceId(this@NetworkService) + locale = BridgePaths.locale() + telemetryConsent = false + env = "" + platform = this@NetworkService + } + } + + @Synchronized + private fun closeTun() { + try { + tunFd?.close() + } finally { + tunFd = null + } + } +} diff --git a/assets/stealth/default_exclusions.json b/assets/stealth/default_exclusions.json new file mode 100644 index 0000000000..caa217de6e --- /dev/null +++ b/assets/stealth/default_exclusions.json @@ -0,0 +1,186 @@ +{ + "schema_version": 1, + "generated_at": "2026-05-15", + "source": { + "database_url": "https://airtable.com/appWkT7FmgHwcP4K1/shr4OxfMOh2kZXH72/tbl4NptrrXE0yf3OB/viwrejZqSFlinGMR7", + "url": "https://files.rks.global/russian_apps_search_for_vpn_en.pdf" + }, + "defaults": [ + { + "package_name": "com.avito.android", + "display_name": "Avito", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "com.idamob.tinkoff.android", + "display_name": "T-Bank", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "com.uma.musicvk", + "display_name": "VK Music", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "com.vk.vkvideo", + "display_name": "VK Video", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "com.vkontakte.android", + "display_name": "VKontakte", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "com.wildberries.ru", + "display_name": "Wildberries", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "com.yandex.browser", + "display_name": "Yandex Browser", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "ru.alfabank.mobile.android", + "display_name": "Alfa-Bank", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "ru.dublgis.dgismobile", + "display_name": "2GIS", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "ru.kinopoisk", + "display_name": "Kinopoisk", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "ru.megamarket.marketplace", + "display_name": "MegaMarket", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "ru.mts.mymts", + "display_name": "My MTS", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "ru.ok.android", + "display_name": "Odnoklassniki", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "ru.oneme.app", + "display_name": "MAX", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "ru.ozon.app.android", + "display_name": "Ozon", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "ru.rutube.app", + "display_name": "Rutube", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "ru.sbcs.store", + "display_name": "Samokat", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "ru.sberbankmobile", + "display_name": "Sberbank Online", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "ru.vk.store", + "display_name": "RuStore", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "ru.vtb24.mobilebanking.android", + "display_name": "VTB Online", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "ru.yandex.music", + "display_name": "Yandex Music", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + }, + { + "package_name": "ru.yandex.yandexmaps", + "display_name": "Yandex Maps", + "reason_flags": ["rks_vpn_detection"], + "source": "rks", + "confidence": "high", + "version": "2026-05-15" + } + ] +} diff --git a/docs/stealth-direct-connection-apps.md b/docs/stealth-direct-connection-apps.md new file mode 100644 index 0000000000..2893f9177d --- /dev/null +++ b/docs/stealth-direct-connection-apps.md @@ -0,0 +1,50 @@ +# Stealth direct-connection app exclusions + +`STEALTH_DIRECT_CONNECTION_APPS=true` changes Android package-name split +tunneling into a local direct-connection exclusion list for stealth VPN builds. +Normal builds keep the existing lantern-core split-tunnel storage and behavior. + +## Behavior + +- Defaults are shipped in `assets/stealth/default_exclusions.json`. +- The Android app loads those defaults from Flutter assets and stores user edits + in `SharedPreferences`. +- Selected package names are applied to each new `VpnService.Builder` with + `addDisallowedApplication`, so those apps connect outside the VPN tunnel. +- User additions and removals are editable through the existing app selection + screen. Changes apply on the next reconnect because Android does not update + `VpnService.Builder` disallowed-app rules in place. +- Website/domain split tunneling is hidden in this stealth UI mode because this + ticket only protects Android apps that can inspect local VPN state. + +## Build input + +Pass the same define to Flutter and Gradle: + +```sh +flutter build apk \ + --dart-define=STEALTH_DIRECT_CONNECTION_APPS=true +``` + +The Android Gradle file reads Flutter `dart-defines`, environment variables, +and project properties in that order. CI may also use: + +```sh +STEALTH_DIRECT_CONNECTION_APPS=true make android-release-ci +``` + +## Updating defaults + +The first default list is based on the RKS/Airtable dataset referenced by the +stealth epic. Each entry must include: + +- `package_name`: lower-case Android package name. +- `display_name`: user-readable app name for review. +- `reason_flags`: list of detection reasons, currently `rks_vpn_detection`. +- `source`, `confidence`, and `version` metadata for support review. + +Run the asset test after edits: + +```sh +flutter test test/features/split_tunneling/default_exclusions_asset_test.dart +``` diff --git a/docs/stealth-novpn-proxy.md b/docs/stealth-novpn-proxy.md new file mode 100644 index 0000000000..55bb62cb89 --- /dev/null +++ b/docs/stealth-novpn-proxy.md @@ -0,0 +1,76 @@ +# Stealth No-VPN Android Proxy Build + +The no-VPN Android build removes Lantern's `VpnService` manifest component and +quick settings VPN tile from the selected build manifest. It starts Radiance in +its existing local proxy mode instead of creating an Android TUN interface. + +## Build + +Use the dedicated Make target: + +```sh +make android-stealth-novpn-release +``` + +The target passes both build-time switches: + +- `ORG_GRADLE_PROJECT_stealthNoVpn=true` selects + `android/app/src/main/AndroidManifest.novpn.xml` and sets + `BuildConfig.STEALTH_NO_VPN`. +- Release builds also enable `proguard-stealth-novpn.pro`, which fails the + build if R8 cannot discard the Android `VpnService` and quick tile service + classes from the no-VPN artifact. +- `--dart-define=STEALTH_NO_VPN=true` hides VPN-only UI and shows proxy setup + instructions. + +Outputs are named: + +- `lantern-installer-stealth-novpn.apk` +- `lantern-installer-stealth-novpn.aab` + +When `BUILD_TYPE` is not `production`, the build type remains in the installer +name before `-stealth-novpn`. + +## Runtime Behavior + +The Android service sets: + +```text +RADIANCE_USE_SOCKS_PROXY=true +RADIANCE_SOCKS_ADDRESS=127.0.0.1:14986 +``` + +Radiance uses a sing-box `mixed` inbound for this mode, so the same loopback +listener accepts SOCKS5 and HTTP CONNECT clients: + +```text +Host: 127.0.0.1 +Port: 14986 +SOCKS5: 127.0.0.1:14986 +HTTP CONNECT: 127.0.0.1:14986 +``` + +Apps and browsers must be configured manually when they support per-app proxy +settings. This build does not route full-device traffic, request Android VPN +permission, expose split tunneling controls, or register Lantern as Android's +active VPN. + +## Known Limitations + +### Loopback proxy access control (experimental) + +The local proxy listener on `127.0.0.1:14986` has **no authentication** in +this release. Any local process or application on the device can use it as a +SOCKS5/HTTP CONNECT proxy without credentials. + +**Threat model:** a hostile application co-installed on the device could +route its traffic through the Lantern proxy without user consent, potentially +leveraging Lantern's circumvention capability or incurring data costs for the +user. This relates to the threat model documented in issue #3573. + +**Status:** experimental until radiance-side SOCKS authentication lands. +Do not ship this build in a production context where hostile local apps are +a realistic threat before the access control gap is closed. + +**Mitigation roadmap:** SOCKS5 username/password auth or a Unix socket +with filesystem permissions is planned for a future radiance release. diff --git a/lantern-core/mobile/mobile.go b/lantern-core/mobile/mobile.go index 11e82681f3..83dcc4066a 100644 --- a/lantern-core/mobile/mobile.go +++ b/lantern-core/mobile/mobile.go @@ -34,8 +34,30 @@ var ( ipcBackend *backend.LocalBackend ipcMu sync.Mutex ipcOnce sync.Once + + // Pin the long-lived foreign (Java) callback objects for the whole process + // lifetime. gomobile tracks each Java object passed to Go with a refcount and + // removes it once every Go-side proxy is finalized; if the proxy these are + // reached through is ever GC'd, a later callback from a Go goroutine aborts + // the process with "Unknown reference: " (go_seq_from_refnum SIGABRT). + // Holding them here keeps a proxy referenced forever so the Java object is + // never released out from under sing-box / the event bus. + pinMu sync.Mutex + pinnedPlatform utils.PlatformInterface + pinnedEventEmitter utils.FlutterEventEmitter ) +func pinForeignRefs(platform utils.PlatformInterface, emitter utils.FlutterEventEmitter) { + pinMu.Lock() + defer pinMu.Unlock() + if platform != nil { + pinnedPlatform = platform + } + if emitter != nil { + pinnedEventEmitter = emitter + } +} + func getCore() (lanterncore.Core, error) { v := lanternCore.Load() if v == nil { @@ -106,6 +128,47 @@ func SetQAEnvOverrides(outboundSocks, tz string) error { return nil } +// SetProxyListenPort overrides the local SOCKS/HTTP listen port used by the +// stealth-novpn SOCKS proxy. Must be called before the proxy connects (the +// inbound reads RADIANCE_PROXY_LISTEN_PORT when it is built). A port <= 0 clears +// the override so radiance falls back to its default. +func SetProxyListenPort(port int) error { + if port <= 0 || port > 65535 { + return os.Unsetenv("RADIANCE_PROXY_LISTEN_PORT") + } + if err := os.Setenv("RADIANCE_PROXY_LISTEN_PORT", fmt.Sprintf("%d", port)); err != nil { + return fmt.Errorf("set RADIANCE_PROXY_LISTEN_PORT: %w", err) + } + slog.Info("proxy listen port override set", "port", port) + return nil +} + +// SetLocalProxy enables the local SOCKS/HTTP proxy data path and sets the +// address its inbound listener binds to. +// +// These must be applied from Go via os.Setenv, NOT from Android's Os.setenv: +// radiance reads them through env.Get → os.LookupEnv, and the Go runtime caches +// its environment at startup. Go's os.Setenv updates both that cache and the C +// environment, but a libc setenv() from Kotlin only touches the C environment, +// so Go's lookup never observes it — which silently leaves UseSocks false and +// makes sing-box bind the default bypass inbound instead of the user-facing +// SOCKS listener. +func SetLocalProxy(enabled bool, address string) error { + if !enabled { + return os.Unsetenv("RADIANCE_USE_SOCKS_PROXY") + } + if err := os.Setenv("RADIANCE_USE_SOCKS_PROXY", "true"); err != nil { + return fmt.Errorf("set RADIANCE_USE_SOCKS_PROXY: %w", err) + } + if address != "" { + if err := os.Setenv("RADIANCE_SOCKS_ADDRESS", address); err != nil { + return fmt.Errorf("set RADIANCE_SOCKS_ADDRESS: %w", err) + } + } + slog.Info("local proxy env set", "enabled", enabled, "address", address) + return nil +} + // InitLogging wires the global slog handler (file + stdout) before any other // Mobile.* call. On Android the entire app runs in a single process, so once // common.Init runs `slog.SetDefault` covers all Go code — but it normally @@ -124,7 +187,7 @@ func SetQAEnvOverrides(outboundSocks, tz string) error { // the early values silently override. func InitLogging(dataDir, logDir, logLevel string) error { _, err := utils.RunOffCgoStack(func() (struct{}, error) { - return struct{}{}, common.Init(dataDir, logDir, logLevel) + return struct{}{}, common.Init(dataDir, logDir, lanterncore.EffectiveLogLevel(logLevel)) }) return err } @@ -132,6 +195,7 @@ func InitLogging(dataDir, logDir, logLevel string) error { func SetupRadiance(opts *utils.Opts, eventEmitter utils.FlutterEventEmitter) error { _, err := utils.RunOffCgoStack(func() (struct{}, error) { slog.Info("Setting up Radiance", "opts", opts) + pinForeignRefs(opts.Platform, eventEmitter) c, err := lanterncore.New(opts, eventEmitter) if err != nil { return struct{}{}, fmt.Errorf("unable to create LanternCore: %v", err) @@ -159,25 +223,6 @@ func IsTelemetryEnabled() bool { return ok } -func IsOAuthLogin() bool { - ok, err := withCoreR(func(c lanterncore.Core) (bool, error) { - return c.IsOAuthLogin(), nil - }) - if err != nil { - return false - } - return ok -} - -func GetOAuthProvider() string { - provider, err := withCoreR(func(c lanterncore.Core) (string, error) { - return c.GetOAuthProvider(), nil - }) - if err != nil { - return "" - } - return provider -} func SetBlockAdsEnabled(enabled bool) error { slog.Info("adblock: SetBlockAdsEnabled", "enabled", enabled) @@ -288,13 +333,16 @@ func StartIPCServer(platform utils.PlatformInterface, opts *utils.Opts) error { if ipcServer != nil { return struct{}{}, nil } + pinForeignRefs(platform, nil) + logLevel := lanterncore.EffectiveLogLevel(opts.LogLevel) + telemetryConsent := lanterncore.EffectiveTelemetryConsent(opts.TelemetryConsent) bopts := backend.Options{ DataDir: opts.DataDir, LogDir: opts.LogDir, Locale: opts.Locale, - LogLevel: opts.LogLevel, + LogLevel: logLevel, DeviceID: opts.Deviceid, - TelemetryConsent: opts.TelemetryConsent, + TelemetryConsent: telemetryConsent, PlatformInterface: platform, } be, err := backend.NewLocalBackend(context.Background(), bopts) @@ -485,17 +533,6 @@ func FetchUserData() (string, error) { }) } -// OAuth Methods -func OAuthLoginUrl(provider string) (string, error) { - return withCoreR(func(c lanterncore.Core) (string, error) { return c.OAuthLoginUrl(provider) }) -} - -func OAuthLoginCallback(oAuthToken string) (string, error) { - return withCoreR(func(c lanterncore.Core) (string, error) { - b, err := c.OAuthLoginCallback(oAuthToken) - return string(b), err - }) -} func StripeSubscription(email, planID string) (string, error) { return withCoreR(func(c lanterncore.Core) (string, error) { return c.StripeSubscription(email, planID) }) diff --git a/lantern-core/utils/common.go b/lantern-core/utils/common.go index fc4cd3db45..4dd21eac0e 100644 --- a/lantern-core/utils/common.go +++ b/lantern-core/utils/common.go @@ -27,8 +27,16 @@ type FlutterEvent struct { Message string `json:"message"` } +// FlutterEventEmitter forwards events to the host (Flutter via gomobile on +// mobile, or the Dart port on FFI). SendEvent passes the fields BY VALUE rather +// than a *FlutterEvent: gomobile marshals a Go pointer argument as a refnum'd +// proxy and resolves it (go_seq_from_refnum) while invoking the foreign method, +// so a GC that collects the short-lived event mid-call aborts the process +// ("Unknown reference: " in cproxyutils_FlutterEventEmitter_SendEvent) — +// reliably hit when the app backgrounds. Strings are copied across the +// boundary, so nothing Go-owned outlives the call. type FlutterEventEmitter interface { - SendEvent(event *FlutterEvent) + SendEvent(eventType string, message string) } // LogListener receives log entries streamed from the IPC client. diff --git a/lib/core/common/stealth_no_vpn_proxy.dart b/lib/core/common/stealth_no_vpn_proxy.dart new file mode 100644 index 0000000000..82af54f7b1 --- /dev/null +++ b/lib/core/common/stealth_no_vpn_proxy.dart @@ -0,0 +1,30 @@ +import 'package:lantern/core/services/injection_container.dart'; +import 'package:lantern/core/services/local_storage_service.dart'; + +/// Local SOCKS/HTTP proxy endpoint for stealth-novpn builds. The listen port is +/// user-editable (persisted) and applied to radiance via +/// LanternService.setProxyListenPort before connecting. +class StealthNoVpnProxy { + static const host = '127.0.0.1'; + + /// Must match BuildConfig.STEALTH_NO_VPN_PROXY_PORT (the SOCKS listener + /// fallback when the user has not overridden the port). + static const defaultPort = 14986; + + static const portKey = 'novpn_proxy_listen_port'; + + static int get port { + final raw = sl().getString(portKey); + final parsed = int.tryParse(raw ?? ''); + if (parsed != null && parsed > 0 && parsed <= 65535) { + return parsed; + } + return defaultPort; + } + + static String get address => '$host:$port'; + + static Future setPort(int value) async { + await sl().setString(portKey, value.toString()); + } +} diff --git a/lib/features/home/no_vpn_proxy_panel.dart b/lib/features/home/no_vpn_proxy_panel.dart new file mode 100644 index 0000000000..485df716ed --- /dev/null +++ b/lib/features/home/no_vpn_proxy_panel.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lantern/core/common/common.dart'; +import 'package:lantern/core/common/stealth_no_vpn_proxy.dart'; +import 'package:lantern/features/vpn/provider/vpn_notifier.dart'; + +class NoVpnProxyPanel extends HookConsumerWidget { + const NoVpnProxyPanel({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final status = ref.watch(vpnProvider); + final active = status == VPNStatus.connected; + final busy = + status == VPNStatus.connecting || status == VPNStatus.disconnecting; + final textTheme = Theme.of(context).textTheme; + + return AppCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text('proxy_mode'.i18n, style: textTheme.titleMedium), + ), + Text( + active ? 'enabled'.i18n : 'disabled'.i18n, + style: textTheme.titleMedium!.copyWith( + color: active + ? context.statusSuccessText + : context.textPrimary, + ), + ), + ], + ), + SizedBox(height: 8), + Text( + 'proxy_mode_description'.i18n, + style: textTheme.bodyMedium!.copyWith(color: context.textSecondary), + ), + SizedBox(height: 12), + _ProxyRow( + label: 'socks5_proxy'.i18n, + value: StealthNoVpnProxy.address, + ), + SizedBox(height: 6), + _ProxyRow( + label: 'http_connect_proxy'.i18n, + value: StealthNoVpnProxy.address, + ), + SizedBox(height: 12), + AppTextButton( + label: active ? 'stop_proxy'.i18n : 'start_proxy'.i18n, + onPressed: busy + ? null + : () async { + final notifier = ref.read(vpnProvider.notifier); + final result = active + ? await notifier.stopVPN() + : await notifier.startVPN(skipConflictCheck: true); + if (!context.mounted) { + return; + } + result.match( + (failure) => + context.showSnackBar(failure.localizedErrorMessage), + (_) => null, + ); + }, + ), + ], + ), + ); + } +} + +class _ProxyRow extends StatelessWidget { + const _ProxyRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + return Row( + children: [ + Expanded( + child: Text( + label, + style: textTheme.labelMedium!.copyWith(color: context.textTertiary), + ), + ), + SelectableText(value, style: textTheme.labelLarge), + ], + ); + } +} diff --git a/lib/features/setting/vpn_setting.dart b/lib/features/setting/vpn_setting.dart index 8c108473a9..1682532c9c 100644 --- a/lib/features/setting/vpn_setting.dart +++ b/lib/features/setting/vpn_setting.dart @@ -3,6 +3,9 @@ import 'package:auto_size_text/auto_size_text.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lantern/core/common/common.dart'; +import 'package:lantern/core/common/stealth_no_vpn_proxy.dart'; +import 'package:lantern/core/services/injection_container.dart'; +import 'package:lantern/lantern/lantern_service.dart'; import 'package:lantern/core/widgets/split_tunneling_tile.dart'; import 'package:lantern/core/widgets/switch_button.dart'; import 'package:lantern/features/home/provider/radiance_settings_providers.dart'; @@ -14,12 +17,15 @@ class VPNSetting extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { return BaseScreen( - title: 'vpn_settings'.i18n, + title: (AppBuildInfo.stealthNoVpn ? 'proxy_setup' : 'vpn_settings').i18n, body: _buildBody(context, ref), ); } Widget _buildBody(BuildContext context, WidgetRef ref) { + if (AppBuildInfo.stealthNoVpn) { + return _buildNoVpnBody(context, ref); + } final textTheme = Theme.of(context).textTheme; final isUserPro = ref.watch(isUserProProvider); final isPrivateServerFound = ref.watch(isPrivateServerFoundProvider); @@ -63,7 +69,7 @@ class VPNSetting extends HookConsumerWidget { icon: AppImagePaths.route, actionText: routingMode.label(), onPressed: () => appRouter.push(const SmartRouting()), - ) + ), }, DividerSpace(), if (PlatformUtils.isAndroid || @@ -72,11 +78,12 @@ class VPNSetting extends HookConsumerWidget { SplitTunnelingTile( label: 'split_tunneling'.i18n, icon: AppImagePaths.callSpilt, - actionText: - splitTunnelingEnabled ? 'enabled'.i18n : 'disabled'.i18n, + actionText: splitTunnelingEnabled + ? 'enabled'.i18n + : 'disabled'.i18n, onPressed: () => appRouter.push(const SplitTunneling()), ), - DividerSpace() + DividerSpace(), }, ], ), @@ -178,9 +185,108 @@ class VPNSetting extends HookConsumerWidget { value: telemetryConsent, onChanged: (value) { appLogger.info('Anonymous usage data consent changed: $value'); + ref.read(radianceSettingsProvider.notifier).setTelemetry(value); + }, + ), + ), + ), + ], + ); + } + + Widget _buildNoVpnBody(BuildContext context, WidgetRef ref) { + final textTheme = Theme.of(context).textTheme; + final isUserPro = ref.watch(isUserProProvider); + final blockAds = ref.watch( + radianceSettingsProvider.select((s) => s.blockAds), + ); + final telemetryConsent = ref.watch( + radianceSettingsProvider.select((s) => s.telemetry), + ); + return ListView( + padding: const EdgeInsets.all(0), + shrinkWrap: true, + children: [ + AppCard( + padding: EdgeInsets.zero, + child: AppTile( + label: 'server_locations'.i18n, + icon: AppImagePaths.location, + trailing: AppImage(path: AppImagePaths.arrowForward, height: 20), + onPressed: () { + appRouter.push(const ServerSelection()); + }, + ), + ), + SizedBox(height: 16), + AppCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('manual_proxy_setup'.i18n, style: textTheme.titleMedium), + SizedBox(height: 8), + Text( + 'manual_proxy_setup_description'.i18n, + style: textTheme.bodyMedium!.copyWith( + color: context.textSecondary, + ), + ), + SizedBox(height: 16), + const _ProxyInfoCard(), + ], + ), + ), + SizedBox(height: 16), + AppCard( + padding: EdgeInsets.zero, + child: AppTile( + label: 'block_ads'.i18n, + icon: AppImagePaths.blockAds, + trailing: SwitchButton( + value: blockAds, + onChanged: (bool? value) { + if (!isUserPro) { + appRouter.push(Plans()); + return; + } ref .read(radianceSettingsProvider.notifier) - .setTelemetry(value); + .setBlockAds(value ?? false); + }, + ), + onPressed: () { + if (!isUserPro) { + appRouter.push(Plans()); + return; + } + ref + .read(radianceSettingsProvider.notifier) + .setBlockAds(!blockAds); + }, + ), + ), + SizedBox(height: 16), + AppCard( + padding: EdgeInsets.zero, + child: AppTile( + minHeight: PlatformUtils.isWindows ? 82.0 : 72.0, + label: 'anonymous_usage_data'.i18n, + icon: AppImagePaths.assessment, + subtitle: AutoSizeText( + 'helps_improve_lantern_performance'.i18n, + minFontSize: 12, + maxFontSize: 12, + maxLines: 2, + style: textTheme.labelMedium!.copyWith( + color: context.textTertiary, + letterSpacing: 0.0, + ), + ), + trailing: SwitchButton( + value: telemetryConsent, + onChanged: (value) { + appLogger.info('Anonymous usage data consent changed: $value'); + ref.read(radianceSettingsProvider.notifier).setTelemetry(value); }, ), ), @@ -189,3 +295,121 @@ class VPNSetting extends HookConsumerWidget { ); } } + +/// Proxy info card for stealth-novpn: shows the host, the editable listen port, +/// and the derived SOCKS5/HTTP addresses. Editing the port persists it and +/// applies it to radiance (takes effect on the next connect). +class _ProxyInfoCard extends StatefulWidget { + const _ProxyInfoCard(); + + @override + State<_ProxyInfoCard> createState() => _ProxyInfoCardState(); +} + +class _ProxyInfoCardState extends State<_ProxyInfoCard> { + Future _editPort() async { + final controller = TextEditingController( + text: StealthNoVpnProxy.port.toString(), + ); + final newPort = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text('proxy_port'.i18n), + content: TextField( + controller: controller, + keyboardType: TextInputType.number, + autofocus: true, + decoration: const InputDecoration(hintText: '1024 - 65535'), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: Text('cancel'.i18n), + ), + TextButton( + onPressed: () { + final p = int.tryParse(controller.text.trim()); + if (p != null && p > 0 && p <= 65535) { + Navigator.of(ctx).pop(p); + } + }, + child: Text('save'.i18n), + ), + ], + ), + ); + if (newPort == null) return; + await StealthNoVpnProxy.setPort(newPort); + await sl().setProxyListenPort(newPort); + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ProxySetting(label: 'proxy_host'.i18n, value: StealthNoVpnProxy.host), + DividerSpace(), + InkWell( + onTap: _editPort, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + Expanded( + child: Text( + 'proxy_port'.i18n, + style: textTheme.labelMedium!.copyWith( + color: context.textTertiary, + ), + ), + ), + Text( + StealthNoVpnProxy.port.toString(), + style: textTheme.labelLarge, + ), + const SizedBox(width: 8), + AppImage(path: AppImagePaths.arrowForward, height: 16), + ], + ), + ), + ), + DividerSpace(), + _ProxySetting( + label: 'socks5_proxy'.i18n, + value: StealthNoVpnProxy.address, + ), + DividerSpace(), + _ProxySetting( + label: 'http_connect_proxy'.i18n, + value: StealthNoVpnProxy.address, + ), + ], + ); + } +} + +class _ProxySetting extends StatelessWidget { + const _ProxySetting({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + return Row( + children: [ + Expanded( + child: Text( + label, + style: textTheme.labelMedium!.copyWith(color: context.textTertiary), + ), + ), + SelectableText(value, style: textTheme.labelLarge), + ], + ); + } +} diff --git a/lib/features/split_tunneling/apps_split_tunneling.dart b/lib/features/split_tunneling/apps_split_tunneling.dart index dd635512e5..23820d623d 100644 --- a/lib/features/split_tunneling/apps_split_tunneling.dart +++ b/lib/features/split_tunneling/apps_split_tunneling.dart @@ -15,6 +15,7 @@ import 'package:lantern/features/split_tunneling/provider/apps_data_provider.dar import 'package:lantern/features/split_tunneling/provider/apps_notifier.dart'; import 'package:lantern/features/split_tunneling/provider/search_query.dart'; import 'package:lantern/features/split_tunneling/utils/split_tunnel_app_utils.dart'; +import 'package:lantern/features/vpn/provider/vpn_notifier.dart'; // Widget to display and manage split tunneling apps @RoutePage(name: 'AppsSplitTunneling') @@ -28,6 +29,20 @@ class AppsSplitTunneling extends ConsumerWidget { final enabledAppsAsync = ref.watch(splitTunnelingAppsProvider); final enabledApps = enabledAppsAsync.value ?? const {}; + final directConnectionApps = + AppBuildInfo.stealthDirectConnectionApps && PlatformUtils.isAndroid; + final title = directConnectionApps + ? 'direct_connection_apps'.i18n + : 'apps_split_tunneling'.i18n; + void showReconnectNoticeIfNeeded(bool changed) { + if (!changed || + !directConnectionApps || + ref.read(vpnProvider) != VPNStatus.connected || + !context.mounted) { + return; + } + context.showSnackBar('changes_applied_after_restart'.i18n); + } final allApps = dedupeAndSortApps( (ref.watch(appsDataProvider).value ?? const []).where( @@ -50,10 +65,10 @@ class AppsSplitTunneling extends ConsumerWidget { .toList(); return BaseScreen( - title: 'apps_split_tunneling'.i18n, + title: title, appBar: AppSearchBar( ref: ref, - title: 'apps_split_tunneling'.i18n, + title: title, hintText: 'search_apps'.i18n, ), body: CustomScrollView( @@ -62,7 +77,11 @@ class AppsSplitTunneling extends ConsumerWidget { child: Row( children: [ SectionLabel( - 'apps_bypassing_vpn'.i18n.fill([enabledApps.length]), + directConnectionApps + ? 'apps_using_direct_connection'.i18n.fill([ + enabledApps.length, + ]) + : 'apps_bypassing_vpn'.i18n.fill([enabledApps.length]), ), const Spacer(), ], @@ -95,7 +114,10 @@ class AppsSplitTunneling extends ConsumerWidget { label: 'deselect_all'.i18n, fontSize: 14, onPressed: () async { - await notifier.deselectApps(filteredEnabled); + final changed = await notifier.deselectApps( + filteredEnabled, + ); + showReconnectNoticeIfNeeded(changed); }, ), ); @@ -104,7 +126,10 @@ class AppsSplitTunneling extends ConsumerWidget { return AppRow( app: app, enabled: true, - onToggle: () => notifier.toggleApp(app), + onToggle: () async { + final changed = await notifier.toggleApp(app); + showReconnectNoticeIfNeeded(changed); + }, ); }, ), @@ -137,9 +162,10 @@ class AppsSplitTunneling extends ConsumerWidget { label: 'select_all'.i18n, fontSize: 14, onPressed: () async { - await notifier.selectApps( + final changed = await notifier.selectApps( filteredDisabled, ); + showReconnectNoticeIfNeeded(changed); }, ), ); @@ -148,7 +174,10 @@ class AppsSplitTunneling extends ConsumerWidget { return AppRow( app: app, enabled: false, - onToggle: () => notifier.toggleApp(app), + onToggle: () async { + final changed = await notifier.toggleApp(app); + showReconnectNoticeIfNeeded(changed); + }, ); }, ), diff --git a/lib/features/split_tunneling/provider/apps_notifier.dart b/lib/features/split_tunneling/provider/apps_notifier.dart index 8cce9d759e..56f06ef8b4 100644 --- a/lib/features/split_tunneling/provider/apps_notifier.dart +++ b/lib/features/split_tunneling/provider/apps_notifier.dart @@ -107,11 +107,11 @@ class SplitTunnelingApps extends _$SplitTunnelingApps { Set _stateIds() => _current().map(normalizedAppId).toSet(); - Future toggleApp(AppData app) async { + Future toggleApp(AppData app) async { final id = normalizedAppId(app); final value = splitTunnelValue(app); if (value == null) { - return; + return false; } final current = _current(); final isEnabled = current.any((a) => normalizedAppId(a) == id); @@ -120,13 +120,14 @@ class SplitTunnelingApps extends _$SplitTunnelingApps { ? await _lanternService.removeSplitTunnelItem(getFilterType(), value) : await _lanternService.addSplitTunnelItem(getFilterType(), value); - await result.match( - (failure) async { + return result.match( + (failure) { appLogger.error( 'Failed to ${isEnabled ? "remove" : "add"} item: ${failure.error}', ); + return false; }, - (_) async { + (_) { // Optional optimistic UI update final next = isEnabled ? current.where((a) => normalizedAppId(a) != id).toSet() @@ -136,18 +137,19 @@ class SplitTunnelingApps extends _$SplitTunnelingApps { // Re-sync from lantern-core (authoritative) ref.invalidateSelf(); + return true; }, ); } /// Select exactly these apps - Future selectApps(Iterable apps) async { + Future selectApps(Iterable apps) async { final current = _current(); final currentIds = _stateIds(); final toAdd = apps .where((a) => !currentIds.contains(normalizedAppId(a))) .toList(); - if (toAdd.isEmpty) return; + if (toAdd.isEmpty) return false; final validToAdd = []; final paths = []; @@ -159,30 +161,34 @@ class SplitTunnelingApps extends _$SplitTunnelingApps { } } - if (paths.isEmpty) return; + if (paths.isEmpty) return false; final result = await _lanternService.addAllItems(getFilterType(), paths); - await result.match( - (l) async => appLogger.error('Failed to add apps: ${l.error}'), - (_) async { + return result.match( + (l) { + appLogger.error('Failed to add apps: ${l.error}'); + return false; + }, + (_) { state = AsyncData({ ...current, ...validToAdd.map((a) => a.copyWith(isEnabled: true)), }); ref.invalidateSelf(); + return true; }, ); } - Future deselectApps(Iterable apps) async { + Future deselectApps(Iterable apps) async { final current = _current(); final currentIds = _stateIds(); final toRemove = apps .where((a) => currentIds.contains(normalizedAppId(a))) .toList(); - if (toRemove.isEmpty) return; + if (toRemove.isEmpty) return false; final validToRemove = []; final paths = []; @@ -194,19 +200,23 @@ class SplitTunnelingApps extends _$SplitTunnelingApps { } } - if (paths.isEmpty) return; + if (paths.isEmpty) return false; final result = await _lanternService.removeAllItems(getFilterType(), paths); - await result.match( - (l) async => appLogger.error('Failed to remove apps: ${l.error}'), - (_) async { + return result.match( + (l) { + appLogger.error('Failed to remove apps: ${l.error}'); + return false; + }, + (_) { final removeIds = validToRemove.map(normalizedAppId).toSet(); state = AsyncData( current.where((a) => !removeIds.contains(normalizedAppId(a))).toSet(), ); ref.invalidateSelf(); + return true; }, ); } diff --git a/lib/features/split_tunneling/provider/apps_notifier.g.dart b/lib/features/split_tunneling/provider/apps_notifier.g.dart index 85504eddab..9ef71b91a5 100644 --- a/lib/features/split_tunneling/provider/apps_notifier.g.dart +++ b/lib/features/split_tunneling/provider/apps_notifier.g.dart @@ -34,7 +34,7 @@ final class SplitTunnelingAppsProvider } String _$splitTunnelingAppsHash() => - r'1e0ed7ac024b4f872533845f2c8ab8275a0bd04c'; + r'1c1e4b837c427771b44503b736e05710db256b07'; abstract class _$SplitTunnelingApps extends $AsyncNotifier> { FutureOr> build(); diff --git a/lib/features/split_tunneling/split_tunneling.dart b/lib/features/split_tunneling/split_tunneling.dart index 5381e18d5e..2e91706795 100644 --- a/lib/features/split_tunneling/split_tunneling.dart +++ b/lib/features/split_tunneling/split_tunneling.dart @@ -21,14 +21,25 @@ class SplitTunneling extends HookConsumerWidget { final splitTunnelingEnabled = ref.watch( radianceSettingsProvider.select((s) => s.splitTunneling), ); + final directConnectionApps = + AppBuildInfo.stealthDirectConnectionApps && PlatformUtils.isAndroid; + final showSplitTunnelingItems = + directConnectionApps || splitTunnelingEnabled; + final title = directConnectionApps + ? 'direct_connection_apps'.i18n + : 'split_tunneling'.i18n; + final subtitle = directConnectionApps + ? 'direct_connection_apps_subtitle'.i18n + : 'add_apps_websites_bypass_vpn'.i18n; final enabledApps = (ref.watch(splitTunnelingAppsProvider).value ?? const {}) .toList(growable: false); - final enabledWebsites = - (ref.watch(splitTunnelingWebsitesProvider).value ?? const {}) - .toList(growable: false); + final enabledWebsites = directConnectionApps + ? const [] + : (ref.watch(splitTunnelingWebsitesProvider).value ?? const {}) + .toList(growable: false); void toggleSplitTunneling() { ref @@ -37,7 +48,7 @@ class SplitTunneling extends HookConsumerWidget { } return BaseScreen( - title: 'split_tunneling'.i18n, + title: title, body: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -47,14 +58,14 @@ class SplitTunneling extends HookConsumerWidget { child: Column( children: [ AppTile( - label: 'split_tunneling'.i18n, + label: title, tileTextStyle: AppTextStyles.bodyMedium.copyWith( fontWeight: FontWeight.w600, fontSize: 16, color: context.textPrimary, ), subtitle: Text( - 'add_apps_websites_bypass_vpn'.i18n, + subtitle, maxLines: 1, overflow: TextOverflow.ellipsis, style: textTheme.labelMedium!.copyWith( @@ -62,33 +73,43 @@ class SplitTunneling extends HookConsumerWidget { letterSpacing: 0.0, ), ), - onPressed: toggleSplitTunneling, - trailing: SwitchButton( - value: splitTunnelingEnabled, - onChanged: (bool? value) { - ref - .read(radianceSettingsProvider.notifier) - .setSplitTunneling(value ?? false); - }, - activeColor: AppColors.green5, - ), + onPressed: directConnectionApps ? null : toggleSplitTunneling, + trailing: directConnectionApps + ? null + : SwitchButton( + value: splitTunnelingEnabled, + onChanged: (bool? value) { + ref + .read(radianceSettingsProvider.notifier) + .setSplitTunneling(value ?? false); + }, + activeColor: AppColors.green5, + ), ), - if (splitTunnelingEnabled) ...{ + if (showSplitTunnelingItems) ...{ DividerSpace(), SplitTunnelingTile( icon: AppImagePaths.keypad, - label: 'apps'.i18n, - actionText: '${enabledApps.length} Added', + label: directConnectionApps + ? 'direct_connection_apps'.i18n + : 'apps'.i18n, + actionText: 'items_added_count'.i18n.fill([ + enabledApps.length, + ]), onPressed: () => appRouter.push(AppsSplitTunneling()), ), - DividerSpace(), - SplitTunnelingTile( - icon: AppImagePaths.world, - label: 'websites'.i18n, - actionText: '${enabledWebsites.length} Added', - onPressed: () => appRouter.push(WebsiteSplitTunneling()), - ), - } + if (!directConnectionApps) ...{ + DividerSpace(), + SplitTunnelingTile( + icon: AppImagePaths.world, + label: 'websites'.i18n, + actionText: 'items_added_count'.i18n.fill([ + enabledWebsites.length, + ]), + onPressed: () => appRouter.push(WebsiteSplitTunneling()), + ), + }, + }, ], ), ), diff --git a/lib/features/vpn/provider/vpn_notifier.dart b/lib/features/vpn/provider/vpn_notifier.dart index 940d751191..cc7c04771b 100644 --- a/lib/features/vpn/provider/vpn_notifier.dart +++ b/lib/features/vpn/provider/vpn_notifier.dart @@ -37,9 +37,10 @@ class VpnNotifier extends _$VpnNotifier { 'ts_ms=${DateTime.now().millisecondsSinceEpoch}', ); final suppressConnectionNotifications = + AppBuildInfo.stealthNoVpn || nextOrigin == VPNStatusOrigin.settingsMutation && - (nextStatus == VPNStatus.connected || - nextStatus == VPNStatus.disconnected); + (nextStatus == VPNStatus.connected || + nextStatus == VPNStatus.disconnected); final isFirstEvent = previous == null || previous.value == null; final statusChanged = !isFirstEvent && previousStatus != nextStatus; @@ -104,7 +105,7 @@ class VpnNotifier extends _$VpnNotifier { }) async { final lantern = ref.read(lanternServiceProvider); - if (!skipConflictCheck) { + if (!skipConflictCheck && !AppBuildInfo.stealthNoVpn) { final conflict = await _checkVpnConflict(); if (conflict != null) return conflict; } @@ -140,7 +141,7 @@ class VpnNotifier extends _$VpnNotifier { // Check for a conflicting VPN before initiating a new connection. // The native side guards against false positives by returning false when // Lantern's own VPN is already active (e.g. server switching while connected). - if (!skipConflictCheck) { + if (!skipConflictCheck && !AppBuildInfo.stealthNoVpn) { final conflict = await _checkVpnConflict(); if (conflict != null) return conflict; } diff --git a/lib/lantern/lantern_core_service.dart b/lib/lantern/lantern_core_service.dart index d1ea462e2e..925ceb8bbb 100644 --- a/lib/lantern/lantern_core_service.dart +++ b/lib/lantern/lantern_core_service.dart @@ -77,6 +77,10 @@ abstract class LanternCoreService { Future> setBlockAdsEnabled(bool enabled); + /// Sets the local SOCKS/HTTP listen port for the stealth-novpn proxy. Takes + /// effect on the next connect. No-op on platforms without the novpn proxy. + Future> setProxyListenPort(int port); + Future> isBlockAdsEnabled(); Future> isSmartRoutingEnabled(); diff --git a/lib/lantern/lantern_ffi_service.dart b/lib/lantern/lantern_ffi_service.dart index 5e55ca13b4..27d9c172ac 100644 --- a/lib/lantern/lantern_ffi_service.dart +++ b/lib/lantern/lantern_ffi_service.dart @@ -1539,6 +1539,12 @@ class LanternFFIService implements LanternCoreService { } } + @override + Future> setProxyListenPort(int port) async { + // Desktop (FFI) builds do not run the stealth-novpn SOCKS proxy; no-op. + return right(unit); + } + @override Future> isBlockAdsEnabled() async { try { diff --git a/lib/lantern/lantern_platform_service.dart b/lib/lantern/lantern_platform_service.dart index 6b49b8c6c4..7fe58c03be 100644 --- a/lib/lantern/lantern_platform_service.dart +++ b/lib/lantern/lantern_platform_service.dart @@ -279,6 +279,17 @@ class LanternPlatformService implements LanternCoreService { } } + @override + Future> setProxyListenPort(int port) async { + try { + await _methodChannel.invokeMethod('setProxyListenPort', {'port': port}); + return right(unit); + } catch (e, st) { + appLogger.error('setProxyListenPort failed', e, st); + return Left(e.toFailure()); + } + } + @override Future> isBlockAdsEnabled() async { try { diff --git a/lib/main_stealth.dart b/lib/main_stealth.dart new file mode 100644 index 0000000000..5f5d8a45d5 --- /dev/null +++ b/lib/main_stealth.dart @@ -0,0 +1,204 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +const _appName = String.fromEnvironment( + 'APP_LABEL', + defaultValue: 'Mobile App', +); +const _localProxyMode = bool.fromEnvironment('LOCAL_PROXY'); +const _proxyHost = String.fromEnvironment( + 'LOCAL_PROXY_HOST', + defaultValue: '127.0.0.1', +); +const _proxyPort = int.fromEnvironment('LOCAL_PROXY_PORT', defaultValue: 14986); + +void main() { + runApp(const StealthApp()); +} + +class StealthApp extends StatelessWidget { + const StealthApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: _appName, + theme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xff2f6f6d), + brightness: Brightness.light, + ), + useMaterial3: true, + ), + home: const StealthHome(), + ); + } +} + +class StealthHome extends StatefulWidget { + const StealthHome({super.key}); + + @override + State createState() => _StealthHomeState(); +} + +class _StealthHomeState extends State { + static const _channel = MethodChannel('foundation.bridge/control'); + String _state = 'disconnected'; + String? _error; + bool _busy = false; + + @override + void initState() { + super.initState(); + _refresh(); + } + + Future _refresh() async { + try { + final state = await _channel.invokeMethod('state'); + if (!mounted) return; + setState(() { + _state = state ?? 'disconnected'; + _error = null; + }); + } catch (e) { + if (!mounted) return; + setState(() => _error = e.toString()); + } + } + + Future _invoke(String method) async { + setState(() { + _busy = true; + _error = null; + }); + try { + await _channel.invokeMethod(method); + await _refresh(); + } catch (e) { + if (!mounted) return; + setState(() => _error = e.toString()); + } finally { + if (!mounted) return; + setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final connected = _state == 'connected'; + final color = switch (_state) { + 'connected' => const Color(0xff2e7d32), + 'connecting' => const Color(0xff1565c0), + _ => const Color(0xff757575), + }; + + return Scaffold( + appBar: AppBar(title: Text(_appName)), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(20), + children: [ + Row( + children: [ + Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + _labelForState(_state), + style: Theme.of(context).textTheme.titleMedium, + ), + ), + IconButton( + onPressed: _busy ? null : _refresh, + icon: const Icon(Icons.refresh), + tooltip: 'Refresh', + ), + ], + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: _busy || connected ? null : () => _invoke('connect'), + icon: _busy && !connected + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.play_arrow), + label: const Text('Connect'), + ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: _busy || !connected + ? null + : () => _invoke('disconnect'), + icon: const Icon(Icons.stop), + label: const Text('Disconnect'), + ), + if (_localProxyMode) ...[ + const SizedBox(height: 32), + Text( + 'Manual setup', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + _InfoRow(label: 'Type', value: 'SOCKS5'), + _InfoRow(label: 'Host', value: _proxyHost), + _InfoRow(label: 'Port', value: '$_proxyPort'), + ], + if (_error != null) ...[ + const SizedBox(height: 24), + Text( + _error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + ], + ), + ), + ); + } + + String _labelForState(String state) { + return switch (state) { + 'connected' => 'Connected', + 'connecting' => 'Connecting', + 'disconnecting' => 'Disconnecting', + _ => 'Disconnected', + }; + } +} + +class _InfoRow extends StatelessWidget { + const _InfoRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + SizedBox(width: 72, child: Text(label)), + Expanded( + child: SelectableText( + value, + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + ], + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index cabdc55cd0..5f973e5100 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -154,6 +154,7 @@ flutter: assets: - assets/ - assets/images/ + - assets/stealth/ - path: assets/images/flags/ platforms: - windows diff --git a/test/features/split_tunneling/default_exclusions_asset_test.dart b/test/features/split_tunneling/default_exclusions_asset_test.dart new file mode 100644 index 0000000000..c3371d8f7a --- /dev/null +++ b/test/features/split_tunneling/default_exclusions_asset_test.dart @@ -0,0 +1,39 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('stealth direct-connection defaults are valid package names', () async { + final raw = await File( + 'assets/stealth/default_exclusions.json', + ).readAsString(); + final decoded = jsonDecode(raw) as Map; + + expect(decoded['schema_version'], 1); + expect(decoded['source'], isA()); + + final defaults = decoded['defaults'] as List; + expect(defaults, isNotEmpty); + + final packageNames = []; + final packageNamePattern = RegExp(r'^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$'); + + for (final entry in defaults.cast>()) { + final packageName = entry['package_name'] as String; + packageNames.add(packageName); + + expect(packageName, packageName.toLowerCase()); + expect(packageNamePattern.hasMatch(packageName), isTrue); + expect(entry['display_name'], isA()); + expect(entry['reason_flags'], isA()); + expect(entry['source'], 'rks'); + } + + expect(packageNames.toSet(), hasLength(packageNames.length)); + expect(packageNames, contains('com.vkontakte.android')); + expect(packageNames, contains('ru.sberbankmobile')); + expect(packageNames, contains('ru.vtb24.mobilebanking.android')); + expect(packageNames, contains('com.yandex.browser')); + }); +}