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:
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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user