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
21 changes: 21 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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; \
Expand All @@ -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

Comment thread
reflog marked this conversation as resolved.
.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
Expand Down
37 changes: 33 additions & 4 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ' <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />'
def sideloadInstallPermission = ' <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />'
Expand Down Expand Up @@ -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']
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'
}
}
}

Expand Down
2 changes: 2 additions & 0 deletions android/app/proguard-stealth-novpn.pro
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-checkdiscard class org.getlantern.lantern.service.LanternVpnService
-checkdiscard class org.getlantern.lantern.service.QuickTileService
129 changes: 129 additions & 0 deletions android/app/src/main/AndroidManifest.novpn.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:installLocation="auto">

<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>

<!--
Stealth no-VPN manifest.

Identity (label, icon, scheme) is injected via manifestPlaceholders so
the integrator can supply neutral / randomized values without modifying
this file. Placeholders mirror the naming used by the identity
randomization PR (#8781):
appLabel – visible app name
appIcon – launcher icon (mipmap reference)
appRoundIcon – round launcher icon (mipmap reference)
appAuthScheme – custom-scheme fallback for deep-links

Surfaces removed vs the normal manifest (stealth / QA requirements):
- usesCleartextTraffic (proxy handles TLS termination)
- Brand domain app-links with domain verification
- Hardcoded custom scheme replaced with ${appAuthScheme} placeholder
- Payment-provider metadata and query intents
- VPN service, quick-tile service, TUN (removed in earlier commits)

android:name references the application class by its current source name.
The identity randomization PR (#8781) renames this class; the reference
here will be updated to match when that lands.
-->

<application
android:name=".LanternApp"
android:icon="${appIcon}"
android:label="${appLabel}"
android:roundIcon="${appRoundIcon}">

<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:exported="true"
android:hardwareAccelerated="true"
android:launchMode="singleTask"
android:taskAffinity=""
android:screenOrientation="portrait"
android:theme="@style/LaunchTheme"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<meta-data
android:name="flutter_deeplinking_enabled"
android:value="false" />

<!-- Custom-scheme deep-link fallback using a neutral per-build scheme.
The ${appAuthScheme} placeholder is supplied by build.gradle
manifestPlaceholders and must be set to a value that does not
contain any brand-identifying token. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="${appAuthScheme}" android:host="auth" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="${appAuthScheme}" android:host="report-issue" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="${appAuthScheme}" android:host="private-server" />
</intent-filter>

</activity>

<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED"/>
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON"/>
</intent-filter>
</receiver>

<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />

<service
android:name="foundation.bridge.SyncService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="User-controlled local proxy connection" />
</service>

</application>

<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain" />
</intent>
</queries>

</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package foundation.bridge

import org.getlantern.lantern.service.NoVpnLanternService

class SyncService : NoVpnLanternService()
69 changes: 67 additions & 2 deletions android/app/src/main/kotlin/org/getlantern/lantern/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -51,6 +54,8 @@ class MainActivity : FlutterFragmentActivity() {

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

private val noVpnServiceClass: Class<out Service>
get() = if (BuildConfig.STEALTH_NO_VPN) SyncService::class.java else NoVpnLanternService::class.java

override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
Expand Down Expand Up @@ -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
Comment thread
reflog marked this conversation as resolved.
}
if (isServiceRunning(this, LanternVpnService::class.java)) {
AppLogger.d(TAG, "LanternService is already running")
return
Expand All @@ -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
Comment thread
reflog marked this conversation as resolved.
} 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) {
Expand All @@ -133,7 +166,7 @@ class MainActivity : FlutterFragmentActivity() {

AppLogger.d(TAG, "Scheduling immediate retry #$retryCount in ${delay}ms")
serviceStartHandler.postDelayed({
startLanternService()
retryServiceStart()
}, delay)
} else {
/*
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading