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
This commit is contained in:
NickVs2015
2026-02-11 06:10:58 +03:00
committed by GitHub
parent c4b568a619
commit dd8c98db16
17 changed files with 637 additions and 16 deletions

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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);
}

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"

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)

View File

@@ -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
}

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"

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>

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>

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>

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>