initial ui changes

This commit is contained in:
Zane Schepke 2024-10-21 23:52:20 -04:00
parent 89f6dec357
commit 553279ea76
46 changed files with 2131 additions and 678 deletions

View File

@ -44,6 +44,8 @@ android {
getByName("debug").assets.srcDirs(files("$projectDir/schemas")) // Room
}
buildConfigField("String[]", "LANGUAGES", "new String[]{ ${languageList().joinToString(separator = ", ") { "\"$it\"" }} }")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { useSupportLibrary = true }
}

View File

@ -3,13 +3,6 @@
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="32"
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
@ -19,7 +12,8 @@
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<!--foreground service exempt android 14-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"
tools:ignore="ProtectedPermissions" />
<!--foreground service permissions-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />

View File

@ -1,11 +1,14 @@
package com.zaneschepke.wireguardautotunnel
import android.app.Application
import android.content.Context
import android.os.StrictMode
import android.os.StrictMode.ThreadPolicy
import com.zaneschepke.logcatter.LogReader
import com.zaneschepke.wireguardautotunnel.data.datastore.LocaleStorage
import com.zaneschepke.wireguardautotunnel.module.ApplicationScope
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import com.zaneschepke.wireguardautotunnel.util.LocaleUtil
import com.zaneschepke.wireguardautotunnel.util.ReleaseTree
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import dagger.hilt.android.HiltAndroidApp
@ -18,6 +21,10 @@ import javax.inject.Inject
@HiltAndroidApp
class WireGuardAutoTunnel : Application() {
val localeStorage: LocaleStorage by lazy {
LocaleStorage(this)
}
@Inject
@ApplicationScope
lateinit var applicationScope: CoroutineScope
@ -52,6 +59,10 @@ class WireGuardAutoTunnel : Application() {
}
}
override fun attachBaseContext(base: Context) {
super.attachBaseContext(LocaleUtil.getLocalizedContext(base, LocaleStorage(base).getPreferredLocale()))
}
companion object {
lateinit var instance: WireGuardAutoTunnel
private set

View File

@ -21,11 +21,12 @@ class DataStoreManager(
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) {
companion object {
val LOCATION_DISCLOSURE_SHOWN = booleanPreferencesKey("LOCATION_DISCLOSURE_SHOWN")
val BATTERY_OPTIMIZE_DISABLE_SHOWN = booleanPreferencesKey("BATTERY_OPTIMIZE_DISABLE_SHOWN")
val CURRENT_SSID = stringPreferencesKey("CURRENT_SSID")
val IS_PIN_LOCK_ENABLED = booleanPreferencesKey("PIN_LOCK_ENABLED")
val IS_TUNNEL_STATS_EXPANDED = booleanPreferencesKey("TUNNEL_STATS_EXPANDED")
val locationDisclosureShown = booleanPreferencesKey("LOCATION_DISCLOSURE_SHOWN")
val batteryDisableShown = booleanPreferencesKey("BATTERY_OPTIMIZE_DISABLE_SHOWN")
val currentSSID = stringPreferencesKey("CURRENT_SSID")
val pinLockEnabled = booleanPreferencesKey("PIN_LOCK_ENABLED")
val tunnelStatsExpanded = booleanPreferencesKey("TUNNEL_STATS_EXPANDED")
val theme = stringPreferencesKey("THEME")
}
// preferences

View File

@ -0,0 +1,17 @@
package com.zaneschepke.wireguardautotunnel.data.datastore
import android.content.Context
import android.content.SharedPreferences
import com.zaneschepke.wireguardautotunnel.util.LocaleUtil
class LocaleStorage(context: Context) {
private var preferences: SharedPreferences = context.getSharedPreferences("sp", Context.MODE_PRIVATE)
fun getPreferredLocale(): String {
return preferences.getString("preferred_locale", LocaleUtil.OPTION_PHONE_LANGUAGE)!!
}
fun setPreferredLocale(localeCode: String) {
preferences.edit().putString("preferred_locale", localeCode).apply()
}
}

View File

@ -1,10 +1,13 @@
package com.zaneschepke.wireguardautotunnel.data.domain
import com.zaneschepke.wireguardautotunnel.ui.theme.Theme
data class GeneralState(
val isLocationDisclosureShown: Boolean = LOCATION_DISCLOSURE_SHOWN_DEFAULT,
val isBatteryOptimizationDisableShown: Boolean = BATTERY_OPTIMIZATION_DISABLE_SHOWN_DEFAULT,
val isPinLockEnabled: Boolean = PIN_LOCK_ENABLED_DEFAULT,
val isTunnelStatsExpanded: Boolean = IS_TUNNEL_STATS_EXPANDED,
val theme: Theme = Theme.AUTOMATIC
) {
companion object {
const val LOCATION_DISCLOSURE_SHOWN_DEFAULT = false

View File

@ -1,6 +1,7 @@
package com.zaneschepke.wireguardautotunnel.data.repository
import com.zaneschepke.wireguardautotunnel.data.domain.GeneralState
import com.zaneschepke.wireguardautotunnel.ui.theme.Theme
import kotlinx.coroutines.flow.Flow
interface AppStateRepository {
@ -24,5 +25,9 @@ interface AppStateRepository {
suspend fun setTunnelStatsExpanded(expanded: Boolean)
suspend fun setTheme(theme: Theme)
suspend fun getTheme() : Theme
val generalStateFlow: Flow<GeneralState>
}

View File

@ -2,6 +2,7 @@ package com.zaneschepke.wireguardautotunnel.data.repository
import com.zaneschepke.wireguardautotunnel.data.datastore.DataStoreManager
import com.zaneschepke.wireguardautotunnel.data.domain.GeneralState
import com.zaneschepke.wireguardautotunnel.ui.theme.Theme
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import timber.log.Timber
@ -11,47 +12,61 @@ class DataStoreAppStateRepository(
) :
AppStateRepository {
override suspend fun isLocationDisclosureShown(): Boolean {
return dataStoreManager.getFromStore(DataStoreManager.LOCATION_DISCLOSURE_SHOWN)
return dataStoreManager.getFromStore(DataStoreManager.locationDisclosureShown)
?: GeneralState.LOCATION_DISCLOSURE_SHOWN_DEFAULT
}
override suspend fun setLocationDisclosureShown(shown: Boolean) {
dataStoreManager.saveToDataStore(DataStoreManager.LOCATION_DISCLOSURE_SHOWN, shown)
dataStoreManager.saveToDataStore(DataStoreManager.locationDisclosureShown, shown)
}
override suspend fun isPinLockEnabled(): Boolean {
return dataStoreManager.getFromStore(DataStoreManager.IS_PIN_LOCK_ENABLED)
return dataStoreManager.getFromStore(DataStoreManager.pinLockEnabled)
?: GeneralState.PIN_LOCK_ENABLED_DEFAULT
}
override suspend fun setPinLockEnabled(enabled: Boolean) {
dataStoreManager.saveToDataStore(DataStoreManager.IS_PIN_LOCK_ENABLED, enabled)
dataStoreManager.saveToDataStore(DataStoreManager.pinLockEnabled, enabled)
}
override suspend fun isBatteryOptimizationDisableShown(): Boolean {
return dataStoreManager.getFromStore(DataStoreManager.BATTERY_OPTIMIZE_DISABLE_SHOWN)
return dataStoreManager.getFromStore(DataStoreManager.batteryDisableShown)
?: GeneralState.BATTERY_OPTIMIZATION_DISABLE_SHOWN_DEFAULT
}
override suspend fun setBatteryOptimizationDisableShown(shown: Boolean) {
dataStoreManager.saveToDataStore(DataStoreManager.BATTERY_OPTIMIZE_DISABLE_SHOWN, shown)
dataStoreManager.saveToDataStore(DataStoreManager.batteryDisableShown, shown)
}
override suspend fun getCurrentSsid(): String? {
return dataStoreManager.getFromStore(DataStoreManager.CURRENT_SSID)
return dataStoreManager.getFromStore(DataStoreManager.currentSSID)
}
override suspend fun setCurrentSsid(ssid: String) {
dataStoreManager.saveToDataStore(DataStoreManager.CURRENT_SSID, ssid)
dataStoreManager.saveToDataStore(DataStoreManager.currentSSID, ssid)
}
override suspend fun isTunnelStatsExpanded(): Boolean {
return dataStoreManager.getFromStore(DataStoreManager.IS_TUNNEL_STATS_EXPANDED)
return dataStoreManager.getFromStore(DataStoreManager.tunnelStatsExpanded)
?: GeneralState.IS_TUNNEL_STATS_EXPANDED
}
override suspend fun setTunnelStatsExpanded(expanded: Boolean) {
dataStoreManager.saveToDataStore(DataStoreManager.IS_TUNNEL_STATS_EXPANDED, expanded)
dataStoreManager.saveToDataStore(DataStoreManager.tunnelStatsExpanded, expanded)
}
override suspend fun setTheme(theme: Theme) {
dataStoreManager.saveToDataStore(DataStoreManager.theme, theme.name)
}
override suspend fun getTheme(): Theme {
return dataStoreManager.getFromStore(DataStoreManager.theme)?.let {
try {
Theme.valueOf(it)
} catch (_ : IllegalArgumentException) {
Theme.AUTOMATIC
}
} ?: Theme.AUTOMATIC
}
override val generalStateFlow: Flow<GeneralState> =
@ -60,15 +75,16 @@ class DataStoreAppStateRepository(
try {
GeneralState(
isLocationDisclosureShown =
pref[DataStoreManager.LOCATION_DISCLOSURE_SHOWN]
pref[DataStoreManager.locationDisclosureShown]
?: GeneralState.LOCATION_DISCLOSURE_SHOWN_DEFAULT,
isBatteryOptimizationDisableShown =
pref[DataStoreManager.BATTERY_OPTIMIZE_DISABLE_SHOWN]
pref[DataStoreManager.batteryDisableShown]
?: GeneralState.BATTERY_OPTIMIZATION_DISABLE_SHOWN_DEFAULT,
isPinLockEnabled =
pref[DataStoreManager.IS_PIN_LOCK_ENABLED]
pref[DataStoreManager.pinLockEnabled]
?: GeneralState.PIN_LOCK_ENABLED_DEFAULT,
isTunnelStatsExpanded = pref[DataStoreManager.IS_TUNNEL_STATS_EXPANDED] ?: GeneralState.IS_TUNNEL_STATS_EXPANDED,
isTunnelStatsExpanded = pref[DataStoreManager.tunnelStatsExpanded] ?: GeneralState.IS_TUNNEL_STATS_EXPANDED,
theme = getTheme()
)
} catch (e: IllegalArgumentException) {
Timber.e(e)

View File

@ -10,4 +10,6 @@ data class AppUiState(
val tunnels: List<TunnelConfig> = emptyList(),
val vpnState: VpnState = VpnState(),
val generalState: GeneralState = GeneralState(),
val isKernelAvailable: Boolean = false,
val isRooted: Boolean = false,
)

View File

@ -2,6 +2,8 @@ package com.zaneschepke.wireguardautotunnel.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wireguard.android.backend.WgQuickBackend
import com.wireguard.android.util.RootShell
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
@ -18,10 +20,13 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import kotlinx.coroutines.withContext
import xyz.teamgravity.pin_lock_compose.PinManager
import javax.inject.Inject
import javax.inject.Provider
@ -32,19 +37,34 @@ class AppViewModel
constructor(
private val appDataRepository: AppDataRepository,
private val tunnelService: Provider<TunnelService>,
private val rootShell: Provider<RootShell>,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : ViewModel() {
private val _appUiState = MutableStateFlow(AppUiState())
val appUiState = _appUiState.onStart {
_appUiState.update {
it.copy(
isRooted = isRooted(),
isKernelAvailable = isKernelSupported(),
)
}
}.stateIn(
viewModelScope + ioDispatcher,
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT),
AppUiState(),
)
val uiState =
combine(
appDataRepository.settings.getSettingsFlow(),
appDataRepository.tunnels.getTunnelConfigsFlow(),
tunnelService.get().vpnState,
appDataRepository.appState.generalStateFlow,
) { settings, tunnels, tunnelState, generalState ->
AppUiState(
appUiState,
) { settings, tunnels, tunnelState, generalState, appUiState ->
appUiState.copy(
settings,
tunnels,
tunnelState,
@ -112,4 +132,21 @@ constructor(
fun onPinLockEnabled() = viewModelScope.launch {
appDataRepository.appState.setPinLockEnabled(true)
}
private suspend fun isKernelSupported(): Boolean {
return withContext(ioDispatcher) {
WgQuickBackend.hasKernelSupport()
}
}
private suspend fun isRooted(): Boolean {
return try {
withContext(ioDispatcher) {
rootShell.get().start()
}
true
} catch (_: Exception) {
false
}
}
}

View File

@ -1,9 +1,8 @@
package com.zaneschepke.wireguardautotunnel.ui
import android.content.Context
import android.os.Bundle
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.core.tween
@ -11,8 +10,12 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.QuestionMark
@ -29,8 +32,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
@ -41,6 +42,8 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.data.datastore.LocaleStorage
import com.zaneschepke.wireguardautotunnel.data.repository.AppStateRepository
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService
@ -48,8 +51,9 @@ import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelState
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavBar
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavItem
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.isCurrentRoute
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.CustomSnackBar
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.CustomSnackBar
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarControllerProvider
import com.zaneschepke.wireguardautotunnel.ui.screens.config.ConfigScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.MainScreen
@ -57,10 +61,15 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.options.OptionsScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.pinlock.PinLockScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.scanner.ScannerScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.SettingsScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.AppearanceScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.display.DisplayScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.language.LanguageScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.AutoTunnelScreen
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.LocaleUtil
import com.zaneschepke.wireguardautotunnel.util.extensions.requestAutoTunnelTileServiceUpdate
import com.zaneschepke.wireguardautotunnel.util.extensions.requestTunnelTileServiceStateUpdate
import dagger.hilt.android.AndroidEntryPoint
@ -68,6 +77,13 @@ import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
private val localeStorage: LocaleStorage by lazy {
(application as WireGuardAutoTunnel).localeStorage
}
private lateinit var oldPrefLocaleCode: String
@Inject
lateinit var appStateRepository: AppStateRepository
@ -78,12 +94,6 @@ class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge(
navigationBarStyle = SystemBarStyle.auto(
lightScrim = Color.Transparent.toArgb(),
darkScrim = Color.Transparent.toArgb(),
),
)
installSplashScreen().apply {
setKeepOnScreenCondition {
@ -113,9 +123,10 @@ class MainActivity : AppCompatActivity() {
CompositionLocalProvider(LocalNavController provides navController) {
SnackbarControllerProvider { host ->
WireguardAutoTunnelTheme {
WireguardAutoTunnelTheme(theme = appUiState.generalState.theme){
val focusRequester = remember { FocusRequester() }
Scaffold(
contentWindowInsets = WindowInsets(0.dp),
snackbarHost = {
SnackbarHost(host) { snackbarData: SnackbarData ->
CustomSnackBar(
@ -160,8 +171,8 @@ class MainActivity : AppCompatActivity() {
),
)
},
) { padding ->
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
) {
Box(modifier = Modifier.fillMaxSize().padding(it)) {
NavHost(
navController,
enterTransition = { fadeIn(tween(Constants.TRANSITION_ANIMATION_TIME)) },
@ -181,6 +192,20 @@ class MainActivity : AppCompatActivity() {
focusRequester = focusRequester,
)
}
composable<Route.AutoTunnel> {
AutoTunnelScreen(
appUiState,
)
}
composable<Route.Appearance> {
AppearanceScreen()
}
composable<Route.Language> {
LanguageScreen(localeStorage)
}
composable<Route.Display> {
DisplayScreen(appUiState)
}
composable<Route.Support> {
SupportScreen(
focusRequester = focusRequester,
@ -222,6 +247,21 @@ class MainActivity : AppCompatActivity() {
}
}
override fun attachBaseContext(newBase: Context) {
oldPrefLocaleCode = LocaleStorage(newBase).getPreferredLocale()
applyOverrideConfiguration(LocaleUtil.getLocalizedConfiguration(oldPrefLocaleCode))
super.attachBaseContext(newBase)
}
override fun onResume() {
val currentLocaleCode = LocaleStorage(this).getPreferredLocale()
if (oldPrefLocaleCode != currentLocaleCode) {
recreate() // locale is changed, restart the activity to update
oldPrefLocaleCode = currentLocaleCode
}
super.onResume()
}
override fun onDestroy() {
super.onDestroy()
tunnelService.cancelStatsJob()

View File

@ -9,6 +9,18 @@ sealed class Route {
@Serializable
data object Settings : Route()
@Serializable
data object AutoTunnel : Route()
@Serializable
data object Appearance : Route()
@Serializable
data object Display : Route()
@Serializable
data object Language : Route()
@Serializable
data object Main : Route()

View File

@ -0,0 +1,37 @@
package com.zaneschepke.wireguardautotunnel.ui.common
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
@Composable
fun SelectedLabel() {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
stringResource(id = R.string.selected),
modifier =
Modifier.padding(
horizontal = 24.dp.scaledWidth(),
vertical = 16.dp.scaledHeight(),
),
color =
MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelSmall,
)
}
}

View File

@ -0,0 +1,89 @@
package com.zaneschepke.wireguardautotunnel.ui.common.button
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
import kotlin.let
@androidx.compose.runtime.Composable
fun IconSurfaceButton(title: String, onClick: () -> Unit, selected: Boolean, leadingIcon: ImageVector? = null, description: String? = null) {
val border: BorderStroke? =
if (selected) BorderStroke(
1.dp,
MaterialTheme.colorScheme.primary
) else null
val interactionSource =
androidx.compose.runtime.remember { androidx.compose.foundation.interaction.MutableInteractionSource() }
androidx.compose.material3.Card(
modifier =
Modifier
.fillMaxWidth()
.height(IntrinsicSize.Min)
.clickable(interactionSource = interactionSource, indication = null) {
onClick()
},
shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp),
border = border,
colors = androidx.compose.material3.CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Column(
modifier =
Modifier
.padding(horizontal = 8.dp.scaledWidth(), vertical = 10.dp.scaledHeight())
.padding(end = 16.dp.scaledWidth()).padding(start = 8.dp.scaledWidth())
.fillMaxSize(),
verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center,
horizontalAlignment = androidx.compose.ui.Alignment.Companion.Start,
) {
androidx.compose.foundation.layout.Row(
verticalAlignment = androidx.compose.ui.Alignment.Companion.CenterVertically,
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(16.dp.scaledWidth()),
) {
androidx.compose.foundation.layout.Row(
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(
16.dp.scaledWidth()
),
verticalAlignment = androidx.compose.ui.Alignment.Companion.CenterVertically,
modifier = Modifier.padding(vertical = if (description == null) 10.dp.scaledHeight() else 0.dp),
) {
leadingIcon?.let {
Icon(
leadingIcon,
leadingIcon.name,
Modifier.Companion.size(iconSize.scaledWidth()),
if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
)
}
Column {
Text(
title,
style = MaterialTheme.typography.titleMedium
)
description?.let {
Text(
description,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
}
}
}

View File

@ -0,0 +1,18 @@
package com.zaneschepke.wireguardautotunnel.ui.common.button
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
@Composable
fun ScaledSwitch(checked: Boolean, onClick: (checked: Boolean) -> Unit, enabled: Boolean = true) {
Switch(
checked,
{ onClick(it) },
Modifier.scale((52.dp.scaledHeight() / 52.dp)),
enabled = enabled
)
}

View File

@ -0,0 +1,63 @@
package com.zaneschepke.wireguardautotunnel.ui.common.button
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.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
@Composable
fun SelectionItemButton(
leading: (@Composable () -> Unit)? = null,
buttonText: String,
trailing: (@Composable () -> Unit)? = null,
onClick: () -> Unit,
ripple: Boolean = true,
) {
Card(
modifier =
Modifier.clip(RoundedCornerShape(8.dp))
.clickable(
indication = if (ripple) ripple() else null,
interactionSource = remember { MutableInteractionSource() },
onClick = { onClick() },
)
.height(56.dp.scaledHeight()),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.background,
),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start,
modifier = Modifier.fillMaxSize(),
) {
leading?.let {
it()
}
Text(
buttonText,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
trailing?.let {
it()
}
}
}
}

View File

@ -0,0 +1,13 @@
package com.zaneschepke.wireguardautotunnel.ui.common.button.surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
data class SelectionItem(
val leadingIcon: ImageVector? = null,
val trailing: (@Composable () -> Unit)? = null,
val title: (@Composable () -> Unit),
val description: (@Composable () -> Unit)? = null,
val onClick: (() -> Unit)? = null,
val height: Int = 64,
)

View File

@ -0,0 +1,93 @@
package com.zaneschepke.wireguardautotunnel.ui.common.button.surface
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowRight
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
@Composable
fun SurfaceSelectionGroupButton(items: List<SelectionItem>) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
items.mapIndexed { index, it ->
Box(
contentAlignment = Alignment.Center,
modifier = (it.onClick?.let {
Modifier
.clickable {
it()
}
} ?: Modifier).fillMaxWidth()
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp.scaledHeight()),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(start = 16.dp.scaledWidth())
.weight(4f, false)
.fillMaxWidth(),
) {
it.leadingIcon?.let { icon ->
Icon(
icon,
icon.name,
modifier = Modifier.size(iconSize.scaledWidth()),
)
}
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterVertically),
modifier = Modifier
.fillMaxWidth()
.padding(start = if (it.leadingIcon != null) 16.dp.scaledWidth() else 0.dp)
.padding(vertical = if (it.description == null) 16.dp.scaledHeight() else 6.dp.scaledHeight()),
) {
it.title()
it.description?.let {
it()
}
}
}
it.trailing?.let {
Box(
contentAlignment = Alignment.CenterEnd,
modifier = Modifier
.padding(end = 24.dp.scaledWidth(), start = 16.dp.scaledWidth())
.weight(1f),
) {
it()
}
}
}
}
if (index + 1 != items.size) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
}
}
}

View File

@ -3,7 +3,6 @@ package com.zaneschepke.wireguardautotunnel.ui.common.config
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
@ -11,22 +10,19 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
@Composable
fun ConfigurationToggle(
label: String,
enabled: Boolean = true,
checked: Boolean,
padding: Dp,
onCheckChanged: (checked: Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(padding),
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {

View File

@ -1,27 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.common.screen
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun LoadingScreen() {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
modifier =
Modifier
.fillMaxSize()
.focusable()
.padding(),
) {
Column(modifier = Modifier.padding(120.dp)) { CircularProgressIndicator() }
}
}

View File

@ -1,4 +1,4 @@
package com.zaneschepke.wireguardautotunnel.ui.common.prompt
package com.zaneschepke.wireguardautotunnel.ui.common.snackbar
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.IntrinsicSize

View File

@ -0,0 +1,110 @@
package com.zaneschepke.wireguardautotunnel.ui.common.textbox
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CustomTextField(
value: String,
modifier: Modifier = Modifier,
textStyle: TextStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
label: @Composable () -> Unit,
containerColor: Color,
onValueChange: (value: String) -> Unit = {},
singleLine: Boolean = false,
placeholder: @Composable (() -> Unit)? = null,
keyboardOptions: KeyboardOptions,
keyboardActions: KeyboardActions,
supportingText: @Composable (() -> Unit)? = null,
leading: @Composable (() -> Unit)? = null,
trailing: @Composable (() -> Unit)? = null,
isError: Boolean = false,
readOnly: Boolean = false,
enabled: Boolean = true,
) {
val interactionSource = remember { MutableInteractionSource() }
val space = " "
BasicTextField(
value = value,
textStyle = textStyle,
onValueChange = {
onValueChange(it)
},
keyboardActions = keyboardActions,
keyboardOptions = keyboardOptions,
readOnly = readOnly,
cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurface),
modifier = modifier,
interactionSource = interactionSource,
enabled = enabled,
singleLine = singleLine,
) {
OutlinedTextFieldDefaults.DecorationBox(
value = space + value,
innerTextField = {
if (value.isEmpty()) {
if (placeholder != null) {
placeholder()
}
}
it.invoke()
},
contentPadding = OutlinedTextFieldDefaults.contentPadding(top = 0.dp, bottom = 0.dp),
leadingIcon = leading,
trailingIcon = trailing,
singleLine = singleLine,
supportingText = supportingText,
colors = TextFieldDefaults.colors().copy(
disabledLabelColor = MaterialTheme.colorScheme.onSurface,
disabledContainerColor = containerColor,
focusedLabelColor = MaterialTheme.colorScheme.onSurface,
focusedContainerColor = containerColor,
unfocusedContainerColor = containerColor,
focusedTextColor = MaterialTheme.colorScheme.onSurface,
cursorColor = MaterialTheme.colorScheme.onSurface,
),
enabled = enabled,
label = label,
visualTransformation = VisualTransformation.None,
interactionSource = interactionSource,
placeholder = placeholder,
container = {
OutlinedTextFieldDefaults.ContainerBox(
enabled,
isError = isError,
interactionSource,
colors = TextFieldDefaults.colors().copy(
errorContainerColor = containerColor,
disabledLabelColor = MaterialTheme.colorScheme.onSurface,
disabledContainerColor = containerColor,
focusedIndicatorColor = MaterialTheme.colorScheme.onSurface,
focusedLabelColor = MaterialTheme.colorScheme.onSurface,
focusedContainerColor = containerColor,
unfocusedContainerColor = containerColor,
focusedTextColor = MaterialTheme.colorScheme.onSurface,
cursorColor = MaterialTheme.colorScheme.onSurface,
),
shape = RoundedCornerShape(8.dp),
focusedBorderThickness = 0.5.dp,
unfocusedBorderThickness = 0.5.dp,
)
},
)
}
}

View File

@ -68,6 +68,7 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.config.components.Applicat
import com.zaneschepke.wireguardautotunnel.ui.screens.main.ConfigType
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import kotlinx.coroutines.delay
@Composable
@ -193,7 +194,7 @@ fun ConfigScreen(tunnelId: Int, focusRequester: FocusRequester) {
}
},
) {
Column(Modifier.padding(it)) {
Column(Modifier.padding(top = 24.dp.scaledHeight()).padding(it)) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
@ -235,7 +236,6 @@ fun ConfigScreen(tunnelId: Int, focusRequester: FocusRequester) {
ConfigurationToggle(
stringResource(id = R.string.show_amnezia_properties),
checked = derivedConfigType.value == ConfigType.AMNEZIA,
padding = screenPadding,
onCheckChanged = { configType = if (it) ConfigType.AMNEZIA else ConfigType.WIREGUARD },
modifier = Modifier.focusRequester(focusRequester),
)

View File

@ -9,7 +9,10 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
@ -146,7 +149,7 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState,
selectedTunnel = null
},
)
},
}.windowInsetsPadding(WindowInsets.systemBars),
floatingActionButtonPosition = FabPosition.End,
floatingActionButton = {
ScrollDismissFab({

View File

@ -163,7 +163,6 @@ fun OptionsScreen(optionsViewModel: OptionsViewModel = hiltViewModel(), focusReq
modifier =
Modifier
.focusRequester(focusRequester),
padding = screenPadding,
onCheckChanged = { optionsViewModel.onTogglePrimaryTunnel(config) },
)
}
@ -201,7 +200,6 @@ fun OptionsScreen(optionsViewModel: OptionsViewModel = hiltViewModel(), focusReq
stringResource(R.string.mobile_data_tunnel),
enabled = true,
checked = config.isMobileDataTunnel,
padding = screenPadding,
onCheckChanged = { optionsViewModel.onToggleIsMobileDataTunnel(config) },
)
Column {
@ -273,7 +271,6 @@ fun OptionsScreen(optionsViewModel: OptionsViewModel = hiltViewModel(), focusReq
stringResource(R.string.restart_on_ping),
enabled = !appUiState.settings.isPingEnabled,
checked = config.isPingEnabled || appUiState.settings.isPingEnabled,
padding = screenPadding,
onCheckChanged = { optionsViewModel.onToggleRestartOnPing(config) },
)
if (config.isPingEnabled || appUiState.settings.isPingEnabled) {

View File

@ -1,8 +1,7 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings
import android.content.Context
import android.location.LocationManager
import androidx.core.location.LocationManagerCompat
import androidx.core.content.FileProvider
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wireguard.android.backend.WgQuickBackend
@ -16,6 +15,7 @@ import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.FileUtils
import com.zaneschepke.wireguardautotunnel.util.StringValue
import com.zaneschepke.wireguardautotunnel.util.extensions.launchShareFile
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
@ -25,7 +25,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.time.Instant
import javax.inject.Inject
import javax.inject.Provider
@ -52,22 +52,6 @@ constructor(
private val settings = appDataRepository.settings.getSettingsFlow()
.stateIn(viewModelScope, SharingStarted.Eagerly, Settings())
fun onSaveTrustedSSID(ssid: String) = viewModelScope.launch {
val trimmed = ssid.trim()
with(settings.value) {
if (!trustedNetworkSSIDs.contains(trimmed)) {
this.trustedNetworkSSIDs.add(ssid)
appDataRepository.settings.save(this)
} else {
SnackbarController.showMessage(
StringValue.StringResource(
R.string.error_ssid_exists,
),
)
}
}
}
fun setLocationDisclosureShown() = viewModelScope.launch {
appDataRepository.appState.setLocationDisclosureShown(true)
}
@ -76,34 +60,6 @@ constructor(
appDataRepository.appState.setBatteryOptimizationDisableShown(true)
}
fun onToggleTunnelOnMobileData() = viewModelScope.launch {
with(settings.value) {
appDataRepository.settings.save(
copy(
isTunnelOnMobileDataEnabled = !this.isTunnelOnMobileDataEnabled,
),
)
}
}
fun onDeleteTrustedSSID(ssid: String) = viewModelScope.launch {
with(settings.value) {
appDataRepository.settings.save(
copy(
trustedNetworkSSIDs = (this.trustedNetworkSSIDs - ssid).toMutableList(),
),
)
}
}
private fun exportTunnels(files: List<File>) = viewModelScope.launch {
fileUtils.saveFilesToZip(files).onSuccess {
SnackbarController.showMessage(StringValue.StringResource(R.string.exported_configs_message))
}.onFailure {
SnackbarController.showMessage(StringValue.StringResource(R.string.export_configs_failed))
}
}
fun onToggleAutoTunnel(context: Context) = viewModelScope.launch {
with(settings.value) {
var isAutoTunnelPaused = this.isAutoTunnelPaused
@ -132,24 +88,6 @@ constructor(
}
}
fun onToggleTunnelOnEthernet() = viewModelScope.launch {
with(settings.value) {
appDataRepository.settings.save(
copy(
isTunnelOnEthernetEnabled = !isTunnelOnEthernetEnabled,
),
)
}
}
fun isLocationEnabled(context: Context): Boolean {
val locationManager =
context.getSystemService(
Context.LOCATION_SERVICE,
) as LocationManager
return LocationManagerCompat.isLocationEnabled(locationManager)
}
fun onToggleShortcutsEnabled() = viewModelScope.launch {
with(settings.value) {
appDataRepository.settings.save(
@ -170,16 +108,6 @@ constructor(
}
}
fun onToggleTunnelOnWifi() = viewModelScope.launch {
with(settings.value) {
appDataRepository.settings.save(
copy(
isTunnelOnWifiEnabled = !isTunnelOnWifiEnabled,
),
)
}
}
fun onToggleAmnezia() = viewModelScope.launch {
with(settings.value) {
if (isKernelEnabled) {
@ -210,16 +138,6 @@ constructor(
}
}
fun onToggleRestartOnPing() = viewModelScope.launch {
with(settings.value) {
appDataRepository.settings.save(
copy(
isPingEnabled = !isPingEnabled,
),
)
}
}
private suspend fun isKernelSupported(): Boolean {
return withContext(ioDispatcher) {
WgQuickBackend.hasKernelSupport()
@ -262,12 +180,16 @@ constructor(
requestRoot()
}
fun exportAllConfigs() = viewModelScope.launch {
fun exportAllConfigs(context: Context) = viewModelScope.launch {
kotlin.runCatching {
val shareFile = fileUtils.createNewShareFile("wg-export_${Instant.now().epochSecond}.zip")
val tunnels = appDataRepository.tunnels.getAll()
val wgFiles = fileUtils.createWgFiles(tunnels)
val amFiles = fileUtils.createAmFiles(tunnels)
exportTunnels(wgFiles + amFiles)
val allFiles = wgFiles + amFiles
fileUtils.zipAll(shareFile, allFiles)
val uri = FileProvider.getUriForFile(context, context.getString(R.string.provider), shareFile)
context.launchShareFile(uri)
}
}
}

View File

@ -0,0 +1,71 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowForward
import androidx.compose.material.icons.outlined.Contrast
import androidx.compose.material.icons.outlined.Translate
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
import com.zaneschepke.wireguardautotunnel.R
@Composable
fun AppearanceScreen() {
val navController = LocalNavController.current
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.Top),
modifier =
Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.systemBars)
.padding(top = 24.dp.scaledHeight())
.padding(horizontal = 24.dp.scaledWidth()),
) {
SurfaceSelectionGroupButton(
listOf(
SelectionItem(
Icons.Outlined.Translate,
title = { Text(stringResource(R.string.language), style = MaterialTheme.typography.bodyLarge.copy(MaterialTheme.colorScheme.onSurface)) },
onClick = { navController.navigate(Route.Language) },
trailing = {
val icon = Icons.AutoMirrored.Outlined.ArrowForward
Icon(icon, icon.name)
}
),
),
)
SurfaceSelectionGroupButton(
listOf(
SelectionItem(
Icons.Outlined.Contrast,
title = { Text(stringResource(R.string.display_theme), style = MaterialTheme.typography.bodyLarge.copy(MaterialTheme.colorScheme.onSurface)) },
onClick = { navController.navigate(Route.Display) },
trailing = {
val icon = Icons.AutoMirrored.Outlined.ArrowForward
Icon(icon, icon.name)
}
),
),
)
}
}

View File

@ -0,0 +1,59 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.display
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.common.button.IconSurfaceButton
import com.zaneschepke.wireguardautotunnel.ui.theme.Theme
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
@Composable
fun DisplayScreen(appUiState: AppUiState, viewModel: DisplayViewModel = hiltViewModel()) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.Top),
modifier =
Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.systemBars)
.padding(top = 24.dp.scaledHeight())
.padding(horizontal = 24.dp.scaledWidth()),
) {
IconSurfaceButton(
title = stringResource(R.string.automatic),
onClick = {
viewModel.onThemeChange(Theme.AUTOMATIC)
},
selected = appUiState.generalState.theme == Theme.AUTOMATIC,
)
IconSurfaceButton(
title = stringResource(R.string.light),
onClick = { viewModel.onThemeChange(Theme.LIGHT) },
selected = appUiState.generalState.theme == Theme.LIGHT,
)
IconSurfaceButton(
title = stringResource(R.string.dark),
onClick = { viewModel.onThemeChange(Theme.DARK) },
selected = appUiState.generalState.theme == Theme.DARK,
)
IconSurfaceButton(
title = stringResource(R.string.dynamic),
onClick = { viewModel.onThemeChange(Theme.DYNAMIC) },
selected = appUiState.generalState.theme == Theme.DYNAMIC,
)
}
}

View File

@ -0,0 +1,21 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.display
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.zaneschepke.wireguardautotunnel.data.repository.AppStateRepository
import com.zaneschepke.wireguardautotunnel.ui.theme.Theme
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class DisplayViewModel
@Inject
constructor(
private val appStateRepository: AppStateRepository
) : ViewModel() {
fun onThemeChange(theme: Theme) = viewModelScope.launch {
appStateRepository.setTheme(theme)
}
}

View File

@ -0,0 +1,106 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.language
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.datastore.LocaleStorage
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.SelectedLabel
import com.zaneschepke.wireguardautotunnel.ui.common.button.SelectionItemButton
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.util.LocaleUtil
import com.zaneschepke.wireguardautotunnel.util.extensions.navigateAndForget
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
import timber.log.Timber
import java.text.Collator
import java.util.Locale
@Composable
fun LanguageScreen(localeStorage: LocaleStorage) {
val navController = LocalNavController.current
val context = LocalContext.current
val collator = Collator.getInstance(Locale.getDefault())
val currentLocale = remember { mutableStateOf(LocaleUtil.OPTION_PHONE_LANGUAGE) }
val locales = LocaleUtil.supportedLocales.map {
val tag = it.replace("_", "-")
Locale.forLanguageTag(tag)
}
val sortedLocales =
remember(locales) {
locales.sortedWith(compareBy(collator) { it.getDisplayName(it) }).toList()
}
LaunchedEffect(Unit) {
currentLocale.value = localeStorage.getPreferredLocale()
}
fun onChangeLocale(locale: String) {
Timber.d("Setting preferred locale: $locale")
localeStorage.setPreferredLocale(locale)
LocaleUtil.applyLocalizedContext(context, locale)
navController.navigateAndForget(Route.Main)
}
LazyColumn(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
modifier =
Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.systemBars)
.padding(top = 24.dp.scaledHeight())
.padding(horizontal = 24.dp.scaledWidth()).windowInsetsPadding(WindowInsets.navigationBars),
) {
item {
SelectionItemButton(
buttonText = stringResource(R.string.automatic),
onClick = {
onChangeLocale(LocaleUtil.OPTION_PHONE_LANGUAGE)
},
trailing = {
if (currentLocale.value == LocaleUtil.OPTION_PHONE_LANGUAGE) {
SelectedLabel()
}
},
ripple = false,
)
}
items(sortedLocales, key = { it }) { locale ->
SelectionItemButton(
buttonText = locale.getDisplayLanguage(locale).capitalize(locale) +
if (locale.toLanguageTag().contains("-")) " (${locale.getDisplayCountry(locale).capitalize(locale)})" else "",
onClick = {
onChangeLocale(locale.toLanguageTag())
},
trailing = {
if (locale.toLanguageTag() == currentLocale.value) {
SelectedLabel()
}
},
ripple = false,
)
}
}
}

View File

@ -0,0 +1,232 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel
import android.Manifest
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.isImeVisible
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.NetworkPing
import androidx.compose.material.icons.outlined.Security
import androidx.compose.material.icons.outlined.SettingsEthernet
import androidx.compose.material.icons.outlined.SignalCellular4Bar
import androidx.compose.material.icons.outlined.Wifi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.common.ClickableIconButton
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.components.TrustedNetworkTextBox
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.WildcardSupportingLabel
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
import com.zaneschepke.wireguardautotunnel.util.extensions.isLocationServicesEnabled
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
@OptIn(ExperimentalPermissionsApi::class, ExperimentalLayoutApi::class)
@Composable
fun AutoTunnelScreen(uiState: AppUiState, viewModel: AutoTunnelViewModel = hiltViewModel()) {
val context = LocalContext.current
val fineLocationState = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION)
var currentText by remember { mutableStateOf("") }
var isBackgroundLocationGranted by remember { mutableStateOf(true) }
var showLocationServicesAlertDialog by remember { mutableStateOf(false) }
var showLocationDialog by remember { mutableStateOf(false) }
LaunchedEffect(uiState.settings.trustedNetworkSSIDs) {
currentText = ""
}
fun onAutoTunnelWifiChecked() {
when (false) {
isBackgroundLocationGranted -> showLocationDialog = true
fineLocationState.status.isGranted -> showLocationDialog = true
context.isLocationServicesEnabled() ->
showLocationServicesAlertDialog = true
else -> {
viewModel.onToggleTunnelOnWifi()
}
}
}
Scaffold(
topBar = {
TopNavBar(stringResource(R.string.auto_tunneling))
},
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.Top),
modifier =
Modifier
.verticalScroll(rememberScrollState())
.fillMaxSize()
.padding(top = 24.dp.scaledHeight()).padding(it)
.padding(horizontal = 24.dp.scaledWidth()),
) {
SurfaceSelectionGroupButton(
mutableListOf(
SelectionItem(
Icons.Outlined.Wifi,
title = { Text(stringResource(R.string.tunnel_on_wifi), style = MaterialTheme.typography.bodyLarge.copy(MaterialTheme.colorScheme.onSurface)) },
description = {
},
trailing = {
ScaledSwitch(
enabled = !uiState.settings.isAlwaysOnVpnEnabled,
checked = uiState.settings.isTunnelOnWifiEnabled,
onClick = { checked ->
if (!checked || uiState.isRooted) viewModel.onToggleTunnelOnWifi().also { return@ScaledSwitch }
onAutoTunnelWifiChecked()
},
)
},
),
SelectionItem(
Icons.Outlined.SignalCellular4Bar,
title = {
Text(
stringResource(R.string.tunnel_mobile_data),
style = MaterialTheme.typography.bodyLarge.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = {
ScaledSwitch(
enabled = !uiState.settings.isAlwaysOnVpnEnabled,
checked = uiState.settings.isTunnelOnMobileDataEnabled,
onClick = { viewModel.onToggleTunnelOnMobileData() },
)
},
),
SelectionItem(
Icons.Outlined.SettingsEthernet,
title = {
Text(
stringResource(R.string.tunnel_on_ethernet),
style = MaterialTheme.typography.bodyLarge.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = {
ScaledSwitch(
enabled = !uiState.settings.isAlwaysOnVpnEnabled,
checked = uiState.settings.isTunnelOnEthernetEnabled,
onClick = { viewModel.onToggleTunnelOnEthernet() },
)
},
),
SelectionItem(
Icons.Outlined.NetworkPing,
title = {
Text(
stringResource(R.string.restart_on_ping),
style = MaterialTheme.typography.bodyLarge.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = {
ScaledSwitch(
checked = uiState.settings.isPingEnabled,
onClick = { viewModel.onToggleRestartOnPing() },
)
},
),
).apply {
if (uiState.settings.isTunnelOnWifiEnabled) {
add(1,
SelectionItem(
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp.scaledHeight()),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.weight(4f, false)
.fillMaxWidth(),
) {
val icon = Icons.Outlined.Security
Icon(
icon,
icon.name,
modifier = Modifier.size(iconSize.scaledWidth()),
)
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterVertically),
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp.scaledWidth())
.padding(vertical = 6.dp.scaledHeight()),
) {
Text(
"Trusted wifi names",
style = MaterialTheme.typography.bodyLarge.copy(MaterialTheme.colorScheme.onSurface),
)
}
}
}
},
description = {
TrustedNetworkTextBox(
uiState.settings.trustedNetworkSSIDs, onDelete = viewModel::onDeleteTrustedSSID,
currentText = currentText,
onSave = viewModel::onSaveTrustedSSID,
onValueChange = { currentText = it }
)
},
)
)
}
},
)
}
}
}

View File

@ -0,0 +1,93 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.domain.Settings
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.util.StringValue
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class AutoTunnelViewModel
@Inject
constructor(
private val appDataRepository: AppDataRepository,
) : ViewModel() {
private val settings = appDataRepository.settings.getSettingsFlow()
.stateIn(viewModelScope, SharingStarted.Eagerly, Settings())
fun onToggleTunnelOnWifi() = viewModelScope.launch {
with(settings.value) {
appDataRepository.settings.save(
copy(
isTunnelOnWifiEnabled = !isTunnelOnWifiEnabled,
),
)
}
}
fun onToggleTunnelOnMobileData() = viewModelScope.launch {
with(settings.value) {
appDataRepository.settings.save(
copy(
isTunnelOnMobileDataEnabled = !this.isTunnelOnMobileDataEnabled,
),
)
}
}
fun onDeleteTrustedSSID(ssid: String) = viewModelScope.launch {
with(settings.value) {
appDataRepository.settings.save(
copy(
trustedNetworkSSIDs = (this.trustedNetworkSSIDs - ssid).toMutableList(),
),
)
}
}
fun onToggleTunnelOnEthernet() = viewModelScope.launch {
with(settings.value) {
appDataRepository.settings.save(
copy(
isTunnelOnEthernetEnabled = !isTunnelOnEthernetEnabled,
),
)
}
}
fun onSaveTrustedSSID(ssid: String) = viewModelScope.launch {
if (ssid.isEmpty()) return@launch
val trimmed = ssid.trim()
with(settings.value) {
if (!trustedNetworkSSIDs.contains(trimmed)) {
this.trustedNetworkSSIDs.add(ssid)
appDataRepository.settings.save(this)
} else {
SnackbarController.showMessage(
StringValue.StringResource(
R.string.error_ssid_exists,
),
)
}
}
}
fun onToggleRestartOnPing() = viewModelScope.launch {
with(settings.value) {
appDataRepository.settings.save(
copy(
isPingEnabled = !isPingEnabled,
),
)
}
}
}

View File

@ -0,0 +1,100 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.ClickableIconButton
import com.zaneschepke.wireguardautotunnel.ui.common.textbox.CustomTextField
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.WildcardSupportingLabel
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun TrustedNetworkTextBox(trustedNetworks: List<String>, onDelete: (ssid: String) -> Unit, currentText: String, onSave : (ssid: String) -> Unit, onValueChange: (network: String) -> Unit) {
val context = LocalContext.current
Column(verticalArrangement = Arrangement.spacedBy(10.dp.scaledHeight())){
FlowRow(
modifier =
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
trustedNetworks.forEach { ssid ->
ClickableIconButton(
onClick = {
if (context.isRunningOnTv()) {
//focusRequester.requestFocus()
onDelete(ssid)
}
},
onIconClick = {
//if (context.isRunningOnTv()) focusRequester.requestFocus()
onDelete(ssid)
},
text = ssid,
icon = Icons.Filled.Close,
)
}
}
CustomTextField(
value = currentText,
onValueChange = onValueChange,
label = { Text(stringResource(R.string.add_trusted_ssid)) },
containerColor = MaterialTheme.colorScheme.surface,
modifier =
Modifier
.padding(
top = 5.dp,
bottom = 10.dp,
).fillMaxWidth().padding(end = 16.dp.scaledWidth()),
supportingText = { WildcardSupportingLabel { context.openWebUrl(it)} },
singleLine = true,
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = { onSave(currentText) }),
trailing = {
if (currentText != "") {
IconButton(onClick = {
onSave(currentText)
}) {
val icon = Icons.Outlined.Add
Icon(
imageVector = icon,
contentDescription = stringResource(
R.string
.trusted_ssid_value_description,
),
tint = MaterialTheme.colorScheme.primary,
)
}
}
},
)
}
}

View File

@ -7,11 +7,14 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
@ -63,6 +66,7 @@ fun SupportScreen(focusRequester: FocusRequester, appUiState: AppUiState) {
modifier =
Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.systemBars)
.verticalScroll(rememberScrollState())
.focusable(),
) {

View File

@ -4,8 +4,11 @@ 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.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState

View File

@ -11,6 +11,7 @@ import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import com.zaneschepke.wireguardautotunnel.module.MainDispatcher
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.FileUtils
import com.zaneschepke.wireguardautotunnel.util.extensions.chunked
import com.zaneschepke.wireguardautotunnel.util.extensions.launchShareFile
import dagger.hilt.android.lifecycle.HiltViewModel
@ -19,7 +20,6 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
import java.time.Duration
import java.time.Instant
import javax.inject.Inject
@ -29,6 +29,7 @@ class LogsViewModel
@Inject
constructor(
private val localLogCollector: LogReader,
private val fileUtils: FileUtils,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
@MainDispatcher private val mainDispatcher: CoroutineDispatcher,
) : ViewModel() {
@ -51,12 +52,7 @@ constructor(
fun shareLogs(context: Context): Job = viewModelScope.launch(ioDispatcher) {
runCatching {
val sharePath = File(context.filesDir, "external_files")
if (sharePath.exists()) sharePath.delete()
sharePath.mkdir()
val file = File("${sharePath.path + "/" + Constants.BASE_LOG_FILE_NAME}-${Instant.now().epochSecond}.zip")
if (file.exists()) file.delete()
file.createNewFile()
val file = fileUtils.createNewShareFile("${Constants.BASE_LOG_FILE_NAME}-${Instant.now().epochSecond}.zip")
localLogCollector.zipLogFiles(file.absolutePath)
val uri = FileProvider.getUriForFile(context, context.getString(R.string.provider), file)
context.launchShareFile(uri)

View File

@ -0,0 +1,6 @@
package com.zaneschepke.wireguardautotunnel.ui.theme
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
val iconSize = 24.dp.scaledHeight()

View File

@ -36,24 +36,34 @@ private val LightColorScheme =
onSecondaryContainer = ThemeColors.Light.primary,
)
enum class Theme {
AUTOMATIC,
LIGHT,
DARK,
DYNAMIC
}
@Composable
fun WireguardAutoTunnelTheme(
// force dark theme
useDarkTheme: Boolean = isSystemInDarkTheme(),
theme: Theme = Theme.AUTOMATIC,
content: @Composable () -> Unit,
) {
val context = LocalContext.current
val colorScheme = when {
(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) -> {
if (useDarkTheme) {
dynamicDarkColorScheme(context)
} else {
dynamicLightColorScheme(context)
}
val isDark = isSystemInDarkTheme()
val autoTheme = if(isDark) DarkColorScheme else LightColorScheme
val colorScheme = when(theme) {
Theme.AUTOMATIC -> autoTheme
Theme.DARK -> DarkColorScheme
Theme.LIGHT -> LightColorScheme
Theme.DYNAMIC -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (isDark) {
dynamicDarkColorScheme(context)
} else {
dynamicLightColorScheme(context)
}
} else autoTheme
}
useDarkTheme -> DarkColorScheme
// TODO force dark theme for now until light theme designed
else -> DarkColorScheme
}
val view = LocalView.current
if (!view.isInEditMode) {
@ -62,8 +72,7 @@ fun WireguardAutoTunnelTheme(
WindowCompat.setDecorFitsSystemWindows(window, false)
window.statusBarColor = Color.Transparent.toArgb()
window.navigationBarColor = Color.Transparent.toArgb()
WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars =
!useDarkTheme
WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars = isDark
}
}

View File

@ -5,9 +5,9 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.util.extensions.scaled
// Set of Material typography styles to start with
@ -17,43 +17,45 @@ val inter = FontFamily(
val Typography =
Typography(
bodyLarge =
TextStyle(
fontFamily = inter,
bodyLarge = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
fontSize = 16.sp.scaled(),
lineHeight = 24.sp.scaled(),
letterSpacing = 0.5.sp,
),
bodySmall = TextStyle(
fontFamily = inter,
fontWeight = FontWeight.Normal,
fontSize = 13.sp,
lineHeight = 20.sp,
fontSize = 13.sp.scaled(),
lineHeight = 20.sp.scaled(),
letterSpacing = 1.sp,
color = LightGrey,
),
bodyMedium = TextStyle(
fontSize = 14.sp.scaled(),
lineHeight = 20.sp.scaled(),
fontWeight = FontWeight(400),
letterSpacing = 0.25.sp,
),
labelLarge = TextStyle(
fontFamily = inter,
fontWeight = FontWeight.Normal,
fontSize = 15.sp,
lineHeight = 18.sp,
fontSize = 15.sp.scaled(),
lineHeight = 18.sp.scaled(),
letterSpacing = 0.sp,
),
labelMedium = TextStyle(
fontFamily = inter,
fontWeight = FontWeight.SemiBold,
fontSize = 12.sp,
lineHeight = 16.sp,
fontSize = 12.sp.scaled(),
lineHeight = 16.sp.scaled(),
letterSpacing = 0.5.sp,
),
titleMedium = TextStyle(
fontFamily = inter,
fontWeight = FontWeight.Bold,
fontSize = 17.sp,
lineHeight = 21.sp,
fontSize = 17.sp.scaled(),
lineHeight = 21.sp.scaled(),
letterSpacing = 0.sp,
),
)
val iconSize = 15.dp

View File

@ -1,19 +1,13 @@
package com.zaneschepke.wireguardautotunnel.util
import android.content.ContentValues
import android.content.Context
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.provider.MediaStore.MediaColumns
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.util.extensions.TunnelConfigs
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.BufferedOutputStream
import java.io.File
import java.io.OutputStream
import java.time.Instant
import java.io.FileOutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
@ -22,73 +16,60 @@ class FileUtils(
private val ioDispatcher: CoroutineDispatcher,
) {
fun createWgFiles(tunnels: TunnelConfigs): List<File> {
return tunnels.map { config ->
val file = File(context.cacheDir, "${config.name}-wg.conf")
file.outputStream().use {
it.write(config.wgQuick.toByteArray())
}
file
}
}
fun createAmFiles(tunnels: TunnelConfigs): List<File> {
return tunnels.filter { it.amQuick != TunnelConfig.AM_QUICK_DEFAULT }.map { config ->
val file = File(context.cacheDir, "${config.name}-am.conf")
file.outputStream().use {
it.write(config.amQuick.toByteArray())
}
file
}
}
suspend fun saveFilesToZip(files: List<File>): Result<Unit> {
suspend fun createWgFiles(tunnels: TunnelConfigs): List<File> {
return withContext(ioDispatcher) {
try {
val zipOutputStream =
createDownloadsFileOutputStream(
"wg-export_${Instant.now().epochSecond}.zip",
Constants.ZIP_FILE_MIME_TYPE,
)
ZipOutputStream(zipOutputStream).use { zos ->
files.forEach { file ->
val entry = ZipEntry(file.name)
zos.putNextEntry(entry)
if (file.isFile) {
file.inputStream().use { fis -> fis.copyTo(zos) }
tunnels.map { config ->
val file = File(context.cacheDir, "${config.name}-wg.conf")
file.outputStream().use {
it.write(config.wgQuick.toByteArray())
}
file
}
}
}
suspend fun createAmFiles(tunnels: TunnelConfigs): List<File> {
return withContext(ioDispatcher) {
tunnels.filter { it.amQuick != TunnelConfig.AM_QUICK_DEFAULT }.map { config ->
val file = File(context.cacheDir, "${config.name}-am.conf")
file.outputStream().use {
it.write(config.amQuick.toByteArray())
}
file
}
}
}
suspend fun zipAll(zipFile: File, files: List<File>) {
withContext(ioDispatcher) {
ZipOutputStream(BufferedOutputStream(FileOutputStream(zipFile))).use { zos ->
files.forEach { file ->
val zipFileName = (
file.parentFile?.let { parent ->
file.absolutePath.removePrefix(parent.absolutePath)
} ?: file.absolutePath
).removePrefix("/")
val entry = ZipEntry("$zipFileName${(if (file.isDirectory) "/" else "")}")
zos.putNextEntry(entry)
if (file.isFile) {
file.inputStream().use {
it.copyTo(zos)
}
}
return@withContext Result.success(Unit)
}
} catch (e: Exception) {
Timber.e(e)
Result.failure(ConfigExportException)
}
}
}
// TODO issue with android 9
private fun createDownloadsFileOutputStream(fileName: String, mimeType: String = Constants.ALL_FILE_TYPES): OutputStream? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val resolver = context.contentResolver
val contentValues =
ContentValues().apply {
put(MediaColumns.DISPLAY_NAME, fileName)
put(MediaColumns.MIME_TYPE, mimeType)
put(MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
}
val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
if (uri != null) {
return resolver.openOutputStream(uri)
}
} else {
val target =
File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
fileName,
)
return target.outputStream()
suspend fun createNewShareFile(name: String): File {
return withContext(ioDispatcher) {
val sharePath = File(context.filesDir, "external_files")
if (sharePath.exists()) sharePath.delete()
sharePath.mkdir()
val file = File("${sharePath.path}/$name")
if (file.exists()) file.delete()
file.createNewFile()
file
}
return null
}
}

View File

@ -0,0 +1,100 @@
package com.zaneschepke.wireguardautotunnel.util
import android.content.Context
import android.content.res.Configuration
import android.content.res.Resources
import android.os.Build
import android.os.LocaleList
import androidx.core.os.ConfigurationCompat
import com.zaneschepke.wireguardautotunnel.BuildConfig
import java.util.Locale
object LocaleUtil {
private const val DEFAULT_LANG = "en"
val supportedLocales: Array<String> = BuildConfig.LANGUAGES
const val OPTION_PHONE_LANGUAGE = "sys_def"
/**
* returns the locale to use depending on the preference value
* when preference value = "sys_def" returns the locale of current system
* else it returns the locale code e.g. "en", "bn" etc.
*/
fun getLocaleFromPrefCode(prefCode: String): Locale {
val localeCode = if (prefCode != OPTION_PHONE_LANGUAGE) {
prefCode
} else {
val systemLang = ConfigurationCompat.getLocales(Resources.getSystem().configuration).get(0)?.language ?: DEFAULT_LANG
if (systemLang in supportedLocales) {
systemLang
} else {
DEFAULT_LANG
}
}
return Locale.forLanguageTag(localeCode)
}
fun getLocalizedConfiguration(prefLocaleCode: String): Configuration {
val locale = getLocaleFromPrefCode(prefLocaleCode)
return getLocalizedConfiguration(locale)
}
private fun getLocalizedConfiguration(locale: Locale): Configuration {
val config = Configuration()
return config.apply {
config.setLayoutDirection(locale)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
config.setLocale(locale)
val localeList = LocaleList(locale)
LocaleList.setDefault(localeList)
config.setLocales(localeList)
} else {
config.setLocale(locale)
}
}
}
fun getLocalizedContext(baseContext: Context, prefLocaleCode: String?): Context {
if (prefLocaleCode == null) return baseContext
val currentLocale = getLocaleFromPrefCode(prefLocaleCode)
val baseLocale = getLocaleFromConfiguration(baseContext.resources.configuration)
Locale.setDefault(currentLocale)
return if (!baseLocale.toString().equals(currentLocale.toString(), ignoreCase = true)) {
val config = getLocalizedConfiguration(currentLocale)
baseContext.createConfigurationContext(config)
baseContext
} else {
baseContext
}
}
fun applyLocalizedContext(baseContext: Context, prefLocaleCode: String) {
val currentLocale = getLocaleFromPrefCode(prefLocaleCode)
val baseLocale = getLocaleFromConfiguration(baseContext.resources.configuration)
Locale.setDefault(currentLocale)
if (!baseLocale.toString().equals(currentLocale.toString(), ignoreCase = true)) {
val config = getLocalizedConfiguration(currentLocale)
baseContext.resources.updateConfiguration(config, baseContext.resources.displayMetrics)
}
}
@Suppress("DEPRECATION")
private fun getLocaleFromConfiguration(configuration: Configuration): Locale {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
configuration.locales.get(0)
} else {
configuration.locale
}
}
fun getLocalizedResources(resources: Resources, prefLocaleCode: String): Resources {
val locale = getLocaleFromPrefCode(prefLocaleCode)
val config = resources.configuration
@Suppress("DEPRECATION")
config.locale = locale
config.setLayoutDirection(locale)
@Suppress("DEPRECATION")
resources.updateConfiguration(config, resources.displayMetrics)
return resources
}
}

View File

@ -4,16 +4,24 @@ import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.location.LocationManager
import android.net.Uri
import android.provider.Settings
import android.service.quicksettings.TileService
import android.widget.Toast
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.core.location.LocationManagerCompat
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.receiver.BackgroundActionReceiver
import com.zaneschepke.wireguardautotunnel.service.tile.AutoTunnelControlTile
import com.zaneschepke.wireguardautotunnel.service.tile.TunnelControlTile
import com.zaneschepke.wireguardautotunnel.util.Constants
private const val BASELINE_HEIGHT = 2201
private const val BASELINE_WIDTH = 1080
private const val BASELINE_DENSITY = 2.625
fun Context.openWebUrl(url: String): Result<Unit> {
return kotlin.runCatching {
val webpage: Uri = Uri.parse(url)
@ -26,6 +34,44 @@ fun Context.openWebUrl(url: String): Result<Unit> {
}
}
val Context.actionBarSize
get() = theme.obtainStyledAttributes(intArrayOf(android.R.attr.actionBarSize))
.let { attrs -> attrs.getDimension(0, 0F).toInt().also { attrs.recycle() } }
fun Context.resizeHeight(dp: Dp): Dp {
val displayMetrics = resources.displayMetrics
val density = displayMetrics.density
val height = displayMetrics.heightPixels - this.actionBarSize
val resizeHeightPercentage =
(height.toFloat() / BASELINE_HEIGHT) * (BASELINE_DENSITY.toFloat() / density)
return dp * resizeHeightPercentage
}
fun Context.resizeHeight(textUnit: TextUnit): TextUnit {
val displayMetrics = resources.displayMetrics
val density = displayMetrics.density
val height = displayMetrics.heightPixels - actionBarSize
val resizeHeightPercentage =
(height.toFloat() / BASELINE_HEIGHT) * (BASELINE_DENSITY.toFloat() / density)
return textUnit * resizeHeightPercentage * 1.1
}
fun Context.resizeWidth(dp: Dp): Dp {
val displayMetrics = resources.displayMetrics
val density = displayMetrics.density
val width = displayMetrics.widthPixels
val resizeWidthPercentage =
(width.toFloat() / BASELINE_WIDTH) * (BASELINE_DENSITY.toFloat() / density)
return dp * resizeWidthPercentage
}
fun Context.launchNotificationSettings() {
val settingsIntent: Intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
this.startActivity(settingsIntent)
}
fun Context.launchShareFile(file: Uri) {
val shareIntent = Intent().apply {
setAction(Intent.ACTION_SEND)
@ -36,6 +82,14 @@ fun Context.launchShareFile(file: Uri) {
this.startActivity(Intent.createChooser(shareIntent, ""))
}
fun Context.isLocationServicesEnabled(): Boolean {
val locationManager =
getSystemService(
Context.LOCATION_SERVICE,
) as LocationManager
return LocationManagerCompat.isLocationEnabled(locationManager)
}
fun Context.showToast(resId: Int) {
Toast.makeText(
this,

View File

@ -1,6 +1,9 @@
package com.zaneschepke.wireguardautotunnel.util.extensions
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.navigation.NavController
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.ui.Route
fun NavController.navigateAndForget(route: Route) {
@ -8,3 +11,15 @@ fun NavController.navigateAndForget(route: Route) {
popUpTo(0)
}
}
fun Dp.scaledHeight(): Dp {
return WireGuardAutoTunnel.instance.resizeHeight(this)
}
fun Dp.scaledWidth(): Dp {
return WireGuardAutoTunnel.instance.resizeWidth(this)
}
fun TextUnit.scaled(): TextUnit {
return WireGuardAutoTunnel.instance.resizeHeight(this)
}

View File

@ -200,4 +200,15 @@
<string name="sec">sec</string>
<string name="handshake">handshake</string>
<string name="logs">Logs</string>
<string name="tunnel_notifications">Tunnel notifications</string>
<string name="kill_switch">Kill switch</string>
<string name="appearance">Appearance</string>
<string name="notifications">Notifications</string>
<string name="automatic">Automatic</string>
<string name="light">Light</string>
<string name="dark">Dark</string>
<string name="dynamic">Dynamic</string>
<string name="language">Language</string>
<string name="display_theme">Display theme</string>
<string name="selected">Selected</string>
</resources>

View File

@ -72,6 +72,18 @@ fun Project.getSigningProperty(property: String): String {
)
}
fun Project.languageList(): List<String> {
return fileTree("../app/src/main/res") { include("**/strings.xml") }
.asSequence()
.map { stringFile -> stringFile.parentFile.name }
.map { valuesFolderName -> valuesFolderName.replace("values-", "") }
.filter { valuesFolderName -> valuesFolderName != "values" }
.map { languageCode -> languageCode.replace("-r", "_") }
.distinct()
.sorted()
.toList() + "en"
}