diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bc1289a..15ed119 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/WireGuardAutoTunnel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/WireGuardAutoTunnel.kt index 3b6507c..7f2366e 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/WireGuardAutoTunnel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/WireGuardAutoTunnel.kt @@ -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 { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/ForegroundService.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/ForegroundService.kt index 9e3c8bb..8135fc4 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/ForegroundService.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/ForegroundService.kt @@ -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 } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardConnectivityWatcherService.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardConnectivityWatcherService.kt index 7e90a5f..44ee168 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardConnectivityWatcherService.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardConnectivityWatcherService.kt @@ -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") } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardTunnelService.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardTunnelService.kt index 303f9c1..d1ca34a 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardTunnelService.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardTunnelService.kt @@ -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) { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/ActivityViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/ActivityViewModel.kt deleted file mode 100644 index b1a09b3..0000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/ActivityViewModel.kt +++ /dev/null @@ -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() { - -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/AppViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/AppViewModel.kt new file mode 100644 index 0000000..3670128 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/AppViewModel.kt @@ -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 + ) + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/MainActivity.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/MainActivity.kt index c3622f9..fb1aa8b 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/MainActivity.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/MainActivity.kt @@ -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() - + val appViewModel = hiltViewModel() + 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, + ) + } + } } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Screen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Screen.kt index cd4f5ea..cb7655f 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Screen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Screen.kt @@ -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") diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/SnackBarState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/SnackBarState.kt new file mode 100644 index 0000000..f7f129b --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/SnackBarState.kt @@ -0,0 +1,6 @@ +package com.zaneschepke.wireguardautotunnel.ui + +data class SnackBarState( + val snackbarMessage: String = "", + val snackbarMessageConsumed: Boolean = true, +) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/text/LogTypeLabel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/text/LogTypeLabel.kt new file mode 100644 index 0000000..2ddd7d4 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/text/LogTypeLabel.kt @@ -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() + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigScreen.kt index d56d638..2863790 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigScreen.kt @@ -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, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigViewModel.kt index 25fd876..ca6ec51 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigViewModel.kt @@ -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)) } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainScreen.kt index a73f546..e04b994 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainScreen.kt @@ -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 { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainViewModel.kt index 5008ccd..e4f2121 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainViewModel.kt @@ -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) "" } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsScreen.kt index 4e59c67..4a47fb5 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsScreen.kt @@ -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 -> {} } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsViewModel.kt index 5eff576..75a6806 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsViewModel.kt @@ -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) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportScreen.kt index 62f347a..f8800ca 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportScreen.kt @@ -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( diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/logs/LogsScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/logs/LogsScreen.kt new file mode 100644 index 0000000..adac5e7 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/logs/LogsScreen.kt @@ -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) + } + } + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/logs/LogsViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/logs/LogsViewModel.kt new file mode 100644 index 0000000..2625b72 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/logs/LogsViewModel.kt @@ -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() + + 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() + } + +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/Constants.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/Constants.kt index 0debacc..bb813d6 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/Constants.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/Constants.kt @@ -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" diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/FileUtils.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/FileUtils.kt index 2c895cd..2e389f8 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/FileUtils.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/FileUtils.kt @@ -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) { val zipOutputStream = createDownloadsFileOutputStream( diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/ReleaseTree.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/ReleaseTree.kt new file mode 100644 index 0000000..cf5f729 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/ReleaseTree.kt @@ -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) + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/StringValue.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/StringValue.kt new file mode 100644 index 0000000..353aedf --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/StringValue.kt @@ -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() + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fc071bc..e1ff481 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -146,10 +146,6 @@ go Read the docs (WIP) Join the community - Discord - Docs - GitHub - Email Send me an email If you are experiencing issues, have improvement ideas, or just want to engage, the following resources are available: Kernel @@ -176,4 +172,9 @@ excluded all Always-on VPN attempted to start a tunnel, but this feature is disabled in settings. + No email app detected + No browser detected + Logs saved to downloads + Open an issue + Read the logs \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index e28deac..2f45261 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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 } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c663c9b..0902a67 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } \ No newline at end of file +kotlinxSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "gradlePlugins-kotlinxSerialization" } +androidLibrary = { id = "com.android.library", version.ref = "androidGradlePlugin" } \ No newline at end of file diff --git a/logcatter/.gitignore b/logcatter/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/logcatter/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/logcatter/build.gradle.kts b/logcatter/build.gradle.kts new file mode 100644 index 0000000..68d4c7c --- /dev/null +++ b/logcatter/build.gradle.kts @@ -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) +} diff --git a/logcatter/consumer-rules.pro b/logcatter/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/logcatter/proguard-rules.pro b/logcatter/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/logcatter/proguard-rules.pro @@ -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 \ No newline at end of file diff --git a/logcatter/src/androidTest/java/com/zaneschepke/ExampleInstrumentedTest.kt b/logcatter/src/androidTest/java/com/zaneschepke/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..5514a87 --- /dev/null +++ b/logcatter/src/androidTest/java/com/zaneschepke/ExampleInstrumentedTest.kt @@ -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) + } +} diff --git a/logcatter/src/main/AndroidManifest.xml b/logcatter/src/main/AndroidManifest.xml new file mode 100644 index 0000000..44008a4 --- /dev/null +++ b/logcatter/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/logcatter/src/main/java/com/zaneschepke/logcatter/Logcatter.kt b/logcatter/src/main/java/com/zaneschepke/logcatter/Logcatter.kt new file mode 100644 index 0000000..4611d62 --- /dev/null +++ b/logcatter/src/main/java/com/zaneschepke/logcatter/Logcatter.kt @@ -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") + } +} diff --git a/logcatter/src/main/java/com/zaneschepke/logcatter/model/LogLevel.kt b/logcatter/src/main/java/com/zaneschepke/logcatter/model/LogLevel.kt new file mode 100644 index 0000000..ac50c86 --- /dev/null +++ b/logcatter/src/main/java/com/zaneschepke/logcatter/model/LogLevel.kt @@ -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 + } + } + } +} diff --git a/logcatter/src/main/java/com/zaneschepke/logcatter/model/LogMessage.kt b/logcatter/src/main/java/com/zaneschepke/logcatter/model/LogMessage.kt new file mode 100644 index 0000000..77156c2 --- /dev/null +++ b/logcatter/src/main/java/com/zaneschepke/logcatter/model/LogMessage.kt @@ -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) + } + } + } +} diff --git a/logcatter/src/test/java/com/zaneschepke/ExampleUnitTest.kt b/logcatter/src/test/java/com/zaneschepke/ExampleUnitTest.kt new file mode 100644 index 0000000..903247b --- /dev/null +++ b/logcatter/src/test/java/com/zaneschepke/ExampleUnitTest.kt @@ -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) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 3678f76..52cf3be 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -41,3 +41,4 @@ fun getLocalProperty(key: String, file: String = "local.properties"): String? { rootProject.name = "WG Tunnel" include(":app") +include(":logcatter")