mirror of
https://github.com/wgtunnel/android.git
synced 2026-07-03 14:07:49 +02:00
feat: add logs screen
This commit is contained in:
@@ -132,6 +132,9 @@ android {
|
||||
val generalImplementation by configurations
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation(project(":logcatter"))
|
||||
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
// optional - helpers for implementing LifecycleOwner in a Service
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.content.ComponentName
|
||||
import android.content.pm.PackageManager
|
||||
import android.service.quicksettings.TileService
|
||||
import com.zaneschepke.wireguardautotunnel.service.tile.TunnelControlTile
|
||||
import com.zaneschepke.wireguardautotunnel.util.ReleaseTree
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import timber.log.Timber
|
||||
|
||||
@@ -13,7 +14,7 @@ class WireGuardAutoTunnel : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
|
||||
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree()) else Timber.plant(ReleaseTree())
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ open class ForegroundService : LifecycleService() {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
} catch (e: Exception) {
|
||||
Timber.d("Service stopped without being started: ${e.message}")
|
||||
Timber.e(e)
|
||||
}
|
||||
isServiceStarted = false
|
||||
}
|
||||
|
||||
+18
-18
@@ -154,10 +154,10 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
|
||||
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag::lock").apply {
|
||||
try {
|
||||
if (isBatterySaverOn) {
|
||||
Timber.d("Initiating wakelock with timeout")
|
||||
Timber.i("Initiating wakelock with 10 min timeout")
|
||||
acquire(Constants.BATTERY_SAVER_WATCHER_WAKE_LOCK_TIMEOUT)
|
||||
} else {
|
||||
Timber.d("Initiating wakelock with zero timeout")
|
||||
Timber.i("Initiating wakelock with 30 min timeout")
|
||||
acquire(Constants.DEFAULT_WATCHER_WAKE_LOCK_TIMEOUT)
|
||||
}
|
||||
} finally {
|
||||
@@ -178,31 +178,31 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val setting = settingsRepository.getSettings()
|
||||
launch {
|
||||
Timber.d("Starting wifi watcher")
|
||||
Timber.i("Starting wifi watcher")
|
||||
watchForWifiConnectivityChanges()
|
||||
}
|
||||
if (setting.isTunnelOnMobileDataEnabled) {
|
||||
launch {
|
||||
Timber.d("Starting mobile data watcher")
|
||||
Timber.i("Starting mobile data watcher")
|
||||
watchForMobileDataConnectivityChanges()
|
||||
}
|
||||
}
|
||||
if (setting.isTunnelOnEthernetEnabled) {
|
||||
launch {
|
||||
Timber.d("Starting ethernet data watcher")
|
||||
Timber.i("Starting ethernet data watcher")
|
||||
watchForEthernetConnectivityChanges()
|
||||
}
|
||||
}
|
||||
launch {
|
||||
Timber.d("Starting vpn state watcher")
|
||||
Timber.i("Starting vpn state watcher")
|
||||
watchForVpnConnectivityChanges()
|
||||
}
|
||||
launch {
|
||||
Timber.d("Starting settings watcher")
|
||||
Timber.i("Starting settings watcher")
|
||||
watchForSettingsChanges()
|
||||
}
|
||||
launch {
|
||||
Timber.d("Starting management watcher")
|
||||
Timber.i("Starting management watcher")
|
||||
manageVpn()
|
||||
}
|
||||
}
|
||||
@@ -212,7 +212,7 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
|
||||
mobileDataService.networkStatus.collect {
|
||||
when (it) {
|
||||
is NetworkStatus.Available -> {
|
||||
Timber.d("Gained Mobile data connection")
|
||||
Timber.i("Gained Mobile data connection")
|
||||
networkEventsFlow.value =
|
||||
networkEventsFlow.value.copy(
|
||||
isMobileDataConnected = true,
|
||||
@@ -223,14 +223,14 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
|
||||
networkEventsFlow.value.copy(
|
||||
isMobileDataConnected = true,
|
||||
)
|
||||
Timber.d("Mobile data capabilities changed")
|
||||
Timber.i("Mobile data capabilities changed")
|
||||
}
|
||||
is NetworkStatus.Unavailable -> {
|
||||
networkEventsFlow.value =
|
||||
networkEventsFlow.value.copy(
|
||||
isMobileDataConnected = false,
|
||||
)
|
||||
Timber.d("Lost mobile data connection")
|
||||
Timber.i("Lost mobile data connection")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -273,14 +273,14 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
|
||||
ethernetService.networkStatus.collect {
|
||||
when (it) {
|
||||
is NetworkStatus.Available -> {
|
||||
Timber.d("Gained Ethernet connection")
|
||||
Timber.i("Gained Ethernet connection")
|
||||
networkEventsFlow.value =
|
||||
networkEventsFlow.value.copy(
|
||||
isEthernetConnected = true,
|
||||
)
|
||||
}
|
||||
is NetworkStatus.CapabilitiesChanged -> {
|
||||
Timber.d("Ethernet capabilities changed")
|
||||
Timber.i("Ethernet capabilities changed")
|
||||
networkEventsFlow.value =
|
||||
networkEventsFlow.value.copy(
|
||||
isEthernetConnected = true,
|
||||
@@ -291,7 +291,7 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
|
||||
networkEventsFlow.value.copy(
|
||||
isEthernetConnected = false,
|
||||
)
|
||||
Timber.d("Lost Ethernet connection")
|
||||
Timber.i("Lost Ethernet connection")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -301,20 +301,20 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
|
||||
wifiService.networkStatus.collect {
|
||||
when (it) {
|
||||
is NetworkStatus.Available -> {
|
||||
Timber.d("Gained Wi-Fi connection")
|
||||
Timber.i("Gained Wi-Fi connection")
|
||||
networkEventsFlow.value =
|
||||
networkEventsFlow.value.copy(
|
||||
isWifiConnected = true,
|
||||
)
|
||||
}
|
||||
is NetworkStatus.CapabilitiesChanged -> {
|
||||
Timber.d("Wifi capabilities changed")
|
||||
Timber.i("Wifi capabilities changed")
|
||||
networkEventsFlow.value =
|
||||
networkEventsFlow.value.copy(
|
||||
isWifiConnected = true,
|
||||
)
|
||||
val ssid = wifiService.getNetworkName(it.networkCapabilities) ?: ""
|
||||
Timber.d("Detected SSID: $ssid")
|
||||
Timber.i("Detected SSID: $ssid")
|
||||
networkEventsFlow.value =
|
||||
networkEventsFlow.value.copy(
|
||||
currentNetworkSSID = ssid,
|
||||
@@ -325,7 +325,7 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
|
||||
networkEventsFlow.value.copy(
|
||||
isWifiConnected = false,
|
||||
)
|
||||
Timber.d("Lost Wi-Fi connection")
|
||||
Timber.i("Lost Wi-Fi connection")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ class WireGuardTunnelService : ForegroundService() {
|
||||
stopService(extras)
|
||||
}
|
||||
} else {
|
||||
Timber.d("Tunnel config null, starting default tunnel or first")
|
||||
Timber.i("Tunnel config null, starting default tunnel or first")
|
||||
val settings = settingsRepository.getSettings()
|
||||
val tunnels = tunnelConfigRepository.getAll()
|
||||
if (settings.isAlwaysOnVpnEnabled) {
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class ActivityViewModel
|
||||
@Inject
|
||||
constructor() : ViewModel() {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui
|
||||
|
||||
import android.app.Application
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.util.Constants
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class AppViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val application: Application,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _snackbarState = MutableStateFlow(SnackBarState())
|
||||
val snackBarState = _snackbarState.asStateFlow()
|
||||
|
||||
fun openWebPage(url: String) {
|
||||
try {
|
||||
val webpage: Uri = Uri.parse(url)
|
||||
val intent = Intent(Intent.ACTION_VIEW, webpage).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
application.startActivity(intent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Timber.e(e)
|
||||
showSnackbarMessage(application.getString(R.string.no_browser_detected))
|
||||
}
|
||||
}
|
||||
|
||||
fun launchEmail() {
|
||||
try {
|
||||
val intent =
|
||||
Intent(Intent.ACTION_SENDTO).apply {
|
||||
type = Constants.EMAIL_MIME_TYPE
|
||||
putExtra(Intent.EXTRA_EMAIL, arrayOf(application.getString(R.string.my_email)))
|
||||
putExtra(Intent.EXTRA_SUBJECT, application.getString(R.string.email_subject))
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
application.startActivity(
|
||||
Intent.createChooser(intent, application.getString(R.string.email_chooser)).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
})
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Timber.e(e)
|
||||
showSnackbarMessage(application.getString(R.string.no_email_detected))
|
||||
}
|
||||
}
|
||||
fun showSnackbarMessage(message : String) {
|
||||
_snackbarState.value = _snackbarState.value.copy(
|
||||
snackbarMessage = message,
|
||||
snackbarMessageConsumed = false
|
||||
)
|
||||
}
|
||||
|
||||
fun snackbarMessageConsumed() {
|
||||
_snackbarState.value = _snackbarState.value.copy(
|
||||
snackbarMessage = "",
|
||||
snackbarMessageConsumed = true
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
@@ -29,6 +30,8 @@ import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
@@ -47,8 +50,10 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.config.ConfigScreen
|
||||
import com.zaneschepke.wireguardautotunnel.ui.screens.main.MainScreen
|
||||
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.SettingsScreen
|
||||
import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen
|
||||
import com.zaneschepke.wireguardautotunnel.ui.screens.support.logs.LogsScreen
|
||||
import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme
|
||||
import com.zaneschepke.wireguardautotunnel.util.Constants
|
||||
import com.zaneschepke.wireguardautotunnel.util.StringValue
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -64,7 +69,7 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
@Inject lateinit var settingsRepository: SettingsRepository
|
||||
@OptIn(
|
||||
ExperimentalPermissionsApi::class,
|
||||
ExperimentalPermissionsApi::class
|
||||
)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -81,8 +86,8 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
setContent {
|
||||
//val activityViewModel = hiltViewModel<ActivityViewModel>()
|
||||
|
||||
val appViewModel = hiltViewModel<AppViewModel>()
|
||||
val snackBarState by appViewModel.snackBarState.collectAsStateWithLifecycle()
|
||||
val navController = rememberNavController()
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
@@ -104,12 +109,11 @@ class MainActivity : AppCompatActivity() {
|
||||
requestNotificationPermission()
|
||||
}
|
||||
|
||||
fun showSnackBarMessage(message: String) {
|
||||
fun showSnackBarMessage(message: StringValue) {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
val result =
|
||||
snackbarHostState.showSnackbar(
|
||||
message = message,
|
||||
actionLabel = applicationContext.getString(R.string.okay),
|
||||
message = message.asString(this@MainActivity),
|
||||
duration = SnackbarDuration.Short,
|
||||
)
|
||||
when (result) {
|
||||
@@ -121,6 +125,13 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(snackBarState.snackbarMessageConsumed) {
|
||||
if(!snackBarState.snackbarMessageConsumed) {
|
||||
showSnackBarMessage(StringValue.DynamicString(snackBarState.snackbarMessage))
|
||||
appViewModel.snackbarMessageConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = {
|
||||
SnackbarHost(snackbarHostState) { snackbarData: SnackbarData ->
|
||||
@@ -173,48 +184,48 @@ class MainActivity : AppCompatActivity() {
|
||||
return@Scaffold
|
||||
}
|
||||
}
|
||||
NavHost(navController, startDestination = Screen.Main.route) {
|
||||
composable(
|
||||
Screen.Main.route,
|
||||
) {
|
||||
MainScreen(
|
||||
focusRequester = focusRequester,
|
||||
showSnackbarMessage = { message -> showSnackBarMessage(message) },
|
||||
navController = navController,
|
||||
)
|
||||
}
|
||||
composable(
|
||||
Screen.Settings.route,
|
||||
) {
|
||||
SettingsScreen(
|
||||
padding = padding,
|
||||
showSnackbarMessage = { message -> showSnackBarMessage(message) },
|
||||
focusRequester = focusRequester,
|
||||
)
|
||||
//
|
||||
}
|
||||
composable(
|
||||
Screen.Support.route,
|
||||
) {
|
||||
SupportScreen(
|
||||
padding = padding,
|
||||
focusRequester = focusRequester,
|
||||
showSnackbarMessage = { message -> showSnackBarMessage(message) },
|
||||
)
|
||||
}
|
||||
composable("${Screen.Config.route}/{id}") {
|
||||
val id = it.arguments?.getString("id")
|
||||
if (!id.isNullOrBlank()) {
|
||||
ConfigScreen(
|
||||
padding = padding,
|
||||
Column(modifier = Modifier.padding(padding)) {
|
||||
NavHost(navController, startDestination = Screen.Main.route) {
|
||||
composable(
|
||||
Screen.Main.route,
|
||||
) {
|
||||
MainScreen(
|
||||
focusRequester = focusRequester,
|
||||
appViewModel = appViewModel,
|
||||
navController = navController,
|
||||
id = id,
|
||||
showSnackbarMessage = { message ->
|
||||
showSnackBarMessage(message)
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(
|
||||
Screen.Settings.route,
|
||||
) {
|
||||
SettingsScreen(
|
||||
appViewModel = appViewModel,
|
||||
focusRequester = focusRequester,
|
||||
)
|
||||
}
|
||||
composable(
|
||||
Screen.Support.route,
|
||||
) {
|
||||
SupportScreen(
|
||||
focusRequester = focusRequester,
|
||||
appViewModel = appViewModel,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
composable(Screen.Support.Logs.route,) {
|
||||
LogsScreen()
|
||||
}
|
||||
composable("${Screen.Config.route}/{id}") {
|
||||
val id = it.arguments?.getString("id")
|
||||
if (!id.isNullOrBlank()) {
|
||||
ConfigScreen(
|
||||
navController = navController,
|
||||
id = id,
|
||||
appViewModel = appViewModel,
|
||||
focusRequester = focusRequester,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ sealed class Screen(val route: String) {
|
||||
route = route,
|
||||
icon = Icons.Rounded.QuestionMark,
|
||||
)
|
||||
data object Logs : Screen("support/logs")
|
||||
}
|
||||
|
||||
data object Config : Screen("config")
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui
|
||||
|
||||
data class SnackBarState(
|
||||
val snackbarMessage: String = "",
|
||||
val snackbarMessageConsumed: Boolean = true,
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.common.text
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun LogTypeLabel(color : Color, content: @Composable () -> Unit,) {
|
||||
Box(
|
||||
modifier = Modifier.size(20.dp).clip(RoundedCornerShape(2.dp)).background(color), contentAlignment = Alignment.Center) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
+8
-10
@@ -7,7 +7,6 @@ import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
@@ -73,6 +72,7 @@ import androidx.navigation.NavController
|
||||
import com.google.accompanist.drawablepainter.DrawablePainter
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
|
||||
import com.zaneschepke.wireguardautotunnel.ui.AppViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.ui.Screen
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.SearchBar
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationTextBox
|
||||
@@ -90,11 +90,10 @@ import kotlinx.coroutines.delay
|
||||
)
|
||||
@Composable
|
||||
fun ConfigScreen(
|
||||
padding: PaddingValues,
|
||||
viewModel: ConfigViewModel = hiltViewModel(),
|
||||
focusRequester: FocusRequester,
|
||||
navController: NavController,
|
||||
showSnackbarMessage: (String) -> Unit,
|
||||
appViewModel: AppViewModel,
|
||||
id: String
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -149,11 +148,11 @@ fun ConfigScreen(
|
||||
},
|
||||
onError = {
|
||||
showAuthPrompt = false
|
||||
showSnackbarMessage(Event.Error.AuthenticationFailed.message)
|
||||
appViewModel.showSnackbarMessage(Event.Error.AuthenticationFailed.message)
|
||||
},
|
||||
onFailure = {
|
||||
showAuthPrompt = false
|
||||
showSnackbarMessage(Event.Error.AuthorizationFailed.message)
|
||||
appViewModel.showSnackbarMessage(Event.Error.AuthorizationFailed.message)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -311,7 +310,7 @@ fun ConfigScreen(
|
||||
var fobColor by remember { mutableStateOf(secondaryColor) }
|
||||
FloatingActionButton(
|
||||
modifier =
|
||||
Modifier.padding(bottom = 90.dp).onFocusChanged {
|
||||
Modifier.onFocusChanged {
|
||||
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
|
||||
fobColor = if (it.isFocused) hoverColor else secondaryColor
|
||||
}
|
||||
@@ -320,10 +319,10 @@ fun ConfigScreen(
|
||||
viewModel.onSaveAllChanges().let {
|
||||
when (it) {
|
||||
is Result.Success -> {
|
||||
showSnackbarMessage(it.data.message)
|
||||
appViewModel.showSnackbarMessage(it.data.message)
|
||||
navController.navigate(Screen.Main.route)
|
||||
}
|
||||
is Result.Error -> showSnackbarMessage(it.error.message)
|
||||
is Result.Error -> appViewModel.showSnackbarMessage(it.error.message)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -346,8 +345,7 @@ fun ConfigScreen(
|
||||
Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.weight(1f, true)
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
Surface(
|
||||
tonalElevation = 2.dp,
|
||||
|
||||
+2
@@ -30,6 +30,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
@@ -216,6 +217,7 @@ constructor(
|
||||
updateTunnelConfig(tunnelConfig)
|
||||
Result.Success(Event.Message.ConfigSaved)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
Result.Error(Event.Error.Exception(e))
|
||||
}
|
||||
}
|
||||
|
||||
+62
-75
@@ -1,5 +1,6 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.screens.main
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
@@ -20,11 +21,9 @@ import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.requiredWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -50,13 +49,11 @@ import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MaterialTheme.typography
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -76,7 +73,6 @@ import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -94,6 +90,7 @@ import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
|
||||
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
|
||||
import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus
|
||||
import com.zaneschepke.wireguardautotunnel.ui.AppViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.ui.CaptureActivityPortrait
|
||||
import com.zaneschepke.wireguardautotunnel.ui.Screen
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.RowListItem
|
||||
@@ -109,12 +106,13 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun MainScreen(
|
||||
viewModel: MainViewModel = hiltViewModel(),
|
||||
appViewModel: AppViewModel,
|
||||
focusRequester: FocusRequester,
|
||||
showSnackbarMessage: (String) -> Unit,
|
||||
navController: NavController
|
||||
) {
|
||||
val haptic = LocalHapticFeedback.current
|
||||
@@ -182,7 +180,7 @@ fun MainScreen(
|
||||
name.startsWith(Constants.ANDROID_TV_EXPLORER_STUB)
|
||||
}
|
||||
) {
|
||||
showSnackbarMessage(Event.Error.FileExplorerRequired.message)
|
||||
appViewModel.showSnackbarMessage(Event.Error.FileExplorerRequired.message)
|
||||
}
|
||||
return intent
|
||||
}
|
||||
@@ -192,7 +190,7 @@ fun MainScreen(
|
||||
scope.launch {
|
||||
viewModel.onTunnelFileSelected(data).let {
|
||||
when (it) {
|
||||
is Result.Error -> showSnackbarMessage(it.error.message)
|
||||
is Result.Error -> appViewModel.showSnackbarMessage(it.error.message)
|
||||
is Result.Success -> {}
|
||||
}
|
||||
}
|
||||
@@ -207,7 +205,7 @@ fun MainScreen(
|
||||
viewModel.onTunnelQrResult(it.contents).let { result ->
|
||||
when (result) {
|
||||
is Result.Success -> {}
|
||||
is Result.Error -> showSnackbarMessage(result.error.message)
|
||||
is Result.Error -> appViewModel.showSnackbarMessage(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -280,58 +278,6 @@ fun MainScreen(
|
||||
)
|
||||
},
|
||||
floatingActionButtonPosition = FabPosition.End,
|
||||
topBar = {
|
||||
if (uiState.settings.isAutoTunnelEnabled)
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
Modifier
|
||||
.requiredWidth(LocalConfiguration.current.screenWidthDp.dp)
|
||||
.padding(end = 5.dp)
|
||||
) {
|
||||
Row {
|
||||
Icon(
|
||||
Icons.Rounded.Bolt,
|
||||
stringResource(id = R.string.auto),
|
||||
modifier = Modifier.size(25.dp),
|
||||
tint =
|
||||
if (uiState.settings.isAutoTunnelPaused) Color.Gray
|
||||
else mint,
|
||||
)
|
||||
val autoTunnelingLabel = buildAnnotatedString {
|
||||
append(stringResource(id = R.string.auto_tunneling))
|
||||
append(": ")
|
||||
if(uiState.settings.isAutoTunnelPaused) append(stringResource(id = R.string.paused)) else append(
|
||||
stringResource(id = R.string.active),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
autoTunnelingLabel.text,
|
||||
style = typography.bodyLarge,
|
||||
modifier = Modifier.padding(start = 10.dp),
|
||||
)
|
||||
}
|
||||
if (uiState.settings.isAutoTunnelPaused)
|
||||
TextButton(
|
||||
onClick = { viewModel.resumeAutoTunneling() },
|
||||
modifier = Modifier.padding(end = 10.dp),
|
||||
) {
|
||||
Text(stringResource(id = R.string.resume))
|
||||
}
|
||||
else
|
||||
TextButton(
|
||||
onClick = { viewModel.pauseAutoTunneling() },
|
||||
modifier = Modifier.padding(end = 10.dp),
|
||||
) {
|
||||
Text(stringResource(id = R.string.pause))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
AnimatedVisibility(
|
||||
visible = isVisible.value,
|
||||
@@ -349,7 +295,6 @@ fun MainScreen(
|
||||
)
|
||||
Modifier.focusRequester(focusRequester)
|
||||
else Modifier)
|
||||
.padding(bottom = 90.dp)
|
||||
.onFocusChanged {
|
||||
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
|
||||
fobColor = if (it.isFocused) hoverColor else secondaryColor
|
||||
@@ -367,14 +312,13 @@ fun MainScreen(
|
||||
}
|
||||
}
|
||||
},
|
||||
) { innerPadding ->
|
||||
) {
|
||||
AnimatedVisibility(uiState.tunnels.isEmpty(), exit = fadeOut(), enter = fadeIn()) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
Text(text = stringResource(R.string.no_tunnels), fontStyle = FontStyle.Italic)
|
||||
}
|
||||
@@ -471,14 +415,57 @@ fun MainScreen(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(.90f)
|
||||
.overscroll(ScrollableDefaults.overscrollEffect())
|
||||
.padding(innerPadding),
|
||||
.overscroll(ScrollableDefaults.overscrollEffect()),
|
||||
state = rememberLazyListState(0, uiState.tunnels.count()),
|
||||
userScrollEnabled = true,
|
||||
reverseLayout = true,
|
||||
reverseLayout = false,
|
||||
flingBehavior = ScrollableDefaults.flingBehavior(),
|
||||
) {
|
||||
item {
|
||||
if(uiState.settings.isAutoTunnelEnabled){
|
||||
val autoTunnelingLabel = buildAnnotatedString {
|
||||
append(stringResource(id = R.string.auto_tunneling))
|
||||
append(": ")
|
||||
if(uiState.settings.isAutoTunnelPaused) append(
|
||||
stringResource(id = R.string.paused)
|
||||
) else append(
|
||||
stringResource(id = R.string.active),
|
||||
)
|
||||
}
|
||||
RowListItem(
|
||||
icon = { Icon(
|
||||
Icons.Rounded.Bolt,
|
||||
stringResource(id = R.string.auto),
|
||||
modifier = Modifier
|
||||
.padding(end = 10.dp)
|
||||
.size(25.dp),
|
||||
tint =
|
||||
if (uiState.settings.isAutoTunnelPaused) Color.Gray
|
||||
else mint,
|
||||
) },
|
||||
text = autoTunnelingLabel.text,
|
||||
rowButton = {
|
||||
if (uiState.settings.isAutoTunnelPaused) {
|
||||
TextButton(
|
||||
onClick = { viewModel.resumeAutoTunneling() },
|
||||
) {
|
||||
Text(stringResource(id = R.string.resume))
|
||||
}
|
||||
} else {
|
||||
TextButton(
|
||||
onClick = { viewModel.pauseAutoTunneling() },
|
||||
) {
|
||||
Text(stringResource(id = R.string.pause))
|
||||
}
|
||||
}
|
||||
},
|
||||
onClick = {},
|
||||
onHold = {},
|
||||
expanded = false,
|
||||
statistics = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
items(
|
||||
uiState.tunnels,
|
||||
key = { tunnel -> tunnel.id },
|
||||
@@ -534,7 +521,7 @@ fun MainScreen(
|
||||
(uiState.vpnState.status == Tunnel.State.UP) &&
|
||||
(tunnel.name == uiState.vpnState.name)
|
||||
) {
|
||||
showSnackbarMessage(Event.Message.TunnelOffAction.message)
|
||||
appViewModel.showSnackbarMessage(Event.Message.TunnelOffAction.message)
|
||||
return@RowListItem
|
||||
}
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
@@ -568,7 +555,7 @@ fun MainScreen(
|
||||
uiState.settings.isAutoTunnelEnabled &&
|
||||
!uiState.settings.isAutoTunnelPaused
|
||||
) {
|
||||
showSnackbarMessage(
|
||||
appViewModel.showSnackbarMessage(
|
||||
Event.Message.AutoTunnelOffAction.message,
|
||||
)
|
||||
} else {
|
||||
@@ -591,7 +578,7 @@ fun MainScreen(
|
||||
) &&
|
||||
!uiState.settings.isAutoTunnelPaused
|
||||
) {
|
||||
showSnackbarMessage(
|
||||
appViewModel.showSnackbarMessage(
|
||||
Event.Message.AutoTunnelOffAction.message,
|
||||
)
|
||||
} else
|
||||
@@ -634,7 +621,7 @@ fun MainScreen(
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (uiState.settings.isAutoTunnelEnabled) {
|
||||
showSnackbarMessage(
|
||||
appViewModel.showSnackbarMessage(
|
||||
Event.Message.AutoTunnelOffAction.message,
|
||||
)
|
||||
} else {
|
||||
@@ -658,7 +645,7 @@ fun MainScreen(
|
||||
) {
|
||||
expanded.value = !expanded.value
|
||||
} else {
|
||||
showSnackbarMessage(
|
||||
appViewModel.showSnackbarMessage(
|
||||
Event.Message.TunnelOnAction.message
|
||||
)
|
||||
}
|
||||
@@ -672,7 +659,7 @@ fun MainScreen(
|
||||
uiState.vpnState.status == Tunnel.State.UP &&
|
||||
tunnel.name == uiState.vpnState.name
|
||||
) {
|
||||
showSnackbarMessage(
|
||||
appViewModel.showSnackbarMessage(
|
||||
Event.Message.TunnelOffAction.message
|
||||
)
|
||||
} else {
|
||||
@@ -690,7 +677,7 @@ fun MainScreen(
|
||||
uiState.vpnState.status == Tunnel.State.UP &&
|
||||
tunnel.name == uiState.vpnState.name
|
||||
) {
|
||||
showSnackbarMessage(
|
||||
appViewModel.showSnackbarMessage(
|
||||
Event.Message.TunnelOffAction.message
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -122,6 +122,7 @@ constructor(
|
||||
addTunnel(tunnelConfig)
|
||||
Result.Success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
Result.Error(Event.Error.InvalidQrCode)
|
||||
}
|
||||
}
|
||||
@@ -158,6 +159,7 @@ constructor(
|
||||
return Result.Error(Event.Error.InvalidFileExtension)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
return Result.Error(Event.Error.FileReadFailed)
|
||||
}
|
||||
}
|
||||
@@ -249,6 +251,7 @@ constructor(
|
||||
return try {
|
||||
fileName.substring(fileName.lastIndexOf('.'))
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
+15
-14
@@ -20,7 +20,6 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -76,6 +75,7 @@ import com.wireguard.android.backend.Tunnel
|
||||
import com.wireguard.android.backend.WgQuickBackend
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
|
||||
import com.zaneschepke.wireguardautotunnel.ui.AppViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.ClickableIconButton
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationToggle
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt
|
||||
@@ -86,6 +86,7 @@ import com.zaneschepke.wireguardautotunnel.util.FileUtils
|
||||
import com.zaneschepke.wireguardautotunnel.util.Result
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
|
||||
@OptIn(
|
||||
@@ -94,9 +95,8 @@ import java.io.File
|
||||
)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
padding: PaddingValues,
|
||||
viewModel: SettingsViewModel = hiltViewModel(),
|
||||
showSnackbarMessage: (String) -> Unit,
|
||||
appViewModel: AppViewModel,
|
||||
focusRequester: FocusRequester
|
||||
) {
|
||||
val scope = rememberCoroutineScope { Dispatchers.IO }
|
||||
@@ -141,9 +141,10 @@ fun SettingsScreen(
|
||||
}
|
||||
FileUtils.saveFilesToZip(context, files)
|
||||
didExportFiles = true
|
||||
showSnackbarMessage(Event.Message.ConfigsExported.message)
|
||||
appViewModel.showSnackbarMessage(Event.Message.ConfigsExported.message)
|
||||
} catch (e: Exception) {
|
||||
showSnackbarMessage(Event.Error.Exception(e).message)
|
||||
Timber.e(e)
|
||||
appViewModel.showSnackbarMessage(Event.Error.Exception(e).message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,7 +175,7 @@ fun SettingsScreen(
|
||||
viewModel.onSaveTrustedSSID(currentText).let {
|
||||
when (it) {
|
||||
is Result.Success -> currentText = ""
|
||||
is Result.Error -> showSnackbarMessage(it.error.message)
|
||||
is Result.Error -> appViewModel.showSnackbarMessage(it.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -248,7 +249,7 @@ fun SettingsScreen(
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Top,
|
||||
modifier = Modifier.fillMaxSize().verticalScroll(scrollState).padding(padding),
|
||||
modifier = Modifier.fillMaxSize().verticalScroll(scrollState),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Rounded.LocationOff,
|
||||
@@ -301,11 +302,11 @@ fun SettingsScreen(
|
||||
},
|
||||
onError = { _ ->
|
||||
showAuthPrompt = false
|
||||
showSnackbarMessage(Event.Error.AuthenticationFailed.message)
|
||||
appViewModel.showSnackbarMessage(Event.Error.AuthenticationFailed.message)
|
||||
},
|
||||
onFailure = {
|
||||
showAuthPrompt = false
|
||||
showSnackbarMessage(Event.Error.AuthorizationFailed.message)
|
||||
appViewModel.showSnackbarMessage(Event.Error.AuthorizationFailed.message)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -314,7 +315,7 @@ fun SettingsScreen(
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxSize().padding(padding),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.one_tunnel_required),
|
||||
@@ -329,7 +330,7 @@ fun SettingsScreen(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Top,
|
||||
modifier =
|
||||
Modifier.fillMaxSize().padding(padding).verticalScroll(scrollState).clickable(
|
||||
Modifier.fillMaxSize().verticalScroll(scrollState).clickable(
|
||||
indication = null,
|
||||
interactionSource = interactionSource,
|
||||
) {
|
||||
@@ -501,11 +502,11 @@ fun SettingsScreen(
|
||||
) {
|
||||
when (false) {
|
||||
isBackgroundLocationGranted ->
|
||||
showSnackbarMessage(
|
||||
appViewModel.showSnackbarMessage(
|
||||
Event.Error.BackgroundLocationRequired.message
|
||||
)
|
||||
fineLocationState.status.isGranted ->
|
||||
showSnackbarMessage(
|
||||
appViewModel.showSnackbarMessage(
|
||||
Event.Error.PreciseLocationRequired.message
|
||||
)
|
||||
viewModel.isLocationEnabled(context) ->
|
||||
@@ -558,7 +559,7 @@ fun SettingsScreen(
|
||||
onCheckChanged = {
|
||||
viewModel.onToggleKernelMode().let {
|
||||
when (it) {
|
||||
is Result.Error -> showSnackbarMessage(it.error.message)
|
||||
is Result.Error -> appViewModel.showSnackbarMessage(it.error.message)
|
||||
is Result.Success -> {}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -183,9 +183,10 @@ constructor(
|
||||
if (!uiState.value.settings.isKernelEnabled) {
|
||||
try {
|
||||
rootShell.start()
|
||||
Timber.d("Root shell accepted!")
|
||||
Timber.i("Root shell accepted!")
|
||||
saveKernelMode(on = true)
|
||||
} catch (e: RootShell.RootShellException) {
|
||||
Timber.e(e)
|
||||
saveKernelMode(on = false)
|
||||
return Result.Error(Event.Error.RootDenied)
|
||||
}
|
||||
|
||||
+71
-63
@@ -1,14 +1,10 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.screens.support
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.Intent.createChooser
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -22,6 +18,7 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowForward
|
||||
import androidx.compose.material.icons.rounded.Book
|
||||
import androidx.compose.material.icons.rounded.FormatListNumbered
|
||||
import androidx.compose.material.icons.rounded.Mail
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -45,21 +42,21 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat.startActivity
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavController
|
||||
import com.zaneschepke.wireguardautotunnel.BuildConfig
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
|
||||
import com.zaneschepke.wireguardautotunnel.ui.AppViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.ui.Screen
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.screen.LoadingScreen
|
||||
import com.zaneschepke.wireguardautotunnel.util.Constants
|
||||
import com.zaneschepke.wireguardautotunnel.util.Event
|
||||
|
||||
@Composable
|
||||
fun SupportScreen(
|
||||
padding: PaddingValues,
|
||||
viewModel: SupportViewModel = hiltViewModel(),
|
||||
showSnackbarMessage: (String) -> Unit,
|
||||
appViewModel: AppViewModel,
|
||||
navController: NavController,
|
||||
focusRequester: FocusRequester
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -67,34 +64,6 @@ fun SupportScreen(
|
||||
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
fun openWebPage(url: String) {
|
||||
try {
|
||||
val webpage: Uri = Uri.parse(url)
|
||||
val intent = Intent(Intent.ACTION_VIEW, webpage)
|
||||
context.startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
showSnackbarMessage(Event.Error.Exception(e).message)
|
||||
}
|
||||
}
|
||||
|
||||
fun launchEmail() {
|
||||
try {
|
||||
val intent =
|
||||
Intent(Intent.ACTION_SENDTO).apply {
|
||||
type = Constants.EMAIL_MIME_TYPE
|
||||
putExtra(Intent.EXTRA_EMAIL, arrayOf(context.getString(R.string.my_email)))
|
||||
putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.email_subject))
|
||||
}
|
||||
startActivity(
|
||||
context,
|
||||
createChooser(intent, context.getString(R.string.email_chooser)),
|
||||
null,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
showSnackbarMessage(Event.Error.Exception(e).message)
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.loading) {
|
||||
LoadingScreen()
|
||||
return
|
||||
@@ -104,9 +73,10 @@ fun SupportScreen(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Top,
|
||||
modifier =
|
||||
Modifier.fillMaxSize().padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.focusable()
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.focusable()
|
||||
) {
|
||||
Surface(
|
||||
tonalElevation = 2.dp,
|
||||
@@ -115,15 +85,19 @@ fun SupportScreen(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
modifier =
|
||||
(if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
|
||||
Modifier.height(IntrinsicSize.Min)
|
||||
.fillMaxWidth(fillMaxWidth)
|
||||
.padding(top = 10.dp)
|
||||
Modifier
|
||||
.height(IntrinsicSize.Min)
|
||||
.fillMaxWidth(fillMaxWidth)
|
||||
.padding(top = 10.dp)
|
||||
} else {
|
||||
Modifier.fillMaxWidth(fillMaxWidth).padding(top = 20.dp)
|
||||
Modifier
|
||||
.fillMaxWidth(fillMaxWidth)
|
||||
.padding(top = 20.dp)
|
||||
})
|
||||
.padding(bottom = 25.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(20.dp)) {
|
||||
val forwardIcon = Icons.AutoMirrored.Rounded.ArrowForward
|
||||
Text(
|
||||
stringResource(R.string.thank_you),
|
||||
textAlign = TextAlign.Start,
|
||||
@@ -138,8 +112,10 @@ fun SupportScreen(
|
||||
modifier = Modifier.padding(bottom = 20.dp),
|
||||
)
|
||||
TextButton(
|
||||
onClick = { openWebPage(context.resources.getString(R.string.docs_url)) },
|
||||
modifier = Modifier.padding(vertical = 5.dp).focusRequester(focusRequester),
|
||||
onClick = { appViewModel.openWebPage(context.resources.getString(R.string.docs_url)) },
|
||||
modifier = Modifier
|
||||
.padding(vertical = 5.dp)
|
||||
.focusRequester(focusRequester),
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
@@ -147,7 +123,8 @@ fun SupportScreen(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row {
|
||||
Icon(Icons.Rounded.Book, stringResource(id = R.string.docs))
|
||||
val icon = Icons.Rounded.Book
|
||||
Icon(icon, icon.name)
|
||||
Text(
|
||||
stringResource(id = R.string.docs_description),
|
||||
textAlign = TextAlign.Justify,
|
||||
@@ -155,8 +132,8 @@ fun SupportScreen(
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
Icons.AutoMirrored.Rounded.ArrowForward,
|
||||
stringResource(id = R.string.go)
|
||||
forwardIcon,
|
||||
forwardIcon.name
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -165,7 +142,7 @@ fun SupportScreen(
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
TextButton(
|
||||
onClick = { openWebPage(context.resources.getString(R.string.discord_url)) },
|
||||
onClick = { appViewModel.openWebPage(context.resources.getString(R.string.discord_url)) },
|
||||
modifier = Modifier.padding(vertical = 5.dp),
|
||||
) {
|
||||
Row(
|
||||
@@ -174,9 +151,10 @@ fun SupportScreen(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row {
|
||||
val icon = ImageVector.vectorResource(R.drawable.discord)
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.discord),
|
||||
stringResource(id = R.string.discord),
|
||||
icon,
|
||||
icon.name,
|
||||
Modifier.size(25.dp),
|
||||
)
|
||||
Text(
|
||||
@@ -186,8 +164,8 @@ fun SupportScreen(
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
Icons.AutoMirrored.Rounded.ArrowForward,
|
||||
stringResource(id = R.string.go)
|
||||
forwardIcon,
|
||||
forwardIcon.name
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -196,7 +174,7 @@ fun SupportScreen(
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
TextButton(
|
||||
onClick = { openWebPage(context.resources.getString(R.string.github_url)) },
|
||||
onClick = { appViewModel.openWebPage(context.resources.getString(R.string.github_url)) },
|
||||
modifier = Modifier.padding(vertical = 5.dp),
|
||||
) {
|
||||
Row(
|
||||
@@ -205,20 +183,21 @@ fun SupportScreen(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row {
|
||||
val icon = ImageVector.vectorResource(R.drawable.github)
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.github),
|
||||
stringResource(id = R.string.github),
|
||||
imageVector = icon,
|
||||
icon.name,
|
||||
Modifier.size(25.dp),
|
||||
)
|
||||
Text(
|
||||
"Open an issue",
|
||||
stringResource(id = R.string.open_issue),
|
||||
textAlign = TextAlign.Justify,
|
||||
modifier = Modifier.padding(start = 10.dp),
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
Icons.AutoMirrored.Rounded.ArrowForward,
|
||||
stringResource(id = R.string.go)
|
||||
forwardIcon,
|
||||
forwardIcon.name
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -227,7 +206,7 @@ fun SupportScreen(
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
TextButton(
|
||||
onClick = { launchEmail() },
|
||||
onClick = { appViewModel.launchEmail() },
|
||||
modifier = Modifier.padding(vertical = 5.dp),
|
||||
) {
|
||||
Row(
|
||||
@@ -236,13 +215,42 @@ fun SupportScreen(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row {
|
||||
Icon(Icons.Rounded.Mail, stringResource(id = R.string.email))
|
||||
val icon = Icons.Rounded.Mail
|
||||
Icon(icon, icon.name)
|
||||
Text(
|
||||
stringResource(id = R.string.email_description),
|
||||
textAlign = TextAlign.Justify,
|
||||
modifier = Modifier.padding(start = 10.dp),
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
forwardIcon,
|
||||
forwardIcon.name
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(
|
||||
thickness = 0.5.dp,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
TextButton(
|
||||
onClick = { navController.navigate(Screen.Support.Logs.route) },
|
||||
modifier = Modifier.padding(vertical = 5.dp),
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row {
|
||||
val icon = Icons.Rounded.FormatListNumbered
|
||||
Icon(icon, icon.name)
|
||||
Text(
|
||||
stringResource(id = R.string.read_logs),
|
||||
textAlign = TextAlign.Justify,
|
||||
modifier = Modifier.padding(start = 10.dp),
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
Icons.AutoMirrored.Rounded.ArrowForward,
|
||||
stringResource(id = R.string.go)
|
||||
@@ -258,7 +266,7 @@ fun SupportScreen(
|
||||
fontSize = 16.sp,
|
||||
modifier =
|
||||
Modifier.clickable {
|
||||
openWebPage(context.resources.getString(R.string.privacy_policy_url))
|
||||
appViewModel.openWebPage(context.resources.getString(R.string.privacy_policy_url))
|
||||
},
|
||||
)
|
||||
Row(
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.screens.support.logs
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.ClipboardManager
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.text.LogTypeLabel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
|
||||
@Composable
|
||||
fun LogsScreen(logsViewModel: LogsViewModel = hiltViewModel()) {
|
||||
|
||||
val logs = remember {
|
||||
logsViewModel.logs
|
||||
}
|
||||
|
||||
val lazyColumnListState = rememberLazyListState()
|
||||
val clipboardManager: ClipboardManager = LocalClipboardManager.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
logsViewModel.readLogCatOutput()
|
||||
}
|
||||
|
||||
|
||||
LaunchedEffect(logs.size){
|
||||
scope.launch {
|
||||
lazyColumnListState.animateScrollToItem(logs.size)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
logsViewModel.saveLogsToFile()
|
||||
},
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
) {
|
||||
val icon = Icons.Filled.Save
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = icon.name,
|
||||
tint = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
LazyColumn(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Top),
|
||||
state = lazyColumnListState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 24.dp)) {
|
||||
items(logs) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(5.dp, Alignment.Start), verticalAlignment = Alignment.Top,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = {
|
||||
clipboardManager.setText(annotatedString = AnnotatedString(it.toString()))
|
||||
}
|
||||
)
|
||||
) {
|
||||
val fontSize = 10.sp
|
||||
Text(text = it.tag, modifier = Modifier.fillMaxSize(0.3f), fontSize = fontSize)
|
||||
LogTypeLabel(color = Color(it.level.color())) {
|
||||
Text(text = it.level.signifier, textAlign = TextAlign.Center, fontSize = fontSize)
|
||||
}
|
||||
Text("${it.message} - ${it.time}", fontSize = fontSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.screens.support.logs
|
||||
|
||||
import android.app.Application
|
||||
import android.widget.Toast
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.zaneschepke.logcatter.Logcatter
|
||||
import com.zaneschepke.logcatter.model.LogMessage
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.util.Constants
|
||||
import com.zaneschepke.wireguardautotunnel.util.FileUtils
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
|
||||
@HiltViewModel
|
||||
class LogsViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val application: Application
|
||||
) : ViewModel() {
|
||||
|
||||
val logs = mutableStateListOf<LogMessage>()
|
||||
|
||||
fun readLogCatOutput() = viewModelScope.launch(viewModelScope.coroutineContext + Dispatchers.IO) {
|
||||
launch {
|
||||
Logcatter.logs {
|
||||
logs.add(it)
|
||||
if (logs.size > Constants.LOG_BUFFER_SIZE) {
|
||||
logs.removeRange(0, (logs.size - Constants.LOG_BUFFER_SIZE).toInt())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearLogs() {
|
||||
logs.clear()
|
||||
Logcatter.clear()
|
||||
}
|
||||
|
||||
fun saveLogsToFile() {
|
||||
val fileName = "${Constants.BASE_LOG_FILE_NAME}-${Instant.now().epochSecond}.txt"
|
||||
val content = logs.joinToString(separator = "\n")
|
||||
FileUtils.saveFileToDownloads(application.applicationContext, content, fileName)
|
||||
Toast.makeText(application, application.getString(R.string.logs_saved), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,17 +1,22 @@
|
||||
package com.zaneschepke.wireguardautotunnel.util
|
||||
|
||||
object Constants {
|
||||
|
||||
const val BASE_LOG_FILE_NAME = "wgtunnel-logs"
|
||||
const val LOG_BUFFER_SIZE = 3_000L
|
||||
|
||||
const val MANUAL_TUNNEL_CONFIG_ID = "0"
|
||||
const val BATTERY_SAVER_WATCHER_WAKE_LOCK_TIMEOUT = 10 * 60 * 1000L // 10 minutes
|
||||
const val DEFAULT_WATCHER_WAKE_LOCK_TIMEOUT = 30 * 60 * 1000L // 30 minutes
|
||||
const val VPN_STATISTIC_CHECK_INTERVAL = 1000L
|
||||
const val VPN_CONNECTED_NOTIFICATION_DELAY = 3000L
|
||||
const val BATTERY_SAVER_WATCHER_WAKE_LOCK_TIMEOUT = 10 * 60 * 1_000L // 10 minutes
|
||||
const val DEFAULT_WATCHER_WAKE_LOCK_TIMEOUT = 30 * 60 * 1_000L // 30 minutes
|
||||
const val VPN_STATISTIC_CHECK_INTERVAL = 1_000L
|
||||
const val VPN_CONNECTED_NOTIFICATION_DELAY = 3_000L
|
||||
const val TOGGLE_TUNNEL_DELAY = 300L
|
||||
const val CONF_FILE_EXTENSION = ".conf"
|
||||
const val ZIP_FILE_EXTENSION = ".zip"
|
||||
const val URI_CONTENT_SCHEME = "content"
|
||||
const val URI_PACKAGE_SCHEME = "package"
|
||||
const val ALLOWED_FILE_TYPES = "*/*"
|
||||
const val TEXT_MIME_TYPE = "text/plain"
|
||||
const val GOOGLE_TV_EXPLORER_STUB = "com.google.android.tv.frameworkpackagestubs"
|
||||
const val ANDROID_TV_EXPLORER_STUB = "com.android.tv.frameworkpackagestubs"
|
||||
const val EMAIL_MIME_TYPE = "message/rfc822"
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import android.provider.MediaStore.MediaColumns
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.OutputStream
|
||||
import java.time.Instant
|
||||
import java.util.zip.ZipEntry
|
||||
@@ -43,6 +44,31 @@ object FileUtils {
|
||||
return null
|
||||
}
|
||||
|
||||
fun saveFileToDownloads(context: Context, content: String, fileName: String) {
|
||||
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val contentValues = ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
|
||||
put(MediaStore.MediaColumns.MIME_TYPE, Constants.TEXT_MIME_TYPE)
|
||||
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
|
||||
}
|
||||
val resolver = context.contentResolver
|
||||
val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
|
||||
if (uri != null) {
|
||||
resolver.openOutputStream(uri).use { output ->
|
||||
output?.write(content.toByteArray())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val target = File(
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
|
||||
fileName
|
||||
)
|
||||
FileOutputStream(target).use { output ->
|
||||
output.write(content.toByteArray())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveFilesToZip(context: Context, files: List<File>) {
|
||||
val zipOutputStream =
|
||||
createDownloadsFileOutputStream(
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.zaneschepke.wireguardautotunnel.util
|
||||
|
||||
import android.util.Log
|
||||
import timber.log.Timber
|
||||
|
||||
class ReleaseTree : Timber.Tree() {
|
||||
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
|
||||
when(priority) {
|
||||
Log.DEBUG -> return
|
||||
}
|
||||
super.log(priority,tag,message,t)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.zaneschepke.wireguardautotunnel.util
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
|
||||
sealed class StringValue {
|
||||
|
||||
data class DynamicString(val value: String) : StringValue()
|
||||
|
||||
data object Empty : StringValue()
|
||||
|
||||
class StringResource(
|
||||
@StringRes val resId: Int,
|
||||
vararg val args: Any
|
||||
) : StringValue()
|
||||
|
||||
fun asString(context: Context?): String {
|
||||
return when (this) {
|
||||
is Empty -> ""
|
||||
is DynamicString -> value
|
||||
is StringResource -> context?.getString(resId, *args).orEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,10 +146,6 @@
|
||||
<string name="go">go</string>
|
||||
<string name="docs_description">Read the docs (WIP)</string>
|
||||
<string name="discord_description">Join the community</string>
|
||||
<string name="discord" translatable="false">Discord</string>
|
||||
<string name="docs">Docs</string>
|
||||
<string name="github" translatable="false">GitHub</string>
|
||||
<string name="email">Email</string>
|
||||
<string name="email_description">Send me an email</string>
|
||||
<string name="support_help_text">If you are experiencing issues, have improvement ideas, or just want to engage, the following resources are available:</string>
|
||||
<string name="kernel">Kernel</string>
|
||||
@@ -176,4 +172,9 @@
|
||||
<string name="excluded">excluded</string>
|
||||
<string name="all">all</string>
|
||||
<string name="always_on_disabled">Always-on VPN attempted to start a tunnel, but this feature is disabled in settings.</string>
|
||||
<string name="no_email_detected">No email app detected</string>
|
||||
<string name="no_browser_detected">No browser detected</string>
|
||||
<string name="logs_saved">Logs saved to downloads</string>
|
||||
<string name="open_issue">Open an issue</string>
|
||||
<string name="read_logs">Read the logs</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user