diff --git a/DataControl-0.1.0.apk b/DataControl-0.1.0.apk index da44467..d4bcf80 100644 Binary files a/DataControl-0.1.0.apk and b/DataControl-0.1.0.apk differ diff --git a/app/src/main/java/com/jttools/datacontrol/data/AppSettings.kt b/app/src/main/java/com/jttools/datacontrol/data/AppSettings.kt index e248913..100d87b 100644 --- a/app/src/main/java/com/jttools/datacontrol/data/AppSettings.kt +++ b/app/src/main/java/com/jttools/datacontrol/data/AppSettings.kt @@ -15,10 +15,15 @@ data class AppSettings( val cutoffIntervalMinutes: Int = 15, val baselineMobileBytesAtReset: Long = 0L, val whitelistedPackages: Set = 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) diff --git a/app/src/main/java/com/jttools/datacontrol/data/PasswordHasher.kt b/app/src/main/java/com/jttools/datacontrol/data/PasswordHasher.kt new file mode 100644 index 0000000..f9cecfc --- /dev/null +++ b/app/src/main/java/com/jttools/datacontrol/data/PasswordHasher.kt @@ -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) } +} diff --git a/app/src/main/java/com/jttools/datacontrol/data/SettingsRepository.kt b/app/src/main/java/com/jttools/datacontrol/data/SettingsRepository.kt index 00f9276..08d2a2f 100644 --- a/app/src/main/java/com/jttools/datacontrol/data/SettingsRepository.kt +++ b/app/src/main/java/com/jttools/datacontrol/data/SettingsRepository.kt @@ -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 = 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 + } } diff --git a/app/src/main/java/com/jttools/datacontrol/ui/about/AboutScreen.kt b/app/src/main/java/com/jttools/datacontrol/ui/about/AboutScreen.kt new file mode 100644 index 0000000..a742f62 --- /dev/null +++ b/app/src/main/java/com/jttools/datacontrol/ui/about/AboutScreen.kt @@ -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 + ) + } + } + } +} diff --git a/app/src/main/java/com/jttools/datacontrol/ui/components/PasswordComponents.kt b/app/src/main/java/com/jttools/datacontrol/ui/components/PasswordComponents.kt new file mode 100644 index 0000000..d3d75d7 --- /dev/null +++ b/app/src/main/java/com/jttools/datacontrol/ui/components/PasswordComponents.kt @@ -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)) } + } + ) +} diff --git a/app/src/main/java/com/jttools/datacontrol/ui/dashboard/DashboardScreen.kt b/app/src/main/java/com/jttools/datacontrol/ui/dashboard/DashboardScreen.kt index b3b8cc0..9ca5f2f 100644 --- a/app/src/main/java/com/jttools/datacontrol/ui/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/jttools/datacontrol/ui/dashboard/DashboardScreen.kt @@ -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 diff --git a/app/src/main/java/com/jttools/datacontrol/ui/dashboard/DashboardViewModel.kt b/app/src/main/java/com/jttools/datacontrol/ui/dashboard/DashboardViewModel.kt index ecd9ab5..77c9a7d 100644 --- a/app/src/main/java/com/jttools/datacontrol/ui/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/com/jttools/datacontrol/ui/dashboard/DashboardViewModel.kt @@ -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) } diff --git a/app/src/main/java/com/jttools/datacontrol/ui/navigation/DataControlNavHost.kt b/app/src/main/java/com/jttools/datacontrol/ui/navigation/DataControlNavHost.kt index 5506f5b..5911a60 100644 --- a/app/src/main/java/com/jttools/datacontrol/ui/navigation/DataControlNavHost.kt +++ b/app/src/main/java/com/jttools/datacontrol/ui/navigation/DataControlNavHost.kt @@ -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() } } } } diff --git a/app/src/main/java/com/jttools/datacontrol/ui/settings/SettingsScreen.kt b/app/src/main/java/com/jttools/datacontrol/ui/settings/SettingsScreen.kt index 4d31f56..956d527 100644 --- a/app/src/main/java/com/jttools/datacontrol/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/jttools/datacontrol/ui/settings/SettingsScreen.kt @@ -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(null) } + var passwordMessage by remember { mutableStateOf(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( diff --git a/app/src/main/java/com/jttools/datacontrol/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/jttools/datacontrol/ui/settings/SettingsViewModel.kt index 3914f00..fd2526c 100644 --- a/app/src/main/java/com/jttools/datacontrol/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/jttools/datacontrol/ui/settings/SettingsViewModel.kt @@ -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) } diff --git a/app/src/main/java/com/jttools/datacontrol/ui/whitelist/WhitelistScreen.kt b/app/src/main/java/com/jttools/datacontrol/ui/whitelist/WhitelistScreen.kt index 449d806..3314b18 100644 --- a/app/src/main/java/com/jttools/datacontrol/ui/whitelist/WhitelistScreen.kt +++ b/app/src/main/java/com/jttools/datacontrol/ui/whitelist/WhitelistScreen.kt @@ -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("") } diff --git a/app/src/main/java/com/jttools/datacontrol/ui/whitelist/WhitelistViewModel.kt b/app/src/main/java/com/jttools/datacontrol/ui/whitelist/WhitelistViewModel.kt index fbd97a6..a35376a 100644 --- a/app/src/main/java/com/jttools/datacontrol/ui/whitelist/WhitelistViewModel.kt +++ b/app/src/main/java/com/jttools/datacontrol/ui/whitelist/WhitelistViewModel.kt @@ -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) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e259bbf..c55b251 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6,6 +6,7 @@ Tableau de bord Réglages Liste blanche + À propos Tableau de bord @@ -57,6 +58,33 @@ Enregistrer Modifier Réglages enregistrés + Sécurité + Mot de passe actif — protège la désactivation et les réglages + Aucun mot de passe défini + Nouveau mot de passe + Confirmer le mot de passe + Définir + Supprimer + Le mot de passe doit contenir au moins 4 caractères + Les mots de passe ne correspondent pas + Mot de passe enregistré + Mot de passe supprimé + + + Zone protégée + Mot de passe + Mot de passe incorrect + Déverrouiller + + + À propos + Version %1$s + 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. + Fonctionnement + 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. + Auteur + JT-Tools — Johnny Liste blanche