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;
}