refactor
This commit is contained in:
parent
f0ec661223
commit
408d88390b
|
@ -22,6 +22,9 @@ interface SettingsDoa {
|
|||
@Query("SELECT * FROM settings")
|
||||
suspend fun getAll(): List<Settings>
|
||||
|
||||
@Query("SELECT * FROM settings LIMIT 1")
|
||||
fun getSettingsFlow(): Flow<Settings>
|
||||
|
||||
@Query("SELECT * FROM settings")
|
||||
fun getAllFlow(): Flow<MutableList<Settings>>
|
||||
|
||||
|
|
|
@ -27,12 +27,12 @@ class DataStoreManager(private val context: Context) {
|
|||
context.dataStore.edit {
|
||||
it[key] = value
|
||||
}
|
||||
|
||||
fun <T> getFromStore(key: Preferences.Key<T>) =
|
||||
context.dataStore.data.map {
|
||||
fun <T> getFromStoreFlow(key: Preferences.Key<T>) = context.dataStore.data.map {
|
||||
it[key]
|
||||
}
|
||||
|
||||
suspend fun <T> getFromStore(key: Preferences.Key<T>) = context.dataStore.data.first { it.contains(key) }[key]
|
||||
|
||||
val locationDisclosureFlow: Flow<Boolean?> = context.dataStore.data.map {
|
||||
it[LOCATION_DISCLOSURE_SHOWN]
|
||||
}
|
||||
|
|
|
@ -1,8 +1,19 @@
|
|||
package com.zaneschepke.wireguardautotunnel.ui
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
|
||||
import com.zaneschepke.wireguardautotunnel.repository.model.Settings
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import javax.inject.Inject
|
||||
|
||||
class ActivityViewModel @Inject constructor() : ViewModel() {
|
||||
// TODO move shared logic to shared viewmodel
|
||||
@HiltViewModel
|
||||
class ActivityViewModel @Inject constructor(
|
||||
private val settingsRepo: SettingsDoa,
|
||||
) : ViewModel() {
|
||||
// val settings = settingsRepo.getSettingsFlow().stateIn(viewModelScope,
|
||||
// SharingStarted.WhileSubscribed(5000L), Settings()
|
||||
// )
|
||||
}
|
||||
|
|
|
@ -32,6 +32,7 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
|
@ -64,8 +65,7 @@ class MainActivity : AppCompatActivity() {
|
|||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
// TODO move shared logic to shared viewmodel
|
||||
// val sharedViewModel = hiltViewModel<ActivityViewModel>()
|
||||
// val activityViewModel = hiltViewModel<ActivityViewModel>()
|
||||
val navController = rememberNavController()
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
|
|
|
@ -8,6 +8,8 @@ import android.os.Build
|
|||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.clickable
|
||||
|
@ -84,6 +86,7 @@ import com.zaneschepke.wireguardautotunnel.R
|
|||
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
|
||||
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
|
||||
import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus
|
||||
import com.zaneschepke.wireguardautotunnel.ui.ActivityViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.ui.CaptureActivityPortrait
|
||||
import com.zaneschepke.wireguardautotunnel.ui.Routes
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.RowListItem
|
||||
|
@ -212,7 +215,7 @@ fun MainScreen(
|
|||
}
|
||||
)
|
||||
|
||||
if (showPrimaryChangeAlertDialog) {
|
||||
AnimatedVisibility(showPrimaryChangeAlertDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
showPrimaryChangeAlertDialog = false
|
||||
|
@ -288,7 +291,7 @@ fun MainScreen(
|
|||
}
|
||||
}
|
||||
) {
|
||||
if (tunnels.isEmpty()) {
|
||||
AnimatedVisibility(tunnels.isEmpty(), exit = fadeOut(), enter = fadeIn()) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
|
|
|
@ -38,11 +38,13 @@ import androidx.compose.material3.Surface
|
|||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
|
@ -71,6 +73,7 @@ import com.wireguard.android.backend.WgQuickBackend
|
|||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
|
||||
import com.zaneschepke.wireguardautotunnel.repository.datastore.DataStoreManager
|
||||
import com.zaneschepke.wireguardautotunnel.ui.ActivityViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.ClickableIconButton
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationToggle
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt
|
||||
|
@ -95,31 +98,29 @@ fun SettingsScreen(
|
|||
val scope = rememberCoroutineScope { Dispatchers.IO }
|
||||
val context = LocalContext.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val scrollState = rememberScrollState()
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
val settings by viewModel.settings.collectAsStateWithLifecycle()
|
||||
val trustedSSIDs by viewModel.trustedSSIDs.collectAsStateWithLifecycle()
|
||||
val tunnels by viewModel.tunnels.collectAsStateWithLifecycle(mutableListOf())
|
||||
val tunnels by viewModel.tunnels.collectAsStateWithLifecycle()
|
||||
val vpnState = viewModel.vpnState.collectAsStateWithLifecycle()
|
||||
|
||||
val fineLocationState = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
var currentText by remember { mutableStateOf("") }
|
||||
val scrollState = rememberScrollState()
|
||||
var isBackgroundLocationGranted by remember { mutableStateOf(true) }
|
||||
var showAuthPrompt by remember { mutableStateOf(false) }
|
||||
var didExportFiles by remember { mutableStateOf(false) }
|
||||
val isLocationDisclosureShown by viewModel.disclosureShown.collectAsStateWithLifecycle(
|
||||
null
|
||||
)
|
||||
val vpnState = viewModel.vpnState.collectAsStateWithLifecycle(initialValue = Tunnel.State.DOWN)
|
||||
var showAuthPrompt by remember { mutableStateOf(false) }
|
||||
var isLocationDisclosureShown by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
val screenPadding = 5.dp
|
||||
val fillMaxWidth = .85f
|
||||
|
||||
fun setLocationDisclosureShown() = scope.launch {
|
||||
viewModel.dataStoreManager.saveToDataStore(
|
||||
DataStoreManager.LOCATION_DISCLOSURE_SHOWN,
|
||||
true
|
||||
)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
isLocationDisclosureShown = viewModel.isLocationDisclosureShown()
|
||||
}
|
||||
|
||||
fun exportAllConfigs() {
|
||||
|
@ -175,25 +176,25 @@ fun SettingsScreen(
|
|||
isBackgroundLocationGranted = if (!backgroundLocationState.status.isGranted) {
|
||||
false
|
||||
} else {
|
||||
SideEffect {
|
||||
setLocationDisclosureShown()
|
||||
if(!isLocationDisclosureShown) {
|
||||
viewModel.setLocationDisclosureShown()
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
|
||||
if (!fineLocationState.status.isGranted) {
|
||||
isBackgroundLocationGranted = false
|
||||
isBackgroundLocationGranted = if (!fineLocationState.status.isGranted) {
|
||||
false
|
||||
} else {
|
||||
SideEffect {
|
||||
setLocationDisclosureShown()
|
||||
viewModel.setLocationDisclosureShown()
|
||||
}
|
||||
isBackgroundLocationGranted = true
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
if (isLocationDisclosureShown != true) {
|
||||
AnimatedVisibility(!isLocationDisclosureShown) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Top,
|
||||
|
@ -238,22 +239,21 @@ fun SettingsScreen(
|
|||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
TextButton(onClick = {
|
||||
setLocationDisclosureShown()
|
||||
viewModel.setLocationDisclosureShown()
|
||||
}) {
|
||||
Text(stringResource(id = R.string.no_thanks))
|
||||
}
|
||||
TextButton(modifier = Modifier.focusRequester(focusRequester), onClick = {
|
||||
openSettings()
|
||||
setLocationDisclosureShown()
|
||||
viewModel.setLocationDisclosureShown()
|
||||
}) {
|
||||
Text(stringResource(id = R.string.turn_on))
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (showAuthPrompt) {
|
||||
AnimatedVisibility(showAuthPrompt) {
|
||||
AuthorizationPrompt(
|
||||
onSuccess = {
|
||||
showAuthPrompt = false
|
||||
|
@ -348,7 +348,7 @@ fun SettingsScreen(
|
|||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(5.dp)
|
||||
) {
|
||||
trustedSSIDs.forEach { ssid ->
|
||||
settings.trustedNetworkSSIDs.forEach { ssid ->
|
||||
ClickableIconButton(
|
||||
onIconClick = {
|
||||
scope.launch {
|
||||
|
@ -360,7 +360,7 @@ fun SettingsScreen(
|
|||
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled)
|
||||
)
|
||||
}
|
||||
if (trustedSSIDs.isEmpty()) {
|
||||
if (settings.trustedNetworkSSIDs.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.none),
|
||||
fontStyle = FontStyle.Italic,
|
||||
|
@ -535,7 +535,8 @@ fun SettingsScreen(
|
|||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(fillMaxWidth).padding(vertical = 10.dp)
|
||||
.fillMaxWidth(fillMaxWidth)
|
||||
.padding(vertical = 10.dp)
|
||||
.padding(bottom = 140.dp)
|
||||
) {
|
||||
Column(
|
||||
|
|
|
@ -6,22 +6,19 @@ import android.location.LocationManager
|
|||
import android.os.Build
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.wireguard.android.backend.Tunnel
|
||||
import com.wireguard.android.util.RootShell
|
||||
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
|
||||
import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao
|
||||
import com.zaneschepke.wireguardautotunnel.repository.datastore.DataStoreManager
|
||||
import com.zaneschepke.wireguardautotunnel.repository.model.Settings
|
||||
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
|
||||
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
|
||||
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
|
||||
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
|
@ -32,110 +29,87 @@ constructor(
|
|||
private val application: Application,
|
||||
private val tunnelRepo: TunnelConfigDao,
|
||||
private val settingsRepo: SettingsDoa,
|
||||
val dataStoreManager: DataStoreManager,
|
||||
private val dataStoreManager: DataStoreManager,
|
||||
private val rootShell: RootShell,
|
||||
private val vpnService: VpnService
|
||||
) : ViewModel() {
|
||||
|
||||
private val _trustedSSIDs = MutableStateFlow(emptyList<String>())
|
||||
val trustedSSIDs = _trustedSSIDs.asStateFlow()
|
||||
private val _settings = MutableStateFlow(Settings())
|
||||
val settings get() = _settings.asStateFlow()
|
||||
val vpnState get() = vpnService.state
|
||||
val tunnels get() = tunnelRepo.getAllFlow()
|
||||
val disclosureShown = dataStoreManager.locationDisclosureFlow
|
||||
val settings = settingsRepo.getSettingsFlow().stateIn(viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5_000L), Settings())
|
||||
val tunnels = tunnelRepo.getAllFlow().stateIn(viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5_000L), emptyList())
|
||||
val vpnState get() = vpnService.state.stateIn(viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5_000L), Tunnel.State.DOWN)
|
||||
|
||||
init {
|
||||
isLocationServicesEnabled()
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
settingsRepo.getAllFlow().filter { it.isNotEmpty() }.collect {
|
||||
val settings = it.first()
|
||||
_settings.emit(settings)
|
||||
_trustedSSIDs.emit(settings.trustedNetworkSSIDs.toList())
|
||||
}
|
||||
}
|
||||
}
|
||||
suspend fun onSaveTrustedSSID(ssid: String) {
|
||||
val trimmed = ssid.trim()
|
||||
if (!_settings.value.trustedNetworkSSIDs.contains(trimmed)) {
|
||||
_settings.value.trustedNetworkSSIDs.add(trimmed)
|
||||
settingsRepo.save(_settings.value)
|
||||
if (!settings.value.trustedNetworkSSIDs.contains(trimmed)) {
|
||||
settings.value.trustedNetworkSSIDs.add(trimmed)
|
||||
settingsRepo.save(settings.value)
|
||||
} else {
|
||||
throw WgTunnelException("SSID already exists.")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun isLocationDisclosureShown() : Boolean {
|
||||
return dataStoreManager.getFromStore(DataStoreManager.LOCATION_DISCLOSURE_SHOWN) ?: false
|
||||
}
|
||||
|
||||
fun setLocationDisclosureShown() {
|
||||
viewModelScope.launch {
|
||||
dataStoreManager.saveToDataStore(DataStoreManager.LOCATION_DISCLOSURE_SHOWN, true)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun onToggleTunnelOnMobileData() {
|
||||
settingsRepo.save(
|
||||
_settings.value.copy(
|
||||
isTunnelOnMobileDataEnabled = !_settings.value.isTunnelOnMobileDataEnabled
|
||||
settings.value.copy(
|
||||
isTunnelOnMobileDataEnabled = !settings.value.isTunnelOnMobileDataEnabled
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun onDeleteTrustedSSID(ssid: String) {
|
||||
_settings.value.trustedNetworkSSIDs.remove(ssid)
|
||||
settingsRepo.save(_settings.value)
|
||||
settings.value.trustedNetworkSSIDs.remove(ssid)
|
||||
settingsRepo.save(settings.value)
|
||||
}
|
||||
|
||||
private fun emitFirstTunnelAsDefault() =
|
||||
viewModelScope.async {
|
||||
_settings.emit(_settings.value.copy(defaultTunnel = getFirstTunnelConfig().toString()))
|
||||
private suspend fun getDefaultTunnelOrFirst() : String {
|
||||
return settings.value.defaultTunnel ?: tunnelRepo.getAll().first().wgQuick
|
||||
}
|
||||
|
||||
suspend fun toggleAutoTunnel() {
|
||||
if (_settings.value.isAutoTunnelEnabled) {
|
||||
val defaultTunnel = getDefaultTunnelOrFirst()
|
||||
if (settings.value.isAutoTunnelEnabled) {
|
||||
ServiceManager.stopWatcherService(application)
|
||||
} else {
|
||||
if (_settings.value.defaultTunnel == null) {
|
||||
emitFirstTunnelAsDefault().await()
|
||||
ServiceManager.startWatcherService(application, defaultTunnel)
|
||||
}
|
||||
val defaultTunnel = _settings.value.defaultTunnel
|
||||
ServiceManager.startWatcherService(application, defaultTunnel!!)
|
||||
}
|
||||
settingsRepo.save(
|
||||
_settings.value.copy(
|
||||
isAutoTunnelEnabled = !_settings.value.isAutoTunnelEnabled
|
||||
saveSettings(
|
||||
settings.value.copy(
|
||||
isAutoTunnelEnabled = settings.value.isAutoTunnelEnabled,
|
||||
defaultTunnel = defaultTunnel
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun getFirstTunnelConfig(): TunnelConfig {
|
||||
return tunnelRepo.getAll().first()
|
||||
}
|
||||
|
||||
suspend fun onToggleAlwaysOnVPN() {
|
||||
if (_settings.value.defaultTunnel == null) {
|
||||
emitFirstTunnelAsDefault().await()
|
||||
}
|
||||
val updatedSettings =
|
||||
_settings.value.copy(
|
||||
isAlwaysOnVpnEnabled = !_settings.value.isAlwaysOnVpnEnabled
|
||||
settings.value.copy(
|
||||
isAlwaysOnVpnEnabled = !settings.value.isAlwaysOnVpnEnabled,
|
||||
defaultTunnel = getDefaultTunnelOrFirst()
|
||||
)
|
||||
emitSettings(updatedSettings)
|
||||
saveSettings(updatedSettings)
|
||||
}
|
||||
|
||||
private suspend fun emitSettings(settings: Settings) {
|
||||
_settings.emit(
|
||||
settings
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun saveSettings(settings: Settings) {
|
||||
settingsRepo.save(settings)
|
||||
}
|
||||
|
||||
suspend fun onToggleTunnelOnEthernet() {
|
||||
if (_settings.value.defaultTunnel == null) {
|
||||
emitFirstTunnelAsDefault().await()
|
||||
}
|
||||
_settings.emit(
|
||||
_settings.value.copy(
|
||||
isTunnelOnEthernetEnabled = !_settings.value.isTunnelOnEthernetEnabled
|
||||
)
|
||||
)
|
||||
settingsRepo.save(_settings.value)
|
||||
saveSettings(settings.value.copy(
|
||||
isTunnelOnEthernetEnabled = !settings.value.isTunnelOnEthernetEnabled
|
||||
))
|
||||
}
|
||||
|
||||
private fun isLocationServicesEnabled(): Boolean {
|
||||
|
@ -149,31 +123,39 @@ constructor(
|
|||
}
|
||||
|
||||
suspend fun onToggleShortcutsEnabled() {
|
||||
settingsRepo.save(
|
||||
_settings.value.copy(
|
||||
isShortcutsEnabled = !_settings.value.isShortcutsEnabled
|
||||
saveSettings(
|
||||
settings.value.copy(
|
||||
isShortcutsEnabled = !settings.value.isShortcutsEnabled
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun onToggleBatterySaver() {
|
||||
settingsRepo.save(
|
||||
_settings.value.copy(
|
||||
isBatterySaverEnabled = !_settings.value.isBatterySaverEnabled
|
||||
saveSettings(
|
||||
settings.value.copy(
|
||||
isBatterySaverEnabled = !settings.value.isBatterySaverEnabled
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun saveKernelMode(on: Boolean) {
|
||||
settingsRepo.save(
|
||||
_settings.value.copy(
|
||||
saveSettings(
|
||||
settings.value.copy(
|
||||
isKernelEnabled = on
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun onToggleTunnelOnWifi() {
|
||||
saveSettings(
|
||||
settings.value.copy(
|
||||
isTunnelOnWifiEnabled = !settings.value.isTunnelOnWifiEnabled
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun onToggleKernelMode() {
|
||||
if (!_settings.value.isKernelEnabled) {
|
||||
if (!settings.value.isKernelEnabled) {
|
||||
try {
|
||||
rootShell.start()
|
||||
Timber.d("Root shell accepted!")
|
||||
|
@ -186,12 +168,4 @@ constructor(
|
|||
saveKernelMode(on = false)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun onToggleTunnelOnWifi() {
|
||||
settingsRepo.save(
|
||||
_settings.value.copy(
|
||||
isTunnelOnWifiEnabled = !_settings.value.isTunnelOnWifiEnabled
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue