diff --git a/DataControl-0.1.0.apk b/DataControl-0.1.0.apk index d4bcf80..c124c8f 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 100d87b..7600be8 100644 --- a/app/src/main/java/com/jttools/datacontrol/data/AppSettings.kt +++ b/app/src/main/java/com/jttools/datacontrol/data/AppSettings.kt @@ -17,7 +17,10 @@ data class AppSettings( val whitelistedPackages: Set = emptySet(), val showSystemApps: Boolean = false, val passwordHash: String? = null, - val passwordSalt: String? = null + val passwordSalt: String? = null, + val cutoffCycleAnchorMillis: Long? = null, + val cutoffInPulse: Boolean = false, + val cutoffPulseStartMillis: Long = 0L ) { val planStartDate: LocalDate get() = LocalDate.ofEpochDay(planStartDateEpochDay) 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 08d2a2f..3fb6538 100644 --- a/app/src/main/java/com/jttools/datacontrol/data/SettingsRepository.kt +++ b/app/src/main/java/com/jttools/datacontrol/data/SettingsRepository.kt @@ -36,6 +36,9 @@ class SettingsRepository @Inject constructor( val SHOW_SYSTEM_APPS = booleanPreferencesKey("show_system_apps") val PASSWORD_HASH = stringPreferencesKey("password_hash") val PASSWORD_SALT = stringPreferencesKey("password_salt") + val CUTOFF_ANCHOR_MILLIS = longPreferencesKey("cutoff_anchor_millis") + val CUTOFF_IN_PULSE = booleanPreferencesKey("cutoff_in_pulse") + val CUTOFF_PULSE_START_MILLIS = longPreferencesKey("cutoff_pulse_start_millis") } val settingsFlow: Flow = context.dataStore.data.map { prefs -> @@ -52,7 +55,10 @@ class SettingsRepository @Inject constructor( whitelistedPackages = prefs[Keys.WHITELISTED_PACKAGES] ?: defaults.whitelistedPackages, showSystemApps = prefs[Keys.SHOW_SYSTEM_APPS] ?: defaults.showSystemApps, passwordHash = prefs[Keys.PASSWORD_HASH], - passwordSalt = prefs[Keys.PASSWORD_SALT] + passwordSalt = prefs[Keys.PASSWORD_SALT], + cutoffCycleAnchorMillis = prefs[Keys.CUTOFF_ANCHOR_MILLIS], + cutoffInPulse = prefs[Keys.CUTOFF_IN_PULSE] ?: defaults.cutoffInPulse, + cutoffPulseStartMillis = prefs[Keys.CUTOFF_PULSE_START_MILLIS] ?: defaults.cutoffPulseStartMillis ) } @@ -126,4 +132,17 @@ class SettingsRepository @Inject constructor( val salt = prefs[Keys.PASSWORD_SALT] ?: return true return PasswordHasher.hash(password, salt) == hash } + + /** + * Persiste l'état du cycle de coupure automatique, pour que le minuteur ne reparte pas de + * zéro si [DataMonitorService] est tué puis relancé par le système avant la fin de l'intervalle + * (fréquent sur les ROM qui tuent agressivement les apps en arrière-plan sans exemption batterie). + */ + suspend fun setCutoffCycleState(anchorMillis: Long?, inPulse: Boolean, pulseStartMillis: Long) { + context.dataStore.edit { prefs -> + if (anchorMillis == null) prefs.remove(Keys.CUTOFF_ANCHOR_MILLIS) else prefs[Keys.CUTOFF_ANCHOR_MILLIS] = anchorMillis + prefs[Keys.CUTOFF_IN_PULSE] = inPulse + prefs[Keys.CUTOFF_PULSE_START_MILLIS] = pulseStartMillis + } + } } 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 19f5976..e38f4b0 100644 --- a/app/src/main/java/com/jttools/datacontrol/service/DataMonitorService.kt +++ b/app/src/main/java/com/jttools/datacontrol/service/DataMonitorService.kt @@ -57,6 +57,7 @@ class DataMonitorService : LifecycleService() { private var cutoffCycleAnchorMillis: Long? = null private var inPulse = false private var pulseStartMillis: Long = 0L + private var settingsLoaded = false private val vpnRevokedReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { @@ -77,6 +78,15 @@ class DataMonitorService : LifecycleService() { lifecycleScope.launch { settingsRepository.settingsFlow.collect { + // Ne recopier l'état persisté du minuteur de coupure qu'à la toute première valeur + // réelle lue en base — pas question d'écraser avec les valeurs par défaut si un + // tick() déclenché par une autre coroutine s'exécute avant cette première émission. + if (!settingsLoaded) { + cutoffCycleAnchorMillis = it.cutoffCycleAnchorMillis + inPulse = it.cutoffInPulse + pulseStartMillis = it.cutoffPulseStartMillis + settingsLoaded = true + } latestSettings = it if (!it.controlEnabled) vpnRevoked = false tick() @@ -135,12 +145,17 @@ class DataMonitorService : LifecycleService() { } else { val now = System.currentTimeMillis() val intervalMs = settings.cutoffIntervalMinutes * 60_000L - val anchor = cutoffCycleAnchorMillis ?: now.also { cutoffCycleAnchorMillis = it } + if (cutoffCycleAnchorMillis == null) { + cutoffCycleAnchorMillis = now + persistCutoffState() + } + val anchor = cutoffCycleAnchorMillis!! if (inPulse) { if (now - pulseStartMillis >= PULSE_DURATION_MS) { inPulse = false cutoffCycleAnchorMillis = now + persistCutoffState() applyVpnMode(VpnMode.FILTER, settings.whitelistedPackages) } else { applyVpnMode(VpnMode.BLOCK_ALL, emptySet()) @@ -148,6 +163,7 @@ class DataMonitorService : LifecycleService() { } else if (now - anchor >= intervalMs) { inPulse = true pulseStartMillis = now + persistCutoffState() applyVpnMode(VpnMode.BLOCK_ALL, emptySet()) } else { applyVpnMode(VpnMode.FILTER, settings.whitelistedPackages) @@ -180,8 +196,17 @@ class DataMonitorService : LifecycleService() { } private fun resetCutoffCycle() { + if (cutoffCycleAnchorMillis == null && !inPulse) return cutoffCycleAnchorMillis = null inPulse = false + persistCutoffState() + } + + private fun persistCutoffState() { + val anchor = cutoffCycleAnchorMillis + val pulse = inPulse + val pulseStart = pulseStartMillis + lifecycleScope.launch { settingsRepository.setCutoffCycleState(anchor, pulse, pulseStart) } } private fun maybeAutoReset(settings: AppSettings) { 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 index a742f62..473cfd1 100644 --- a/app/src/main/java/com/jttools/datacontrol/ui/about/AboutScreen.kt +++ b/app/src/main/java/com/jttools/datacontrol/ui/about/AboutScreen.kt @@ -20,11 +20,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap 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 androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.toBitmap import com.jttools.datacontrol.R @Composable @@ -34,6 +36,15 @@ fun AboutScreen() { runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName } .getOrNull() ?: "?" } + // R.mipmap.ic_launcher est une (avant-plan/arrière-plan séparés) : painterResource() + // ne sait pas la charger directement (IllegalArgumentException à l'exécution), il faut la rasteriser. + val iconBitmap = remember { + runCatching { + ContextCompat.getDrawable(context, R.mipmap.ic_launcher) + ?.toBitmap(width = 144, height = 144) + ?.asImageBitmap() + }.getOrNull() + } Column( modifier = Modifier @@ -52,11 +63,13 @@ fun AboutScreen() { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp) ) { - Image( - painter = painterResource(R.mipmap.ic_launcher), - contentDescription = null, - modifier = Modifier.size(72.dp) - ) + if (iconBitmap != null) { + Image( + bitmap = iconBitmap, + contentDescription = null, + modifier = Modifier.size(72.dp) + ) + } Text( stringResource(R.string.app_name), style = MaterialTheme.typography.titleLarge,