From 7834444a4f26379e8ffcc11ac475bb3d913a8b75 Mon Sep 17 00:00:00 2001 From: Noah Richardson <25447795+ngregrichardson@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:45:28 -0400 Subject: [PATCH 1/5] Add quick settings tile --- app/src/main/AndroidManifest.xml | 17 +++++ .../Pangolin/tile/PangolinTileService.kt | 73 +++++++++++++++++++ .../pangolin/Pangolin/util/TunnelManager.kt | 12 +++ 3 files changed, 102 insertions(+) create mode 100644 app/src/main/java/net/pangolin/Pangolin/tile/PangolinTileService.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 54b13ae..2b07c54 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -88,6 +88,23 @@ android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" /> + + + + + + + + diff --git a/app/src/main/java/net/pangolin/Pangolin/tile/PangolinTileService.kt b/app/src/main/java/net/pangolin/Pangolin/tile/PangolinTileService.kt new file mode 100644 index 0000000..6ffe47a --- /dev/null +++ b/app/src/main/java/net/pangolin/Pangolin/tile/PangolinTileService.kt @@ -0,0 +1,73 @@ +package net.pangolin.Pangolin.tile + +import android.graphics.drawable.Icon +import android.service.quicksettings.Tile +import android.service.quicksettings.TileService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import net.pangolin.Pangolin.util.TunnelManager + +class PangolinTileService : TileService() { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + + override fun onStartListening() { + super.onStartListening() + updateTile() + } + + override fun onDestroy() { + super.onDestroy() + scope.cancel() + } + + override fun onClick() { + super.onClick() + + val tunnelManager = TunnelManager.getInstance() ?: return + + val state = tunnelManager.tunnelState.value + + if(state.isServiceRunning) { + scope.launch { + tunnelManager.disconnect() + } + + setTileState(active = true) + } else { + scope.launch { + tunnelManager.connect() + } + + setTileState(active = false) + } + } + + private fun updateTile() { + val tunnelManager = TunnelManager.getInstance() ?: return + + setTileState(tunnelManager.connectionStatus.value?.connected) + } + + private fun setTileState(active: Boolean?) { + val tile = qsTile ?: return + + tile.contentDescription = "Pangolin VPN" + tile.icon = Icon.createWithResource(this, net.pangolin.Pangolin.R.drawable.ic_launcher_foreground) + + tile.state = when(active) { + true -> Tile.STATE_ACTIVE + else -> Tile.STATE_INACTIVE + } + + tile.label = when(active) { + true -> "Connected" + false -> "Disconnected" + else -> "Unknown" + } + + tile.updateTile() + } +} \ No newline at end of file diff --git a/app/src/main/java/net/pangolin/Pangolin/util/TunnelManager.kt b/app/src/main/java/net/pangolin/Pangolin/util/TunnelManager.kt index e4469d3..0c9cd83 100644 --- a/app/src/main/java/net/pangolin/Pangolin/util/TunnelManager.kt +++ b/app/src/main/java/net/pangolin/Pangolin/util/TunnelManager.kt @@ -1,6 +1,8 @@ package net.pangolin.Pangolin.util +import android.content.ComponentName import android.content.Context +import android.service.quicksettings.TileService import android.util.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -19,6 +21,7 @@ import net.pangolin.Pangolin.PacketTunnel.GoBackend import net.pangolin.Pangolin.PacketTunnel.InitConfig import net.pangolin.Pangolin.PacketTunnel.Tunnel import net.pangolin.Pangolin.PacketTunnel.TunnelConfig +import net.pangolin.Pangolin.tile.PangolinTileService import java.io.File /** @@ -416,6 +419,8 @@ class TunnelManager private constructor( */ private fun updateState(newState: TunnelState) { _tunnelState.value = newState + + notifyTileUpdate(context) } /** @@ -462,6 +467,13 @@ class TunnelManager private constructor( scope.cancel() } + private fun notifyTileUpdate(context: Context) { + TileService.requestListeningState( + context, + ComponentName(context, PangolinTileService::class.java) + ) + } + companion object { @Volatile private var instance: TunnelManager? = null From 3f6e921c68c7ef235c7d294df67d1ac89a9105de Mon Sep 17 00:00:00 2001 From: Noah Richardson <25447795+ngregrichardson@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:15:13 -0400 Subject: [PATCH 2/5] Use existing status message as quick tile status --- .../Pangolin/tile/PangolinTileService.kt | 55 +++++++++++-------- .../pangolin/Pangolin/util/TunnelManager.kt | 8 +-- 2 files changed, 37 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/net/pangolin/Pangolin/tile/PangolinTileService.kt b/app/src/main/java/net/pangolin/Pangolin/tile/PangolinTileService.kt index 6ffe47a..fedceda 100644 --- a/app/src/main/java/net/pangolin/Pangolin/tile/PangolinTileService.kt +++ b/app/src/main/java/net/pangolin/Pangolin/tile/PangolinTileService.kt @@ -12,10 +12,17 @@ import net.pangolin.Pangolin.util.TunnelManager class PangolinTileService : TileService() { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private var stateWatchJob: kotlinx.coroutines.Job? = null override fun onStartListening() { super.onStartListening() - updateTile() + watchState() + } + + override fun onStopListening() { + super.onStopListening() + stateWatchJob?.cancel() + stateWatchJob = null } override fun onDestroy() { @@ -23,51 +30,55 @@ class PangolinTileService : TileService() { scope.cancel() } + private fun watchState() { + val tunnelManager = TunnelManager.getInstance() ?: run { + setTileState(active = false, label = "Disconnected", clickable = true) + return + } + + stateWatchJob?.cancel() + stateWatchJob = scope.launch { + tunnelManager.tunnelState.collect { state -> + setTileState( + active = state.isServiceRunning, + label = state.statusMessage, + clickable = state.canDisable || state.canEnable + ) + } + } + } + override fun onClick() { super.onClick() val tunnelManager = TunnelManager.getInstance() ?: return - val state = tunnelManager.tunnelState.value - if(state.isServiceRunning) { + if (state.isServiceRunning || state.isConnecting) { scope.launch { tunnelManager.disconnect() } - - setTileState(active = true) } else { scope.launch { tunnelManager.connect() } - - setTileState(active = false) } } - private fun updateTile() { - val tunnelManager = TunnelManager.getInstance() ?: return - - setTileState(tunnelManager.connectionStatus.value?.connected) - } - - private fun setTileState(active: Boolean?) { + private fun setTileState(active: Boolean, label: String, clickable: Boolean) { val tile = qsTile ?: return tile.contentDescription = "Pangolin VPN" tile.icon = Icon.createWithResource(this, net.pangolin.Pangolin.R.drawable.ic_launcher_foreground) - tile.state = when(active) { - true -> Tile.STATE_ACTIVE + tile.state = when { + !clickable -> Tile.STATE_UNAVAILABLE + active -> Tile.STATE_ACTIVE else -> Tile.STATE_INACTIVE } - tile.label = when(active) { - true -> "Connected" - false -> "Disconnected" - else -> "Unknown" - } + tile.label = label tile.updateTile() } -} \ No newline at end of file +} diff --git a/app/src/main/java/net/pangolin/Pangolin/util/TunnelManager.kt b/app/src/main/java/net/pangolin/Pangolin/util/TunnelManager.kt index 0c9cd83..55e47d7 100644 --- a/app/src/main/java/net/pangolin/Pangolin/util/TunnelManager.kt +++ b/app/src/main/java/net/pangolin/Pangolin/util/TunnelManager.kt @@ -132,13 +132,13 @@ class TunnelManager private constructor( val isConnected = status.connected && status.registered == true val isRegistered = status.registered == true - _tunnelState.value = currentState.copy( + updateState(currentState.copy( isSocketConnected = status.connected, isRegistered = isRegistered, isConnecting = !isConnected && status.connected, statusMessage = determineStatusMessage(status), errorMessage = if (status.terminated) "Connection terminated" else null - ) + )) } /** @@ -420,7 +420,7 @@ class TunnelManager private constructor( private fun updateState(newState: TunnelState) { _tunnelState.value = newState - notifyTileUpdate(context) + notifyTileUpdate() } /** @@ -467,7 +467,7 @@ class TunnelManager private constructor( scope.cancel() } - private fun notifyTileUpdate(context: Context) { + private fun notifyTileUpdate() { TileService.requestListeningState( context, ComponentName(context, PangolinTileService::class.java) From e5939e2c6bfb0a41a9ff23fdf0040428e52a20e2 Mon Sep 17 00:00:00 2001 From: noah <25447795+ngregrichardson@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:30:13 -0400 Subject: [PATCH 3/5] Fix tile icon --- app/src/main/AndroidManifest.xml | 2 +- .../Pangolin/tile/PangolinTileService.kt | 2 +- app/src/main/res/drawable/ic_tile.xml | 17 +++++++++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 app/src/main/res/drawable/ic_tile.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2b07c54..1431ddd 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -92,7 +92,7 @@ diff --git a/app/src/main/java/net/pangolin/Pangolin/tile/PangolinTileService.kt b/app/src/main/java/net/pangolin/Pangolin/tile/PangolinTileService.kt index fedceda..05b5396 100644 --- a/app/src/main/java/net/pangolin/Pangolin/tile/PangolinTileService.kt +++ b/app/src/main/java/net/pangolin/Pangolin/tile/PangolinTileService.kt @@ -69,7 +69,7 @@ class PangolinTileService : TileService() { val tile = qsTile ?: return tile.contentDescription = "Pangolin VPN" - tile.icon = Icon.createWithResource(this, net.pangolin.Pangolin.R.drawable.ic_launcher_foreground) + tile.icon = Icon.createWithResource(this, net.pangolin.Pangolin.R.drawable.ic_tile) tile.state = when { !clickable -> Tile.STATE_UNAVAILABLE diff --git a/app/src/main/res/drawable/ic_tile.xml b/app/src/main/res/drawable/ic_tile.xml new file mode 100644 index 0000000..729e102 --- /dev/null +++ b/app/src/main/res/drawable/ic_tile.xml @@ -0,0 +1,17 @@ + + + + + \ No newline at end of file From 4ca213cb4cf7e207aefe667901ba144c600dc832 Mon Sep 17 00:00:00 2001 From: noah <25447795+ngregrichardson@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:09:19 -0400 Subject: [PATCH 4/5] If tunnel manager doesn't exist, don't allow clicking --- .../java/net/pangolin/Pangolin/tile/PangolinTileService.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/net/pangolin/Pangolin/tile/PangolinTileService.kt b/app/src/main/java/net/pangolin/Pangolin/tile/PangolinTileService.kt index 05b5396..826bc5b 100644 --- a/app/src/main/java/net/pangolin/Pangolin/tile/PangolinTileService.kt +++ b/app/src/main/java/net/pangolin/Pangolin/tile/PangolinTileService.kt @@ -32,7 +32,7 @@ class PangolinTileService : TileService() { private fun watchState() { val tunnelManager = TunnelManager.getInstance() ?: run { - setTileState(active = false, label = "Disconnected", clickable = true) + setTileState(active = false, label = "Unavailable", clickable = false) return } @@ -54,6 +54,10 @@ class PangolinTileService : TileService() { val tunnelManager = TunnelManager.getInstance() ?: return val state = tunnelManager.tunnelState.value + if (!state.canDisable && !state.canEnable) { + return + } + if (state.isServiceRunning || state.isConnecting) { scope.launch { tunnelManager.disconnect() From 6cbc9a5cdeaed616b16d60a030853e77026ff35a Mon Sep 17 00:00:00 2001 From: noah <25447795+ngregrichardson@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:21:45 -0400 Subject: [PATCH 5/5] If the app is force quit, clicking on the tile will open and try to start --- .../net/pangolin/Pangolin/MainActivity.kt | 11 ++++++++ .../Pangolin/tile/PangolinTileService.kt | 25 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/app/src/main/java/net/pangolin/Pangolin/MainActivity.kt b/app/src/main/java/net/pangolin/Pangolin/MainActivity.kt index 61276dc..317a8b2 100644 --- a/app/src/main/java/net/pangolin/Pangolin/MainActivity.kt +++ b/app/src/main/java/net/pangolin/Pangolin/MainActivity.kt @@ -145,6 +145,17 @@ class MainActivity : BaseNavigationActivity() { contentBinding.loadingOverlay.visibility = android.view.View.VISIBLE contentBinding.mainContent.visibility = android.view.View.GONE + if (intent?.getBooleanExtra("auto_connect", false) == true) { + val currentState = tunnelManager.tunnelState.value + if (!currentState.isServiceRunning && !currentState.isConnecting) { + lifecycleScope.launch { + tunnelManager.connect() + } + } + + intent.removeExtra("auto_connect") + } + // Setup toggle switch listener with helper function fun setupToggleListener() { contentBinding.toggleConnect.setOnCheckedChangeListener { _, isChecked -> diff --git a/app/src/main/java/net/pangolin/Pangolin/tile/PangolinTileService.kt b/app/src/main/java/net/pangolin/Pangolin/tile/PangolinTileService.kt index 826bc5b..f9afe91 100644 --- a/app/src/main/java/net/pangolin/Pangolin/tile/PangolinTileService.kt +++ b/app/src/main/java/net/pangolin/Pangolin/tile/PangolinTileService.kt @@ -1,6 +1,10 @@ package net.pangolin.Pangolin.tile +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.content.Intent import android.graphics.drawable.Icon +import android.os.Build import android.service.quicksettings.Tile import android.service.quicksettings.TileService import kotlinx.coroutines.CoroutineScope @@ -8,6 +12,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import net.pangolin.Pangolin.MainActivity import net.pangolin.Pangolin.util.TunnelManager class PangolinTileService : TileService() { @@ -30,9 +35,29 @@ class PangolinTileService : TileService() { scope.cancel() } + @SuppressLint("StartActivityAndCollapseDeprecated") private fun watchState() { val tunnelManager = TunnelManager.getInstance() ?: run { setTileState(active = false, label = "Unavailable", clickable = false) + + val intent = Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + putExtra("auto_connect", true) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val pendingIntent = PendingIntent.getActivity( + this, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + startActivityAndCollapse(pendingIntent) + } else { + @Suppress("DEPRECATION") + startActivityAndCollapse(intent) + } + return }