mirror of
https://github.com/amnezia-vpn/amneziawg-android.git
synced 2026-05-17 08:26:12 +03:00
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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
213
ui/src/main/java/org/amnezia/awg/util/NetworkState.kt
Normal file
213
ui/src/main/java/org/amnezia/awg/util/NetworkState.kt
Normal 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
|
||||
}
|
||||
|
||||
@@ -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
ui/src/main/res/values-night/colors.xml
Normal file
8
ui/src/main/res/values-night/colors.xml
Normal 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user