DataControl v0.1.0 : contrôle du forfait data mobile via pare-feu VPN local

Surveillance de la consommation de données mobiles avec coupure automatique
en cas de dépassement de forfait, sans root ni impact sur le Wi-Fi.

- Pare-feu local (VpnService) actif uniquement sur données mobiles
- Liste blanche d'applications toujours autorisées
- Coupure cyclique totale après dépassement (intervalle paramétrable)
- Réglages complets (forfait, prix, date de début, reset auto/manuel)
- Estimation du coût total en temps réel
- Notification permanente colorée par état
- UI Compose Material 3

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
JohnnyandClaude Sonnet 5 committed 2026-07-02 18:37:45 +02:00
1 parent a71026898b
commit 536a981f9e
57 files changed
+2871

No files matched your search

@@ -0,0 +1,10 @@
package com.jttools.datacontrol.data
import android.graphics.drawable.Drawable
data class AppInfo(
val packageName: String,
val label: String,
val icon: Drawable?,
val isSystemApp: Boolean
)
@@ -0,0 +1,51 @@
package com.jttools.datacontrol.data
import java.time.LocalDate
/**
* Réglages persistés de l'application, avec leurs valeurs par défaut.
*/
data class AppSettings(
val controlEnabled: Boolean = false,
val planLimitMo: Long = 5000L,
val planPriceEuro: Double = 19.99,
val overagePricePerMoEuro: Double = 0.01,
val planStartDateEpochDay: Long = LocalDate.now().toEpochDay(),
val autoResetEnabled: Boolean = true,
val cutoffIntervalMinutes: Int = 15,
val baselineMobileBytesAtReset: Long = 0L,
val whitelistedPackages: Set<String> = emptySet(),
val showSystemApps: Boolean = false
) {
val planStartDate: LocalDate get() = LocalDate.ofEpochDay(planStartDateEpochDay)
/** Prochaine date de réinitialisation automatique (anniversaire mensuel du début de forfait). */
fun nextAutoResetDate(): LocalDate {
var next = planStartDate.plusMonths(1)
val today = LocalDate.now()
while (!next.isAfter(today)) {
next = next.plusMonths(1)
}
return next
}
}
/** État calculé de la consommation, dérivé des réglages + de la mesure de données réelle. */
data class UsageState(
val consumedMo: Long,
val planLimitMo: Long,
val overageMo: Long,
val isOverPlan: Boolean,
val estimatedTotalPriceEuro: Double
) {
val percentOfPlan: Float
get() = if (planLimitMo <= 0) 0f else (consumedMo.toFloat() / planLimitMo.toFloat()).coerceAtLeast(0f)
}
enum class DataState {
OK, // sous le forfait
WARNING, // proche du forfait (>= 80%)
OVERRUN_FILTER, // dépassé, liste blanche seule active
OVERRUN_CUT, // dépassé, coupure totale cyclique en cours
WIFI // Wi-Fi actif, aucun filtrage appliqué
}
@@ -0,0 +1,34 @@
package com.jttools.datacontrol.data
import android.content.Context
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class InstalledAppsRepository @Inject constructor(
@ApplicationContext private val context: Context
) {
@Suppress("DEPRECATION")
suspend fun listInstalledApps(): List<AppInfo> = withContext(Dispatchers.Default) {
val pm = context.packageManager
val ownPackage = context.packageName
pm.getInstalledApplications(PackageManager.GET_META_DATA)
.asSequence()
.filter { it.packageName != ownPackage }
.map { appInfo ->
AppInfo(
packageName = appInfo.packageName,
label = pm.getApplicationLabel(appInfo).toString(),
icon = runCatching { pm.getApplicationIcon(appInfo) }.getOrNull(),
isSystemApp = (appInfo.flags and ApplicationInfo.FLAG_SYSTEM) != 0
)
}
.sortedBy { it.label.lowercase() }
.toList()
}
}
@@ -0,0 +1,98 @@
package com.jttools.datacontrol.data
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
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.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.time.LocalDate
import javax.inject.Inject
import javax.inject.Singleton
private val Context.dataStore by preferencesDataStore(name = "datacontrol_settings")
@Singleton
class SettingsRepository @Inject constructor(
@ApplicationContext private val context: Context
) {
private object Keys {
val CONTROL_ENABLED = booleanPreferencesKey("control_enabled")
val PLAN_LIMIT_MO = longPreferencesKey("plan_limit_mo")
val PLAN_PRICE_EURO = doublePreferencesKey("plan_price_euro")
val OVERAGE_PRICE_PER_MO = doublePreferencesKey("overage_price_per_mo")
val PLAN_START_DATE_EPOCH_DAY = longPreferencesKey("plan_start_date_epoch_day")
val AUTO_RESET_ENABLED = booleanPreferencesKey("auto_reset_enabled")
val CUTOFF_INTERVAL_MINUTES = intPreferencesKey("cutoff_interval_minutes")
val BASELINE_MOBILE_BYTES = longPreferencesKey("baseline_mobile_bytes")
val WHITELISTED_PACKAGES = stringSetPreferencesKey("whitelisted_packages")
val SHOW_SYSTEM_APPS = booleanPreferencesKey("show_system_apps")
}
val settingsFlow: Flow<AppSettings> = context.dataStore.data.map { prefs ->
val defaults = AppSettings()
AppSettings(
controlEnabled = prefs[Keys.CONTROL_ENABLED] ?: defaults.controlEnabled,
planLimitMo = prefs[Keys.PLAN_LIMIT_MO] ?: defaults.planLimitMo,
planPriceEuro = prefs[Keys.PLAN_PRICE_EURO] ?: defaults.planPriceEuro,
overagePricePerMoEuro = prefs[Keys.OVERAGE_PRICE_PER_MO] ?: defaults.overagePricePerMoEuro,
planStartDateEpochDay = prefs[Keys.PLAN_START_DATE_EPOCH_DAY] ?: defaults.planStartDateEpochDay,
autoResetEnabled = prefs[Keys.AUTO_RESET_ENABLED] ?: defaults.autoResetEnabled,
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
)
}
suspend fun setControlEnabled(enabled: Boolean) {
context.dataStore.edit { it[Keys.CONTROL_ENABLED] = enabled }
}
suspend fun updatePlan(
planLimitMo: Long,
planPriceEuro: Double,
overagePricePerMoEuro: Double,
cutoffIntervalMinutes: Int,
autoResetEnabled: Boolean
) {
context.dataStore.edit { prefs ->
prefs[Keys.PLAN_LIMIT_MO] = planLimitMo
prefs[Keys.PLAN_PRICE_EURO] = planPriceEuro
prefs[Keys.OVERAGE_PRICE_PER_MO] = overagePricePerMoEuro
prefs[Keys.CUTOFF_INTERVAL_MINUTES] = cutoffIntervalMinutes
prefs[Keys.AUTO_RESET_ENABLED] = autoResetEnabled
}
}
/** Change uniquement la date de début du cycle (référence pour le reset automatique), sans toucher au compteur en cours. */
suspend fun setStartDate(date: LocalDate) {
context.dataStore.edit { it[Keys.PLAN_START_DATE_EPOCH_DAY] = date.toEpochDay() }
}
suspend fun setWhitelistedPackages(packages: Set<String>) {
context.dataStore.edit { it[Keys.WHITELISTED_PACKAGES] = packages }
}
suspend fun setShowSystemApps(show: Boolean) {
context.dataStore.edit { it[Keys.SHOW_SYSTEM_APPS] = show }
}
/** Réinitialise le compteur de consommation : nouvelle date de début + nouvelle base de référence. */
suspend fun resetPlan(currentMobileBytes: Long, startDate: LocalDate = LocalDate.now()) {
context.dataStore.edit { prefs ->
prefs[Keys.PLAN_START_DATE_EPOCH_DAY] = startDate.toEpochDay()
prefs[Keys.BASELINE_MOBILE_BYTES] = currentMobileBytes
}
}
/** Corrige la base de référence sans changer la date de début (ex: compteur système remis à zéro après redémarrage). */
suspend fun rebaseline(currentMobileBytes: Long) {
context.dataStore.edit { it[Keys.BASELINE_MOBILE_BYTES] = currentMobileBytes }
}
}
@@ -0,0 +1,46 @@
package com.jttools.datacontrol.data
import android.net.TrafficStats
import javax.inject.Inject
import javax.inject.Singleton
/**
* Mesure la consommation de données mobiles via TrafficStats (compteur cumulatif depuis
* le dernier redémarrage de l'appareil, aucune permission spéciale requise).
*
* Le compteur total est comparé à une "baseline" capturée au dernier reset du forfait
* (voir [SettingsRepository.resetPlan]) pour obtenir la consommation depuis le début du cycle.
*/
@Singleton
class UsageRepository @Inject constructor() {
/** Total d'octets mobiles (émis + reçus) depuis le dernier redémarrage de l'appareil. -1 si indisponible. */
fun totalMobileBytes(): Long {
val rx = TrafficStats.getMobileRxBytes()
val tx = TrafficStats.getMobileTxBytes()
if (rx == TrafficStats.UNSUPPORTED.toLong() || tx == TrafficStats.UNSUPPORTED.toLong()) {
return -1L
}
return rx + tx
}
/**
* Octets consommés depuis [baselineBytes]. Si le compteur système est inférieur à la baseline
* (redémarrage de l'appareil détecté), retourne 0 — l'appelant doit alors rebasculer la baseline.
*/
fun consumedBytesSince(baselineBytes: Long): Long {
val total = totalMobileBytes()
if (total < 0) return 0L
return (total - baselineBytes).coerceAtLeast(0L)
}
fun hasCounterReset(baselineBytes: Long): Boolean {
val total = totalMobileBytes()
return total in 0 until baselineBytes
}
companion object {
fun bytesToMo(bytes: Long): Long = bytes / (1024L * 1024L)
fun moToBytes(mo: Long): Long = mo * 1024L * 1024L
}
}