update + Fix

This commit is contained in:
Johnny
2026-07-03 08:10:43 +02:00
parent 96e5bacc5a
commit ecdb915da8
11 changed files with 241 additions and 9 deletions

Binary file not shown.

View File

@@ -1,7 +1,25 @@
package com.jttools.datacontrol
import android.app.Application
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import com.jttools.datacontrol.service.DataMonitorRestartWorker
import dagger.hilt.android.HiltAndroidApp
import java.util.concurrent.TimeUnit
@HiltAndroidApp
class DataControlApp : Application()
class DataControlApp : Application() {
override fun onCreate() {
super.onCreate()
// Filet de sécurité : relance périodiquement DataMonitorService s'il a été tué en arrière-plan.
// enqueueUniquePeriodicWork + KEEP : sans effet si déjà planifié, donc sûr à appeler à chaque démarrage.
val request = PeriodicWorkRequestBuilder<DataMonitorRestartWorker>(15, TimeUnit.MINUTES).build()
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
DataMonitorRestartWorker.WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
request
)
}
}

View File

@@ -15,6 +15,8 @@ data class AppSettings(
val cutoffIntervalMinutes: Int = 15,
val baselineMobileBytesAtReset: Long = 0L,
val whitelistedPackages: Set<String> = emptySet(),
val whitelistedPackagesWifi: Set<String> = emptySet(),
val wifiFilterEnabled: Boolean = false,
val showSystemApps: Boolean = false,
val passwordHash: String? = null,
val passwordSalt: String? = null,

View File

@@ -33,6 +33,8 @@ class SettingsRepository @Inject constructor(
val CUTOFF_INTERVAL_MINUTES = intPreferencesKey("cutoff_interval_minutes")
val BASELINE_MOBILE_BYTES = longPreferencesKey("baseline_mobile_bytes")
val WHITELISTED_PACKAGES = stringSetPreferencesKey("whitelisted_packages")
val WHITELISTED_PACKAGES_WIFI = stringSetPreferencesKey("whitelisted_packages_wifi")
val WIFI_FILTER_ENABLED = booleanPreferencesKey("wifi_filter_enabled")
val SHOW_SYSTEM_APPS = booleanPreferencesKey("show_system_apps")
val PASSWORD_HASH = stringPreferencesKey("password_hash")
val PASSWORD_SALT = stringPreferencesKey("password_salt")
@@ -53,6 +55,8 @@ 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,
whitelistedPackagesWifi = prefs[Keys.WHITELISTED_PACKAGES_WIFI] ?: defaults.whitelistedPackagesWifi,
wifiFilterEnabled = prefs[Keys.WIFI_FILTER_ENABLED] ?: defaults.wifiFilterEnabled,
showSystemApps = prefs[Keys.SHOW_SYSTEM_APPS] ?: defaults.showSystemApps,
passwordHash = prefs[Keys.PASSWORD_HASH],
passwordSalt = prefs[Keys.PASSWORD_SALT],
@@ -91,6 +95,14 @@ class SettingsRepository @Inject constructor(
context.dataStore.edit { it[Keys.WHITELISTED_PACKAGES] = packages }
}
suspend fun setWhitelistedPackagesWifi(packages: Set<String>) {
context.dataStore.edit { it[Keys.WHITELISTED_PACKAGES_WIFI] = packages }
}
suspend fun setWifiFilterEnabled(enabled: Boolean) {
context.dataStore.edit { it[Keys.WIFI_FILTER_ENABLED] = enabled }
}
suspend fun setShowSystemApps(show: Boolean) {
context.dataStore.edit { it[Keys.SHOW_SYSTEM_APPS] = show }
}
@@ -108,6 +120,16 @@ class SettingsRepository @Inject constructor(
context.dataStore.edit { it[Keys.BASELINE_MOBILE_BYTES] = currentMobileBytes }
}
/**
* Force la consommation affichée à [consumedMo] sans changer la date de début du cycle, en
* recalculant la base de référence à partir du compteur système actuel ([currentTotalBytes]).
* Utile pour recaler l'app sur le décompte opérateur réel.
*/
suspend fun setConsumedMo(consumedMo: Long, currentTotalBytes: Long) {
val baseline = (currentTotalBytes - UsageRepository.moToBytes(consumedMo)).coerceAtLeast(0L)
context.dataStore.edit { it[Keys.BASELINE_MOBILE_BYTES] = baseline }
}
/** Définit (ou supprime, si [password] est vide/null) le mot de passe de verrouillage. */
suspend fun setPassword(password: String?) {
if (password.isNullOrBlank()) {

View File

@@ -0,0 +1,42 @@
package com.jttools.datacontrol.service
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.jttools.datacontrol.data.SettingsRepository
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.flow.first
/**
* Filet de sécurité périodique (WorkManager) : relance [DataMonitorService] s'il a été tué par le
* système alors que le contrôle est censé être actif. START_STICKY et le redémarrage au boot
* (voir [BootReceiver]) ne suffisent pas toujours sur les ROM qui tuent agressivement les apps en
* arrière-plan (ex: ColorOS) sans exemption d'optimisation batterie. Démarrer un service déjà en
* cours d'exécution est sans effet, cet appel est donc sûr à répéter à chaque déclenchement.
*/
class DataMonitorRestartWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface RestartWorkerEntryPoint {
fun settingsRepository(): SettingsRepository
}
override suspend fun doWork(): Result {
val appContext = applicationContext
val entryPoint = EntryPointAccessors.fromApplication(appContext, RestartWorkerEntryPoint::class.java)
val enabled = entryPoint.settingsRepository().settingsFlow.first().controlEnabled
if (enabled) DataMonitorService.start(appContext)
return Result.success()
}
companion object {
const val WORK_NAME = "data_monitor_restart_watchdog"
}
}

View File

@@ -68,6 +68,13 @@ class DataMonitorService : LifecycleService() {
private enum class VpnMode { FILTER, BLOCK_ALL, STOPPED }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
// START_STICKY : si le système tue ce service (mémoire faible), il le relance
// automatiquement (avec un Intent null) dès que les ressources redeviennent disponibles.
return START_STICKY
}
override fun onCreate() {
super.onCreate()
ContextCompat.registerReceiver(
@@ -133,7 +140,11 @@ class DataMonitorService : LifecycleService() {
when {
latestTransport == NetworkTransport.WIFI -> {
applyVpnMode(VpnMode.STOPPED, emptySet())
if (settings.wifiFilterEnabled && FirewallVpnService.isPrepared(this)) {
applyVpnMode(VpnMode.FILTER, settings.whitelistedPackagesWifi)
} else {
applyVpnMode(VpnMode.STOPPED, emptySet())
}
resetCutoffCycle()
dataState = DataState.WIFI
}

View File

@@ -10,6 +10,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@@ -72,6 +73,10 @@ private fun SettingsContent(
var autoReset by remember(settings.autoResetEnabled) { mutableStateOf(settings.autoResetEnabled) }
var showDatePicker by remember { mutableStateOf(false) }
val consumedMo by viewModel.consumedMo.collectAsStateWithLifecycle()
var showConsumedDialog by remember { mutableStateOf(false) }
var consumedMoText by remember { mutableStateOf("") }
var vpnGranted by remember { mutableStateOf(systemActions.isVpnPrepared()) }
var notifGranted by remember { mutableStateOf(systemActions.hasNotificationPermission()) }
var batteryIgnored by remember { mutableStateOf(systemActions.isIgnoringBatteryOptimizations()) }
@@ -142,6 +147,25 @@ private fun SettingsContent(
OutlinedButton(onClick = { showDatePicker = true }) { Text(stringResource(R.string.settings_edit_date)) }
}
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(stringResource(R.string.settings_current_consumption), style = MaterialTheme.typography.bodyMedium)
Text(
"$consumedMo Mo",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
OutlinedButton(onClick = {
consumedMoText = consumedMo.toString()
showConsumedDialog = true
}) { Text(stringResource(R.string.settings_edit_date)) }
}
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -294,6 +318,32 @@ private fun SettingsContent(
}
) { DatePicker(state = datePickerState) }
}
if (showConsumedDialog) {
AlertDialog(
onDismissRequest = { showConsumedDialog = false },
title = { Text(stringResource(R.string.settings_current_consumption)) },
text = {
OutlinedTextField(
value = consumedMoText,
onValueChange = { consumedMoText = it.filter(Char::isDigit) },
label = { Text(stringResource(R.string.settings_plan_limit_mo_unit)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
},
confirmButton = {
Button(onClick = {
consumedMoText.toLongOrNull()?.let { viewModel.setConsumedMo(it.coerceAtLeast(0L)) }
showConsumedDialog = false
}) { Text(stringResource(R.string.common_confirm)) }
},
dismissButton = {
OutlinedButton(onClick = { showConsumedDialog = false }) { Text(stringResource(R.string.common_cancel)) }
}
)
}
}
@Composable

View File

@@ -4,8 +4,11 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.jttools.datacontrol.data.AppSettings
import com.jttools.datacontrol.data.SettingsRepository
import com.jttools.datacontrol.data.UsageRepository
import com.jttools.datacontrol.service.MonitorStateHolder
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.time.LocalDate
@@ -13,13 +16,19 @@ import javax.inject.Inject
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val settingsRepository: SettingsRepository
private val settingsRepository: SettingsRepository,
private val usageRepository: UsageRepository,
stateHolder: MonitorStateHolder
) : ViewModel() {
val settings = settingsRepository.settingsFlow.stateIn(
viewModelScope, SharingStarted.WhileSubscribed(5_000), AppSettings()
)
val consumedMo = stateHolder.snapshot.map { it.consumedMo }.stateIn(
viewModelScope, SharingStarted.WhileSubscribed(5_000), 0L
)
fun updatePlan(
planLimitMo: Long,
planPriceEuro: Double,
@@ -38,6 +47,10 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { settingsRepository.setStartDate(date) }
}
fun setConsumedMo(consumedMo: Long) {
viewModelScope.launch { settingsRepository.setConsumedMo(consumedMo, usageRepository.totalMobileBytes()) }
}
fun setPassword(password: String?) {
viewModelScope.launch { settingsRepository.setPassword(password) }
}

View File

@@ -20,11 +20,15 @@ import androidx.compose.material3.Checkbox
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -56,11 +60,15 @@ private fun WhitelistContent(viewModel: WhitelistViewModel, settings: com.jttool
val apps by viewModel.apps.collectAsStateWithLifecycle()
val loading by viewModel.loading.collectAsStateWithLifecycle()
var query by remember { mutableStateOf("") }
var tabIndex by remember { mutableIntStateOf(0) }
val forWifi = tabIndex == 1
val filtered = apps
.filter { settings.showSystemApps || !it.isSystemApp }
.filter { it.label.contains(query, ignoreCase = true) || it.packageName.contains(query, ignoreCase = true) }
val activeSet = if (forWifi) settings.whitelistedPackagesWifi else settings.whitelistedPackages
Column(Modifier.fillMaxSize().padding(20.dp)) {
Text(stringResource(R.string.whitelist_title), style = MaterialTheme.typography.headlineMedium)
Text(
@@ -70,13 +78,44 @@ private fun WhitelistContent(viewModel: WhitelistViewModel, settings: com.jttool
modifier = Modifier.padding(top = 6.dp, bottom = 14.dp)
)
TabRow(selectedTabIndex = tabIndex) {
Tab(
selected = tabIndex == 0,
onClick = { tabIndex = 0 },
text = { Text(stringResource(R.string.whitelist_tab_mobile)) }
)
Tab(
selected = tabIndex == 1,
onClick = { tabIndex = 1 },
text = { Text(stringResource(R.string.whitelist_tab_wifi)) }
)
}
if (forWifi) {
Row(
Modifier.fillMaxWidth().padding(top = 10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(Modifier.weight(1f)) {
Text(stringResource(R.string.whitelist_wifi_filter_toggle), style = MaterialTheme.typography.bodyMedium)
Text(
stringResource(R.string.whitelist_wifi_filter_help),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(checked = settings.wifiFilterEnabled, onCheckedChange = { viewModel.setWifiFilterEnabled(it) })
}
}
OutlinedTextField(
value = query,
onValueChange = { query = it },
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
label = { Text(stringResource(R.string.whitelist_search_hint)) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth().padding(top = 10.dp)
)
Row(
@@ -87,6 +126,20 @@ private fun WhitelistContent(viewModel: WhitelistViewModel, settings: com.jttool
Text(stringResource(R.string.whitelist_system_apps_toggle), style = MaterialTheme.typography.bodyMedium)
Switch(checked = settings.showSystemApps, onCheckedChange = { viewModel.setShowSystemApps(it) })
}
Row(
Modifier.fillMaxWidth().padding(bottom = 10.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
OutlinedButton(
onClick = { viewModel.setWhitelistedBulk(filtered.map { it.packageName }, whitelisted = true, forWifi = forWifi) },
modifier = Modifier.weight(1f)
) { Text(stringResource(R.string.whitelist_select_all)) }
OutlinedButton(
onClick = { viewModel.setWhitelistedBulk(filtered.map { it.packageName }, whitelisted = false, forWifi = forWifi) },
modifier = Modifier.weight(1f)
) { Text(stringResource(R.string.whitelist_select_none)) }
}
HorizontalDivider()
if (loading) {
@@ -96,8 +149,8 @@ private fun WhitelistContent(viewModel: WhitelistViewModel, settings: com.jttool
items(filtered, key = { it.packageName }) { app ->
AppRow(
app = app,
checked = settings.whitelistedPackages.contains(app.packageName),
onCheckedChange = { viewModel.setWhitelisted(app.packageName, it) }
checked = activeSet.contains(app.packageName),
onCheckedChange = { viewModel.setWhitelisted(app.packageName, it, forWifi) }
)
}
}

View File

@@ -37,14 +37,27 @@ class WhitelistViewModel @Inject constructor(
}
}
fun setWhitelisted(packageName: String, whitelisted: Boolean) {
fun setWhitelisted(packageName: String, whitelisted: Boolean, forWifi: Boolean) {
viewModelScope.launch {
val current = settings.value.whitelistedPackages
val current = if (forWifi) settings.value.whitelistedPackagesWifi else settings.value.whitelistedPackages
val updated = if (whitelisted) current + packageName else current - packageName
settingsRepository.setWhitelistedPackages(updated)
if (forWifi) settingsRepository.setWhitelistedPackagesWifi(updated) else settingsRepository.setWhitelistedPackages(updated)
}
}
/** Sélectionne ou désélectionne en bloc les paquets [packageNames] (ex : la liste actuellement filtrée à l'écran). */
fun setWhitelistedBulk(packageNames: List<String>, whitelisted: Boolean, forWifi: Boolean) {
viewModelScope.launch {
val current = if (forWifi) settings.value.whitelistedPackagesWifi else settings.value.whitelistedPackages
val updated = if (whitelisted) current + packageNames else current - packageNames.toSet()
if (forWifi) settingsRepository.setWhitelistedPackagesWifi(updated) else settingsRepository.setWhitelistedPackages(updated)
}
}
fun setWifiFilterEnabled(enabled: Boolean) {
viewModelScope.launch { settingsRepository.setWifiFilterEnabled(enabled) }
}
fun setShowSystemApps(show: Boolean) {
viewModelScope.launch { settingsRepository.setShowSystemApps(show) }
}

View File

@@ -37,6 +37,8 @@
<string name="settings_title">Réglages</string>
<string name="settings_section_plan">Forfait</string>
<string name="settings_plan_limit_mo">Forfait data (Mo)</string>
<string name="settings_plan_limit_mo_unit">Mo</string>
<string name="settings_current_consumption">Consommation actuelle</string>
<string name="settings_plan_price">Prix du forfait (€)</string>
<string name="settings_overage_price">Prix par Mo hors-forfait (€)</string>
<string name="settings_start_date">Date de début du forfait</string>
@@ -91,6 +93,12 @@
<string name="whitelist_description">Ces applications gardent toujours accès aux données mobiles, y compris en dépassement de forfait (sauf pendant les coupures cycliques totales).</string>
<string name="whitelist_search_hint">Rechercher une application</string>
<string name="whitelist_system_apps_toggle">Afficher les applications système</string>
<string name="whitelist_tab_mobile">Données mobiles</string>
<string name="whitelist_tab_wifi">Wi-Fi</string>
<string name="whitelist_wifi_filter_toggle">Filtrer aussi le Wi-Fi</string>
<string name="whitelist_wifi_filter_help">Par défaut, le Wi-Fi n\'est jamais filtré. Activez pour restreindre l\'accès Wi-Fi aux applications cochées ci-dessous.</string>
<string name="whitelist_select_all">Tout sélectionner</string>
<string name="whitelist_select_none">Tout désélectionner</string>
<!-- Notification -->
<string name="notification_channel_name">Surveillance des données</string>