diff --git a/Makefile b/Makefile index 1c964e7dbd..aea7cf9264 100644 --- a/Makefile +++ b/Makefile @@ -120,6 +120,8 @@ ANDROID_AAB_TARGET_PLATFORMS := android-arm64 ANDROID_TARGET_PLATFORMS := $(ANDROID_AAB_TARGET_PLATFORMS) ANDROID_RELEASE_APK := $(INSTALLER_NAME)$(if $(filter-out production,$(BUILD_TYPE)),-$(BUILD_TYPE)).apk ANDROID_RELEASE_AAB := $(INSTALLER_NAME)$(if $(filter-out production,$(BUILD_TYPE)),-$(BUILD_TYPE)).aab +ANDROID_STEALTH_NOVPN_APK := $(INSTALLER_NAME)$(if $(filter-out production,$(BUILD_TYPE)),-$(BUILD_TYPE))-stealth-novpn.apk +ANDROID_STEALTH_NOVPN_AAB := $(INSTALLER_NAME)$(if $(filter-out production,$(BUILD_TYPE)),-$(BUILD_TYPE))-stealth-novpn.aab ANDROID_MAPPING_SRC := build/app/outputs/mapping/release/mapping.txt ANDROID_SYMBOLS_SRC := build/app/outputs/native-debug-symbols/release/native-debug-symbols.zip ANDROID_NDK_VERSION ?= 28.2.13676358 @@ -173,6 +175,7 @@ get-command = $(shell which="$$(which $(1) 2> /dev/null)" && if [[ ! -z "$$which APPDMG := $(call get-command,appdmg) DART_DEFINES := --dart-define=BUILD_TYPE=$(BUILD_TYPE) $(if $(VERSION),--dart-define=VERSION=$(VERSION),) +STEALTH_NOVPN_DART_DEFINES := $(DART_DEFINES) --dart-define=STEALTH_NO_VPN=true INSTALLER_RESOURCES := installer-resources @@ -532,6 +535,10 @@ android-apk-release: android-aab-release: flutter build appbundle --target-platform $(ANDROID_AAB_TARGET_PLATFORMS) --verbose --release $(DART_DEFINES) cp $(ANDROID_AAB_RELEASE_BUILD) $(ANDROID_RELEASE_AAB) + $(MAKE) android-copy-play-artifacts + +.PHONY: android-copy-play-artifacts +android-copy-play-artifacts: # Copy Play console artifacts @if [ -f "$(ANDROID_MAPPING_SRC)" ]; then \ cp "$(ANDROID_MAPPING_SRC)" mapping.txt; \ @@ -543,6 +550,20 @@ android-aab-release: (cd build/app/intermediates/merged_native_libs/release/out && zip -r ../../../../../../debug-symbols.zip lib >/dev/null); \ fi +.PHONY: android-stealth-novpn-apk-release +android-stealth-novpn-apk-release: + ORG_GRADLE_PROJECT_stealthNoVpn=true flutter build apk --target-platform $(ANDROID_APK_TARGET_PLATFORMS) --verbose --release $(STEALTH_NOVPN_DART_DEFINES) + cp $(ANDROID_APK_RELEASE_BUILD) $(ANDROID_STEALTH_NOVPN_APK) + +.PHONY: android-stealth-novpn-aab-release +android-stealth-novpn-aab-release: + ORG_GRADLE_PROJECT_stealthNoVpn=true flutter build appbundle --target-platform $(ANDROID_AAB_TARGET_PLATFORMS) --verbose --release $(STEALTH_NOVPN_DART_DEFINES) + cp $(ANDROID_AAB_RELEASE_BUILD) $(ANDROID_STEALTH_NOVPN_AAB) + $(MAKE) android-copy-play-artifacts + +.PHONY: android-stealth-novpn-release +android-stealth-novpn-release: android pubget gen android-stealth-novpn-apk-release android-stealth-novpn-aab-release + .PHONY: android-release android-release: clean android pubget gen android-apk-release diff --git a/android/app/build.gradle b/android/app/build.gradle index 57bb8ad393..a6e83a7743 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -17,7 +17,10 @@ def sideloadSigningCertificateSha256 = : "108f612ae55354078ec12b10bb705362840d48fa78b9262c11b6d0adeff6f289" def sideloadUpdates = project.findProperty("lantern.sideloadUpdates") == "true" def sideloadManifestPath = "$buildDir/generated/lantern/sideload/AndroidManifest.xml" +def stealthNoVpn = project.findProperty("stealthNoVpn")?.toString()?.toBoolean() ?: false def generateSideloadManifest = tasks.register("generateSideloadManifest") { + // The stealth no-VPN manifest does not include QUERY_ALL_PACKAGES, so + // sideload updates are only supported for the normal (VPN) manifest. def sourceManifest = file("src/main/AndroidManifest.xml") def permissionAnchor = ' ' def sideloadInstallPermission = ' ' @@ -49,10 +52,15 @@ android { sourceSets { main { - // Keep one Android release variant. The sideload APK opts into - // REQUEST_INSTALL_PACKAGES with -Plantern.sideloadUpdates=true; - // the Play AAB uses the normal manifest and omits that permission. - if (sideloadUpdates) { + // Manifest variant selection: + // - stealthNoVpn=true → stealth no-VPN manifest (no VPN service, restricted permissions) + // - sideloadUpdates=true → normal manifest with REQUEST_INSTALL_PACKAGES injected + // - neither → normal manifest (default, implicit) + // Note: sideloadUpdates is not supported for stealthNoVpn builds because the + // no-VPN manifest omits QUERY_ALL_PACKAGES (the anchor used for sideload injection). + if (stealthNoVpn) { + manifest.srcFile 'src/main/AndroidManifest.novpn.xml' + } else if (sideloadUpdates) { manifest.srcFile sideloadManifestPath } jniLibs.srcDirs = ['libs'] @@ -115,6 +123,24 @@ android { versionCode = code versionName = flutter.versionName buildConfigField "String", "SIDELOAD_SIGNING_CERTIFICATE_SHA256", "\"${sideloadSigningCertificateSha256}\"" + buildConfigField "boolean", "STEALTH_NO_VPN", stealthNoVpn.toString() + buildConfigField "String", "STEALTH_NO_VPN_PROXY_HOST", '"127.0.0.1"' + buildConfigField "int", "STEALTH_NO_VPN_PROXY_PORT", "14986" + + if (stealthNoVpn) { + // Neutral manifest placeholder defaults for stealth no-VPN builds. + // These mirror the placeholder names used by the identity randomization + // PR (#8781) so the novpn manifest and the main manifest share the same + // injection point. The integrator MUST override these with per-build + // values (from the stealth profile) before shipping any artifact; + // the defaults below are compile-safe stubs only. + manifestPlaceholders = [ + appLabel : "App", + appIcon : "@mipmap/ic_launcher", + appRoundIcon : "@mipmap/ic_launcher_round", + appAuthScheme : "app", + ] + } ndk { // arm64 only (APK + AAB) — armeabi-v7a dropped, see the comment @@ -165,6 +191,9 @@ android { } buildConfigField "boolean", "DEVELOPMENT_MODE", "false" proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + if (stealthNoVpn) { + proguardFiles 'proguard-stealth-novpn.pro' + } } } 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/AndroidManifest.novpn.xml b/android/app/src/main/AndroidManifest.novpn.xml new file mode 100644 index 0000000000..f6c29f28e1 --- /dev/null +++ b/android/app/src/main/AndroidManifest.novpn.xml @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/foundation/bridge/NoVpnComponents.kt b/android/app/src/main/kotlin/foundation/bridge/NoVpnComponents.kt new file mode 100644 index 0000000000..845e8ab9bf --- /dev/null +++ b/android/app/src/main/kotlin/foundation/bridge/NoVpnComponents.kt @@ -0,0 +1,5 @@ +package foundation.bridge + +import org.getlantern.lantern.service.NoVpnLanternService + +class SyncService : NoVpnLanternService() 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..061af1a0f6 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 @@ -10,6 +11,7 @@ import android.os.Looper import android.util.Log import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat +import foundation.bridge.SyncService import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.engine.FlutterEngine import kotlinx.coroutines.CoroutineScope @@ -20,6 +22,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 @@ -51,6 +54,8 @@ class MainActivity : FlutterFragmentActivity() { private val serviceStartHandler = Handler(Looper.getMainLooper()) + private val noVpnServiceClass: Class + get() = if (BuildConfig.STEALTH_NO_VPN) SyncService::class.java else NoVpnLanternService::class.java override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) @@ -94,12 +99,19 @@ 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 (BuildConfig.STEALTH_NO_VPN) { + AppLogger.d(TAG, "Stealth no-VPN build skips proxy autostart") + pendingServiceStart = false + retryCount = 0 + retryCountResume = 0 + return + } if (isServiceRunning(this, LanternVpnService::class.java)) { AppLogger.d(TAG, "LanternService is already running") return @@ -125,6 +137,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 +166,7 @@ class MainActivity : FlutterFragmentActivity() { AppLogger.d(TAG, "Scheduling immediate retry #$retryCount in ${delay}ms") serviceStartHandler.postDelayed({ - startLanternService() + retryServiceStart() }, delay) } else { /* @@ -148,8 +181,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 @@ -180,6 +225,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 @@ -211,6 +263,19 @@ class MainActivity : FlutterFragmentActivity() { fun stopVPN() { + 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, LanternVpnService::class.java)) { LanternApp.application.sendBroadcast( Intent(LanternVpnService.ACTION_STOP_VPN) 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 5637ce57f6..c435aa4c2b 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,10 @@ 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.utils.AppLogger import org.getlantern.lantern.utils.PrivateServerListener import org.getlantern.lantern.utils.VpnStatusManager @@ -124,7 +123,6 @@ enum class Methods(val method: String) { GetDataCapInfo("getDataCapInfo"), UpdateLocale("updateLocale"), UpdateTelemetryEvents("updateTelemetryEvents"), - InstallSideloadUpdate("installSideloadUpdate"), // Smart routing SetRoutingMode("setRoutingMode"), @@ -262,6 +260,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") } @@ -278,6 +280,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 -> @@ -1057,16 +1063,11 @@ class MethodHandler : FlutterPlugin, Methods.GetSplitTunnelItems.method -> { scope.launch { result.runCatching { - val filterType = - call.argument("filterType") ?: error("Missing filterType") + val filterType = call.argument("filterType") ?: error("Missing filterType") val json = Mobile.getSplitTunnelItems(filterType) withContext(Dispatchers.Main) { success(json) } }.onFailure { e -> - result.error( - "GET_SPLIT_TUNNEL_ITEMS_ERROR", - e.localizedMessage ?: "Failed to get split tunnel items", - e - ) + result.error("GET_SPLIT_TUNNEL_ITEMS_ERROR", e.localizedMessage ?: "Failed to get split tunnel items", e) } } } @@ -1077,11 +1078,7 @@ class MethodHandler : FlutterPlugin, val json = Mobile.getSplitTunnelStateJSON() withContext(Dispatchers.Main) { success(json) } }.onFailure { e -> - result.error( - "GET_SPLIT_TUNNEL_STATE_ERROR", - e.localizedMessage ?: "Failed to get split tunnel state", - e - ) + result.error("GET_SPLIT_TUNNEL_STATE_ERROR", e.localizedMessage ?: "Failed to get split tunnel state", e) } } } @@ -1102,33 +1099,6 @@ class MethodHandler : FlutterPlugin, } } } - - Methods.InstallSideloadUpdate.method -> { - scope.launch { - result.runCatching { - val update = AndroidSideloadUpdateRequest( - url = call.argument("url") ?: error("Missing url"), - checksum = call.argument("checksum") - ?: error("Missing checksum"), - version = call.argument("version") - ?: error("Missing version"), - ) - val status = - AndroidSideloadInstaller.install(MainActivity.instance, update) - withContext(Dispatchers.Main) { - result.success(status) - } - - }.onFailure { e -> - result.error( - "install_sideload_update", - e.localizedMessage ?: "Failed to install sideload update", - e - ) - } - } - } - //Change Email Methods.StartChangeEmail.method -> { scope.launch { @@ -1253,6 +1223,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) @@ -1327,12 +1301,7 @@ class MethodHandler : FlutterPlugin, try { if (!currentUrl.startsWith("intent:", ignoreCase = true)) { - return startExternalIntent( - Intent( - Intent.ACTION_VIEW, - Uri.parse(currentUrl) - ) - ) + return startExternalIntent(Intent(Intent.ACTION_VIEW, Uri.parse(currentUrl))) } val intent = Intent.parseUri(currentUrl, Intent.URI_INTENT_SCHEME) @@ -1412,13 +1381,7 @@ private inline fun CoroutineScope.handleValue( ) = launch { runCatching { block() } .onSuccess { v -> result.mainSuccess(v) } - .onFailure { e -> - result.mainError( - errorCode, - e.localizedMessage ?: "Please try again", - e - ) - } + .onFailure { e -> result.mainError(errorCode, e.localizedMessage ?: "Please try again", e) } } private inline fun CoroutineScope.handleResult( @@ -1428,13 +1391,7 @@ private inline fun CoroutineScope.handleResult( ) = launch { runCatching { block() } .onSuccess { result.mainSuccess() } - .onFailure { e -> - result.mainError( - errorCode, - e.localizedMessage ?: "Please try again", - e - ) - } + .onFailure { e -> result.mainError(errorCode, e.localizedMessage ?: "Please try again", e) } } private data class AppEntry(val label: String, val packageName: String) 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..982edbb065 --- /dev/null +++ b/android/app/src/main/kotlin/org/getlantern/lantern/service/NoVpnLanternService.kt @@ -0,0 +1,329 @@ +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) + // Bug #2: report stop failure so the UI and status listeners can recover + VpnStatusManager.postVPNError("stop_proxy", "Failed to stop local proxy", e) + } + // Bug #3: always close the IPC server/backend regardless of stopVPN() outcome, + // so the proxy backend is not left running after the service is destroyed. + runCatching { + Mobile.closeIPCServer() + }.onFailure { e -> + AppLogger.e(TAG, "Failed to close IPC server", 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() { + Os.setenv("RADIANCE_USE_SOCKS_PROXY", "true", true) + Os.setenv("RADIANCE_SOCKS_ADDRESS", proxyAddress(), true) + } + + private fun proxyAddress(): String { + return "${BuildConfig.STEALTH_NO_VPN_PROXY_HOST}:${BuildConfig.STEALTH_NO_VPN_PROXY_PORT}" + } + + override fun openTun(tunOptions: TunOptions): Int { + error("TUN is disabled in stealth no-VPN builds") + } + + 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/assets/locales/en.po b/assets/locales/en.po index e9591d27f7..1b5d3eb020 100644 --- a/assets/locales/en.po +++ b/assets/locales/en.po @@ -63,6 +63,39 @@ msgstr "Help Fight Global Internet Censorship" msgid "vpn_settings" msgstr "VPN Settings" +msgid "proxy_setup" +msgstr "Proxy Setup" + +msgid "proxy_mode" +msgstr "Proxy Mode" + +msgid "proxy_mode_description" +msgstr "Lantern is available as a local proxy for apps and browsers that let you set a manual proxy." + +msgid "manual_proxy_setup" +msgstr "Manual Proxy Setup" + +msgid "manual_proxy_setup_description" +msgstr "In a supported app or browser, set the proxy type to SOCKS5 or HTTP CONNECT and use the local address below." + +msgid "proxy_host" +msgstr "Host" + +msgid "proxy_port" +msgstr "Port" + +msgid "socks5_proxy" +msgstr "SOCKS5 Proxy" + +msgid "http_connect_proxy" +msgstr "HTTP CONNECT Proxy" + +msgid "start_proxy" +msgstr "Start Proxy" + +msgid "stop_proxy" +msgstr "Stop Proxy" + msgid "split_tunneling" msgstr "Split Tunneling" @@ -165,30 +198,6 @@ msgstr "Sign Out" msgid "check_for_updates" msgstr "Check for Updates" -msgid "update_available" -msgstr "Update available" - -msgid "android_sideload_update_available_message" -msgstr "Lantern %s is available. Lantern will download and verify the APK before opening Android's installer." - -msgid "download_and_install_update" -msgstr "Download and install" - -msgid "allow_unknown_app_installs" -msgstr "Allow app installs" - -msgid "android_sideload_install_permission_message" -msgstr "Android needs permission to install updates from Lantern. Turn on Allow from this source, then check for updates again." - -msgid "error_install_update" -msgstr "Unable to install update" - -msgid "android_sideload_install_failed_message" -msgstr "Lantern could not verify or install this update. Please try again later." - -msgid "lantern_is_up_to_date" -msgstr "Lantern is up to date." - msgid "support" msgstr "Support" @@ -393,11 +402,8 @@ msgstr "Add your email to access your subscription on any device." msgid "confirm_email_code" msgstr "Confirmation Code" -msgid "confirm_email_code_message_part_one" -msgstr "We sent a code to " - -msgid "confirm_email_code_message_part_two" -msgstr " Enter it below. If it's not in your inbox, check your spam folder." +msgid "confirm_email_code_message" +msgstr "Confirm that this email address belongs to you, enter the code sent to: " msgid "resend_email" msgstr "Resend Email" @@ -1010,9 +1016,6 @@ msgstr "Change Email" msgid "change_email_message" msgstr "This will become your new login email. You’ll need to verify it before the change takes effect." -msgid "email_must_be_different" -msgstr "Please enter a different email address." - msgid "enter_new_email" msgstr "Enter your new Email" @@ -1613,24 +1616,3 @@ msgstr "Connection failed. Please try again." msgid "err_ruleset_failed" msgstr "Unable to load routing configuration. Retrying..." - -msgid "update_now" -msgstr "Update now" - -msgid "later" -msgstr "Later" - -msgid "update_ready_to_install" -msgstr "Lantern %s is ready to install" - -msgid "up_to_date" -msgstr "Up to date" - -msgid "running_latest_version" -msgstr "You're running the latest version of Lantern %s" - -msgid "couldnt_check_for_updates" -msgstr "Couldn't check for updates" - -msgid "check_connection_and_retry" -msgstr "Check your connection and try again" diff --git a/docs/stealth-novpn-proxy.md b/docs/stealth-novpn-proxy.md new file mode 100644 index 0000000000..31e5123f77 --- /dev/null +++ b/docs/stealth-novpn-proxy.md @@ -0,0 +1,56 @@ +# 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. diff --git a/lib/core/common/app_build_info.dart b/lib/core/common/app_build_info.dart index dd800e9920..e3ad3ce3e5 100644 --- a/lib/core/common/app_build_info.dart +++ b/lib/core/common/app_build_info.dart @@ -17,6 +17,11 @@ class AppBuildInfo { defaultValue: false, ); + static const bool stealthNoVpn = bool.fromEnvironment( + 'STEALTH_NO_VPN', + defaultValue: false, + ); + /// Developer mode is exposed in debug and nightly builds only. static bool get isDevModeEnabled => kDebugMode || buildType == 'nightly'; } 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..36a00a0fbe --- /dev/null +++ b/lib/core/common/stealth_no_vpn_proxy.dart @@ -0,0 +1,5 @@ +class StealthNoVpnProxy { + static const host = '127.0.0.1'; + static const port = 14986; + static const address = '$host:$port'; +} diff --git a/lib/features/home/home.dart b/lib/features/home/home.dart index 961deb9022..11e5d021f2 100644 --- a/lib/features/home/home.dart +++ b/lib/features/home/home.dart @@ -14,6 +14,7 @@ import 'package:lantern/features/home/provider/app_setting_notifier.dart'; import 'package:lantern/features/home/provider/feature_flag_notifier.dart'; import 'package:lantern/features/home/provider/home_notifier.dart'; import 'package:lantern/features/home/provider/radiance_settings_providers.dart'; +import 'package:lantern/features/home/no_vpn_proxy_panel.dart'; import 'package:lantern/features/vpn/location_setting.dart'; import 'package:lantern/features/vpn/provider/available_servers_notifier.dart'; import 'package:lantern/features/vpn/provider/server_location_notifier.dart'; @@ -160,7 +161,10 @@ class _HomeState extends ConsumerState { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ if (isUserPro) SizedBox(height: 0) else ProBanner(), - VPNSwitch(), + if (AppBuildInfo.stealthNoVpn) + const NoVpnProxyPanel() + else + VPNSwitch(), Column( mainAxisSize: MainAxisSize.min, children: [ @@ -206,10 +210,9 @@ class _HomeState extends ConsumerState { margin: EdgeInsets.zero, child: Column( children: [ - VpnStatus(), - DividerSpace(), + if (!AppBuildInfo.stealthNoVpn) ...[VpnStatus(), DividerSpace()], LocationSetting(), - if (!PlatformUtils.isIOS) ...{ + if (!AppBuildInfo.stealthNoVpn && !PlatformUtils.isIOS) ...{ DividerSpace(), SettingTile( label: 'routing_mode'.i18n, @@ -230,9 +233,10 @@ class _HomeState extends ConsumerState { onTap: () => onSettingTileTap(_SettingTileType.smartRouting), ), }, - if (PlatformUtils.isAndroid || - PlatformUtils.isMacOS || - PlatformUtils.isWindows) ...{ + if (!AppBuildInfo.stealthNoVpn && + (PlatformUtils.isAndroid || + PlatformUtils.isMacOS || + PlatformUtils.isWindows)) ...{ DividerSpace(), SettingTile( label: 'split_tunneling'.i18n, 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/setting.dart b/lib/features/setting/setting.dart index 027eb40279..4f35d69af6 100644 --- a/lib/features/setting/setting.dart +++ b/lib/features/setting/setting.dart @@ -38,21 +38,6 @@ class Setting extends StatefulHookConsumerWidget { class _SettingState extends ConsumerState with RestorePurchaseMixin { - late final Future _canCheckForUpdates = _canCheckForUpdatesSafely(); - - Future _canCheckForUpdatesSafely() async { - if (!sl.isRegistered()) { - appLogger.warning('Updater not registered, hiding update check setting'); - return false; - } - try { - return await sl().canCheckForUpdates(); - } catch (e, st) { - appLogger.error('Failed to determine update check availability', e, st); - return false; - } - } - @override Widget build(BuildContext context) { final isExpired = ref.watch(isUserExpiredProvider); @@ -138,7 +123,11 @@ class _SettingState extends ConsumerState child: Column( children: [ AppTile( - label: 'vpn_settings'.i18n, + label: + (AppBuildInfo.stealthNoVpn + ? 'proxy_setup' + : 'vpn_settings') + .i18n, icon: AppImagePaths.glob, onPressed: () => settingMenuTap(_SettingType.vpnSetting), ), @@ -179,29 +168,15 @@ class _SettingState extends ConsumerState icon: AppImagePaths.support, onPressed: () => settingMenuTap(_SettingType.support), ), - FutureBuilder( - future: _canCheckForUpdates, - builder: (context, snapshot) { - final show = - PlatformUtils.isDesktop || - (snapshot.connectionState == ConnectionState.done && - snapshot.data == true); - if (!show) return const SizedBox.shrink(); - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - DividerSpace(), - AppTile( - label: 'check_for_updates'.i18n, - icon: AppImagePaths.update, - onPressed: () async => await settingMenuTap( - _SettingType.checkForUpdates, - ), - ), - ], - ); - }, - ), + if (PlatformUtils.isDesktop) ...{ + DividerSpace(), + AppTile( + label: 'check_for_updates'.i18n, + icon: AppImagePaths.update, + onPressed: () async => + await settingMenuTap(_SettingType.checkForUpdates), + ), + }, DividerSpace(), AppTile( label: 'get_30_days_of_pro_free'.i18n, diff --git a/lib/features/setting/vpn_setting.dart b/lib/features/setting/vpn_setting.dart index 8c108473a9..f2ef805a0f 100644 --- a/lib/features/setting/vpn_setting.dart +++ b/lib/features/setting/vpn_setting.dart @@ -3,6 +3,7 @@ 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/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 +15,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 +67,7 @@ class VPNSetting extends HookConsumerWidget { icon: AppImagePaths.route, actionText: routingMode.label(), onPressed: () => appRouter.push(const SmartRouting()), - ) + ), }, DividerSpace(), if (PlatformUtils.isAndroid || @@ -72,11 +76,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,13 +183,153 @@ 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), + _ProxySetting( + label: 'proxy_host'.i18n, + value: StealthNoVpnProxy.host, + ), + DividerSpace(), + _ProxySetting( + label: 'proxy_port'.i18n, + value: StealthNoVpnProxy.port.toString(), + ), + DividerSpace(), + _ProxySetting( + label: 'socks5_proxy'.i18n, + value: StealthNoVpnProxy.address, + ), + DividerSpace(), + _ProxySetting( + label: 'http_connect_proxy'.i18n, + value: StealthNoVpnProxy.address, + ), + ], + ), + ), + 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); + }, + ), + ), + ), + ], + ); + } +} + +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/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; }