feat: add quick setting tile

Add quick settings tile for easy tunnel toggling and auto-tunnel override.

Fix bug on AndroidTV D-pad tunnel control for multiple tunnels.

Closes #18 , Closes #20
This commit is contained in:
Zane Schepke 2023-08-30 23:58:03 -04:00
parent f513297ba0
commit 0e64bbb4e1
7 changed files with 210 additions and 24 deletions

View File

@ -17,7 +17,7 @@ android {
val versionMajor = 2 val versionMajor = 2
val versionMinor = 3 val versionMinor = 3
val versionPatch = 5 val versionPatch = 6
val versionBuild = 0 val versionBuild = 0
defaultConfig { defaultConfig {
@ -89,7 +89,7 @@ dependencies {
implementation("com.jakewharton.timber:timber:5.0.1") implementation("com.jakewharton.timber:timber:5.0.1")
// compose navigation // compose navigation
implementation("androidx.navigation:navigation-compose:2.7.0") implementation("androidx.navigation:navigation-compose:2.7.1")
implementation("androidx.hilt:hilt-navigation-compose:1.0.0") implementation("androidx.hilt:hilt-navigation-compose:1.0.0")
// hilt // hilt

View File

@ -64,6 +64,20 @@
android:foregroundServiceType="remoteMessaging" android:foregroundServiceType="remoteMessaging"
android:exported="false"> android:exported="false">
</service> </service>
<service
android:exported="true"
android:name=".service.TunnelControlTile"
android:icon="@drawable/shield"
android:label="WG Tunnel"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<meta-data android:name="android.service.quicksettings.ACTIVE_TILE"
android:value="true" />
<meta-data android:name="android.service.quicksettings.TOGGLEABLE_TILE"
android:value="true" />
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
<service <service
android:name=".service.foreground.WireGuardTunnelService" android:name=".service.foreground.WireGuardTunnelService"
android:permission="android.permission.BIND_VPN_SERVICE" android:permission="android.permission.BIND_VPN_SERVICE"

View File

@ -0,0 +1,151 @@
package com.zaneschepke.wireguardautotunnel.service
import android.os.Build
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import com.wireguard.android.backend.Tunnel
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.repository.Repository
import com.zaneschepke.wireguardautotunnel.service.foreground.Action
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceTracker
import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardConnectivityWatcherService
import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardTunnelService
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.Settings
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint
class TunnelControlTile : TileService() {
@Inject
lateinit var settingsRepo : Repository<Settings>
@Inject
lateinit var configRepo : Repository<TunnelConfig>
@Inject
lateinit var vpnService : VpnService
private val scope = CoroutineScope(Dispatchers.Main);
private lateinit var job : Job
override fun onStartListening() {
if (!this::job.isInitialized) {
job = scope.launch {
updateTileState()
}
}
Timber.d("On start listening")
super.onStartListening()
}
override fun onTileAdded() {
super.onTileAdded()
qsTile.contentDescription = "Toggle VPN"
scope.launch {
updateTileState();
}
}
override fun onTileRemoved() {
super.onTileRemoved()
cancelJob()
}
override fun onClick() {
unlockAndRun {
scope.launch {
try {
if(vpnService.getState() == Tunnel.State.UP) {
stopTunnel();
return@launch
}
val settings = settingsRepo.getAll()
if (!settings.isNullOrEmpty()) {
val setting = settings.first()
if (setting.defaultTunnel != null) {
startTunnel(setting.defaultTunnel!!)
} else {
val config = configRepo.getAll()?.first();
if(config != null) {
startTunnel(config.toString());
}
}
}
} finally {
cancel()
}
}
super.onClick()
}
}
private fun stopTunnel() {
ServiceTracker.actionOnService(
Action.STOP, this@TunnelControlTile,
WireGuardConnectivityWatcherService::class.java)
ServiceTracker.actionOnService(
Action.STOP, this@TunnelControlTile,
WireGuardTunnelService::class.java)
}
private fun startTunnel(tunnelConfig : String) {
ServiceTracker.actionOnService(
Action.START, this.applicationContext,
WireGuardTunnelService::class.java,
mapOf(this.applicationContext.resources.
getString(R.string.tunnel_extras_key) to
tunnelConfig))
}
private suspend fun updateTileState() {
vpnService.state.collect {
when(it) {
Tunnel.State.UP -> {
setTileOn()
}
Tunnel.State.DOWN -> {
setTileOff()
}
else -> {
qsTile.state = Tile.STATE_UNAVAILABLE
}
}
qsTile.updateTile()
}
}
private fun setTileOff() {
qsTile.state = Tile.STATE_INACTIVE;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
qsTile.subtitle = "Off"
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
qsTile.stateDescription = "VPN Off";
}
}
private fun setTileOn() {
qsTile.state = Tile.STATE_ACTIVE;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
qsTile.subtitle = "On"
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
qsTile.stateDescription = "VPN On";
}
}
private fun cancelJob() {
if(this::job.isInitialized) {
job.cancel();
}
}
}

View File

@ -152,7 +152,7 @@ class MainActivity : AppCompatActivity() {
} }
} }
}) { }) {
MainScreen(padding = padding, snackbarHostState = snackbarHostState, navController = navController, focusRequester = focusRequester) MainScreen(padding = padding, snackbarHostState = snackbarHostState, navController = navController)
} }
composable(Routes.Settings.name, enterTransition = { composable(Routes.Settings.name, enterTransition = {
when (initialState.destination.route) { when (initialState.destination.route) {

View File

@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FileOpen import androidx.compose.material.icons.filled.FileOpen
@ -85,7 +86,6 @@ import kotlinx.coroutines.launch
@Composable @Composable
fun MainScreen( fun MainScreen(
viewModel: MainViewModel = hiltViewModel(), padding: PaddingValues, viewModel: MainViewModel = hiltViewModel(), padding: PaddingValues,
focusRequester: FocusRequester,
snackbarHostState: SnackbarHostState, navController: NavController snackbarHostState: SnackbarHostState, navController: NavController
) { ) {
@ -241,9 +241,12 @@ fun MainScreen(
.padding(padding) .padding(padding)
) { ) {
LazyColumn(modifier = Modifier.fillMaxSize() LazyColumn(
.nestedScroll(nestedScrollConnection),) { modifier = Modifier.fillMaxSize()
items(tunnels.toList()) { tunnel -> .nestedScroll(nestedScrollConnection),
) {
itemsIndexed(tunnels.toList()) { index, tunnel ->
val focusRequester = FocusRequester();
RowListItem(leadingIcon = Icons.Rounded.Circle, RowListItem(leadingIcon = Icons.Rounded.Circle,
leadingIconColor = if (tunnelName == tunnel.name) when (handshakeStatus) { leadingIconColor = if (tunnelName == tunnel.name) when (handshakeStatus) {
HandshakeStatus.HEALTHY -> mint HandshakeStatus.HEALTHY -> mint
@ -263,7 +266,7 @@ fun MainScreen(
selectedTunnel = tunnel; selectedTunnel = tunnel;
}, },
onClick = { onClick = {
if(!WireGuardAutoTunnel.isRunningOnAndroidTv(context)){ if (!WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
navController.navigate("${Routes.Detail.name}/${tunnel.id}") navController.navigate("${Routes.Detail.name}/${tunnel.id}")
} else { } else {
focusRequester.requestFocus() focusRequester.requestFocus()
@ -271,7 +274,7 @@ fun MainScreen(
}, },
rowButton = { rowButton = {
if (tunnel.id == selectedTunnel?.id) { if (tunnel.id == selectedTunnel?.id) {
Row() { Row {
IconButton(onClick = { IconButton(onClick = {
navController.navigate("${Routes.Config.name}/${selectedTunnel?.id}") navController.navigate("${Routes.Config.name}/${selectedTunnel?.id}")
}) { }) {
@ -287,9 +290,11 @@ fun MainScreen(
} }
} }
} else { } else {
if(WireGuardAutoTunnel.isRunningOnAndroidTv(context)){ if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
Row() { Row {
IconButton(modifier = Modifier.focusRequester(focusRequester),onClick = { IconButton(
modifier = Modifier.focusRequester(focusRequester),
onClick = {
navController.navigate("${Routes.Detail.name}/${tunnel.id}") navController.navigate("${Routes.Detail.name}/${tunnel.id}")
}) { }) {
Icon(Icons.Rounded.Info, "Info") Icon(Icons.Rounded.Info, "Info")
@ -297,17 +302,28 @@ fun MainScreen(
IconButton(onClick = { IconButton(onClick = {
if (state == Tunnel.State.UP && tunnel.name == tunnelName) if (state == Tunnel.State.UP && tunnel.name == tunnelName)
scope.launch { scope.launch {
viewModel.showSnackBarMessage(context.resources.getString(R.string.turn_off_tunnel)) viewModel.showSnackBarMessage(
context.resources.getString(
R.string.turn_off_tunnel
)
)
} else { } else {
navController.navigate("${Routes.Config.name}/${tunnel.id}") navController.navigate("${Routes.Config.name}/${tunnel.id}")
} }
}) { }) {
Icon(Icons.Rounded.Edit, stringResource(id = R.string.edit)) Icon(
Icons.Rounded.Edit,
stringResource(id = R.string.edit)
)
} }
IconButton(onClick = { IconButton(onClick = {
if (state == Tunnel.State.UP && tunnel.name == tunnelName) if (state == Tunnel.State.UP && tunnel.name == tunnelName)
scope.launch { scope.launch {
viewModel.showSnackBarMessage(context.resources.getString(R.string.turn_off_tunnel)) viewModel.showSnackBarMessage(
context.resources.getString(
R.string.turn_off_tunnel
)
)
} else { } else {
viewModel.onDelete(tunnel) viewModel.onDelete(tunnel)
} }

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M12,1L3,5v6c0,5.55 3.84,10.74 9,12 5.16,-1.26 9,-6.45 9,-12V5l-9,-4z"/>
</vector>

View File

@ -13,7 +13,7 @@ buildscript {
} }
plugins { plugins {
id("com.android.application") version "8.2.0-alpha15" apply false id("com.android.application") version "8.2.0-beta01" apply false
id("org.jetbrains.kotlin.android") version "1.8.22" apply false id("org.jetbrains.kotlin.android") version "1.8.22" apply false
id("com.google.dagger.hilt.android") version "2.44" apply false id("com.google.dagger.hilt.android") version "2.44" apply false
kotlin("plugin.serialization") version "1.8.22" apply false kotlin("plugin.serialization") version "1.8.22" apply false