Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f0bf1ca
Add stealth Android manifest filtering
reflog May 15, 2026
96fdce7
Address review feedback for manifest filtering
reflog May 15, 2026
94a4407
Fix stealth manifest filter output write
reflog May 15, 2026
982358b
Add stealth manifest filter coverage
reflog May 15, 2026
87f604b
test: run stealth manifest filter checks in CI
reflog May 16, 2026
74e1ef5
fix: harden manifest filter build wiring
reflog May 16, 2026
a54dd0b
fix: neutralize stealth manifest components
reflog May 17, 2026
34999ed
fix: allow configurable stealth manifest python
reflog May 17, 2026
a826e46
fix: validate stealth manifest inputs
reflog May 17, 2026
238f584
fix: choose ndk prebuilt by host
reflog May 17, 2026
41ccdef
merge no-vpn runtime for manifest stack
reflog May 17, 2026
6c76ff3
Merge branch 'stealth/8769-novpn-proxy' into stealth/8771-android-man…
reflog May 17, 2026
7fa55f2
fix: clean up manifest stack review issues
reflog May 17, 2026
f7ceb47
Merge branch 'stealth/8769-novpn-proxy' into stealth/8771-android-man…
reflog May 17, 2026
a0e7d82
Merge branch 'stealth/8769-novpn-proxy' into stealth/8771-android-man…
reflog May 17, 2026
491f054
fix: clarify stealth manifest docs
reflog May 17, 2026
8cf26ac
Merge branch 'stealth/8769-novpn-proxy' into stealth/8771-android-man…
reflog May 17, 2026
85011ed
fix: harden stealth manifest build inputs
reflog May 17, 2026
e834a7d
fix: use unittest discover for stealth manifest filter test
Copilot May 17, 2026
03076b1
docs: document stealth novpn compatibility flag
reflog May 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/build-android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,9 @@ jobs:
env:
GOMOBILECACHE: ${{ env.GOMOBILECACHE }}

- name: Run stealth manifest filter tests
run: make stealth-manifest-filter-test

