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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,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 @@ -564,6 +565,9 @@ android-stealth-novpn-aab-release:
.PHONY: android-stealth-novpn-release
android-stealth-novpn-release: android pubget gen android-stealth-novpn-apk-release android-stealth-novpn-aab-release

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

.PHONY: android-release
android-release: clean android pubget gen android-apk-release
Expand Down
118 changes: 108 additions & 10 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
@@ -1,3 +1,48 @@
import com.android.build.api.artifact.SingleArtifact
import org.gradle.api.DefaultTask
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.TaskAction

/**
* Gradle task that runs the stealth manifest filter on a single input manifest
* file and writes the result to an output file. Participates in AGP's artifact
* transform pipeline via SingleArtifact.MERGED_MANIFEST so the filter sees the
* fully-merged, placeholder-resolved manifest (library AARs already contributed).
*/
abstract class StealthManifestFilterTask extends DefaultTask {
@InputFile
abstract RegularFileProperty getInputManifest()

@OutputFile
abstract RegularFileProperty getOutputManifest()

@Input
abstract Property<String> getStealthMode()

@Input
abstract Property<String> getStealthPython()

@InputFile
abstract RegularFileProperty getFilterScript()

@TaskAction
void filter() {
project.exec {
commandLine(
stealthPython.get(),
filterScript.asFile.get().absolutePath,
"--mode", stealthMode.get(),
"--input", inputManifest.asFile.get().absolutePath,
"--output", outputManifest.asFile.get().absolutePath,
)
}
}
}

plugins {
id "com.android.application"
id "kotlin-android"
Expand All @@ -17,7 +62,36 @@ 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
// ── Stealth manifest mode ────────────────────────────────────────────────────
// STEALTH_MODE accepts "stealth-vpn" or "stealth-novpn" (the "stealth-" prefix
// is stripped internally). The legacy -PstealthNoVpn=true flag is kept for
// backward compat with older automation; it implies novpn when STEALTH_MODE
// is not set.
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=stealth-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 stealth-vpn or stealth-novpn")
}
Comment thread
reflog marked this conversation as resolved.
def stealthNoVpn = explicitStealthNoVpn || stealthMode == "novpn"
def stealthManifestFilter = file("${rootProject.projectDir}/../scripts/stealth/android_manifest_filter.py")
def stealthPython = (project.findProperty("stealthPython") ?: System.getenv("PYTHON") ?: "python3").toString()
// ─────────────────────────────────────────────────────────────────────────────

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.
Expand Down Expand Up @@ -53,14 +127,14 @@ android {
sourceSets {
main {
// 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) {
// - STEALTH_MODE set → src/main manifest used as-is; the stealth
// filter runs on the AGP-merged manifest in
// afterEvaluate (post-library-merge).
// - sideloadUpdates=true (non-stealth only) → source manifest with
// REQUEST_INSTALL_PACKAGES injected pre-merge.
// - neither → normal manifest (default, implicit).
// Note: sideload updates are not supported for stealth builds.
if (sideloadUpdates && !stealthMode) {
manifest.srcFile sideloadManifestPath
}
jniLibs.srcDirs = ['libs']
Expand Down Expand Up @@ -123,6 +197,7 @@ android {
versionCode = code
versionName = flutter.versionName
buildConfigField "String", "SIDELOAD_SIGNING_CERTIFICATE_SHA256", "\"${sideloadSigningCertificateSha256}\""
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 @@ -199,11 +274,34 @@ android {

}

if (sideloadUpdates) {
if (sideloadUpdates && !stealthMode) {
tasks.matching { it.name.startsWith("process") && it.name.endsWith("MainManifest") }
.configureEach { dependsOn(generateSideloadManifest) }
}

// Wire the stealth manifest filter into AGP's artifact transform pipeline.
// SingleArtifact.MERGED_MANIFEST is the post-library-merge, post-placeholder-
// resolved manifest — the only point where library-injected entries (Stripe,
// Google Play Billing, Firebase/MLKit) are present for removal.
if (stealthMode) {
androidComponents {
onVariants { variant ->
def filterTask = tasks.register(
"filter${variant.name.capitalize()}Manifest",
StealthManifestFilterTask
) { task ->
task.stealthMode.set(stealthMode)
task.stealthPython.set(stealthPython)
task.filterScript.set(stealthManifestFilter)
}
variant.artifacts
.use(filterTask)
.wiredWithFiles({ it.getInputManifest() }, { it.getOutputManifest() })
.toTransform(SingleArtifact.MERGED_MANIFEST)
}
}
}

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.
16 changes: 10 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,6 +55,9 @@ 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

Expand Down Expand Up @@ -112,12 +116,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 +216,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 +252,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 +280,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
32 changes: 32 additions & 0 deletions docs/stealth-builds.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# 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.8 or newer through one of those paths.

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

The filter runs on the AGP-merged manifest (after library AARs such as Stripe
and Google Play Billing have contributed their activities and services) so that
all library-injected manifest entries are available for removal.

`-PstealthNoVpn=true` is kept as a compatibility switch for older automation.
It only selects `novpn` mode when `STEALTH_MODE` is **not** set; if both
`STEALTH_MODE` and `-PstealthNoVpn` are unset, the build is a normal
non-stealth build. When `-PstealthNoVpn=true` and `STEALTH_MODE=stealth-vpn`
are both set, Gradle fails fast because the two inputs conflict. Prefer
`-PSTEALTH_MODE=stealth-novpn` for new build scripts.

`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
Loading