Compare commits

..

1 Commits

Author SHA1 Message Date
Yaroslav Gurov ce41645f70 chore: bump awg-go to the latest version 2025-12-01 16:38:51 +01:00
20 changed files with 20 additions and 782 deletions
-141
View File
@@ -1,141 +0,0 @@
name: Android build (signed AAB / debug APK)
run-name: "Android • ${{ inputs.target }} • ${{ inputs.ref || github.ref_name }}"
on:
workflow_dispatch:
inputs:
target:
description: "What to build"
type: choice
required: true
default: release_aab
options:
- release_aab
- debug_apk
ref:
description: "Git ref (branch/tag/sha)"
required: false
default: "master"
jobs:
build:
name: "Build (${{ inputs.target }})"
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
submodules: recursive
fetch-depth: 0
- name: Set up JDK
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- name: Configure build
id: cfg
shell: bash
run: |
set -euo pipefail
case "${{ inputs.target }}" in
release_aab)
echo "gradle_task=bundleRelease" >> "$GITHUB_OUTPUT"
echo "kind=aab" >> "$GITHUB_OUTPUT"
echo "artifact_name=release-aab" >> "$GITHUB_OUTPUT"
echo "needs_signing=true" >> "$GITHUB_OUTPUT"
;;
debug_apk)
echo "gradle_task=assembleDebug" >> "$GITHUB_OUTPUT"
echo "kind=apk" >> "$GITHUB_OUTPUT"
echo "artifact_name=debug-apk" >> "$GITHUB_OUTPUT"
echo "needs_signing=false" >> "$GITHUB_OUTPUT"
;;
*)
echo "Unknown target: ${{ inputs.target }}" >&2
exit 1
;;
esac
- name: Decode keystore from secret
if: ${{ steps.cfg.outputs.needs_signing == 'true' }}
shell: bash
env:
KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
set -euo pipefail
if [[ -z "${KEYSTORE_B64}" ]]; then
echo "Missing secret ANDROID_KEYSTORE_BASE64" >&2
exit 1
fi
echo "${KEYSTORE_B64}" | base64 --decode > release.keystore.jks
echo "KEYSTORE_PATH=${PWD}/release.keystore.jks" >> "$GITHUB_ENV"
- name: Build
shell: bash
run: |
set -euo pipefail
chmod +x ./gradlew
./gradlew --no-daemon --stacktrace ${{ steps.cfg.outputs.gradle_task }}
- name: Locate built artifact
id: locate
shell: bash
run: |
set -euo pipefail
if [[ "${{ steps.cfg.outputs.kind }}" == "aab" ]]; then
FILE="./ui/build/outputs/bundle/release/ui-release.aab"
else
FILE="./ui/build/outputs/apk/debug/ui-debug.apk"
fi
if [[ -z "${FILE}" ]]; then
echo "Built file not found" >&2
exit 1
fi
echo "built_file=${FILE}" >> "$GITHUB_OUTPUT"
- name: Sign AAB
if: ${{ steps.cfg.outputs.needs_signing == 'true' }}
id: sign
shell: bash
env:
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
run: |
set -euo pipefail
AAB="${{ steps.locate.outputs.built_file }}"
SIGNED_AAB="${AAB%.aab}-signed.aab"
if [[ -z "${KEYSTORE_PASSWORD}" || -z "${KEY_ALIAS}" ]]; then
echo "Missing one of secrets: ANDROID_KEYSTORE_PASSWORD / ANDROID_KEY_ALIAS" >&2
exit 1
fi
jarsigner \
-keystore "${KEYSTORE_PATH}" \
-storepass "${KEYSTORE_PASSWORD}" \
-signedjar "${SIGNED_AAB}" \
"${AAB}" \
"${KEY_ALIAS}"
echo "final_file=${SIGNED_AAB}" >> "$GITHUB_OUTPUT"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ steps.cfg.outputs.artifact_name }}
path: ${{ steps.sign.outputs.final_file || steps.locate.outputs.built_file }}
if-no-files-found: error
+3 -10
View File
@@ -1,5 +1,5 @@
amneziawgVersionCode=10
amneziawgVersionName=2.0.0
amneziawgVersionCode=7
amneziawgVersionName=1.1.4
amneziawgPackageName=org.amnezia.awg
# When configured, Gradle will run in incubating parallel mode.
@@ -17,14 +17,7 @@ android.useAndroidX=true
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m --add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED
# Use Java 17+ for Gradle. Set JAVA_HOME or org.gradle.java.home if needed.
# On macOS with Homebrew: org.gradle.java.home=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home
# On Linux: org.gradle.java.home=/usr/lib/jvm/java-17-openjdk
# Kotlin daemon JVM args to allow KAPT access to internal JDK modules
kotlin.daemon.jvmargs=-Xmx1536m --add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED
org.gradle.jvmargs=-Xmx1536m
# Turn off AP discovery in compile path to enable compile avoidance
kapt.include.compile.classpath=false
@@ -46,9 +46,6 @@ public final class AwgQuickBackend implements Backend {
private final Map<Tunnel, Config> runningConfigs = new HashMap<>();
private final ToolsInstaller toolsInstaller;
private boolean multipleTunnels;
@Nullable private Thread statusThread;
@Nullable private StatusCallback statusCallback;
@Nullable private Tunnel currentTunnel;
public AwgQuickBackend(final Context context, final RootShell rootShell, final ToolsInstaller toolsInstaller) {
localTemporaryDir = new File(context.getCacheDir(), "tmp");
@@ -81,106 +78,6 @@ public final class AwgQuickBackend implements Backend {
return getRunningTunnelNames().contains(tunnel.getName()) ? State.UP : State.DOWN;
}
@Override
public long getLastHandshake(final Tunnel tunnel) {
if (getState(tunnel) != State.UP) {
return -3; // Tunnel not active
}
final Collection<String> output = new ArrayList<>();
try {
if (rootShell.run(output, String.format("awg show '%s' latest-handshakes", tunnel.getName())) != 0) {
Log.e(TAG, "Failed to get latest handshakes");
return -2;
}
} catch (final Exception e) {
Log.e(TAG, "Failed to get latest handshakes", e);
return -2;
}
for (final String line : output) {
final String[] parts = line.split("\\t");
if (parts.length >= 2) {
try {
return Long.parseLong(parts[1]);
} catch (final NumberFormatException ignored) {
Log.e(TAG, "Failed to parse handshake time");
return -2;
}
}
}
Log.e(TAG, "No handshake time found");
return -1;
}
/**
* Set a callback to be notified when connection status changes.
*
* @param callback The callback to invoke on status change
*/
public void setStatusCallback(@Nullable final StatusCallback callback) {
this.statusCallback = callback;
}
/**
* Launch a background thread to poll handshake status and determine connection state.
* This is called after tunnel creation to wait for the first successful handshake.
*/
private void launchStatusJob() {
stopStatusJob();
Log.d(TAG, "Launch status job");
statusThread = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
final long lastHandshake = getLastHandshake(currentTunnel);
// Check if tunnel is no longer active (race condition protection)
if (lastHandshake == -3L) {
Log.d(TAG, "Tunnel is no longer active, stopping status job");
break;
}
// 0 means no handshake yet, wait and retry
if (lastHandshake == 0L) {
try {
Thread.sleep(1000);
} catch (final InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
continue;
}
// Only positive handshake time indicates successful connection
// -1 may be returned if unable to parse output (doesn't mean no connection)
// -2 indicates command execution error (also doesn't mean no connection)
if (lastHandshake > 0L) {
if (statusCallback != null) {
statusCallback.onStatusChanged(true);
}
break;
}
// For -1 or -2, retry after delay instead of reporting disconnected
try {
Thread.sleep(1000);
} catch (final InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
statusThread = null;
}, "StatusJob");
statusThread.start();
}
/**
* Stop the status polling thread if running.
*/
private void stopStatusJob() {
if (statusThread != null) {
statusThread.interrupt();
statusThread = null;
}
}
@Override
public Statistics getStatistics(final Tunnel tunnel) {
final Statistics stats = new Statistics();
@@ -288,15 +185,10 @@ public final class AwgQuickBackend implements Backend {
if (result != 0)
throw new BackendException(Reason.AWG_QUICK_CONFIG_ERROR_CODE, result);
if (state == State.UP) {
if (state == State.UP)
runningConfigs.put(tunnel, config);
currentTunnel = tunnel;
launchStatusJob();
} else {
stopStatusJob();
else
runningConfigs.remove(tunnel);
currentTunnel = null;
}
tunnel.onStateChange(state);
}
@@ -34,16 +34,6 @@ public interface Backend {
*/
Tunnel.State getState(Tunnel tunnel) throws Exception;
/**
* Get the last handshake time for a tunnel.
*
* @param tunnel The tunnel to examine.
* @return Last handshake time in seconds (>=0 means valid handshake time, 0 means no handshake yet),
* -1 if parsing failed, -2 on command execution error, -3 if tunnel not active.
* @throws Exception Exception raised when retrieving handshake time.
*/
long getLastHandshake(Tunnel tunnel) throws Exception;
/**
* Get statistics about traffic and errors on this tunnel. If the tunnel is not running, the
* statistics object will be filled with zero values.
@@ -74,11 +64,4 @@ public interface Backend {
* @throws Exception Exception raised while changing state.
*/
Tunnel.State setState(Tunnel tunnel, Tunnel.State state, @Nullable Config config) throws Exception;
/**
* Set the callback for status changes (e.g. handshake / connection state).
*
* @param callback The callback to invoke on status changes, or null to clear.
*/
void setStatusCallback(@Nullable StatusCallback callback);
}
@@ -51,8 +51,6 @@ public final class GoBackend implements Backend {
@Nullable private Config currentConfig;
@Nullable private Tunnel currentTunnel;
private int currentTunnelHandle = -1;
@Nullable private Thread statusThread;
@Nullable private StatusCallback statusCallback;
/**
* Public constructor for GoBackend.
@@ -171,108 +169,6 @@ public final class GoBackend implements Backend {
return stats;
}
/**
* Get the last handshake time for a given {@link Tunnel}.
*
* @param tunnel The tunnel to retrieve the last handshake time for.
* @return Last handshake time in seconds (>=0), -1 if no handshake found, -2 on error, -3 if tunnel not active.
*/
@Override
public long getLastHandshake(final Tunnel tunnel) {
if (tunnel != currentTunnel || currentTunnelHandle == -1)
return -3; // Tunnel not active
final String config = awgGetConfig(currentTunnelHandle);
if (config == null) {
Log.e(TAG, "Failed to get tunnel config");
return -2;
}
for (final String line : config.split("\\n")) {
if (line.startsWith("last_handshake_time_sec=")) {
try {
return Long.parseLong(line.substring(24));
} catch (final NumberFormatException ignored) {
Log.e(TAG, "Failed to parse last_handshake_time_sec");
return -2;
}
}
}
Log.e(TAG, "Failed to get last_handshake_time_sec");
return -1;
}
/**
* Set a callback to be notified when connection status changes.
*
* @param callback The callback to invoke on status change
*/
public void setStatusCallback(@Nullable final StatusCallback callback) {
this.statusCallback = callback;
}
/**
* Launch a background thread to poll handshake status and determine connection state.
* This is called after tunnel creation to wait for the first successful handshake.
*/
private void launchStatusJob() {
stopStatusJob();
Log.d(TAG, "Launch status job");
statusThread = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
final long lastHandshake = getLastHandshake(currentTunnel);
// Check if tunnel is no longer active (race condition protection)
if (lastHandshake == -3L) {
Log.d(TAG, "Tunnel is no longer active, stopping status job");
break;
}
// 0 means no handshake yet, wait and retry
if (lastHandshake == 0L) {
try {
Thread.sleep(1000);
} catch (final InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
continue;
}
// Only positive handshake time indicates successful connection
// -1 may be returned if unable to parse output (doesn't mean no connection)
// -2 indicates command execution error (also doesn't mean no connection)
if (lastHandshake > 0L) {
if (statusCallback != null) {
statusCallback.onStatusChanged(true);
}
break;
}
// For -1 or -2, retry after delay instead of reporting disconnected
try {
Thread.sleep(1000);
} catch (final InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
statusThread = null;
}, "StatusJob");
statusThread.start();
}
/**
* Stop the status polling thread if running.
*/
private void stopStatusJob() {
if (statusThread != null) {
statusThread.interrupt();
statusThread = null;
}
}
/**
* Get the version of the underlying amneziawg-go library.
*
@@ -428,14 +324,11 @@ public final class GoBackend implements Backend {
service.protect(awgGetSocketV4(currentTunnelHandle));
service.protect(awgGetSocketV6(currentTunnelHandle));
launchStatusJob();
} else {
if (currentTunnelHandle == -1) {
Log.w(TAG, "Tunnel already down");
return;
}
stopStatusJob();
int handleToClose = currentTunnelHandle;
currentTunnel = null;
currentTunnelHandle = -1;
@@ -1,14 +0,0 @@
package org.amnezia.awg.backend;
/**
* Callback for status changes detected by the status polling job.
*/
public interface StatusCallback {
/**
* Called when connection status is determined.
*
* @param connected true if handshake was successful (connected), false if disconnected
*/
void onStatusChanged(boolean connected);
}
@@ -14,7 +14,7 @@ import java.util.regex.Pattern;
@NonNullForAll
public final class Attribute {
private static final Pattern LINE_PATTERN = Pattern.compile("(\\w+)\\s*=\\s*(.*?)\\s*$");
private static final Pattern LINE_PATTERN = Pattern.compile("(\\w+)\\s*=\\s*([^\\s#][^#]*)");
private static final Pattern LIST_SEPARATOR = Pattern.compile("\\s*,\\s*");
private final String key;
-1
View File
@@ -5,7 +5,6 @@
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission
android:name="android.permission.SYSTEM_ALERT_WINDOW"
@@ -22,8 +22,6 @@ import org.amnezia.awg.backend.GoBackend
import org.amnezia.awg.backend.AwgQuickBackend
import org.amnezia.awg.configStore.FileConfigStore
import org.amnezia.awg.model.TunnelManager
import org.amnezia.awg.util.NetworkState
import org.amnezia.awg.util.NetworkType
import org.amnezia.awg.util.RootShell
import org.amnezia.awg.util.ToolsInstaller
import org.amnezia.awg.util.UserKnobs
@@ -49,12 +47,10 @@ class Application : android.app.Application() {
private lateinit var preferencesDataStore: DataStore<Preferences>
private lateinit var toolsInstaller: ToolsInstaller
private lateinit var tunnelManager: TunnelManager
private lateinit var networkState: NetworkState
override fun attachBaseContext(context: Context) {
super.attachBaseContext(context)
if (BuildConfig.MIN_SDK_VERSION > Build.VERSION.SDK_INT) {
@Suppress("UnsafeImplicitIntentLaunch")
val intent = Intent(Intent.ACTION_MAIN)
intent.addCategory(Intent.CATEGORY_HOME)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
@@ -111,18 +107,10 @@ class Application : android.app.Application() {
}
tunnelManager = TunnelManager(FileConfigStore(applicationContext))
tunnelManager.onCreate()
// Initialize network state monitor for auto-reconnection
networkState = NetworkState(applicationContext) { oldType, newType ->
Log.i(TAG, "NetworkState callback: Network changed: $oldType -> $newType")
onNetworkChange(oldType, newType)
}
coroutineScope.launch(Dispatchers.IO) {
try {
backend = determineBackend()
futureBackend.complete(backend!!)
networkState.bindNetworkListener()
} catch (e: Throwable) {
Log.e(TAG, Log.getStackTraceString(e))
}
@@ -135,55 +123,10 @@ class Application : android.app.Application() {
}
override fun onTerminate() {
networkState.unbindNetworkListener()
coroutineScope.cancel()
super.onTerminate()
}
/**
* Called when network changes (e.g., WiFi to Mobile or vice versa).
* Reconnects active tunnels to ensure VPN connection works on new network.
*/
private fun onNetworkChange(oldType: NetworkType, newType: NetworkType) {
Log.i(TAG, "onNetworkChange called: $oldType -> $newType")
if (newType == NetworkType.NONE) {
Log.i(TAG, "Network lost, waiting for new connection...")
return
}
coroutineScope.launch {
try {
val activeTunnels = tunnelManager.getTunnels().filter {
it.state == org.amnezia.awg.backend.Tunnel.State.UP
}
if (activeTunnels.isEmpty()) {
Log.d(TAG, "No active tunnels, skipping reconnection")
return@launch
}
Log.i(TAG, "Reconnecting ${activeTunnels.size} tunnel(s) after network change: $oldType -> $newType")
for (tunnel in activeTunnels) {
try {
Log.d(TAG, "Disconnecting tunnel: ${tunnel.name}")
// Toggle tunnel off and on to reconnect
tunnel.setStateAsync(org.amnezia.awg.backend.Tunnel.State.DOWN)
kotlinx.coroutines.delay(500) // Small delay for cleanup
Log.d(TAG, "Reconnecting tunnel: ${tunnel.name}")
tunnel.setStateAsync(org.amnezia.awg.backend.Tunnel.State.UP)
Log.i(TAG, "Successfully reconnected tunnel: ${tunnel.name}")
} catch (e: Exception) {
Log.e(TAG, "Failed to reconnect tunnel ${tunnel.name}", e)
}
}
} catch (e: Exception) {
Log.e(TAG, "Error during network change handling", e)
}
}
}
companion object {
val USER_AGENT = String.format(Locale.ENGLISH, "AmneziaWG/%s (Android %d; %s; %s; %s %s; %s)", BuildConfig.VERSION_NAME, Build.VERSION.SDK_INT, if (Build.SUPPORTED_ABIS.isNotEmpty()) Build.SUPPORTED_ABIS[0] else "unknown ABI", Build.BOARD, Build.MANUFACTURER, Build.MODEL, Build.FINGERPRINT)
private const val TAG = "AmneziaWG/Application"
@@ -204,8 +147,6 @@ class Application : android.app.Application() {
fun getTunnelManager() = get().tunnelManager
fun getCoroutineScope() = get().coroutineScope
fun getNetworkState() = get().networkState
}
init {
@@ -59,7 +59,7 @@ class QuickTileService : TileService() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startActivityAndCollapse(PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE))
} else {
@Suppress("DEPRECATION", "StartActivityAndCollapseDeprecated")
@Suppress("DEPRECATION")
startActivityAndCollapse(intent)
}
}
@@ -57,8 +57,8 @@ import kotlinx.coroutines.withContext
import java.io.File
class TvMainActivity : AppCompatActivity() {
private val tunnelFileImportResultLauncher = registerForActivityResult(object : ActivityResultContracts.OpenDocument() {
override fun createIntent(context: Context, input: Array<String>): Intent {
private val tunnelFileImportResultLauncher = registerForActivityResult(object : ActivityResultContracts.GetContent() {
override fun createIntent(context: Context, input: String): Intent {
val intent = super.createIntent(context, input)
/* AndroidTV now comes with stubs that do nothing but display a Toast less helpful than
@@ -209,15 +209,12 @@ class TvMainActivity : AppCompatActivity() {
}
} else {
try {
tunnelFileImportResultLauncher.launch(arrayOf("*/*"))
tunnelFileImportResultLauncher.launch("*/*")
} catch (_: Throwable) {
MaterialAlertDialogBuilder(binding.root.context).setMessage(R.string.tv_no_file_picker).setCancelable(false)
.setPositiveButton(android.R.string.ok) { _, _ ->
try {
startActivity(Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse("https://play.google.com/store/apps/details?id=com.cxinventor.file.explorer")
setPackage("com.android.vending")
})
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://webstoreredirect")))
} catch (_: Throwable) {
}
}.show()
@@ -50,42 +50,17 @@ class ObservableTunnel internal constructor(
var state = state
private set
@get:Bindable
var connectionStatus: ConnectionStatus = ConnectionStatus.DISCONNECTED
private set
override fun onStateChange(newState: Tunnel.State) {
onStateChanged(newState)
}
fun onStateChanged(state: Tunnel.State): Tunnel.State {
if (state != Tunnel.State.UP) {
onStatisticsChanged(null)
onConnectionStatusChanged(ConnectionStatus.DISCONNECTED)
} else if (connectionStatus == ConnectionStatus.DISCONNECTED) {
// When state changes to UP, set to CONNECTING until handshake confirms
onConnectionStatusChanged(ConnectionStatus.CONNECTING)
}
if (state != Tunnel.State.UP) onStatisticsChanged(null)
this.state = state
notifyPropertyChanged(BR.state)
return state
}
fun onConnectionStatusChanged(status: ConnectionStatus): ConnectionStatus {
if (status != this.connectionStatus) {
this.connectionStatus = status
notifyPropertyChanged(BR.connectionStatus)
Log.d(TAG, "Connection status changed for $name: $status")
}
return status
}
enum class ConnectionStatus {
DISCONNECTED,
CONNECTING,
CONNECTED
}
suspend fun setStateAsync(state: Tunnel.State): Tunnel.State = withContext(Dispatchers.Main.immediate) {
if (state != this@ObservableTunnel.state)
manager.setTunnelState(this@ObservableTunnel, state)
@@ -18,7 +18,6 @@ import org.amnezia.awg.Application.Companion.getTunnelManager
import org.amnezia.awg.BR
import org.amnezia.awg.R
import org.amnezia.awg.backend.Statistics
import org.amnezia.awg.backend.StatusCallback
import org.amnezia.awg.backend.Tunnel
import org.amnezia.awg.configStore.ConfigStore
import org.amnezia.awg.databinding.ObservableSortedKeyedArrayList
@@ -103,41 +102,12 @@ class TunnelManager(private val configStore: ConfigStore) : BaseObservable() {
applicationScope.launch {
try {
onTunnelsLoaded(withContext(Dispatchers.IO) { configStore.enumerate() }, withContext(Dispatchers.IO) { getBackend().runningTunnelNames })
setupStatusCallbacks()
} catch (e: Throwable) {
Log.e(TAG, Log.getStackTraceString(e))
}
}
}
private fun setupStatusCallbacks() {
applicationScope.launch {
try {
val backend = getBackend()
val statusCallback = object : StatusCallback {
override fun onStatusChanged(connected: Boolean) {
applicationScope.launch(Dispatchers.Main) {
// Find the currently active tunnel
val activeTunnel = tunnelMap.firstOrNull { it.state == Tunnel.State.UP }
if (activeTunnel != null) {
val newStatus = if (connected) {
ObservableTunnel.ConnectionStatus.CONNECTED
} else {
ObservableTunnel.ConnectionStatus.CONNECTING
}
activeTunnel.onConnectionStatusChanged(newStatus)
}
}
}
}
backend.setStatusCallback(statusCallback)
} catch (e: Throwable) {
Log.e(TAG, "Failed to setup status callbacks", e)
}
}
}
private fun onTunnelsLoaded(present: Iterable<String>, running: Collection<String>) {
for (name in present)
addToList(name, null, if (running.contains(name)) Tunnel.State.UP else Tunnel.State.DOWN)
@@ -1,213 +0,0 @@
/*
* Copyright © 2025 AmneziaWG. All Rights conneserved.
* SPDX-License-Identifier: Apache-2.0
*/
package org.amnezia.awg.util
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.ConnectivityManager.NetworkCallback
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET
import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED
import android.net.NetworkCapabilities.TRANSPORT_CELLULAR
import android.net.NetworkCapabilities.TRANSPORT_WIFI
import android.net.NetworkRequest
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import kotlinx.coroutines.delay
private const val TAG = "AmneziaWG/NetworkState"
private const val BIND_NETWORK_RETRY_ATTEMPTS = 5
enum class NetworkType {
NONE, WIFI, CELLULAR, OTHER
}
class NetworkState(
private val context: Context,
private val onNetworkChange: (NetworkType, NetworkType) -> Unit
) {
private var currentNetwork: Network? = null
private var currentNetworkType: NetworkType = NetworkType.NONE
private var validated: Boolean = false
private var isListenerBound = false
private val handler: Handler by lazy {
Handler(Looper.getMainLooper())
}
private val connectivityManager: ConnectivityManager by lazy {
context.getSystemService<ConnectivityManager>()!!
}
private val networkRequest: NetworkRequest by lazy {
NetworkRequest.Builder()
.addCapability(NET_CAPABILITY_INTERNET)
.addTransportType(TRANSPORT_WIFI)
.addTransportType(TRANSPORT_CELLULAR)
.build()
}
private val networkCallback: NetworkCallback by lazy {
object : NetworkCallback() {
override fun onAvailable(network: Network) {
Log.d(TAG, "onAvailable: $network")
}
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
val networkType = getNetworkType(networkCapabilities)
val isValidated = networkCapabilities.hasCapability(NET_CAPABILITY_VALIDATED)
Log.d(TAG, "onCapabilitiesChanged: network=$network, type=$networkType, validated=$isValidated")
checkNetworkState(network, networkCapabilities)
}
private fun checkNetworkState(network: Network, networkCapabilities: NetworkCapabilities) {
val newNetworkType = getNetworkType(networkCapabilities)
val isValidated = networkCapabilities.hasCapability(NET_CAPABILITY_VALIDATED)
if (currentNetwork == null) {
// First network connection
currentNetwork = network
currentNetworkType = newNetworkType
validated = isValidated
Log.d(TAG, "Initial network: $newNetworkType, validated: $validated")
} else {
if (currentNetwork != network || currentNetworkType != newNetworkType) {
// Network changed (e.g., WiFi to Cellular or vice versa)
val oldNetworkType = currentNetworkType
currentNetwork = network
currentNetworkType = newNetworkType
validated = false
Log.d(TAG, "Network changed: $oldNetworkType -> $newNetworkType")
if (isValidated) {
validated = true
handler.post {
onNetworkChange(oldNetworkType, newNetworkType)
}
}
} else if (!validated && isValidated) {
// Same network became validated
validated = true
Log.d(TAG, "Network validated: $newNetworkType")
handler.post {
onNetworkChange(currentNetworkType, newNetworkType)
}
}
}
}
private fun getNetworkType(capabilities: NetworkCapabilities): NetworkType {
return when {
capabilities.hasTransport(TRANSPORT_WIFI) -> NetworkType.WIFI
capabilities.hasTransport(TRANSPORT_CELLULAR) -> NetworkType.CELLULAR
else -> NetworkType.OTHER
}
}
override fun onLost(network: Network) {
Log.d(TAG, "onLost: $network, currentNetwork: $currentNetwork")
if (currentNetwork == network) {
val oldType = currentNetworkType
currentNetwork = null
currentNetworkType = NetworkType.NONE
validated = false
Log.d(TAG, "Network lost: $oldType -> NONE")
handler.post {
onNetworkChange(oldType, NetworkType.NONE)
}
}
}
}
}
suspend fun bindNetworkListener() {
if (isListenerBound) {
Log.d(TAG, "Network listener already bound")
return
}
// Check if we have the required permission
if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_NETWORK_STATE) != PackageManager.PERMISSION_GRANTED) {
Log.e(TAG, "ACCESS_NETWORK_STATE permission not granted, cannot bind network listener")
return
}
Log.i(TAG, "Binding network listener (SDK ${Build.VERSION.SDK_INT})")
var attemptCount = 0
while (true) {
try {
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
connectivityManager.registerBestMatchingNetworkCallback(networkRequest, networkCallback, handler)
}
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> {
connectivityManager.registerNetworkCallback(networkRequest, networkCallback, handler)
}
else -> {
connectivityManager.registerNetworkCallback(networkRequest, networkCallback)
}
}
isListenerBound = true
Log.i(TAG, "Network listener bound successfully")
break
} catch (e: SecurityException) {
Log.e(TAG, "Failed to bind network listener: $e")
// Android 11 bug: https://issuetracker.google.com/issues/175055271
if (e.message?.startsWith("Package android does not belong to") == true) {
if (++attemptCount >= BIND_NETWORK_RETRY_ATTEMPTS) {
throw e
}
delay(1000)
continue
} else {
throw e
}
} catch (e: Exception) {
Log.e(TAG, "Failed to bind network listener", e)
throw e
}
}
}
fun unbindNetworkListener() {
if (!isListenerBound) {
Log.d(TAG, "Network listener not bound, nothing to unbind")
return
}
Log.d(TAG, "Unbind network listener")
try {
connectivityManager.unregisterNetworkCallback(networkCallback)
Log.d(TAG, "Network listener unbound successfully")
} catch (e: SecurityException) {
Log.e(TAG, "SecurityException while unbinding network listener", e)
} catch (e: IllegalArgumentException) {
// Callback was not registered, ignore
Log.w(TAG, "Callback was not registered", e)
} catch (e: Exception) {
Log.e(TAG, "Failed to unbind network listener", e)
}
isListenerBound = false
currentNetwork = null
currentNetworkType = NetworkType.NONE
validated = false
}
fun getCurrentNetworkType(): NetworkType = currentNetworkType
fun isConnected(): Boolean = validated && currentNetworkType != NetworkType.NONE
}
+7 -29
View File
@@ -7,8 +7,6 @@
<import type="org.amnezia.awg.model.ObservableTunnel" />
<import type="org.amnezia.awg.model.ObservableTunnel.ConnectionStatus" />
<import type="org.amnezia.awg.backend.Tunnel.State" />
<variable
@@ -39,37 +37,17 @@
android:paddingHorizontal="16dp"
android:paddingVertical="8dp">
<LinearLayout
android:id="@+id/tunnel_info_layout"
<TextView
android:id="@+id/tunnel_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:layout_toStartOf="@+id/tunnel_switch"
android:orientation="vertical">
<TextView
android:id="@+id/tunnel_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="@{key}"
android:textAppearance="?attr/textAppearanceBodyLarge"
tools:text="@sample/interface_names.json/names/names/name" />
<TextView
android:id="@+id/tunnel_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="@{item.connectionStatus == ConnectionStatus.CONNECTED ? @string/tunnel_status_connected : @string/tunnel_status_connecting}"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="@{item.connectionStatus == ConnectionStatus.CONNECTED ? @color/tunnel_status_connected : @color/tunnel_status_connecting}"
android:visibility="@{item.connectionStatus == ConnectionStatus.DISCONNECTED ? android.view.View.GONE : android.view.View.VISIBLE}"
tools:text="Подключено"
tools:textColor="@color/tunnel_status_connected" />
</LinearLayout>
android:ellipsize="end"
android:maxLines="1"
android:text="@{key}"
android:textAppearance="?attr/textAppearanceBodyLarge"
tools:text="@sample/interface_names.json/names/names/name" />
<org.amnezia.awg.widget.ToggleSwitch
android:id="@+id/tunnel_switch"
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Status colors for dark theme -->
<color name="tunnel_status_connected">#66BB6A</color>
<color name="tunnel_status_connecting">#FFB74D</color>
<color name="tunnel_status_disconnected">#EF5350</color>
</resources>
-2
View File
@@ -270,6 +270,4 @@
<string name="biometric_auth_error">Ошибка аутентификации</string>
<string name="biometric_auth_error_reason">Ошибка аутентификации: %s</string>
<string name="import_disclaimer">Убедитесь, что вы получили файл конфигурации в надёжном источнике.\n\nОфициальные сервисы Amnezia доступны только на сайте <a href="https://storage.googleapis.com/amnezia/amnezia.org">amnezia.org</a>\n</string>
<string name="tunnel_status_connected">Подключено</string>
<string name="tunnel_status_connecting">Подключение…</string>
</resources>
-3
View File
@@ -60,7 +60,4 @@
<color name="md_theme_dark_surfaceTint">#ADC7FF</color>
<color name="md_theme_dark_outlineVariant">#44474F</color>
<color name="md_theme_dark_scrim">#000000</color>
<color name="tunnel_status_connected">#4CAF50</color>
<color name="tunnel_status_connecting">#FF9800</color>
<color name="tunnel_status_disconnected">#F44336</color>
</resources>
-2
View File
@@ -261,6 +261,4 @@
<string name="biometric_auth_error">Authentication failure</string>
<string name="biometric_auth_error_reason">Authentication failure: %s</string>
<string name="import_disclaimer">Ensure that you obtained the configuration file from a trusted source.\n\nOfficial Amnezia services are available only at <a href="https://amnezia.org">amnezia.org</a>\n</string>
<string name="tunnel_status_connected">Connected</string>
<string name="tunnel_status_connecting">Connecting…</string>
</resources>