- name: Build Android (APK + AAB)
run: make android-release-ci
env:
Expand Down
5 changes: 5 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ ANDROID_STEALTH_NOVPN_APK := $(INSTALLER_NAME)$(if $(filter-out production,$(BUI
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
PYTHON ?= python3
ANDROID_NDK_VERSION ?= 28.2.13676358
ANDROID_CMAKE_VERSION ?= 3.22.1
ANDROID_BUILD_TOOLS_VERSION ?= 35.0.0
Expand Down Expand Up @@ -551,6 +552,10 @@ android-release: clean android pubget gen android-apk-release
.PHONY: android-release-ci
android-release-ci: android pubget gen android-apk-release android-aab-release

.PHONY: stealth-manifest-filter-test
stealth-manifest-filter-test:
$(PYTHON) -m unittest discover -s scripts/stealth -p '*_test.py'

# iOS Build
.PHONY: install-ios-deps

Expand Down
51 changes: 48 additions & 3 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,30 @@ def hasReleaseKeystore =
def start = new Date(2015, 1, 1).getTime()
def now = System.currentTimeMillis()
def code = (int)((now - start) / 1000)
def stealthNoVpn = project.findProperty("stealthNoVpn")?.toString()?.toBoolean() ?: false
def rawStealthMode = (project.findProperty("STEALTH_MODE") ?: "").toString()
rawStealthMode = rawStealthMode.trim().toLowerCase(java.util.Locale.ROOT)
def stealthModePrefix = "stealth-"
def stealthMode = rawStealthMode.startsWith(stealthModePrefix)
? rawStealthMode.substring(stealthModePrefix.length())
: rawStealthMode
if (rawStealthMode.startsWith(stealthModePrefix) && !stealthMode) {
throw new GradleException("Unsupported STEALTH_MODE '${rawStealthMode}'. Expected stealth-vpn or stealth-novpn")
}
def explicitStealthNoVpn = project.findProperty("stealthNoVpn")?.toString()?.toBoolean() ?: false
if (stealthMode == "vpn" && explicitStealthNoVpn) {
throw new GradleException("Conflicting stealth inputs: STEALTH_MODE=vpn cannot be combined with -PstealthNoVpn=true")
}
if (!stealthMode && explicitStealthNoVpn) {
stealthMode = "novpn"
Comment thread
reflog marked this conversation as resolved.
}
def stealthModes = ["vpn", "novpn"]
if (stealthMode && !stealthModes.contains(stealthMode)) {
throw new GradleException("Unsupported STEALTH_MODE '${stealthMode}'. Expected one of: ${stealthModes.join(', ')}")
}
Comment thread
reflog marked this conversation as resolved.
def stealthNoVpn = explicitStealthNoVpn || stealthMode == "novpn"
def stealthManifest = layout.buildDirectory.file("generated/stealth/${stealthMode}/AndroidManifest.xml").get().asFile
def stealthManifestFilter = file("${rootProject.projectDir}/../scripts/stealth/android_manifest_filter.py")
def stealthPython = (project.findProperty("stealthPython") ?: System.getenv("PYTHON") ?: "python3").toString()

android {
namespace = "org.getlantern.lantern"
Expand All @@ -24,7 +47,9 @@ android {

sourceSets {
main {
manifest.srcFile stealthNoVpn ? 'src/main/AndroidManifest.novpn.xml' : 'src/main/AndroidManifest.xml'
if (stealthMode) {
manifest.srcFile stealthManifest
}
jniLibs.srcDirs = ['libs']
jniLibs.srcDirs += ['src/main/jniLibs']
}
Expand Down Expand Up @@ -60,7 +85,6 @@ android {
targetSdk = 36
versionCode = code
versionName = flutter.versionName

ndk {
// Must match the ABIs Flutter actually builds (ANDROID_TARGET_PLATFORMS
// in the top-level Makefile: android-arm + android-arm64). Listing an
Expand All @@ -80,6 +104,7 @@ android {
buildFeatures {
buildConfig true
}
buildConfigField "boolean", "STEALTH_ENABLED", (stealthMode ? "true" : "false")
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"
Expand Down Expand Up @@ -123,6 +148,26 @@ android {

}

if (stealthMode) {
tasks.register("generateStealthManifest", Exec) {
inputs.file(file("src/main/AndroidManifest.xml"))
inputs.file(stealthManifestFilter)
inputs.property("stealthMode", stealthMode)
outputs.file(stealthManifest)
commandLine(
stealthPython,
stealthManifestFilter.absolutePath,
"--mode", stealthMode,
"--input", "${projectDir}/src/main/AndroidManifest.xml",
"--output", stealthManifest.absolutePath,
)
Comment thread
reflog marked this conversation as resolved.
}

tasks.matching { it.name == "preBuild" }.configureEach {
dependsOn(tasks.named("generateStealthManifest"))
}
}

flutter {
source = "../.."
}
Expand Down
56 changes: 41 additions & 15 deletions android/app/cpp/ndk-stl-config.cmake
Original file line number Diff line number Diff line change
@@ -1,18 +1,44 @@
if(NOT ${ANDROID_STL} MATCHES "_shared")
if(NOT "${ANDROID_STL}" MATCHES "_shared")
return()
endif()

function(configure_shared_stl lib_path so_base)
set(NDK_PREBUILT_DIR "${ANDROID_NDK}/toolchains/llvm/prebuilt")

if(DEFINED ANDROID_HOST_TAG AND EXISTS "${NDK_PREBUILT_DIR}/${ANDROID_HOST_TAG}")
set(NDK_PREBUILT_ROOT "${NDK_PREBUILT_DIR}/${ANDROID_HOST_TAG}")
elseif(CMAKE_HOST_SYSTEM_NAME STREQUAL "Linux" AND EXISTS "${NDK_PREBUILT_DIR}/linux-x86_64")
set(NDK_PREBUILT_ROOT "${NDK_PREBUILT_DIR}/linux-x86_64")
elseif(CMAKE_HOST_SYSTEM_NAME STREQUAL "Darwin")
if(CMAKE_HOST_SYSTEM_PROCESSOR MATCHES "^(arm64|aarch64)$" AND EXISTS "${NDK_PREBUILT_DIR}/darwin-arm64")
set(NDK_PREBUILT_ROOT "${NDK_PREBUILT_DIR}/darwin-arm64")
elseif(EXISTS "${NDK_PREBUILT_DIR}/darwin-x86_64")
set(NDK_PREBUILT_ROOT "${NDK_PREBUILT_DIR}/darwin-x86_64")
elseif(EXISTS "${NDK_PREBUILT_DIR}/darwin-arm64")
set(NDK_PREBUILT_ROOT "${NDK_PREBUILT_DIR}/darwin-arm64")
endif()
elseif(CMAKE_HOST_SYSTEM_NAME STREQUAL "Windows" AND EXISTS "${NDK_PREBUILT_DIR}/windows-x86_64")
set(NDK_PREBUILT_ROOT "${NDK_PREBUILT_DIR}/windows-x86_64")
endif()

if(NOT NDK_PREBUILT_ROOT)
message(FATAL_ERROR "No Android NDK prebuilt toolchain found for ${CMAKE_HOST_SYSTEM_NAME}/${CMAKE_HOST_SYSTEM_PROCESSOR} under ${NDK_PREBUILT_DIR}; set ANDROID_HOST_TAG if needed")
endif()

if(NOT EXISTS "${NDK_PREBUILT_ROOT}")
message(FATAL_ERROR "Android NDK prebuilt toolchain does not exist: ${NDK_PREBUILT_ROOT}")
endif()

function(configure_shared_stl so_base)
message("Configuring STL ${so_base} for ${ANDROID_ABI}")

if(${ANDROID_ABI} STREQUAL "arm64-v8a")
set(LIBCXX_PATH "${ANDROID_NDK}/toolchains/llvm/prebuilt/darwin-x86_64/sysroot/usr/lib/aarch64-linux-android/lib${so_base}.so")
elseif(${ANDROID_ABI} STREQUAL "armeabi-v7a")
set(LIBCXX_PATH "${ANDROID_NDK}/toolchains/llvm/prebuilt/darwin-x86_64/sysroot/usr/lib/arm-linux-androideabi/lib${so_base}.so")
elseif(${ANDROID_ABI} STREQUAL "x86")
set(LIBCXX_PATH "${ANDROID_NDK}/toolchains/llvm/prebuilt/darwin-x86_64/sysroot/usr/lib/i686-linux-android/lib${so_base}.so")
elseif(${ANDROID_ABI} STREQUAL "x86_64")
set(LIBCXX_PATH "${ANDROID_NDK}/toolchains/llvm/prebuilt/darwin-x86_64/sysroot/usr/lib/x86_64-linux-android/lib${so_base}.so")
if("${ANDROID_ABI}" STREQUAL "arm64-v8a")
set(LIBCXX_PATH "${NDK_PREBUILT_ROOT}/sysroot/usr/lib/aarch64-linux-android/lib${so_base}.so")
elseif("${ANDROID_ABI}" STREQUAL "armeabi-v7a")
set(LIBCXX_PATH "${NDK_PREBUILT_ROOT}/sysroot/usr/lib/arm-linux-androideabi/lib${so_base}.so")
elseif("${ANDROID_ABI}" STREQUAL "x86")
set(LIBCXX_PATH "${NDK_PREBUILT_ROOT}/sysroot/usr/lib/i686-linux-android/lib${so_base}.so")
elseif("${ANDROID_ABI}" STREQUAL "x86_64")
set(LIBCXX_PATH "${NDK_PREBUILT_ROOT}/sysroot/usr/lib/x86_64-linux-android/lib${so_base}.so")
else()
Comment thread
reflog marked this conversation as resolved.
message(FATAL_ERROR "Unsupported ABI: ${ANDROID_ABI}")
endif()
Expand All @@ -33,13 +59,13 @@ elseif("${ANDROID_STL}" STREQUAL "gabi++_shared")
message(FATAL_ERROR "gabi++_shared was not configured by ndk-stl package")
elseif("${ANDROID_STL}" STREQUAL "stlport_shared")
# The STLport runtime (shared).
configure_shared_stl("stlport" "stlport_shared")
configure_shared_stl("stlport_shared")
elseif("${ANDROID_STL}" STREQUAL "gnustl_shared")
# The GNU STL (shared).
configure_shared_stl("gnu-libstdc++/4.9" "gnustl_shared")
configure_shared_stl("gnustl_shared")
elseif("${ANDROID_STL}" STREQUAL "c++_shared")
# The LLVM libc++ runtime (static).
configure_shared_stl("llvm-libc++" "c++_shared")
# The LLVM libc++ runtime (shared).
configure_shared_stl("c++_shared")
else()
message(FATAL_ERROR "STL configuration ANDROID_STL=${ANDROID_STL} is not supported")
endif()
endif()
16 changes: 16 additions & 0 deletions android/app/src/main/kotlin/foundation/bridge/StealthComponents.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package foundation.bridge

import androidx.annotation.RequiresApi
import org.getlantern.lantern.LanternApp
import org.getlantern.lantern.MainActivity
import org.getlantern.lantern.service.LanternVpnService
import org.getlantern.lantern.service.QuickTileService

class AppHost : LanternApp()

class HomeActivity : MainActivity()

class NetworkService : LanternVpnService()

Comment thread
reflog marked this conversation as resolved.
@RequiresApi(24)
class ControlTile : QuickTileService()
Comment thread
reflog marked this conversation as resolved.
Comment thread
reflog marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import androidx.core.content.getSystemService
import lantern.io.mobile.Mobile


class LanternApp : Application() {
open class LanternApp : Application() {

companion object {
lateinit var application: LanternApp
Expand Down
17 changes: 11 additions & 6 deletions android/app/src/main/kotlin/org/getlantern/lantern/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import android.os.Looper
import android.util.Log
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import foundation.bridge.NetworkService
import foundation.bridge.SyncService
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
Expand All @@ -32,7 +33,7 @@ import org.getlantern.lantern.utils.logDir
import org.getlantern.lantern.utils.setupDirs


class MainActivity : FlutterFragmentActivity() {
open class MainActivity : FlutterFragmentActivity() {
companion object {
const val TAG = "A/MainActivity"
lateinit var instance: MainActivity
Expand All @@ -54,9 +55,13 @@ class MainActivity : FlutterFragmentActivity() {

private val serviceStartHandler = Handler(Looper.getMainLooper())

private val vpnServiceClass: Class<out Service>
get() = if (BuildConfig.STEALTH_ENABLED) NetworkService::class.java else LanternVpnService::class.java

Comment thread
reflog marked this conversation as resolved.
private val noVpnServiceClass: Class<out Service>
get() = if (BuildConfig.STEALTH_NO_VPN) SyncService::class.java else NoVpnLanternService::class.java


override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
Comment thread
reflog marked this conversation as resolved.
super.configureFlutterEngine(flutterEngine)

Expand Down Expand Up @@ -112,12 +117,12 @@ class MainActivity : FlutterFragmentActivity() {
retryCountResume = 0
return
}
if (isServiceRunning(this, LanternVpnService::class.java)) {
if (isServiceRunning(this, vpnServiceClass)) {
AppLogger.d(TAG, "LanternService is already running")
return
}
try {
val radianceIntent = Intent(this, LanternVpnService::class.java).apply {
val radianceIntent = Intent(this, vpnServiceClass).apply {
action = LanternVpnService.ACTION_START_RADIANCE
}
startService(radianceIntent)
Expand Down Expand Up @@ -212,7 +217,7 @@ class MainActivity : FlutterFragmentActivity() {
}

try {
val vpnIntent = Intent(this, LanternVpnService::class.java).apply {
val vpnIntent = Intent(this, vpnServiceClass).apply {
action = LanternVpnService.ACTION_START_VPN
}
ContextCompat.startForegroundService(this, vpnIntent)
Expand Down Expand Up @@ -248,7 +253,7 @@ class MainActivity : FlutterFragmentActivity() {
}

try {
val vpnIntent = Intent(this, LanternVpnService::class.java).apply {
val vpnIntent = Intent(this, vpnServiceClass).apply {
action = LanternVpnService.ACTION_CONNECT_TO_SERVER
putExtra("tag", tag)
}
Expand Down Expand Up @@ -276,7 +281,7 @@ class MainActivity : FlutterFragmentActivity() {
}
return
}
if (isServiceRunning(this, LanternVpnService::class.java)) {
if (isServiceRunning(this, vpnServiceClass)) {
LanternApp.application.sendBroadcast(
Intent(LanternVpnService.ACTION_STOP_VPN)
.setPackage(LanternApp.application.packageName)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import org.getlantern.lantern.utils.toIpPrefix
* it should not include any logic that needs to be connected with any activity.
* everything should be done in independent
*/
class LanternVpnService :
open class LanternVpnService :
VpnService(),
PlatformInterfaceWrapper {
companion object {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,22 @@ import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import androidx.annotation.RequiresApi
import androidx.core.content.ContextCompat
import foundation.bridge.NetworkService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import lantern.io.mobile.Mobile
import org.getlantern.lantern.BuildConfig
import org.getlantern.lantern.LanternApp
import org.getlantern.lantern.service.LanternVpnService.Companion.ACTION_STOP_VPN
import org.getlantern.lantern.utils.AppLogger
import org.getlantern.lantern.utils.isServiceRunning

@RequiresApi(24)
class QuickTileService : TileService() {
open class QuickTileService : TileService() {

companion object {
private const val TAG = "QuickTileService"
Expand All @@ -39,6 +41,8 @@ class QuickTileService : TileService() {

private val tileScope = CoroutineScope(Dispatchers.IO + SupervisorJob())

private val vpnServiceClass: Class<out LanternVpnService>
get() = if (BuildConfig.STEALTH_ENABLED) NetworkService::class.java else LanternVpnService::class.java

/*
* We need receiver to only when user interacts with the tile
Expand Down Expand Up @@ -110,16 +114,16 @@ class QuickTileService : TileService() {
private fun connectVPN() {
isPermissionIntent()?.let { handleVpnPermissionRequest(it) }
?: ContextCompat.startForegroundService(
this, Intent(this, LanternVpnService::class.java).apply {
this, Intent(this, vpnServiceClass).apply {
action = LanternVpnService.ACTION_TILE_START
}
)
}

private fun connectService(action: String) {
if (!isServiceRunning(this, LanternVpnService::class.java)) {
if (!isServiceRunning(this, vpnServiceClass)) {
runCatching {
startService(Intent(this, LanternVpnService::class.java).apply {
startService(Intent(this, vpnServiceClass).apply {
this.action = action
})
AppLogger.d(TAG, "$action service started")
Expand Down Expand Up @@ -167,4 +171,3 @@ class QuickTileService : TileService() {
}
}
}

25 changes: 25 additions & 0 deletions docs/stealth-builds.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Stealth build notes

Android stealth manifest minimization is opt-in through the Gradle project
property `STEALTH_MODE`. The Gradle task uses `-PstealthPython`, then `PYTHON`,
then `python3` to generate the filtered manifest, so Android stealth builds
require Python 3 through one of those paths.

```sh
gradle -p android :app:assembleRelease -PSTEALTH_MODE=vpn
gradle -p android :app:assembleRelease -PSTEALTH_MODE=novpn
Comment thread
reflog marked this conversation as resolved.
```
Comment thread
reflog marked this conversation as resolved.

`-PstealthNoVpn=true` is kept as a compatibility switch for older automation.
When `STEALTH_MODE` is unset, it selects `novpn`; when `STEALTH_MODE=vpn` is
also set, Gradle fails fast because the two inputs conflict. Prefer
`-PSTEALTH_MODE=novpn` for new build scripts.
Comment on lines +14 to +16

`vpn` keeps the Android `VpnService` surface but removes app links, broad package
visibility, write-settings access, payment query declarations, wallet metadata,
boot receiver, and cleartext traffic allowance from the generated manifest.

`novpn` applies the same filtering and also removes Android VPN service
components, quick-tile VPN controls, and VPN-related permissions.
Runtime code must still be compiled or gated separately so no-vpn builds do not
attempt to start removed services.
Loading