Compare commits

...

11 Commits

Author SHA1 Message Date
NickVs2015 4941fcad23 feat: add edge-to-edge android 15 view 2026-03-24 13:12:45 +03:00
vkamn 4116c83624 chore: bump version (#63) 2026-03-13 10:56:03 +08:00
albexk 762e82c71f Feat/build action (#62)
* feat: add build script

* fix: fix build script

* fix: remove `jarsigner -verify`
2026-03-02 20:23:16 +03:00
albexk 8db4f52988 feat: add build script (#61) 2026-03-02 19:52:39 +03:00
Yaroslav Gurov 728c92916f feat: update awg-tools 2026-02-23 13:08:17 +01:00
albexk 9b3f6c0e60 Chore/bump (#58)
* chore: version bump to 2.0.0
2026-02-16 13:23:07 +03:00
albexk 1377eff79c chore: version bump to 1.1.5 (#57) 2026-02-16 13:06:20 +03:00
NickVs2015 108ae68256 feat: parse awg2 config (#54) 2026-02-11 11:11:24 +08:00
NickVs2015 dd8c98db16 feat: add new features with connection status and support ATV (#53)
* feat: add check status from AWG

* feat: add network reconnection

* feat: atv 14 fix open files

* feat: add UI status connection, fix review issue

* fix after review
2026-02-11 11:10:58 +08:00
Yaroslav Gurov c4b568a619 chore: bump awg-go to the latest version (#51) 2025-12-01 17:03:06 +01:00
albexk 24ef24e68b sdk 35 (#49) 2025-11-24 16:41:11 +03:00
28 changed files with 827 additions and 44 deletions
+141
View File
@@ -0,0 +1,141 @@
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
+10 -3
View File
@@ -1,5 +1,5 @@
amneziawgVersionCode=7
amneziawgVersionName=1.1.4
amneziawgVersionCode=10
amneziawgVersionName=2.0.0
amneziawgPackageName=org.amnezia.awg
# When configured, Gradle will run in incubating parallel mode.
@@ -17,7 +17,14 @@ 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
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
# Turn off AP discovery in compile path to enable compile avoidance
kapt.include.compile.classpath=false
+2 -2
View File
@@ -27,8 +27,8 @@ include(":tunnel")
include(":ui")
configure<SettingsExtension> {
buildToolsVersion = "34.0.0"
compileSdk = 34
buildToolsVersion = "35.0.0"
compileSdk = 35
minSdk = 24
ndkVersion = "26.1.10909125"
}
@@ -46,6 +46,9 @@ 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");
@@ -78,6 +81,106 @@ 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();
@@ -185,10 +288,15 @@ 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);
else
currentTunnel = tunnel;
launchStatusJob();
} else {
stopStatusJob();
runningConfigs.remove(tunnel);
currentTunnel = null;
}
tunnel.onStateChange(state);
}
@@ -34,6 +34,16 @@ 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.
@@ -64,4 +74,11 @@ 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,6 +51,8 @@ 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.
@@ -169,6 +171,108 @@ 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.
*
@@ -324,11 +428,14 @@ 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;
@@ -0,0 +1,14 @@
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 -4
View File
@@ -3,15 +3,12 @@ module github.com/amnezia-vpn/amneziawg-android
go 1.24.4
require (
github.com/amnezia-vpn/amneziawg-go v0.2.15
github.com/amnezia-vpn/amneziawg-go v0.2.16
golang.org/x/sys v0.33.0
)
require (
github.com/tevino/abool v1.2.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/crypto v0.39.0 // indirect
golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 // indirect
golang.org/x/net v0.41.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
)
+2 -14
View File
@@ -1,21 +1,11 @@
github.com/amnezia-vpn/amneziawg-go v0.2.15 h1:hQnFOJJHXrInorORe3JJwiAD55m1an81EGSW3AdbT74=
github.com/amnezia-vpn/amneziawg-go v0.2.15/go.mod h1:nRkPpIzjCxMW8pZKXTRkpqAQVlmFJdVOGkeQSC7wbms=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/amnezia-vpn/amneziawg-go v0.2.16 h1:XY6HOq/xtqH8ZXMncRWkjFs85EKdN10NLNnw23kTpE0=
github.com/amnezia-vpn/amneziawg-go v0.2.16/go.mod h1:nRkPpIzjCxMW8pZKXTRkpqAQVlmFJdVOGkeQSC7wbms=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tevino/abool v1.2.0 h1:heAkClL8H6w+mK5md9dzsuohKeXHUpY7Vw0ZCKW+huA=
github.com/tevino/abool v1.2.0/go.mod h1:qc66Pna1RiIsPa7O4Egxxs9OqkuxDX55zznh9K07Tzg=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 h1:Di6/M8l0O2lCLc6VVRWhgCiApHV8MnQurBnFSHsQtNY=
golang.org/x/exp v0.0.0-20230725093048-515e97ebf090/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
@@ -24,7 +14,5 @@ golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gvisor.dev/gvisor v0.0.0-20231202080848-1f7806d17489 h1:ze1vwAdliUAr68RQ5NtufWaXaOg8WUO2OACzEV+TNdE=
gvisor.dev/gvisor v0.0.0-20231202080848-1f7806d17489/go.mod h1:10sU+Uh5KKNv1+2x2A0Gvzt8FjD3ASIhorV3YsauXhk=
+1 -1
View File
@@ -20,7 +20,7 @@ android {
namespace = pkg
defaultConfig {
applicationId = pkg
targetSdk = 34
targetSdk = 35
versionCode = providers.gradleProperty("amneziawgVersionCode").get().toInt()
versionName = providers.gradleProperty("amneziawgVersionName").get()
buildConfigField("int", "MIN_SDK_VERSION", minSdk.toString())
+2
View File
@@ -5,6 +5,7 @@
<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"
@@ -77,6 +78,7 @@
<activity
android:name=".activity.SettingsActivity"
android:label="@string/settings"
android:theme="@style/AppTheme.NoActionBar"
android:parentActivityName=".activity.MainActivity" />
<activity
@@ -22,6 +22,8 @@ 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
@@ -47,10 +49,12 @@ 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)
@@ -107,10 +111,18 @@ 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))
}
@@ -123,10 +135,55 @@ 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"
@@ -147,6 +204,8 @@ 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")
@Suppress("DEPRECATION", "StartActivityAndCollapseDeprecated")
startActivityAndCollapse(intent)
}
}
@@ -29,9 +29,12 @@ import kotlinx.coroutines.withContext
class SettingsActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (supportFragmentManager.findFragmentById(android.R.id.content) == null) {
setContentView(R.layout.activity_settings)
setSupportActionBar(findViewById(R.id.toolbar))
supportActionBar?.setDisplayHomeAsUpEnabled(true)
if (supportFragmentManager.findFragmentById(R.id.settings_container) == null) {
supportFragmentManager.commit {
add(android.R.id.content, SettingsFragment())
add(R.id.settings_container, SettingsFragment())
}
}
}
@@ -57,8 +57,8 @@ import kotlinx.coroutines.withContext
import java.io.File
class TvMainActivity : AppCompatActivity() {
private val tunnelFileImportResultLauncher = registerForActivityResult(object : ActivityResultContracts.GetContent() {
override fun createIntent(context: Context, input: String): Intent {
private val tunnelFileImportResultLauncher = registerForActivityResult(object : ActivityResultContracts.OpenDocument() {
override fun createIntent(context: Context, input: Array<String>): Intent {
val intent = super.createIntent(context, input)
/* AndroidTV now comes with stubs that do nothing but display a Toast less helpful than
@@ -209,12 +209,15 @@ class TvMainActivity : AppCompatActivity() {
}
} else {
try {
tunnelFileImportResultLauncher.launch("*/*")
tunnelFileImportResultLauncher.launch(arrayOf("*/*"))
} 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, Uri.parse("market://webstoreredirect")))
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")
})
} catch (_: Throwable) {
}
}.show()
@@ -50,7 +50,7 @@ class AppListDialogFragment : DialogFragment() {
val packageName = it.packageName
val appInfo = it.applicationInfo
val appData =
ApplicationData(appInfo.loadIcon(pm), appInfo.loadLabel(pm).toString(), packageName, currentlySelectedApps.contains(packageName))
ApplicationData(appInfo?.loadIcon(pm) ?: pm.defaultActivityIcon, appInfo?.loadLabel(pm)?.toString() ?: packageName, packageName, currentlySelectedApps.contains(packageName))
applicationData.add(appData)
appData.addOnPropertyChangedCallback(object : Observable.OnPropertyChangedCallback() {
override fun onPropertyChanged(sender: Observable?, propertyId: Int) {
@@ -50,17 +50,42 @@ 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)
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)
}
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,6 +18,7 @@ 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
@@ -102,12 +103,41 @@ 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)
@@ -0,0 +1,213 @@
/*
* 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
}
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/app_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fitsSystemWindows="true">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize" />
</com.google.android.material.appbar.AppBarLayout>
<FrameLayout
android:id="@+id/settings_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
+29 -7
View File
@@ -7,6 +7,8 @@
<import type="org.amnezia.awg.model.ObservableTunnel" />
<import type="org.amnezia.awg.model.ObservableTunnel.ConnectionStatus" />
<import type="org.amnezia.awg.backend.Tunnel.State" />
<variable
@@ -37,17 +39,37 @@
android:paddingHorizontal="16dp"
android:paddingVertical="8dp">
<TextView
android:id="@+id/tunnel_name"
<LinearLayout
android:id="@+id/tunnel_info_layout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:ellipsize="end"
android:maxLines="1"
android:text="@{key}"
android:textAppearance="?attr/textAppearanceBodyLarge"
tools:text="@sample/interface_names.json/names/names/name" />
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>
<org.amnezia.awg.widget.ToggleSwitch
android:id="@+id/tunnel_switch"
+8
View File
@@ -0,0 +1,8 @@
<?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,4 +270,6 @@
<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,4 +60,7 @@
<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,4 +261,6 @@
<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>
+5
View File
@@ -15,6 +15,11 @@
<!-- Various additional API-specific features in values-v*/styles.xml -->
<style name="AppTheme" parent="AppThemeBase" />
<style name="AppTheme.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
</style>
<style name="AmneziaWgTheme.MaterialCardView" parent="Widget.Material3.CardView.Elevated">
<item name="cornerRadius">4dp</item>
<item name="contentPadding">8dp</item>