diff --git a/DataControl-0.1.0.apk b/DataControl-0.1.0.apk index c124c8f..bf89686 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/DataControlApp.kt b/app/src/main/java/com/jttools/datacontrol/DataControlApp.kt index 20095bc..3b2ef2f 100644 --- a/app/src/main/java/com/jttools/datacontrol/DataControlApp.kt +++ b/app/src/main/java/com/jttools/datacontrol/DataControlApp.kt @@ -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(15, TimeUnit.MINUTES).build() + WorkManager.getInstance(this).enqueueUniquePeriodicWork( + DataMonitorRestartWorker.WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + request + ) + } +} 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 7600be8..0033034 100644 --- a/app/src/main/java/com/jttools/datacontrol/data/AppSettings.kt +++ b/app/src/main/java/com/jttools/datacontrol/data/AppSettings.kt @@ -15,6 +15,8 @@ data class AppSettings( val cutoffIntervalMinutes: Int = 15, val baselineMobileBytesAtReset: Long = 0L, val whitelistedPackages: Set = emptySet(), + val whitelistedPackagesWifi: Set = emptySet(), + val wifiFilterEnabled: Boolean = false, val showSystemApps: Boolean = false, val passwordHash: String? = null, val passwordSalt: String? = null, 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 3fb6538..0bd5ffe 100644 --- a/app/src/main/java/com/jttools/datacontrol/data/SettingsRepository.kt +++ b/app/src/main/java/com/jttools/datacontrol/data/SettingsRepository.kt @@ -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) { + 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()) { diff --git a/app/src/main/java/com/jttools/datacontrol/service/DataMonitorRestartWorker.kt b/app/src/main/java/com/jttools/datacontrol/service/DataMonitorRestartWorker.kt new file mode 100644 index 0000000..20c19b8 --- /dev/null +++ b/app/src/main/java/com/jttools/datacontrol/service/DataMonitorRestartWorker.kt @@ -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" + } +} diff --git a/app/src/main/java/com/jttools/datacontrol/service/DataMonitorService.kt b/app/src/main/java/com/jttools/datacontrol/service/DataMonitorService.kt index e38f4b0..098c4b0 100644 --- a/app/src/main/java/com/jttools/datacontrol/service/DataMonitorService.kt +++ b/app/src/main/java/com/jttools/datacontrol/service/DataMonitorService.kt @@ -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 } 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 956d527..8d7bc10 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 @@ -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 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 fd2526c..ebbf422 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 @@ -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) } } 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 3314b18..5083930 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 @@ -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) } ) } } 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 a35376a..d19ba03 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 @@ -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, 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) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c55b251..a93f077 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -37,6 +37,8 @@ Réglages Forfait Forfait data (Mo) + Mo + Consommation actuelle Prix du forfait (€) Prix par Mo hors-forfait (€) Date de début du forfait @@ -91,6 +93,12 @@ Ces applications gardent toujours accès aux données mobiles, y compris en dépassement de forfait (sauf pendant les coupures cycliques totales). Rechercher une application Afficher les applications système + Données mobiles + Wi-Fi + Filtrer aussi le Wi-Fi + Par défaut, le Wi-Fi n\'est jamais filtré. Activez pour restreindre l\'accès Wi-Fi aux applications cochées ci-dessous. + Tout sélectionner + Tout désélectionner Surveillance des données