update + Fix
This commit is contained in:
Binary file not shown.
@@ -15,10 +15,15 @@ data class AppSettings(
|
||||
val cutoffIntervalMinutes: Int = 15,
|
||||
val baselineMobileBytesAtReset: Long = 0L,
|
||||
val whitelistedPackages: Set<String> = emptySet(),
|
||||
val showSystemApps: Boolean = false
|
||||
val showSystemApps: Boolean = false,
|
||||
val passwordHash: String? = null,
|
||||
val passwordSalt: String? = null
|
||||
) {
|
||||
val planStartDate: LocalDate get() = LocalDate.ofEpochDay(planStartDateEpochDay)
|
||||
|
||||
/** Un mot de passe de verrouillage protège la désactivation du contrôle et l'accès aux réglages. */
|
||||
val hasPassword: Boolean get() = !passwordHash.isNullOrEmpty()
|
||||
|
||||
/** Prochaine date de réinitialisation automatique (anniversaire mensuel du début de forfait). */
|
||||
fun nextAutoResetDate(): LocalDate {
|
||||
var next = planStartDate.plusMonths(1)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.jttools.datacontrol.data
|
||||
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
|
||||
/** Hachage salé (SHA-256) du mot de passe de verrouillage — jamais stocké en clair. */
|
||||
object PasswordHasher {
|
||||
private const val SALT_BYTES = 16
|
||||
|
||||
fun newSalt(): String {
|
||||
val bytes = ByteArray(SALT_BYTES)
|
||||
SecureRandom().nextBytes(bytes)
|
||||
return bytes.toHex()
|
||||
}
|
||||
|
||||
fun hash(password: String, saltHex: String): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
digest.update(saltHex.toByteArray(Charsets.UTF_8))
|
||||
return digest.digest(password.toByteArray(Charsets.UTF_8)).toHex()
|
||||
}
|
||||
|
||||
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
@@ -6,10 +6,12 @@ import androidx.datastore.preferences.core.doublePreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import java.time.LocalDate
|
||||
import javax.inject.Inject
|
||||
@@ -32,6 +34,8 @@ class SettingsRepository @Inject constructor(
|
||||
val BASELINE_MOBILE_BYTES = longPreferencesKey("baseline_mobile_bytes")
|
||||
val WHITELISTED_PACKAGES = stringSetPreferencesKey("whitelisted_packages")
|
||||
val SHOW_SYSTEM_APPS = booleanPreferencesKey("show_system_apps")
|
||||
val PASSWORD_HASH = stringPreferencesKey("password_hash")
|
||||
val PASSWORD_SALT = stringPreferencesKey("password_salt")
|
||||
}
|
||||
|
||||
val settingsFlow: Flow<AppSettings> = context.dataStore.data.map { prefs ->
|
||||
@@ -46,7 +50,9 @@ class SettingsRepository @Inject constructor(
|
||||
cutoffIntervalMinutes = prefs[Keys.CUTOFF_INTERVAL_MINUTES] ?: defaults.cutoffIntervalMinutes,
|
||||
baselineMobileBytesAtReset = prefs[Keys.BASELINE_MOBILE_BYTES] ?: defaults.baselineMobileBytesAtReset,
|
||||
whitelistedPackages = prefs[Keys.WHITELISTED_PACKAGES] ?: defaults.whitelistedPackages,
|
||||
showSystemApps = prefs[Keys.SHOW_SYSTEM_APPS] ?: defaults.showSystemApps
|
||||
showSystemApps = prefs[Keys.SHOW_SYSTEM_APPS] ?: defaults.showSystemApps,
|
||||
passwordHash = prefs[Keys.PASSWORD_HASH],
|
||||
passwordSalt = prefs[Keys.PASSWORD_SALT]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -95,4 +101,29 @@ class SettingsRepository @Inject constructor(
|
||||
suspend fun rebaseline(currentMobileBytes: Long) {
|
||||
context.dataStore.edit { it[Keys.BASELINE_MOBILE_BYTES] = currentMobileBytes }
|
||||
}
|
||||
|
||||
/** Définit (ou supprime, si [password] est vide/null) le mot de passe de verrouillage. */
|
||||
suspend fun setPassword(password: String?) {
|
||||
if (password.isNullOrBlank()) {
|
||||
context.dataStore.edit {
|
||||
it.remove(Keys.PASSWORD_HASH)
|
||||
it.remove(Keys.PASSWORD_SALT)
|
||||
}
|
||||
} else {
|
||||
val salt = PasswordHasher.newSalt()
|
||||
val hash = PasswordHasher.hash(password, salt)
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[Keys.PASSWORD_HASH] = hash
|
||||
prefs[Keys.PASSWORD_SALT] = salt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Vrai si aucun mot de passe n'est défini, ou si [password] correspond au mot de passe stocké. */
|
||||
suspend fun verifyPassword(password: String): Boolean {
|
||||
val prefs = context.dataStore.data.first()
|
||||
val hash = prefs[Keys.PASSWORD_HASH] ?: return true
|
||||
val salt = prefs[Keys.PASSWORD_SALT] ?: return true
|
||||
return PasswordHasher.hash(password, salt) == hash
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.jttools.datacontrol.ui.about
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.jttools.datacontrol.R
|
||||
|
||||
@Composable
|
||||
fun AboutScreen() {
|
||||
val context = LocalContext.current
|
||||
val versionName = remember {
|
||||
runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName }
|
||||
.getOrNull() ?: "?"
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.about_title), style = MaterialTheme.typography.headlineMedium)
|
||||
|
||||
Card(shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(2.dp)) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.mipmap.ic_launcher),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(72.dp)
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.about_version, versionName),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Card(shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(2.dp)) {
|
||||
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(stringResource(R.string.about_section_description), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
HorizontalDivider()
|
||||
Text(stringResource(R.string.about_description), style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
||||
Card(shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(2.dp)) {
|
||||
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(stringResource(R.string.about_section_how), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
HorizontalDivider()
|
||||
Text(stringResource(R.string.about_how_it_works), style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
||||
Card(shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(2.dp)) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(20.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(stringResource(R.string.about_author), style = MaterialTheme.typography.bodyMedium)
|
||||
Text(
|
||||
stringResource(R.string.about_author_value),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package com.jttools.datacontrol.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.jttools.datacontrol.R
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Verrouille [content] derrière une saisie de mot de passe tant que [passwordSet] est vrai.
|
||||
* Le déverrouillage n'est valable que pour la composition courante (redemandé à chaque visite de l'écran).
|
||||
*/
|
||||
@Composable
|
||||
fun PasswordGate(
|
||||
passwordSet: Boolean,
|
||||
verify: suspend (String) -> Boolean,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
var unlocked by remember(passwordSet) { mutableStateOf(!passwordSet) }
|
||||
if (unlocked) {
|
||||
content()
|
||||
return
|
||||
}
|
||||
|
||||
var password by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(Icons.Filled.Lock, contentDescription = null, modifier = Modifier.size(48.dp))
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(stringResource(R.string.password_gate_title), style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it; error = false },
|
||||
label = { Text(stringResource(R.string.password_field_label)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
isError = error,
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
if (error) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
stringResource(R.string.password_incorrect),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
if (verify(password)) {
|
||||
unlocked = true
|
||||
} else {
|
||||
error = true
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) { Text(stringResource(R.string.password_unlock)) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Boîte de dialogue de saisie de mot de passe pour confirmer une action ponctuelle (ex : désactivation). */
|
||||
@Composable
|
||||
fun PasswordPromptDialog(
|
||||
title: String,
|
||||
error: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (String) -> Unit
|
||||
) {
|
||||
var password by remember { mutableStateOf("") }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text(stringResource(R.string.password_field_label)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
isError = error,
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
if (error) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
stringResource(R.string.password_incorrect),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = { onConfirm(password) }) { Text(stringResource(R.string.common_confirm)) }
|
||||
},
|
||||
dismissButton = {
|
||||
OutlinedButton(onClick = onDismiss) { Text(stringResource(R.string.common_cancel)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -40,12 +41,14 @@ import com.jttools.datacontrol.R
|
||||
import com.jttools.datacontrol.data.DataState
|
||||
import com.jttools.datacontrol.service.DataMonitorService
|
||||
import com.jttools.datacontrol.ui.SystemActions
|
||||
import com.jttools.datacontrol.ui.components.PasswordPromptDialog
|
||||
import com.jttools.datacontrol.ui.theme.StateCut
|
||||
import com.jttools.datacontrol.ui.theme.StateOk
|
||||
import com.jttools.datacontrol.ui.theme.StateWarning
|
||||
import com.jttools.datacontrol.ui.theme.StateWifi
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun DashboardScreen(systemActions: SystemActions) {
|
||||
@@ -54,6 +57,9 @@ fun DashboardScreen(systemActions: SystemActions) {
|
||||
val settings by viewModel.settings.collectAsStateWithLifecycle()
|
||||
val snapshot by viewModel.snapshot.collectAsStateWithLifecycle()
|
||||
var showResetDialog by remember { mutableStateOf(false) }
|
||||
var showDisablePasswordDialog by remember { mutableStateOf(false) }
|
||||
var disablePasswordError by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val stateColor = when {
|
||||
!snapshot.serviceRunning -> Color.Gray
|
||||
@@ -165,7 +171,12 @@ fun DashboardScreen(systemActions: SystemActions) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
viewModel.setControlEnabled(false)
|
||||
if (settings.hasPassword) {
|
||||
disablePasswordError = false
|
||||
showDisablePasswordDialog = true
|
||||
} else {
|
||||
viewModel.setControlEnabled(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -205,6 +216,24 @@ fun DashboardScreen(systemActions: SystemActions) {
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showDisablePasswordDialog) {
|
||||
PasswordPromptDialog(
|
||||
title = stringResource(R.string.password_gate_title),
|
||||
error = disablePasswordError,
|
||||
onDismiss = { showDisablePasswordDialog = false },
|
||||
onConfirm = { password ->
|
||||
scope.launch {
|
||||
if (viewModel.verifyPassword(password)) {
|
||||
viewModel.setControlEnabled(false)
|
||||
showDisablePasswordDialog = false
|
||||
} else {
|
||||
disablePasswordError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -32,4 +32,6 @@ class DashboardViewModel @Inject constructor(
|
||||
fun resetPlanNow() {
|
||||
viewModelScope.launch { settingsRepository.resetPlan(usageRepository.totalMobileBytes()) }
|
||||
}
|
||||
|
||||
suspend fun verifyPassword(password: String): Boolean = settingsRepository.verifyPassword(password)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.jttools.datacontrol.ui.navigation
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.List
|
||||
import androidx.compose.material.icons.filled.Dashboard
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
@@ -22,6 +23,7 @@ import androidx.navigation.compose.rememberNavController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.jttools.datacontrol.R
|
||||
import com.jttools.datacontrol.ui.SystemActions
|
||||
import com.jttools.datacontrol.ui.about.AboutScreen
|
||||
import com.jttools.datacontrol.ui.dashboard.DashboardScreen
|
||||
import com.jttools.datacontrol.ui.settings.SettingsScreen
|
||||
import com.jttools.datacontrol.ui.whitelist.WhitelistScreen
|
||||
@@ -30,9 +32,10 @@ private sealed class Destination(val route: String, val labelRes: Int, val icon:
|
||||
data object Dashboard : Destination("dashboard", R.string.nav_dashboard, Icons.Filled.Dashboard)
|
||||
data object Settings : Destination("settings", R.string.nav_settings, Icons.Filled.Settings)
|
||||
data object Whitelist : Destination("whitelist", R.string.nav_whitelist, Icons.AutoMirrored.Filled.List)
|
||||
data object About : Destination("about", R.string.nav_about, Icons.Filled.Info)
|
||||
}
|
||||
|
||||
private val destinations = listOf(Destination.Dashboard, Destination.Settings, Destination.Whitelist)
|
||||
private val destinations = listOf(Destination.Dashboard, Destination.Settings, Destination.Whitelist, Destination.About)
|
||||
|
||||
@Composable
|
||||
fun DataControlNavHost(systemActions: SystemActions) {
|
||||
@@ -69,6 +72,7 @@ fun DataControlNavHost(systemActions: SystemActions) {
|
||||
composable(Destination.Dashboard.route) { DashboardScreen(systemActions) }
|
||||
composable(Destination.Settings.route) { SettingsScreen(systemActions) }
|
||||
composable(Destination.Whitelist.route) { WhitelistScreen() }
|
||||
composable(Destination.About.route) { AboutScreen() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,11 +33,14 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.jttools.datacontrol.R
|
||||
import com.jttools.datacontrol.ui.SystemActions
|
||||
import com.jttools.datacontrol.ui.components.PasswordGate
|
||||
import com.jttools.datacontrol.ui.theme.StateOk
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneOffset
|
||||
@@ -50,6 +53,18 @@ fun SettingsScreen(systemActions: SystemActions) {
|
||||
val viewModel: SettingsViewModel = hiltViewModel()
|
||||
val settings by viewModel.settings.collectAsStateWithLifecycle()
|
||||
|
||||
PasswordGate(passwordSet = settings.hasPassword, verify = { viewModel.verifyPassword(it) }) {
|
||||
SettingsContent(systemActions, viewModel, settings)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SettingsContent(
|
||||
systemActions: SystemActions,
|
||||
viewModel: SettingsViewModel,
|
||||
settings: com.jttools.datacontrol.data.AppSettings
|
||||
) {
|
||||
var planLimitText by remember(settings.planLimitMo) { mutableStateOf(settings.planLimitMo.toString()) }
|
||||
var planPriceText by remember(settings.planPriceEuro) { mutableStateOf(settings.planPriceEuro.toString()) }
|
||||
var overagePriceText by remember(settings.overagePricePerMoEuro) { mutableStateOf(settings.overagePricePerMoEuro.toString()) }
|
||||
@@ -61,6 +76,15 @@ fun SettingsScreen(systemActions: SystemActions) {
|
||||
var notifGranted by remember { mutableStateOf(systemActions.hasNotificationPermission()) }
|
||||
var batteryIgnored by remember { mutableStateOf(systemActions.isIgnoringBatteryOptimizations()) }
|
||||
|
||||
var newPassword by remember { mutableStateOf("") }
|
||||
var confirmPassword by remember { mutableStateOf("") }
|
||||
var passwordError by remember { mutableStateOf<String?>(null) }
|
||||
var passwordMessage by remember { mutableStateOf<String?>(null) }
|
||||
val passwordTooShort = stringResource(R.string.settings_password_too_short)
|
||||
val passwordMismatch = stringResource(R.string.settings_password_mismatch)
|
||||
val passwordSetMessage = stringResource(R.string.settings_password_set_confirmation)
|
||||
val passwordRemovedMessage = stringResource(R.string.settings_password_removed_confirmation)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
vpnGranted = systemActions.isVpnPrepared()
|
||||
notifGranted = systemActions.hasNotificationPermission()
|
||||
@@ -174,6 +198,68 @@ fun SettingsScreen(systemActions: SystemActions) {
|
||||
}
|
||||
}
|
||||
|
||||
Card(shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(2.dp)) {
|
||||
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(stringResource(R.string.settings_section_security), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
HorizontalDivider()
|
||||
Text(
|
||||
if (settings.hasPassword) stringResource(R.string.settings_password_active)
|
||||
else stringResource(R.string.settings_password_inactive),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (settings.hasPassword) StateOk else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = newPassword,
|
||||
onValueChange = { newPassword = it; passwordError = null; passwordMessage = null },
|
||||
label = { Text(stringResource(R.string.settings_password_new)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = confirmPassword,
|
||||
onValueChange = { confirmPassword = it; passwordError = null; passwordMessage = null },
|
||||
label = { Text(stringResource(R.string.settings_password_confirm)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
passwordError?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
passwordMessage?.let {
|
||||
Text(it, color = StateOk, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Button(onClick = {
|
||||
when {
|
||||
newPassword.length < 4 -> passwordError = passwordTooShort
|
||||
newPassword != confirmPassword -> passwordError = passwordMismatch
|
||||
else -> {
|
||||
viewModel.setPassword(newPassword)
|
||||
newPassword = ""
|
||||
confirmPassword = ""
|
||||
passwordError = null
|
||||
passwordMessage = passwordSetMessage
|
||||
}
|
||||
}
|
||||
}) { Text(stringResource(R.string.settings_password_set)) }
|
||||
|
||||
if (settings.hasPassword) {
|
||||
OutlinedButton(onClick = {
|
||||
viewModel.setPassword(null)
|
||||
newPassword = ""
|
||||
confirmPassword = ""
|
||||
passwordError = null
|
||||
passwordMessage = passwordRemovedMessage
|
||||
}) { Text(stringResource(R.string.settings_password_remove)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.updatePlan(
|
||||
|
||||
@@ -37,4 +37,10 @@ class SettingsViewModel @Inject constructor(
|
||||
fun setStartDate(date: LocalDate) {
|
||||
viewModelScope.launch { settingsRepository.setStartDate(date) }
|
||||
}
|
||||
|
||||
fun setPassword(password: String?) {
|
||||
viewModelScope.launch { settingsRepository.setPassword(password) }
|
||||
}
|
||||
|
||||
suspend fun verifyPassword(password: String): Boolean = settingsRepository.verifyPassword(password)
|
||||
}
|
||||
|
||||
@@ -39,11 +39,20 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.jttools.datacontrol.R
|
||||
import com.jttools.datacontrol.data.AppInfo
|
||||
import com.jttools.datacontrol.ui.components.PasswordGate
|
||||
|
||||
@Composable
|
||||
fun WhitelistScreen() {
|
||||
val viewModel: WhitelistViewModel = hiltViewModel()
|
||||
val settings by viewModel.settings.collectAsStateWithLifecycle()
|
||||
|
||||
PasswordGate(passwordSet = settings.hasPassword, verify = { viewModel.verifyPassword(it) }) {
|
||||
WhitelistContent(viewModel, settings)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WhitelistContent(viewModel: WhitelistViewModel, settings: com.jttools.datacontrol.data.AppSettings) {
|
||||
val apps by viewModel.apps.collectAsStateWithLifecycle()
|
||||
val loading by viewModel.loading.collectAsStateWithLifecycle()
|
||||
var query by remember { mutableStateOf("") }
|
||||
|
||||
@@ -48,4 +48,6 @@ class WhitelistViewModel @Inject constructor(
|
||||
fun setShowSystemApps(show: Boolean) {
|
||||
viewModelScope.launch { settingsRepository.setShowSystemApps(show) }
|
||||
}
|
||||
|
||||
suspend fun verifyPassword(password: String): Boolean = settingsRepository.verifyPassword(password)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<string name="nav_dashboard">Tableau de bord</string>
|
||||
<string name="nav_settings">Réglages</string>
|
||||
<string name="nav_whitelist">Liste blanche</string>
|
||||
<string name="nav_about">À propos</string>
|
||||
|
||||
<!-- Dashboard -->
|
||||
<string name="dashboard_title">Tableau de bord</string>
|
||||
@@ -57,6 +58,33 @@
|
||||
<string name="settings_save">Enregistrer</string>
|
||||
<string name="settings_edit_date">Modifier</string>
|
||||
<string name="settings_saved">Réglages enregistrés</string>
|
||||
<string name="settings_section_security">Sécurité</string>
|
||||
<string name="settings_password_active">Mot de passe actif — protège la désactivation et les réglages</string>
|
||||
<string name="settings_password_inactive">Aucun mot de passe défini</string>
|
||||
<string name="settings_password_new">Nouveau mot de passe</string>
|
||||
<string name="settings_password_confirm">Confirmer le mot de passe</string>
|
||||
<string name="settings_password_set">Définir</string>
|
||||
<string name="settings_password_remove">Supprimer</string>
|
||||
<string name="settings_password_too_short">Le mot de passe doit contenir au moins 4 caractères</string>
|
||||
<string name="settings_password_mismatch">Les mots de passe ne correspondent pas</string>
|
||||
<string name="settings_password_set_confirmation">Mot de passe enregistré</string>
|
||||
<string name="settings_password_removed_confirmation">Mot de passe supprimé</string>
|
||||
|
||||
<!-- Verrouillage par mot de passe -->
|
||||
<string name="password_gate_title">Zone protégée</string>
|
||||
<string name="password_field_label">Mot de passe</string>
|
||||
<string name="password_incorrect">Mot de passe incorrect</string>
|
||||
<string name="password_unlock">Déverrouiller</string>
|
||||
|
||||
<!-- About -->
|
||||
<string name="about_title">À propos</string>
|
||||
<string name="about_version">Version %1$s</string>
|
||||
<string name="about_section_description">Description</string>
|
||||
<string name="about_description">DataControl surveille en arrière-plan la consommation de données mobiles et coupe automatiquement l\'accès à Internet en cas de dépassement du forfait, sans jamais toucher au Wi-Fi.</string>
|
||||
<string name="about_section_how">Fonctionnement</string>
|
||||
<string name="about_how_it_works">Le filtrage repose sur un pare-feu local basé sur VpnService (sans root) : le trafic des applications non autorisées est capturé dans un tunnel VPN local puis silencieusement ignoré, tandis que les applications de la liste blanche continuent de communiquer normalement.</string>
|
||||
<string name="about_author">Auteur</string>
|
||||
<string name="about_author_value">JT-Tools — Johnny</string>
|
||||
|
||||
<!-- Whitelist -->
|
||||
<string name="whitelist_title">Liste blanche</string>
|
||||
|
||||
Reference in New Issue
Block a user