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:
parent
f513297ba0
commit
0e64bbb4e1
|
@ -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
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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) {
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue