feat: add logs screen

This commit is contained in:
Zane Schepke 2024-03-13 02:52:57 -04:00
parent c0cff297b2
commit 4fc8ffbcbb
38 changed files with 786 additions and 249 deletions

View File

@ -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

View File

@ -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 {

View File

@ -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
}

View File

@ -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")
}
}
}

View File

@ -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) {

View File

@ -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() {
}

View File

@ -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
)
}
}

View File

@ -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,
)
}
}
}
}
}

View File

@ -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")

View File

@ -0,0 +1,6 @@
package com.zaneschepke.wireguardautotunnel.ui
data class SnackBarState(
val snackbarMessage: String = "",
val snackbarMessageConsumed: Boolean = true,
)

View File

@ -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()
}
}

View File

@ -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,

View File

@ -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))
}
}

View File

@ -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 {

View File

@ -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)
""
}
}

View File

@ -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 -> {}
}
}

View File

@ -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)
}

View File

@ -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(

View File

@ -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)
}
}
}
}
}

View File

@ -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()
}
}

View File

@ -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"

View File

@ -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(

View File

@ -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)
}
}

View File

@ -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()
}
}
}

View File

@ -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>

View File

@ -4,4 +4,5 @@ plugins {
alias(libs.plugins.hilt.android) apply false
alias(libs.plugins.kotlinxSerialization) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.androidLibrary) apply false
}

View File

@ -29,6 +29,7 @@ zxingCore = "3.5.3"
#plugins
gradlePlugins-kotlinxSerialization = "1.8.21"
material = "1.10.0"
[libraries]
@ -84,10 +85,12 @@ tunnel = { module = "com.zaneschepke:wireguard-android", version.ref = "tunnel"
zxing-core = { module = "com.google.zxing:core", version.ref = "zxingCore" }
zxing-android-embedded = { module = "com.journeyapps:zxing-android-embedded", version.ref = "zxingAndroidEmbedded" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
[plugins]
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hiltAndroid" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
kotlinxSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "gradlePlugins-kotlinxSerialization" }
kotlinxSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "gradlePlugins-kotlinxSerialization" }
androidLibrary = { id = "com.android.library", version.ref = "androidGradlePlugin" }

1
logcatter/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

View File

@ -0,0 +1,43 @@
plugins {
alias(libs.plugins.androidLibrary)
alias(libs.plugins.kotlin.android)
}
android {
namespace = "com.zaneschepke.logcatter"
compileSdk = 34
defaultConfig {
minSdk = 26
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.material)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
}

View File

21
logcatter/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@ -0,0 +1,22 @@
package com.zaneschepke
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.zaneschepke.test", appContext.packageName)
}
}

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest>
</manifest>

View File

@ -0,0 +1,19 @@
package com.zaneschepke.logcatter
import com.zaneschepke.logcatter.model.LogMessage
object Logcatter {
fun logs(callback: (input: LogMessage) -> Unit) {
clear()
Runtime.getRuntime().exec("logcat -v epoch")
.inputStream
.bufferedReader()
.useLines { lines ->
lines.forEach { callback(LogMessage.from(it)) }
}
}
fun clear() {
Runtime.getRuntime().exec("logcat -c")
}
}

View File

@ -0,0 +1,48 @@
package com.zaneschepke.logcatter.model
enum class LogLevel(val signifier: String) {
DEBUG("D") {
override fun color(): Long {
return 0xFF2196F3
}
},
INFO("I"){
override fun color(): Long {
return 0xFF4CAF50
}
},
ASSERT("A"){
override fun color(): Long {
return 0xFF9C27B0
}
},
WARNING("W"){
override fun color(): Long {
return 0xFFFFC107
}
},
ERROR("E"){
override fun color(): Long {
return 0xFFF44336
}
},
VERBOSE("V"){
override fun color(): Long {
return 0xFF000000
}
};
abstract fun color() : Long
companion object {
fun fromSignifier(signifier: String) : LogLevel {
return when(signifier) {
DEBUG.signifier -> DEBUG
INFO.signifier -> INFO
WARNING.signifier -> WARNING
VERBOSE.signifier -> VERBOSE
ASSERT.signifier -> ASSERT
ERROR.signifier -> ERROR
else -> VERBOSE
}
}
}
}

View File

@ -0,0 +1,28 @@
package com.zaneschepke.logcatter.model
import java.time.Instant
data class LogMessage(
val time: Instant,
val pid: String,
val tid: String,
val level : LogLevel,
val tag: String,
val message: String
) {
override fun toString(): String {
return "$time $pid $tid $level $tag message= $message"
}
companion object {
fun from(logcatLine : String) : LogMessage {
return if(logcatLine.contains("---------")) LogMessage(Instant.now(), "0","0",LogLevel.VERBOSE,"System", logcatLine)
else {
//TODO improve this
val parts = logcatLine.trim().split(" ").filter { it.isNotEmpty() }
val epochParts = parts[0].split(".").map { it.toLong() }
val message = parts.subList(5, parts.size).joinToString(" ")
LogMessage(Instant.ofEpochSecond(epochParts[0], epochParts[1]), parts[1], parts[2], LogLevel.fromSignifier(parts[3]), parts[4], message)
}
}
}
}

View File

@ -0,0 +1,16 @@
package com.zaneschepke
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

View File

@ -41,3 +41,4 @@ fun getLocalProperty(key: String, file: String = "local.properties"): String? {
rootProject.name = "WG Tunnel"
include(":app")
include(":logcatter")