diff --git a/gradle.properties b/gradle.properties index b1c93b01..818a22e7 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 diff --git a/tunnel/src/main/java/org/amnezia/awg/backend/AwgQuickBackend.java b/tunnel/src/main/java/org/amnezia/awg/backend/AwgQuickBackend.java index 58e00ec3..baebf90a 100644 --- a/tunnel/src/main/java/org/amnezia/awg/backend/AwgQuickBackend.java +++ b/tunnel/src/main/java/org/amnezia/awg/backend/AwgQuickBackend.java @@ -46,6 +46,9 @@ public final class AwgQuickBackend implements Backend { private final Map 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 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); } diff --git a/tunnel/src/main/java/org/amnezia/awg/backend/Backend.java b/tunnel/src/main/java/org/amnezia/awg/backend/Backend.java index 7c452cdd..652e095f 100644 --- a/tunnel/src/main/java/org/amnezia/awg/backend/Backend.java +++ b/tunnel/src/main/java/org/amnezia/awg/backend/Backend.java @@ -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); } diff --git a/tunnel/src/main/java/org/amnezia/awg/backend/GoBackend.java b/tunnel/src/main/java/org/amnezia/awg/backend/GoBackend.java index 9495ce7b..2bb8026a 100644 --- a/tunnel/src/main/java/org/amnezia/awg/backend/GoBackend.java +++ b/tunnel/src/main/java/org/amnezia/awg/backend/GoBackend.java @@ -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; diff --git a/tunnel/src/main/java/org/amnezia/awg/backend/StatusCallback.java b/tunnel/src/main/java/org/amnezia/awg/backend/StatusCallback.java new file mode 100644 index 00000000..f51256b8 --- /dev/null +++ b/tunnel/src/main/java/org/amnezia/awg/backend/StatusCallback.java @@ -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); +} + diff --git a/ui/src/main/AndroidManifest.xml b/ui/src/main/AndroidManifest.xml index 878c2612..3075040a 100644 --- a/ui/src/main/AndroidManifest.xml +++ b/ui/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + 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 { diff --git a/ui/src/main/java/org/amnezia/awg/QuickTileService.kt b/ui/src/main/java/org/amnezia/awg/QuickTileService.kt index a47c241c..9952a163 100644 --- a/ui/src/main/java/org/amnezia/awg/QuickTileService.kt +++ b/ui/src/main/java/org/amnezia/awg/QuickTileService.kt @@ -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) } } diff --git a/ui/src/main/java/org/amnezia/awg/activity/TvMainActivity.kt b/ui/src/main/java/org/amnezia/awg/activity/TvMainActivity.kt index 10aaf3d7..42b3125a 100644 --- a/ui/src/main/java/org/amnezia/awg/activity/TvMainActivity.kt +++ b/ui/src/main/java/org/amnezia/awg/activity/TvMainActivity.kt @@ -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): 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() diff --git a/ui/src/main/java/org/amnezia/awg/model/ObservableTunnel.kt b/ui/src/main/java/org/amnezia/awg/model/ObservableTunnel.kt index 3e43a715..5024a72c 100644 --- a/ui/src/main/java/org/amnezia/awg/model/ObservableTunnel.kt +++ b/ui/src/main/java/org/amnezia/awg/model/ObservableTunnel.kt @@ -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) diff --git a/ui/src/main/java/org/amnezia/awg/model/TunnelManager.kt b/ui/src/main/java/org/amnezia/awg/model/TunnelManager.kt index e16b4642..e8ab4265 100644 --- a/ui/src/main/java/org/amnezia/awg/model/TunnelManager.kt +++ b/ui/src/main/java/org/amnezia/awg/model/TunnelManager.kt @@ -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, running: Collection) { for (name in present) addToList(name, null, if (running.contains(name)) Tunnel.State.UP else Tunnel.State.DOWN) diff --git a/ui/src/main/java/org/amnezia/awg/util/NetworkState.kt b/ui/src/main/java/org/amnezia/awg/util/NetworkState.kt new file mode 100644 index 00000000..2f3110a9 --- /dev/null +++ b/ui/src/main/java/org/amnezia/awg/util/NetworkState.kt @@ -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()!! + } + + 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 +} + diff --git a/ui/src/main/res/layout/tunnel_list_item.xml b/ui/src/main/res/layout/tunnel_list_item.xml index 1af03cd0..b71814b1 100644 --- a/ui/src/main/res/layout/tunnel_list_item.xml +++ b/ui/src/main/res/layout/tunnel_list_item.xml @@ -7,6 +7,8 @@ + + - + android:layout_toStartOf="@+id/tunnel_switch" + android:orientation="vertical"> + + + + + + + + #66BB6A + #FFB74D + #EF5350 + + diff --git a/ui/src/main/res/values-ru/strings.xml b/ui/src/main/res/values-ru/strings.xml index 0c99924b..a0603ded 100644 --- a/ui/src/main/res/values-ru/strings.xml +++ b/ui/src/main/res/values-ru/strings.xml @@ -270,4 +270,6 @@ Ошибка аутентификации Ошибка аутентификации: %s Убедитесь, что вы получили файл конфигурации в надёжном источнике.\n\nОфициальные сервисы Amnezia доступны только на сайте amnezia.org\n + Подключено + Подключение… diff --git a/ui/src/main/res/values/colors.xml b/ui/src/main/res/values/colors.xml index 243bfe83..8e7a9307 100644 --- a/ui/src/main/res/values/colors.xml +++ b/ui/src/main/res/values/colors.xml @@ -60,4 +60,7 @@ #ADC7FF #44474F #000000 + #4CAF50 + #FF9800 + #F44336 diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 154ace50..375e838f 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -261,4 +261,6 @@ Authentication failure Authentication failure: %s Ensure that you obtained the configuration file from a trusted source.\n\nOfficial Amnezia services are available only at amnezia.org\n + Connected + Connecting…