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
}