update + Fix
This commit is contained in:
Binary file not shown.
@@ -15,10 +15,15 @@ data class AppSettings(
|
|||||||
val cutoffIntervalMinutes: Int = 15,
|
val cutoffIntervalMinutes: Int = 15,
|
||||||
val baselineMobileBytesAtReset: Long = 0L,
|
val baselineMobileBytesAtReset: Long = 0L,
|
||||||
val whitelistedPackages: Set<String> = emptySet(),
|
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)
|
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). */
|
/** Prochaine date de réinitialisation automatique (anniversaire mensuel du début de forfait). */
|
||||||
fun nextAutoResetDate(): LocalDate {
|
fun nextAutoResetDate(): LocalDate {
|
||||||
var next = planStartDate.plusMonths(1)
|
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.edit
|
||||||
import androidx.datastore.preferences.core.longPreferencesKey
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
import androidx.datastore.preferences.core.intPreferencesKey
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -32,6 +34,8 @@ class SettingsRepository @Inject constructor(
|
|||||||
val BASELINE_MOBILE_BYTES = longPreferencesKey("baseline_mobile_bytes")
|
val BASELINE_MOBILE_BYTES = longPreferencesKey("baseline_mobile_bytes")
|
||||||
val WHITELISTED_PACKAGES = stringSetPreferencesKey("whitelisted_packages")
|
val WHITELISTED_PACKAGES = stringSetPreferencesKey("whitelisted_packages")
|
||||||
val SHOW_SYSTEM_APPS = booleanPreferencesKey("show_system_apps")
|
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 ->
|
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,
|
cutoffIntervalMinutes = prefs[Keys.CUTOFF_INTERVAL_MINUTES] ?: defaults.cutoffIntervalMinutes,
|
||||||
baselineMobileBytesAtReset = prefs[Keys.BASELINE_MOBILE_BYTES] ?: defaults.baselineMobileBytesAtReset,
|
baselineMobileBytesAtReset = prefs[Keys.BASELINE_MOBILE_BYTES] ?: defaults.baselineMobileBytesAtReset,
|
||||||
whitelistedPackages = prefs[Keys.WHITELISTED_PACKAGES] ?: defaults.whitelistedPackages,
|
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) {
|
suspend fun rebaseline(currentMobileBytes: Long) {
|
||||||
context.dataStore.edit { it[Keys.BASELINE_MOBILE_BYTES] = currentMobileBytes }
|
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.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -40,12 +41,14 @@ import com.jttools.datacontrol.R
|
|||||||
import com.jttools.datacontrol.data.DataState
|
import com.jttools.datacontrol.data.DataState
|
||||||
import com.jttools.datacontrol.service.DataMonitorService
|
import com.jttools.datacontrol.service.DataMonitorService
|
||||||
import com.jttools.datacontrol.ui.SystemActions
|
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.StateCut
|
||||||
import com.jttools.datacontrol.ui.theme.StateOk
|
import com.jttools.datacontrol.ui.theme.StateOk
|
||||||
import com.jttools.datacontrol.ui.theme.StateWarning
|
import com.jttools.datacontrol.ui.theme.StateWarning
|
||||||
import com.jttools.datacontrol.ui.theme.StateWifi
|
import com.jttools.datacontrol.ui.theme.StateWifi
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DashboardScreen(systemActions: SystemActions) {
|
fun DashboardScreen(systemActions: SystemActions) {
|
||||||
@@ -54,6 +57,9 @@ fun DashboardScreen(systemActions: SystemActions) {
|
|||||||
val settings by viewModel.settings.collectAsStateWithLifecycle()
|
val settings by viewModel.settings.collectAsStateWithLifecycle()
|
||||||
val snapshot by viewModel.snapshot.collectAsStateWithLifecycle()
|
val snapshot by viewModel.snapshot.collectAsStateWithLifecycle()
|
||||||
var showResetDialog by remember { mutableStateOf(false) }
|
var showResetDialog by remember { mutableStateOf(false) }
|
||||||
|
var showDisablePasswordDialog by remember { mutableStateOf(false) }
|
||||||
|
var disablePasswordError by remember { mutableStateOf(false) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val stateColor = when {
|
val stateColor = when {
|
||||||
!snapshot.serviceRunning -> Color.Gray
|
!snapshot.serviceRunning -> Color.Gray
|
||||||
@@ -164,10 +170,15 @@ fun DashboardScreen(systemActions: SystemActions) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
if (settings.hasPassword) {
|
||||||
|
disablePasswordError = false
|
||||||
|
showDisablePasswordDialog = true
|
||||||
} else {
|
} else {
|
||||||
viewModel.setControlEnabled(false)
|
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
|
@Composable
|
||||||
|
|||||||
@@ -32,4 +32,6 @@ class DashboardViewModel @Inject constructor(
|
|||||||
fun resetPlanNow() {
|
fun resetPlanNow() {
|
||||||
viewModelScope.launch { settingsRepository.resetPlan(usageRepository.totalMobileBytes()) }
|
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.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.List
|
import androidx.compose.material.icons.automirrored.filled.List
|
||||||
import androidx.compose.material.icons.filled.Dashboard
|
import androidx.compose.material.icons.filled.Dashboard
|
||||||
|
import androidx.compose.material.icons.filled.Info
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.NavigationBar
|
import androidx.compose.material3.NavigationBar
|
||||||
@@ -22,6 +23,7 @@ import androidx.navigation.compose.rememberNavController
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import com.jttools.datacontrol.R
|
import com.jttools.datacontrol.R
|
||||||
import com.jttools.datacontrol.ui.SystemActions
|
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.dashboard.DashboardScreen
|
||||||
import com.jttools.datacontrol.ui.settings.SettingsScreen
|
import com.jttools.datacontrol.ui.settings.SettingsScreen
|
||||||
import com.jttools.datacontrol.ui.whitelist.WhitelistScreen
|
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 Dashboard : Destination("dashboard", R.string.nav_dashboard, Icons.Filled.Dashboard)
|
||||||
data object Settings : Destination("settings", R.string.nav_settings, Icons.Filled.Settings)
|
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 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
|
@Composable
|
||||||
fun DataControlNavHost(systemActions: SystemActions) {
|
fun DataControlNavHost(systemActions: SystemActions) {
|
||||||
@@ -69,6 +72,7 @@ fun DataControlNavHost(systemActions: SystemActions) {
|
|||||||
composable(Destination.Dashboard.route) { DashboardScreen(systemActions) }
|
composable(Destination.Dashboard.route) { DashboardScreen(systemActions) }
|
||||||
composable(Destination.Settings.route) { SettingsScreen(systemActions) }
|
composable(Destination.Settings.route) { SettingsScreen(systemActions) }
|
||||||
composable(Destination.Whitelist.route) { WhitelistScreen() }
|
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.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.jttools.datacontrol.R
|
import com.jttools.datacontrol.R
|
||||||
import com.jttools.datacontrol.ui.SystemActions
|
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.Instant
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
@@ -50,6 +53,18 @@ fun SettingsScreen(systemActions: SystemActions) {
|
|||||||
val viewModel: SettingsViewModel = hiltViewModel()
|
val viewModel: SettingsViewModel = hiltViewModel()
|
||||||
val settings by viewModel.settings.collectAsStateWithLifecycle()
|
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 planLimitText by remember(settings.planLimitMo) { mutableStateOf(settings.planLimitMo.toString()) }
|
||||||
var planPriceText by remember(settings.planPriceEuro) { mutableStateOf(settings.planPriceEuro.toString()) }
|
var planPriceText by remember(settings.planPriceEuro) { mutableStateOf(settings.planPriceEuro.toString()) }
|
||||||
var overagePriceText by remember(settings.overagePricePerMoEuro) { mutableStateOf(settings.overagePricePerMoEuro.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 notifGranted by remember { mutableStateOf(systemActions.hasNotificationPermission()) }
|
||||||
var batteryIgnored by remember { mutableStateOf(systemActions.isIgnoringBatteryOptimizations()) }
|
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) {
|
LaunchedEffect(Unit) {
|
||||||
vpnGranted = systemActions.isVpnPrepared()
|
vpnGranted = systemActions.isVpnPrepared()
|
||||||
notifGranted = systemActions.hasNotificationPermission()
|
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(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
viewModel.updatePlan(
|
viewModel.updatePlan(
|
||||||
|
|||||||
@@ -37,4 +37,10 @@ class SettingsViewModel @Inject constructor(
|
|||||||
fun setStartDate(date: LocalDate) {
|
fun setStartDate(date: LocalDate) {
|
||||||
viewModelScope.launch { settingsRepository.setStartDate(date) }
|
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 androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.jttools.datacontrol.R
|
import com.jttools.datacontrol.R
|
||||||
import com.jttools.datacontrol.data.AppInfo
|
import com.jttools.datacontrol.data.AppInfo
|
||||||
|
import com.jttools.datacontrol.ui.components.PasswordGate
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun WhitelistScreen() {
|
fun WhitelistScreen() {
|
||||||
val viewModel: WhitelistViewModel = hiltViewModel()
|
val viewModel: WhitelistViewModel = hiltViewModel()
|
||||||
val settings by viewModel.settings.collectAsStateWithLifecycle()
|
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 apps by viewModel.apps.collectAsStateWithLifecycle()
|
||||||
val loading by viewModel.loading.collectAsStateWithLifecycle()
|
val loading by viewModel.loading.collectAsStateWithLifecycle()
|
||||||
var query by remember { mutableStateOf("") }
|
var query by remember { mutableStateOf("") }
|
||||||
|
|||||||
@@ -48,4 +48,6 @@ class WhitelistViewModel @Inject constructor(
|
|||||||
fun setShowSystemApps(show: Boolean) {
|
fun setShowSystemApps(show: Boolean) {
|
||||||
viewModelScope.launch { settingsRepository.setShowSystemApps(show) }
|
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_dashboard">Tableau de bord</string>
|
||||||
<string name="nav_settings">Réglages</string>
|
<string name="nav_settings">Réglages</string>
|
||||||
<string name="nav_whitelist">Liste blanche</string>
|
<string name="nav_whitelist">Liste blanche</string>
|
||||||
|
<string name="nav_about">À propos</string>
|
||||||
|
|
||||||
<!-- Dashboard -->
|
<!-- Dashboard -->
|
||||||
<string name="dashboard_title">Tableau de bord</string>
|
<string name="dashboard_title">Tableau de bord</string>
|
||||||
@@ -57,6 +58,33 @@
|
|||||||
<string name="settings_save">Enregistrer</string>
|
<string name="settings_save">Enregistrer</string>
|
||||||
<string name="settings_edit_date">Modifier</string>
|
<string name="settings_edit_date">Modifier</string>
|
||||||
<string name="settings_saved">Réglages enregistrés</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 -->
|
<!-- Whitelist -->
|
||||||
<string name="whitelist_title">Liste blanche</string>
|
<string name="whitelist_title">Liste blanche</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user