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:
Johnny
2026-07-02 18:37:45 +02:00
parent a71026898b
commit 485d21344e
58 changed files with 2871 additions and 0 deletions

13
.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
*.iml
.gradle/
/local.properties
/.idea/
.DS_Store
/build/
/app/build/
/captures/
.externalNativeBuild/
.cxx/
local.properties
*.apk
!DataControl-*.apk

15
CHANGELOG.md Normal file
View File

@@ -0,0 +1,15 @@
# Changelog
## v0.1.0 2026-07-02
Première version.
- Pare-feu local basé sur `VpnService` (sans root), actif uniquement sur données mobiles — le Wi-Fi n'est jamais filtré.
- Liste blanche d'applications autorisées à accéder aux données mobiles, active en permanence.
- Coupure totale cyclique (toutes les X minutes, paramétrable) une fois le forfait dépassé.
- Réglages du forfait : volume (Mo), prix du forfait, prix par Mo hors-forfait, date de début.
- Réinitialisation automatique mensuelle du compteur + réinitialisation manuelle.
- Estimation du coût total en temps réel (forfait + hors-forfait).
- Notification permanente avec code couleur d'état et rafraîchissement automatique.
- Redémarrage automatique de la surveillance après reboot de l'appareil.
- Interface Material 3 (thème clair/sombre, couleurs dynamiques Android 12+).

BIN
DataControl-0.1.0.apk Normal file

Binary file not shown.

82
Makefile Normal file
View File

@@ -0,0 +1,82 @@
## DataControl Android Build & Release Makefile
## Author: JT-Tools by Johnny
VERSION_NAME := $(shell grep 'versionName' version.properties | cut -d= -f2 | tr -d ' ')
VERSION_CODE := $(shell grep 'versionCode' version.properties | cut -d= -f2 | tr -d ' ')
GRADLEW := ./gradlew
APK_RELEASE := app/build/outputs/apk/release/app-release.apk
APK_DEBUG := app/build/outputs/apk/debug/app-debug.apk
OUTPUT_APK := DataControl-$(VERSION_NAME).apk
.DEFAULT_GOAL := help
.PHONY: help build release install push version-bump changelog clean
## help: Show this help message
help:
@echo "DataControl v$(VERSION_NAME) (build $(VERSION_CODE))"
@echo ""
@echo "Available targets:"
@grep -E '^## [a-z]' Makefile | sed 's/## / make /' | column -t -s ':'
## build: Build debug APK
build:
@echo "[BUILD] Building debug APK..."
$(GRADLEW) assembleDebug
@echo "[BUILD] Done: $(APK_DEBUG)"
## release: Build release APK and copy to project root
release:
@echo "[RELEASE] Building release APK for v$(VERSION_NAME) ($(VERSION_CODE))..."
$(GRADLEW) assembleRelease
@cp $(APK_RELEASE) $(OUTPUT_APK)
@echo "[RELEASE] Done: $(OUTPUT_APK)"
## install: Install debug APK on connected device
install:
@echo "[INSTALL] Installing debug APK..."
$(GRADLEW) installDebug
@echo "[INSTALL] Done."
## push: Git add, commit, tag and push (uses current version)
push:
@echo "[PUSH] Committing and pushing v$(VERSION_NAME)..."
git add -A
git commit -m "Release v$(VERSION_NAME) (build $(VERSION_CODE))"
git tag -a "v$(VERSION_NAME)" -m "Release v$(VERSION_NAME)"
git push origin main
git push origin "v$(VERSION_NAME)"
@echo "[PUSH] Done."
## version-bump: Increment versionCode and update versionName patch
version-bump:
@NEW_CODE=$$(( $(VERSION_CODE) + 1 )); \
PATCH=$$(echo $(VERSION_NAME) | cut -d. -f3); \
MAJOR=$$(echo $(VERSION_NAME) | cut -d. -f1); \
MINOR=$$(echo $(VERSION_NAME) | cut -d. -f2); \
NEW_PATCH=$$(( PATCH + 1 )); \
NEW_VERSION="$${MAJOR}.$${MINOR}.$${NEW_PATCH}"; \
sed -i "s/versionName=.*/versionName=$${NEW_VERSION}/" version.properties; \
sed -i "s/versionCode=.*/versionCode=$${NEW_CODE}/" version.properties; \
echo "[VERSION-BUMP] Bumped to $${NEW_VERSION} (build $${NEW_CODE})"
## changelog: Generate a changelog entry from git log since last tag
changelog:
@echo "[CHANGELOG] Generating entries since last tag..."
@LAST_TAG=$$(git describe --tags --abbrev=0 2>/dev/null || echo ""); \
if [ -z "$$LAST_TAG" ]; then \
git log --oneline --pretty=format:"- %s" >> CHANGELOG.md; \
else \
echo "" >> CHANGELOG.md; \
echo "## v$(VERSION_NAME) $$(date +%Y-%m-%d)" >> CHANGELOG.md; \
git log $$LAST_TAG..HEAD --oneline --pretty=format:"- %s" >> CHANGELOG.md; \
fi; \
echo "[CHANGELOG] Done."
## clean: Clean build artifacts
clean:
@echo "[CLEAN] Cleaning build artifacts..."
$(GRADLEW) clean
@rm -f DataControl-*.apk
@echo "[CLEAN] Done."

View File

@@ -0,0 +1,81 @@
# DataControl
**DataControl** est une application Android qui surveille en arrière-plan la consommation de données mobiles et coupe automatiquement l'accès à Internet en cas de dépassement du forfait — sans jamais toucher au Wi-Fi.
> JT-Tools by Johnny — v0.1.0
---
## Fonctionnalités
- **Suivi de la consommation** — Mesure en continu les données mobiles consommées depuis le début du cycle de forfait (`TrafficStats`, aucune permission spéciale requise).
- **Forfait configurable** — Volume du forfait (Mo), prix du forfait (€), prix par Mo hors-forfait (€), date de début du cycle.
- **Estimation du coût** — Calcul automatique : prix du forfait + (Mo hors-forfait × prix/Mo), affiché en temps réel sur le tableau de bord.
- **Réinitialisation** — Automatique chaque mois à la date anniversaire du début de forfait, ou manuelle à tout moment depuis le tableau de bord.
- **Liste blanche d'applications** — Les applications cochées gardent toujours accès aux données mobiles ; toutes les autres en sont privées en permanence (voir *Comportement du pare-feu* ci-dessous). Le Wi-Fi n'est jamais filtré.
- **Coupure cyclique en cas de dépassement** — Une fois le forfait dépassé, une coupure totale (même pour la liste blanche) se déclenche automatiquement toutes les X minutes (paramétrable), pendant une courte pulsation, avant de revenir en mode "liste blanche seule".
- **Notification permanente** — Affiche la consommation (Mo / forfait / %), l'état des données avec un code couleur (🟢 sous le forfait, 🟠 proche du forfait, 🔴 hors-forfait/coupure, 🔵 Wi-Fi actif), et le coût estimé. Rafraîchie toutes les 20 secondes et à chaque changement de réseau.
- **Démarrage au boot** — Le service redémarre automatiquement après un redémarrage de l'appareil si le contrôle était actif.
- **Interface Material 3** — Thème clair/sombre avec couleurs dynamiques (Android 12+), 3 écrans : Tableau de bord, Réglages, Liste blanche.
---
## Comportement du pare-feu (important)
Android interdit depuis longtemps aux applications normales de couper les données mobiles par programmation (l'API historique a été bloquée depuis Android 5). DataControl **ne nécessite ni root ni Shizuku** : il utilise un pare-feu local basé sur `VpnService` (comme NetGuard), qui ne route ni ne transmet aucun paquet — il capture le trafic des applications non autorisées dans un tunnel VPN local et le laisse silencieusement tomber ("trou noir"), tandis que les applications autorisées sont explicitement exclues du tunnel (`addDisallowedApplication`) et continuent de communiquer normalement.
Règles appliquées :
| Réseau actif | Sous le forfait | Forfait dépassé |
|---|---|---|
| **Wi-Fi** | Aucun filtrage, jamais | Aucun filtrage, jamais |
| **Données mobiles** | Liste blanche active en permanence : seules les apps cochées ont accès | Liste blanche active **+** coupure totale cyclique toutes les X minutes (pulsation de 30 s, même les apps de la liste blanche sont coupées pendant la pulsation) |
Le pare-feu est démarré/arrêté automatiquement selon le transport réseau actif détecté (`ConnectivityManager.NetworkCallback`) : dès que l'appareil bascule sur le Wi-Fi, le tunnel VPN est entièrement fermé.
---
## Autorisations requises
| Permission | Usage |
|---|---|
| VPN (consentement système) | Établir le pare-feu local — demandé au premier `Activer le contrôle` |
| Notifications (Android 13+) | Afficher la notification permanente d'état |
| Ignorer l'optimisation de la batterie | Éviter que le système ne tue le service de surveillance |
| `QUERY_ALL_PACKAGES` | Lister toutes les applications installées dans l'écran Liste blanche |
| `RECEIVE_BOOT_COMPLETED` | Relancer la surveillance après redémarrage si le contrôle était actif |
---
## Limites connues
- Le compteur de consommation repose sur `TrafficStats` (cumul depuis le dernier redémarrage de l'appareil). Un redémarrage réinitialise ce compteur système ; DataControl détecte cette situation et rebascule automatiquement sa référence à 0 pour éviter un calcul négatif, mais la consommation entre le redémarrage et la détection n'est pas comptabilisée.
- `QUERY_ALL_PACKAGES` est soumis à restriction sur le Google Play Store pour les apps grand public ; sans distribution via le Store, cela ne pose pas de problème.
- Le pare-feu VPN local est incompatible avec l'usage simultané d'un autre VPN (limitation système Android : un seul VPN actif à la fois).
---
## Architecture
- **Kotlin + Jetpack Compose (Material 3)**, Hilt pour l'injection de dépendances.
- `data/``SettingsRepository` (DataStore Preferences), `UsageRepository` (TrafficStats), `InstalledAppsRepository` (PackageManager).
- `service/``FirewallVpnService` (VpnService), `ConnectivityObserver` (détection Wi-Fi/cellulaire), `DataMonitorService` (orchestration + notification), `MonitorStateHolder` (pont service ↔ UI), `BootReceiver`.
- `ui/` — écrans Compose (`dashboard`, `settings`, `whitelist`), navigation par `NavigationBar`, thème dynamique.
---
## Build
```bash
make build # APK debug
make release # APK release signé, copié en DataControl-<version>.apk
make install # Installe le debug sur un appareil connecté (adb)
```
Prérequis : Android SDK (API 34), JDK 17. Le wrapper Gradle télécharge Gradle 8.13 au premier lancement.
---
## Licence
Projet personnel — JT-Tools by Johnny.

98
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,98 @@
import java.util.Properties
import java.util.Date
import java.text.SimpleDateFormat
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.hilt.android)
alias(libs.plugins.kotlin.kapt)
}
val versionProps = Properties().apply {
load(rootProject.file("version.properties").inputStream())
}
android {
namespace = "com.jttools.datacontrol"
compileSdk = 34
defaultConfig {
applicationId = "com.jttools.datacontrol"
minSdk = 26
targetSdk = 34
versionCode = versionProps.getProperty("versionCode").toInt()
versionName = versionProps.getProperty("versionName")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
buildConfigField("String", "BUILD_DATE", "\"${SimpleDateFormat("yyyy-MM-dd").format(Date())}\"")
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
signingConfig = signingConfigs.getByName("debug")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.14"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.material.icons.extended)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.service)
implementation(libs.androidx.navigation.compose)
implementation(libs.hilt.android)
kapt(libs.hilt.android.compiler)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.datastore.preferences)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.androidx.work.runtime.ktx)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
}

9
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,9 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
# Keep data models (DataStore / VPN service reflection safety)
-keep class com.jttools.datacontrol.data.** { *; }
# Hilt
-dontwarn dagger.hilt.**

View File

@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!-- Nécessaire pour lister TOUTES les apps installées dans l'écran "Liste blanche" -->
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<application
android:name=".DataControlApp"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.DataControl">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.DataControl">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".service.FirewallVpnService"
android:permission="android.permission.BIND_VPN_SERVICE"
android:exported="false"
android:foregroundServiceType="dataSync">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
</service>
<service
android:name=".service.DataMonitorService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<receiver
android:name=".service.BootReceiver"
android:exported="true"
android:enabled="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application>
</manifest>

View File

@@ -0,0 +1,7 @@
package com.jttools.datacontrol
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class DataControlApp : Application()

View File

@@ -0,0 +1,27 @@
package com.jttools.datacontrol
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import com.jttools.datacontrol.ui.SystemActions
import com.jttools.datacontrol.ui.navigation.DataControlNavHost
import com.jttools.datacontrol.ui.theme.DataControlTheme
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
private lateinit var systemActions: SystemActions
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
systemActions = SystemActions(this)
enableEdgeToEdge()
setContent {
DataControlTheme {
DataControlNavHost(systemActions = systemActions)
}
}
}
}

View File

@@ -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
)

View File

@@ -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é
}

View File

@@ -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()
}
}

View File

@@ -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 }
}
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,39 @@
package com.jttools.datacontrol.service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.jttools.datacontrol.data.SettingsRepository
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.first
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.hilt.EntryPoint
class BootReceiver : BroadcastReceiver() {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface BootReceiverEntryPoint {
fun settingsRepository(): SettingsRepository
}
override fun onReceive(context: Context, intent: Intent?) {
if (intent?.action != Intent.ACTION_BOOT_COMPLETED) return
val appContext = context.applicationContext
val entryPoint = EntryPointAccessors.fromApplication(appContext, BootReceiverEntryPoint::class.java)
val pendingResult = goAsync()
CoroutineScope(Dispatchers.Default).launch {
try {
val enabled = entryPoint.settingsRepository().settingsFlow.first().controlEnabled
if (enabled) DataMonitorService.start(appContext)
} finally {
pendingResult.finish()
}
}
}
}

View File

@@ -0,0 +1,56 @@
package com.jttools.datacontrol.service
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import javax.inject.Inject
import javax.inject.Singleton
enum class NetworkTransport { CELLULAR, WIFI, OTHER, NONE }
/**
* Observe le réseau par défaut de l'appareil et expose son transport (cellulaire / Wi-Fi / autre).
* Le pare-feu VPN ne doit être actif QUE lorsque le transport actif est CELLULAR — sur Wi-Fi,
* l'accès Internet de toutes les applications reste totalement libre.
*/
@Singleton
class ConnectivityObserver @Inject constructor(
@ApplicationContext private val context: Context
) {
fun observe(): Flow<NetworkTransport> = callbackFlow {
val cm = context.getSystemService(ConnectivityManager::class.java)
fun transportOf(caps: NetworkCapabilities?): NetworkTransport = when {
caps == null -> NetworkTransport.NONE
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> NetworkTransport.CELLULAR
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> NetworkTransport.WIFI
else -> NetworkTransport.OTHER
}
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
trySend(transportOf(caps))
}
override fun onLost(network: Network) {
trySend(NetworkTransport.NONE)
}
}
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
trySend(transportOf(cm.getNetworkCapabilities(cm.activeNetwork)))
cm.registerNetworkCallback(request, callback)
awaitClose { cm.unregisterNetworkCallback(callback) }
}.distinctUntilChanged()
}

View File

@@ -0,0 +1,281 @@
package com.jttools.datacontrol.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.Color
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import com.jttools.datacontrol.MainActivity
import com.jttools.datacontrol.R
import com.jttools.datacontrol.data.AppSettings
import com.jttools.datacontrol.data.DataState
import com.jttools.datacontrol.data.SettingsRepository
import com.jttools.datacontrol.data.UsageRepository
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.time.LocalDate
import javax.inject.Inject
import kotlin.math.max
/**
* Service de premier plan qui surveille en continu la consommation de données mobiles,
* pilote le pare-feu [FirewallVpnService] et met à jour la notification permanente.
*
* Règles appliquées (voir README) :
* - Sur Wi-Fi : aucun filtrage, jamais (le pare-feu est arrêté).
* - Sur données mobiles, sous le forfait : liste blanche déjà active en permanence
* (seules les apps cochées ont accès aux données mobiles).
* - Sur données mobiles, forfait dépassé : la liste blanche reste active, ET une coupure
* totale (même liste blanche) se déclenche cycliquement toutes les X minutes pendant
* une courte pulsation avant de revenir en mode "liste blanche seule".
*/
@AndroidEntryPoint
class DataMonitorService : LifecycleService() {
@Inject lateinit var settingsRepository: SettingsRepository
@Inject lateinit var usageRepository: UsageRepository
@Inject lateinit var connectivityObserver: ConnectivityObserver
@Inject lateinit var stateHolder: MonitorStateHolder
private var latestSettings: AppSettings = AppSettings()
private var latestTransport: NetworkTransport = NetworkTransport.NONE
private var vpnRevoked = false
private var lastAppliedMode: VpnMode? = null
private var lastAppliedWhitelist: Set<String> = emptySet()
private var cutoffCycleAnchorMillis: Long? = null
private var inPulse = false
private var pulseStartMillis: Long = 0L
private val vpnRevokedReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
vpnRevoked = true
lifecycleScope.launch { settingsRepository.setControlEnabled(false) }
}
}
private enum class VpnMode { FILTER, BLOCK_ALL, STOPPED }
override fun onCreate() {
super.onCreate()
ContextCompat.registerReceiver(
this, vpnRevokedReceiver, IntentFilter(FirewallVpnService.ACTION_VPN_REVOKED),
ContextCompat.RECEIVER_NOT_EXPORTED
)
startForeground(NOTIF_ID, buildNotification(stateHolder.snapshot.value))
lifecycleScope.launch {
settingsRepository.settingsFlow.collect {
latestSettings = it
if (!it.controlEnabled) vpnRevoked = false
tick()
}
}
lifecycleScope.launch {
connectivityObserver.observe().collect {
latestTransport = it
tick()
}
}
lifecycleScope.launch {
while (isActive) {
tick()
kotlinx.coroutines.delay(TICK_INTERVAL_MS)
}
}
}
private fun tick() {
val settings = latestSettings
if (!settings.controlEnabled) {
applyVpnMode(VpnMode.STOPPED, emptySet())
stateHolder.update { it.copy(serviceRunning = false) }
updateNotification(stateHolder.snapshot.value)
return
}
maybeAutoReset(settings)
val consumedBytes = usageRepository.consumedBytesSince(settings.baselineMobileBytesAtReset)
if (usageRepository.hasCounterReset(settings.baselineMobileBytesAtReset)) {
lifecycleScope.launch { settingsRepository.rebaseline(usageRepository.totalMobileBytes()) }
}
val consumedMo = UsageRepository.bytesToMo(consumedBytes)
val overageMo = max(0L, consumedMo - settings.planLimitMo)
val isOverPlan = consumedMo > settings.planLimitMo
val estimatedTotal = settings.planPriceEuro + overageMo * settings.overagePricePerMoEuro
val percent = if (settings.planLimitMo > 0) consumedMo.toFloat() / settings.planLimitMo else 0f
val dataState: DataState
var nextCutoffAt: Long? = null
when {
latestTransport == NetworkTransport.WIFI -> {
applyVpnMode(VpnMode.STOPPED, emptySet())
resetCutoffCycle()
dataState = DataState.WIFI
}
latestTransport == NetworkTransport.CELLULAR && !vpnRevoked && FirewallVpnService.isPrepared(this) -> {
if (!isOverPlan) {
resetCutoffCycle()
applyVpnMode(VpnMode.FILTER, settings.whitelistedPackages)
dataState = if (percent >= WARNING_THRESHOLD) DataState.WARNING else DataState.OK
} else {
val now = System.currentTimeMillis()
val intervalMs = settings.cutoffIntervalMinutes * 60_000L
val anchor = cutoffCycleAnchorMillis ?: now.also { cutoffCycleAnchorMillis = it }
if (inPulse) {
if (now - pulseStartMillis >= PULSE_DURATION_MS) {
inPulse = false
cutoffCycleAnchorMillis = now
applyVpnMode(VpnMode.FILTER, settings.whitelistedPackages)
} else {
applyVpnMode(VpnMode.BLOCK_ALL, emptySet())
}
} else if (now - anchor >= intervalMs) {
inPulse = true
pulseStartMillis = now
applyVpnMode(VpnMode.BLOCK_ALL, emptySet())
} else {
applyVpnMode(VpnMode.FILTER, settings.whitelistedPackages)
}
nextCutoffAt = if (inPulse) pulseStartMillis + PULSE_DURATION_MS else anchor + intervalMs
dataState = if (inPulse) DataState.OVERRUN_CUT else DataState.OVERRUN_FILTER
}
}
else -> {
// Pas de réseau, transport inconnu, ou VPN non préparé/révoqué : rien à filtrer.
applyVpnMode(VpnMode.STOPPED, emptySet())
resetCutoffCycle()
dataState = if (percent >= WARNING_THRESHOLD) DataState.WARNING else DataState.OK
}
}
stateHolder.update {
it.copy(
consumedMo = consumedMo,
planLimitMo = settings.planLimitMo,
overageMo = overageMo,
estimatedTotalPriceEuro = estimatedTotal,
dataState = dataState,
nextAutoResetDate = settings.nextAutoResetDate(),
nextCutoffAtMillis = nextCutoffAt,
serviceRunning = true
)
}
updateNotification(stateHolder.snapshot.value)
}
private fun resetCutoffCycle() {
cutoffCycleAnchorMillis = null
inPulse = false
}
private fun maybeAutoReset(settings: AppSettings) {
if (settings.autoResetEnabled && !LocalDate.now().isBefore(settings.nextAutoResetDate())) {
lifecycleScope.launch {
settingsRepository.resetPlan(usageRepository.totalMobileBytes(), settings.nextAutoResetDate())
}
}
}
private fun applyVpnMode(mode: VpnMode, whitelist: Set<String>) {
if (mode == lastAppliedMode && (mode != VpnMode.FILTER || whitelist == lastAppliedWhitelist)) return
lastAppliedMode = mode
lastAppliedWhitelist = whitelist
when (mode) {
VpnMode.STOPPED -> ContextCompat.startForegroundService(this, FirewallVpnService.stopIntent(this))
VpnMode.FILTER -> ContextCompat.startForegroundService(
this, FirewallVpnService.startFilterIntent(this, whitelist)
)
VpnMode.BLOCK_ALL -> ContextCompat.startForegroundService(
this, FirewallVpnService.startBlockAllIntent(this)
)
}
}
private fun buildNotification(snapshot: MonitorSnapshot): Notification {
val nm = getSystemService(NotificationManager::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_LOW
).apply { description = getString(R.string.notification_channel_description) }
nm.createNotificationChannel(channel)
}
val (emoji, stateLabel, color) = when {
!snapshot.serviceRunning -> Triple("", getString(R.string.dashboard_control_disabled), Color.parseColor("#757575"))
snapshot.dataState == DataState.OK -> Triple("🟢", getString(R.string.dashboard_state_ok), Color.parseColor("#2E7D32"))
snapshot.dataState == DataState.WARNING -> Triple("🟠", getString(R.string.dashboard_state_warning), Color.parseColor("#EF6C00"))
snapshot.dataState == DataState.OVERRUN_FILTER -> Triple("🔴", getString(R.string.dashboard_state_overrun_filtered), Color.parseColor("#C62828"))
snapshot.dataState == DataState.OVERRUN_CUT -> Triple("🔴", getString(R.string.dashboard_state_overrun_cut), Color.parseColor("#C62828"))
else -> Triple("🔵", getString(R.string.dashboard_state_wifi), Color.parseColor("#1565C0"))
}
val percentText = if (snapshot.planLimitMo > 0) {
"${(snapshot.consumedMo * 100 / snapshot.planLimitMo)}%"
} else ""
val priceText = String.format("%.2f", snapshot.estimatedTotalPriceEuro)
val contentText = "$emoji $stateLabel · ${snapshot.consumedMo}/${snapshot.planLimitMo} Mo ($percentText)"
val bigText = "$contentText\n${getString(R.string.dashboard_cost_total)} : $priceText"
val openApp = PendingIntent.getActivity(
this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.notification_title))
.setContentText(contentText)
.setStyle(NotificationCompat.BigTextStyle().bigText(bigText))
.setSmallIcon(R.drawable.ic_notification)
.setColor(color)
.setColorized(true)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setContentIntent(openApp)
.build()
}
private fun updateNotification(snapshot: MonitorSnapshot) {
val nm = getSystemService(NotificationManager::class.java)
nm.notify(NOTIF_ID, buildNotification(snapshot))
}
override fun onDestroy() {
runCatching { unregisterReceiver(vpnRevokedReceiver) }
super.onDestroy()
}
companion object {
private const val CHANNEL_ID = "datacontrol_monitor"
private const val NOTIF_ID = 1001
private const val TICK_INTERVAL_MS = 20_000L
private const val PULSE_DURATION_MS = 30_000L
private const val WARNING_THRESHOLD = 0.8f
fun start(context: Context) {
ContextCompat.startForegroundService(context, Intent(context, DataMonitorService::class.java))
}
fun stop(context: Context) {
context.stopService(Intent(context, DataMonitorService::class.java))
}
}
}

View File

@@ -0,0 +1,171 @@
package com.jttools.datacontrol.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.VpnService
import android.os.Build
import android.os.ParcelFileDescriptor
import android.util.Log
import androidx.core.app.NotificationCompat
import com.jttools.datacontrol.MainActivity
import com.jttools.datacontrol.R
import java.io.FileInputStream
/**
* Pare-feu local basé sur [VpnService], sans root.
*
* Ce service ne route ni ne transmet aucun paquet : les applications "autorisées" sont
* exclues du tunnel via [Builder.addDisallowedApplication] (leur trafic continue de
* transiter normalement par la vraie interface réseau). Toutes les autres applications
* voient leur trafic capturé dans le tunnel VPN puis silencieusement ignoré ("trou noir"),
* ce qui coupe leur accès Internet sans jamais toucher au Wi-Fi : ce service n'est démarré
* par [DataMonitorService] que lorsque le réseau actif est la donnée mobile.
*/
class FirewallVpnService : VpnService() {
private var tunInterface: ParcelFileDescriptor? = null
private var readerThread: Thread? = null
@Volatile private var running = false
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP -> {
stopTunnel()
stopSelf()
return START_NOT_STICKY
}
ACTION_START_FILTER, ACTION_START_BLOCK_ALL -> {
val allowed = intent.getStringArrayListExtra(EXTRA_ALLOWED_PACKAGES)?.toSet() ?: emptySet()
startForeground(NOTIF_ID, buildNotification())
establishTunnel(allowed)
}
}
return START_STICKY
}
private fun establishTunnel(allowedPackages: Set<String>) {
stopTunnel(keepForeground = true)
val builder = Builder()
.setSession("DataControl")
.addAddress(TUNNEL_ADDRESS, 32)
.addRoute("0.0.0.0", 0)
.setBlocking(true)
// Notre propre app doit toujours pouvoir communiquer normalement.
addDisallowed(builder, packageName)
allowedPackages.forEach { addDisallowed(builder, it) }
tunInterface = runCatching { builder.establish() }.getOrElse {
Log.e(TAG, "Échec établissement du tunnel VPN", it)
null
}
val fd = tunInterface ?: return
running = true
readerThread = Thread({ blackhole(fd) }, "datacontrol-vpn-blackhole").apply {
isDaemon = true
start()
}
}
private fun addDisallowed(builder: Builder, packageName: String) {
try {
builder.addDisallowedApplication(packageName)
} catch (e: PackageManager.NameNotFoundException) {
// Application désinstallée entre-temps : rien à exclure.
}
}
/** Lit et jette tout paquet appartenant aux applications non-autorisées : leur trafic n'aboutit jamais. */
private fun blackhole(fd: ParcelFileDescriptor) {
val input = FileInputStream(fd.fileDescriptor)
val buffer = ByteArray(32_767)
try {
while (running) {
val read = input.read(buffer)
if (read < 0) break
}
} catch (_ : Exception) {
// Interface fermée depuis stopTunnel() : fin normale du thread.
}
}
private fun stopTunnel(keepForeground: Boolean = false) {
running = false
readerThread?.interrupt()
readerThread = null
tunInterface?.let { runCatching { it.close() } }
tunInterface = null
if (!keepForeground) {
stopForeground(STOP_FOREGROUND_REMOVE)
}
}
override fun onRevoke() {
stopTunnel()
sendBroadcast(Intent(ACTION_VPN_REVOKED).setPackage(packageName))
super.onRevoke()
}
override fun onDestroy() {
stopTunnel()
super.onDestroy()
}
private fun buildNotification(): Notification {
val nm = getSystemService(NotificationManager::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_MIN
)
nm.createNotificationChannel(channel)
}
val openApp = PendingIntent.getActivity(
this, 0, Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.notification_title))
.setSmallIcon(R.drawable.ic_notification)
.setPriority(NotificationCompat.PRIORITY_MIN)
.setContentIntent(openApp)
.setOngoing(true)
.build()
}
companion object {
private const val TAG = "FirewallVpnService"
private const val CHANNEL_ID = "datacontrol_vpn"
private const val NOTIF_ID = 2001
private const val TUNNEL_ADDRESS = "10.10.10.1"
const val ACTION_START_FILTER = "com.jttools.datacontrol.action.VPN_START_FILTER"
const val ACTION_START_BLOCK_ALL = "com.jttools.datacontrol.action.VPN_START_BLOCK_ALL"
const val ACTION_STOP = "com.jttools.datacontrol.action.VPN_STOP"
const val ACTION_VPN_REVOKED = "com.jttools.datacontrol.action.VPN_REVOKED"
const val EXTRA_ALLOWED_PACKAGES = "allowed_packages"
fun isPrepared(context: Context): Boolean = prepare(context) == null
fun startFilterIntent(context: Context, allowedPackages: Collection<String>): Intent =
Intent(context, FirewallVpnService::class.java)
.setAction(ACTION_START_FILTER)
.putStringArrayListExtra(EXTRA_ALLOWED_PACKAGES, ArrayList(allowedPackages))
fun startBlockAllIntent(context: Context): Intent =
Intent(context, FirewallVpnService::class.java)
.setAction(ACTION_START_BLOCK_ALL)
.putStringArrayListExtra(EXTRA_ALLOWED_PACKAGES, ArrayList())
fun stopIntent(context: Context): Intent =
Intent(context, FirewallVpnService::class.java).setAction(ACTION_STOP)
}
}

View File

@@ -0,0 +1,33 @@
package com.jttools.datacontrol.service
import com.jttools.datacontrol.data.DataState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.time.LocalDate
import javax.inject.Inject
import javax.inject.Singleton
data class MonitorSnapshot(
val consumedMo: Long = 0L,
val planLimitMo: Long = 0L,
val overageMo: Long = 0L,
val estimatedTotalPriceEuro: Double = 0.0,
val dataState: DataState = DataState.OK,
val nextAutoResetDate: LocalDate = LocalDate.now(),
val nextCutoffAtMillis: Long? = null,
val serviceRunning: Boolean = false
)
/**
* Point de partage d'état entre [DataMonitorService] (producteur) et l'UI Compose (consommateur),
* pour que le tableau de bord reflète l'état réel même si l'Activity n'est pas liée au service.
*/
@Singleton
class MonitorStateHolder @Inject constructor() {
private val _snapshot = MutableStateFlow(MonitorSnapshot())
val snapshot = _snapshot.asStateFlow()
fun update(transform: (MonitorSnapshot) -> MonitorSnapshot) {
_snapshot.value = transform(_snapshot.value)
}
}

View File

@@ -0,0 +1,78 @@
package com.jttools.datacontrol.ui
import android.annotation.SuppressLint
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.net.VpnService
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import com.jttools.datacontrol.service.FirewallVpnService
/**
* Regroupe les autorisations système nécessaires (VPN, notifications, batterie) pour que
* les écrans Compose puissent les demander sans dépendre directement de l'Activity.
*/
class SystemActions(private val activity: ComponentActivity) {
private var onVpnResult: ((Boolean) -> Unit)? = null
private var onNotificationResult: ((Boolean) -> Unit)? = null
private val vpnLauncher: ActivityResultLauncher<Intent> = activity.registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
onVpnResult?.invoke(result.resultCode == android.app.Activity.RESULT_OK)
}
private val notificationLauncher: ActivityResultLauncher<String> = activity.registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted -> onNotificationResult?.invoke(granted) }
fun isVpnPrepared(): Boolean = FirewallVpnService.isPrepared(activity)
fun requestVpnPermission(onResult: (Boolean) -> Unit) {
val intent = VpnService.prepare(activity)
if (intent == null) {
onResult(true)
} else {
onVpnResult = onResult
vpnLauncher.launch(intent)
}
}
fun hasNotificationPermission(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true
return ContextCompat.checkSelfPermission(
activity, android.Manifest.permission.POST_NOTIFICATIONS
) == android.content.pm.PackageManager.PERMISSION_GRANTED
}
fun requestNotificationPermission(onResult: (Boolean) -> Unit) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
onResult(true)
return
}
onNotificationResult = onResult
notificationLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS)
}
fun isIgnoringBatteryOptimizations(): Boolean {
val pm = activity.getSystemService(Context.POWER_SERVICE) as PowerManager
return pm.isIgnoringBatteryOptimizations(activity.packageName)
}
@SuppressLint("BatteryLife")
fun requestIgnoreBatteryOptimizations() {
val intent = Intent(
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
Uri.parse("package:${activity.packageName}")
)
activity.startActivity(intent)
}
}

View File

@@ -0,0 +1,224 @@
package com.jttools.datacontrol.ui.dashboard
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
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
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.jttools.datacontrol.R
import com.jttools.datacontrol.data.DataState
import com.jttools.datacontrol.service.DataMonitorService
import com.jttools.datacontrol.ui.SystemActions
import com.jttools.datacontrol.ui.theme.StateCut
import com.jttools.datacontrol.ui.theme.StateOk
import com.jttools.datacontrol.ui.theme.StateWarning
import com.jttools.datacontrol.ui.theme.StateWifi
import java.time.format.DateTimeFormatter
import java.util.Locale
@Composable
fun DashboardScreen(systemActions: SystemActions) {
val context = androidx.compose.ui.platform.LocalContext.current
val viewModel: DashboardViewModel = hiltViewModel()
val settings by viewModel.settings.collectAsStateWithLifecycle()
val snapshot by viewModel.snapshot.collectAsStateWithLifecycle()
var showResetDialog by remember { mutableStateOf(false) }
val stateColor = when {
!snapshot.serviceRunning -> Color.Gray
snapshot.dataState == DataState.OK -> StateOk
snapshot.dataState == DataState.WARNING -> StateWarning
snapshot.dataState == DataState.WIFI -> StateWifi
else -> StateCut
}
val stateLabel = when {
!snapshot.serviceRunning -> stringResource(R.string.dashboard_control_disabled)
snapshot.dataState == DataState.OK -> stringResource(R.string.dashboard_state_ok)
snapshot.dataState == DataState.WARNING -> stringResource(R.string.dashboard_state_warning)
snapshot.dataState == DataState.OVERRUN_FILTER -> stringResource(R.string.dashboard_state_overrun_filtered)
snapshot.dataState == DataState.OVERRUN_CUT -> stringResource(R.string.dashboard_state_overrun_cut)
else -> stringResource(R.string.dashboard_state_wifi)
}
val consumedMo = if (snapshot.serviceRunning) snapshot.consumedMo else 0L
val planLimitMo = settings.planLimitMo.coerceAtLeast(1L)
val progress = (consumedMo.toFloat() / planLimitMo.toFloat()).coerceIn(0f, 1f)
val overageMo = (consumedMo - settings.planLimitMo).coerceAtLeast(0L)
val estimatedTotal = settings.planPriceEuro + overageMo * settings.overagePricePerMoEuro
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
stringResource(R.string.dashboard_title),
style = MaterialTheme.typography.headlineMedium
)
// Carte état + consommation
Card(shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(2.dp)) {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Surface(shape = RoundedCornerShape(50), color = stateColor, modifier = Modifier.size(14.dp)) {}
Text(stateLabel, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
}
Text(stringResource(R.string.dashboard_consumed), style = MaterialTheme.typography.bodyMedium)
Text(
"$consumedMo / ${settings.planLimitMo} Mo",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
LinearProgressIndicator(
progress = { progress },
modifier = Modifier
.fillMaxWidth()
.height(10.dp),
color = stateColor,
trackColor = MaterialTheme.colorScheme.surfaceVariant
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
"${stringResource(R.string.dashboard_remaining)}: ${(settings.planLimitMo - consumedMo).coerceAtLeast(0L)} Mo",
style = MaterialTheme.typography.bodyMedium
)
if (overageMo > 0) {
Text(
"${stringResource(R.string.dashboard_overage)}: $overageMo Mo",
style = MaterialTheme.typography.bodyMedium,
color = StateCut
)
}
}
}
}
// Carte coût estimé
Card(shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(2.dp)) {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.dashboard_cost_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
HorizontalDivider()
CostRow(stringResource(R.string.dashboard_cost_plan), settings.planPriceEuro)
CostRow(stringResource(R.string.dashboard_cost_overage), overageMo * settings.overagePricePerMoEuro)
HorizontalDivider()
CostRow(stringResource(R.string.dashboard_cost_total), estimatedTotal, emphasize = true)
}
}
// Carte contrôle
Card(shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(2.dp)) {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
if (settings.controlEnabled) stringResource(R.string.dashboard_toggle_disable)
else stringResource(R.string.dashboard_toggle_enable),
style = MaterialTheme.typography.titleMedium
)
Switch(
checked = settings.controlEnabled,
onCheckedChange = { checked ->
if (checked) {
systemActions.requestNotificationPermission { _ ->
systemActions.requestVpnPermission { granted ->
if (granted) {
viewModel.setControlEnabled(true)
DataMonitorService.start(context)
}
}
}
} else {
viewModel.setControlEnabled(false)
}
}
)
}
Text(
"${stringResource(R.string.dashboard_next_reset)}: ${
settings.nextAutoResetDate().format(
DateTimeFormatter.ofPattern("d MMMM yyyy", Locale.FRENCH)
)
}",
style = MaterialTheme.typography.bodyMedium
)
OutlinedButton(onClick = { showResetDialog = true }, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.dashboard_reset_now))
}
}
}
}
if (showResetDialog) {
AlertDialog(
onDismissRequest = { showResetDialog = false },
title = { Text(stringResource(R.string.dashboard_reset_confirm_title)) },
text = { Text(stringResource(R.string.dashboard_reset_confirm_message)) },
confirmButton = {
Button(onClick = {
viewModel.resetPlanNow()
showResetDialog = false
}) { Text(stringResource(R.string.common_confirm)) }
},
dismissButton = {
OutlinedButton(onClick = { showResetDialog = false }) {
Text(stringResource(R.string.common_cancel))
}
}
)
}
}
@Composable
private fun CostRow(label: String, value: Double, emphasize: Boolean = false) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
label,
style = if (emphasize) MaterialTheme.typography.titleMedium else MaterialTheme.typography.bodyMedium,
fontWeight = if (emphasize) FontWeight.Bold else FontWeight.Normal
)
Text(
String.format(Locale.FRENCH, "%.2f €", value),
style = if (emphasize) MaterialTheme.typography.titleMedium else MaterialTheme.typography.bodyMedium,
fontWeight = if (emphasize) FontWeight.Bold else FontWeight.Normal
)
}
}

View File

@@ -0,0 +1,35 @@
package com.jttools.datacontrol.ui.dashboard
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.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class DashboardViewModel @Inject constructor(
private val settingsRepository: SettingsRepository,
private val usageRepository: UsageRepository,
stateHolder: MonitorStateHolder
) : ViewModel() {
val settings = settingsRepository.settingsFlow.stateIn(
viewModelScope, SharingStarted.WhileSubscribed(5_000), AppSettings()
)
val snapshot = stateHolder.snapshot
fun setControlEnabled(enabled: Boolean) {
viewModelScope.launch { settingsRepository.setControlEnabled(enabled) }
}
fun resetPlanNow() {
viewModelScope.launch { settingsRepository.resetPlan(usageRepository.totalMobileBytes()) }
}
}

View File

@@ -0,0 +1,74 @@
package com.jttools.datacontrol.ui.navigation
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.compose.ui.res.stringResource
import com.jttools.datacontrol.R
import com.jttools.datacontrol.ui.SystemActions
import com.jttools.datacontrol.ui.dashboard.DashboardScreen
import com.jttools.datacontrol.ui.settings.SettingsScreen
import com.jttools.datacontrol.ui.whitelist.WhitelistScreen
private sealed class Destination(val route: String, val labelRes: Int, val icon: androidx.compose.ui.graphics.vector.ImageVector) {
data object Dashboard : Destination("dashboard", R.string.nav_dashboard, Icons.Filled.Dashboard)
data object Settings : Destination("settings", R.string.nav_settings, Icons.Filled.Settings)
data object Whitelist : Destination("whitelist", R.string.nav_whitelist, Icons.AutoMirrored.Filled.List)
}
private val destinations = listOf(Destination.Dashboard, Destination.Settings, Destination.Whitelist)
@Composable
fun DataControlNavHost(systemActions: SystemActions) {
val navController = rememberNavController()
Scaffold(
bottomBar = {
NavigationBar {
val backStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = backStackEntry?.destination
destinations.forEach { destination ->
val selected = currentDestination?.hierarchy?.any { it.route == destination.route } == true
NavigationBarItem(
selected = selected,
onClick = {
navController.navigate(destination.route) {
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
launchSingleTop = true
restoreState = true
}
},
icon = { Icon(destination.icon, contentDescription = null) },
label = { Text(stringResource(destination.labelRes)) }
)
}
}
}
) { padding ->
NavHost(
navController = navController,
startDestination = Destination.Dashboard.route,
modifier = Modifier.padding(padding)
) {
composable(Destination.Dashboard.route) { DashboardScreen(systemActions) }
composable(Destination.Settings.route) { SettingsScreen(systemActions) }
composable(Destination.Whitelist.route) { WhitelistScreen() }
}
}
}

View File

@@ -0,0 +1,234 @@
package com.jttools.datacontrol.ui.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.jttools.datacontrol.R
import com.jttools.datacontrol.ui.SystemActions
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.util.Locale
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(systemActions: SystemActions) {
val viewModel: SettingsViewModel = hiltViewModel()
val settings by viewModel.settings.collectAsStateWithLifecycle()
var planLimitText by remember(settings.planLimitMo) { mutableStateOf(settings.planLimitMo.toString()) }
var planPriceText by remember(settings.planPriceEuro) { mutableStateOf(settings.planPriceEuro.toString()) }
var overagePriceText by remember(settings.overagePricePerMoEuro) { mutableStateOf(settings.overagePricePerMoEuro.toString()) }
var cutoffText by remember(settings.cutoffIntervalMinutes) { mutableStateOf(settings.cutoffIntervalMinutes.toString()) }
var autoReset by remember(settings.autoResetEnabled) { mutableStateOf(settings.autoResetEnabled) }
var showDatePicker by remember { mutableStateOf(false) }
var vpnGranted by remember { mutableStateOf(systemActions.isVpnPrepared()) }
var notifGranted by remember { mutableStateOf(systemActions.hasNotificationPermission()) }
var batteryIgnored by remember { mutableStateOf(systemActions.isIgnoringBatteryOptimizations()) }
LaunchedEffect(Unit) {
vpnGranted = systemActions.isVpnPrepared()
notifGranted = systemActions.hasNotificationPermission()
batteryIgnored = systemActions.isIgnoringBatteryOptimizations()
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(stringResource(R.string.settings_title), style = MaterialTheme.typography.headlineMedium)
Card(shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(2.dp)) {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Text(stringResource(R.string.settings_section_plan), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
OutlinedTextField(
value = planLimitText,
onValueChange = { planLimitText = it.filter(Char::isDigit) },
label = { Text(stringResource(R.string.settings_plan_limit_mo)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = planPriceText,
onValueChange = { planPriceText = it.filter { c -> c.isDigit() || c == '.' } },
label = { Text(stringResource(R.string.settings_plan_price)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = overagePriceText,
onValueChange = { overagePriceText = it.filter { c -> c.isDigit() || c == '.' } },
label = { Text(stringResource(R.string.settings_overage_price)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.fillMaxWidth()
)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(stringResource(R.string.settings_start_date), style = MaterialTheme.typography.bodyMedium)
Text(
settings.planStartDate.format(DateTimeFormatter.ofPattern("d MMMM yyyy", Locale.FRENCH)),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
OutlinedButton(onClick = { showDatePicker = true }) { Text(stringResource(R.string.settings_edit_date)) }
}
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(stringResource(R.string.settings_auto_reset), style = MaterialTheme.typography.bodyMedium)
Switch(checked = autoReset, onCheckedChange = { autoReset = it })
}
}
}
Card(shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(2.dp)) {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(stringResource(R.string.settings_section_cutoff), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
OutlinedTextField(
value = cutoffText,
onValueChange = { cutoffText = it.filter(Char::isDigit) },
label = { Text(stringResource(R.string.settings_cutoff_interval)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
Text(
stringResource(R.string.settings_cutoff_help),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Card(shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(2.dp)) {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(stringResource(R.string.settings_section_permissions), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
HorizontalDivider()
PermissionRow(
title = stringResource(R.string.settings_perm_vpn),
description = stringResource(R.string.settings_perm_vpn_desc),
granted = vpnGranted
) { systemActions.requestVpnPermission { vpnGranted = it } }
PermissionRow(
title = stringResource(R.string.settings_perm_notifications),
description = stringResource(R.string.settings_perm_notifications_desc),
granted = notifGranted
) { systemActions.requestNotificationPermission { notifGranted = it } }
PermissionRow(
title = stringResource(R.string.settings_perm_battery),
description = stringResource(R.string.settings_perm_battery_desc),
granted = batteryIgnored
) {
systemActions.requestIgnoreBatteryOptimizations()
batteryIgnored = systemActions.isIgnoringBatteryOptimizations()
}
}
}
Button(
onClick = {
viewModel.updatePlan(
planLimitMo = planLimitText.toLongOrNull() ?: settings.planLimitMo,
planPriceEuro = planPriceText.toDoubleOrNull() ?: settings.planPriceEuro,
overagePricePerMoEuro = overagePriceText.toDoubleOrNull() ?: settings.overagePricePerMoEuro,
cutoffIntervalMinutes = cutoffText.toIntOrNull()?.coerceAtLeast(1) ?: settings.cutoffIntervalMinutes,
autoResetEnabled = autoReset
)
},
modifier = Modifier.fillMaxWidth()
) { Text(stringResource(R.string.settings_save)) }
}
if (showDatePicker) {
val datePickerState = rememberDatePickerState(
initialSelectedDateMillis = settings.planStartDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
)
DatePickerDialog(
onDismissRequest = { showDatePicker = false },
confirmButton = {
Button(onClick = {
datePickerState.selectedDateMillis?.let { millis ->
val date = Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate()
viewModel.setStartDate(date)
}
showDatePicker = false
}) { Text(stringResource(R.string.common_confirm)) }
},
dismissButton = {
OutlinedButton(onClick = { showDatePicker = false }) { Text(stringResource(R.string.common_cancel)) }
}
) { DatePicker(state = datePickerState) }
}
}
@Composable
private fun PermissionRow(title: String, description: String, granted: Boolean, onRequest: () -> Unit) {
Row(
Modifier.fillMaxWidth().padding(vertical = 10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
Text(description, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (granted) {
Text(
stringResource(R.string.settings_perm_granted),
color = com.jttools.datacontrol.ui.theme.StateOk,
style = MaterialTheme.typography.labelLarge
)
} else {
OutlinedButton(onClick = onRequest) { Text(stringResource(R.string.settings_perm_missing)) }
}
}
}

View File

@@ -0,0 +1,40 @@
package com.jttools.datacontrol.ui.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.jttools.datacontrol.data.AppSettings
import com.jttools.datacontrol.data.SettingsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.time.LocalDate
import javax.inject.Inject
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val settingsRepository: SettingsRepository
) : ViewModel() {
val settings = settingsRepository.settingsFlow.stateIn(
viewModelScope, SharingStarted.WhileSubscribed(5_000), AppSettings()
)
fun updatePlan(
planLimitMo: Long,
planPriceEuro: Double,
overagePricePerMoEuro: Double,
cutoffIntervalMinutes: Int,
autoResetEnabled: Boolean
) {
viewModelScope.launch {
settingsRepository.updatePlan(
planLimitMo, planPriceEuro, overagePricePerMoEuro, cutoffIntervalMinutes, autoResetEnabled
)
}
}
fun setStartDate(date: LocalDate) {
viewModelScope.launch { settingsRepository.setStartDate(date) }
}
}

View File

@@ -0,0 +1,45 @@
package com.jttools.datacontrol.ui.theme
import androidx.compose.ui.graphics.Color
// Primary - Deep Teal
val Primary = Color(0xFF00695C)
val PrimaryVariant = Color(0xFF004D40)
val OnPrimary = Color(0xFFFFFFFF)
// Secondary - Cyan accent
val Secondary = Color(0xFF00BFA5)
val SecondaryVariant = Color(0xFF00897B)
val OnSecondary = Color(0xFF000000)
// Tertiary - Amber (alertes)
val Tertiary = Color(0xFFFFA000)
val OnTertiary = Color(0xFF000000)
// Background/Surface
val Background = Color(0xFFF3F6F5)
val OnBackground = Color(0xFF1B1F1E)
val Surface = Color(0xFFFFFFFF)
val OnSurface = Color(0xFF1B1F1E)
val SurfaceVariant = Color(0xFFDCE9E6)
// Error
val Error = Color(0xFFB3261E)
val OnError = Color(0xFFFFFFFF)
// Dark theme variants
val DarkPrimary = Color(0xFF4DB6AC)
val DarkPrimaryVariant = Color(0xFF00897B)
val DarkOnPrimary = Color(0xFF00201C)
val DarkSecondary = Color(0xFF64FFDA)
val DarkBackground = Color(0xFF121212)
val DarkOnBackground = Color(0xFFE4E6E5)
val DarkSurface = Color(0xFF1E1E1E)
val DarkOnSurface = Color(0xFFE4E6E5)
val DarkSurfaceVariant = Color(0xFF1B3B36)
// États data (utilisés dans Dashboard + notification)
val StateOk = Color(0xFF2E7D32) // vert — sous le forfait
val StateWarning = Color(0xFFEF6C00) // orange — proche du forfait
val StateCut = Color(0xFFC62828) // rouge — coupure / hors-forfait
val StateWifi = Color(0xFF1565C0) // bleu — Wi-Fi, aucun filtrage

View File

@@ -0,0 +1,75 @@
package com.jttools.datacontrol.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val DarkColorScheme = darkColorScheme(
primary = DarkPrimary,
onPrimary = DarkOnPrimary,
primaryContainer = DarkPrimaryVariant,
secondary = DarkSecondary,
background = DarkBackground,
surface = DarkSurface,
onBackground = DarkOnBackground,
onSurface = DarkOnSurface,
surfaceVariant = DarkSurfaceVariant,
error = Error,
onError = OnError
)
private val LightColorScheme = lightColorScheme(
primary = Primary,
onPrimary = OnPrimary,
primaryContainer = SurfaceVariant,
secondary = Secondary,
background = Background,
surface = Surface,
onBackground = OnBackground,
onSurface = OnSurface,
surfaceVariant = SurfaceVariant,
error = Error,
onError = OnError
)
@Composable
fun DataControlTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val context = LocalContext.current
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primary.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

View File

@@ -0,0 +1,39 @@
package com.jttools.datacontrol.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography = Typography(
headlineMedium = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 28.sp,
lineHeight = 34.sp
),
titleLarge = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 22.sp,
lineHeight = 28.sp
),
titleMedium = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 24.sp
),
bodyLarge = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp
),
bodyMedium = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp
),
labelLarge = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp
)
)

View File

@@ -0,0 +1,129 @@
package com.jttools.datacontrol.ui.whitelist
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Checkbox
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.graphics.drawable.toBitmap
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.jttools.datacontrol.R
import com.jttools.datacontrol.data.AppInfo
@Composable
fun WhitelistScreen() {
val viewModel: WhitelistViewModel = hiltViewModel()
val settings by viewModel.settings.collectAsStateWithLifecycle()
val apps by viewModel.apps.collectAsStateWithLifecycle()
val loading by viewModel.loading.collectAsStateWithLifecycle()
var query by remember { mutableStateOf("") }
val filtered = apps
.filter { settings.showSystemApps || !it.isSystemApp }
.filter { it.label.contains(query, ignoreCase = true) || it.packageName.contains(query, ignoreCase = true) }
Column(Modifier.fillMaxSize().padding(20.dp)) {
Text(stringResource(R.string.whitelist_title), style = MaterialTheme.typography.headlineMedium)
Text(
stringResource(R.string.whitelist_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp, bottom = 14.dp)
)
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()
)
Row(
Modifier.fillMaxWidth().padding(vertical = 10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(stringResource(R.string.whitelist_system_apps_toggle), style = MaterialTheme.typography.bodyMedium)
Switch(checked = settings.showSystemApps, onCheckedChange = { viewModel.setShowSystemApps(it) })
}
HorizontalDivider()
if (loading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
} else {
LazyColumn {
items(filtered, key = { it.packageName }) { app ->
AppRow(
app = app,
checked = settings.whitelistedPackages.contains(app.packageName),
onCheckedChange = { viewModel.setWhitelisted(app.packageName, it) }
)
}
}
}
}
}
@Composable
private fun AppRow(app: AppInfo, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Row(
Modifier.fillMaxWidth().padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
AppIcon(app)
Column(Modifier.weight(1f)) {
Text(app.label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
Text(app.packageName, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Checkbox(checked = checked, onCheckedChange = onCheckedChange)
}
}
@Composable
private fun AppIcon(app: AppInfo) {
val bitmap = remember(app.packageName) {
app.icon?.let { runCatching { it.toBitmap(width = 96, height = 96).asImageBitmap() }.getOrNull() }
}
if (bitmap != null) {
Image(bitmap = bitmap, contentDescription = null, modifier = Modifier.size(40.dp))
} else {
Box(
modifier = Modifier
.size(40.dp)
.background(MaterialTheme.colorScheme.surfaceVariant, CircleShape)
)
}
}

View File

@@ -0,0 +1,51 @@
package com.jttools.datacontrol.ui.whitelist
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.jttools.datacontrol.data.AppInfo
import com.jttools.datacontrol.data.AppSettings
import com.jttools.datacontrol.data.InstalledAppsRepository
import com.jttools.datacontrol.data.SettingsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class WhitelistViewModel @Inject constructor(
private val settingsRepository: SettingsRepository,
private val installedAppsRepository: InstalledAppsRepository
) : ViewModel() {
val settings = settingsRepository.settingsFlow.stateIn(
viewModelScope, SharingStarted.WhileSubscribed(5_000), AppSettings()
)
private val _apps = MutableStateFlow<List<AppInfo>>(emptyList())
val apps = _apps.asStateFlow()
private val _loading = MutableStateFlow(true)
val loading = _loading.asStateFlow()
init {
viewModelScope.launch {
_apps.value = installedAppsRepository.listInstalledApps()
_loading.value = false
}
}
fun setWhitelisted(packageName: String, whitelisted: Boolean) {
viewModelScope.launch {
val current = settings.value.whitelistedPackages
val updated = if (whitelisted) current + packageName else current - packageName
settingsRepository.setWhitelistedPackages(updated)
}
}
fun setShowSystemApps(show: Boolean) {
viewModelScope.launch { settingsRepository.setShowSystemApps(show) }
}
}

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#00695C"
android:pathData="M0,0h108v108H0z" />
<path
android:fillColor="#00BFA5"
android:fillAlpha="0.25"
android:pathData="M0,108 C30,88 78,88 108,108 L108,0 C78,20 30,20 0,0 Z" />
</vector>

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Cadran de jauge (data gauge) -->
<path
android:fillColor="#FFFFFF"
android:pathData="M54,30 A24,24 0 1,1 30,54 A24,24 0 0,1 54,30 Z M54,36 A18,18 0 1,0 72,54 A18,18 0 0,0 54,36 Z" />
<!-- Aiguille -->
<path
android:fillColor="#FFFFFF"
android:pathData="M54,54 L64,42 L56,52 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M51,54 m-3,0 a3,3 0 1,0 6,0 a3,3 0 1,0 -6,0" />
<!-- Barres de signal en dessous -->
<path android:fillColor="#FFFFFF" android:pathData="M36,78 L42,78 L42,86 L36,86 Z" />
<path android:fillColor="#FFFFFF" android:pathData="M48,74 L54,74 L54,86 L48,86 Z" />
<path android:fillColor="#FFFFFF" android:pathData="M60,68 L66,68 L66,86 L60,86 Z" />
<path android:fillColor="#00BFA5" android:pathData="M72,62 L78,62 L78,86 L72,86 Z" />
</vector>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path
android:fillColor="#FF000000"
android:pathData="M4,14 L8,14 L8,20 L4,20 Z M10,9 L14,9 L14,20 L10,20 Z M16,4 L20,4 L20,20 L16,20 Z" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="primary">#00695C</color>
</resources>

View File

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">DataControl</string>
<!-- Navigation -->
<string name="nav_dashboard">Tableau de bord</string>
<string name="nav_settings">Réglages</string>
<string name="nav_whitelist">Liste blanche</string>
<!-- Dashboard -->
<string name="dashboard_title">Tableau de bord</string>
<string name="dashboard_consumed">Données consommées</string>
<string name="dashboard_plan_label">Forfait</string>
<string name="dashboard_remaining">Restant</string>
<string name="dashboard_overage">Dépassement</string>
<string name="dashboard_state_label">État des données</string>
<string name="dashboard_state_ok">Sous le forfait</string>
<string name="dashboard_state_warning">Proche du forfait</string>
<string name="dashboard_state_overrun_filtered">Hors-forfait — liste blanche seule</string>
<string name="dashboard_state_overrun_cut">Hors-forfait — coupure en cours</string>
<string name="dashboard_state_wifi">Wi-Fi actif — aucun filtrage</string>
<string name="dashboard_cost_title">Estimation du coût</string>
<string name="dashboard_cost_plan">Prix du forfait</string>
<string name="dashboard_cost_overage">Coût hors-forfait</string>
<string name="dashboard_cost_total">Total estimé</string>
<string name="dashboard_next_reset">Prochaine réinitialisation</string>
<string name="dashboard_toggle_enable">Activer le contrôle</string>
<string name="dashboard_toggle_disable">Désactiver le contrôle</string>
<string name="dashboard_reset_now">Réinitialiser le forfait maintenant</string>
<string name="dashboard_reset_confirm_title">Réinitialiser le forfait ?</string>
<string name="dashboard_reset_confirm_message">Le compteur de données consommées sera remis à zéro immédiatement.</string>
<string name="dashboard_permissions_needed">Autorisations requises pour fonctionner correctement</string>
<string name="dashboard_control_disabled">Contrôle désactivé</string>
<!-- Settings -->
<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_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>
<string name="settings_auto_reset">Réinitialisation automatique mensuelle</string>
<string name="settings_section_cutoff">Coupure automatique</string>
<string name="settings_cutoff_interval">Intervalle de coupure (minutes)</string>
<string name="settings_cutoff_help">Une fois le forfait dépassé, une coupure totale (même pour la liste blanche) se déclenche brièvement à cet intervalle.</string>
<string name="settings_section_permissions">Autorisations</string>
<string name="settings_perm_usage_access">Accès aux statistiques d\'utilisation</string>
<string name="settings_perm_usage_access_desc">Nécessaire pour mesurer la consommation de données mobiles</string>
<string name="settings_perm_vpn">Pare-feu local (VPN)</string>
<string name="settings_perm_vpn_desc">Nécessaire pour filtrer les applications sur les données mobiles</string>
<string name="settings_perm_notifications">Notifications</string>
<string name="settings_perm_notifications_desc">Nécessaire pour afficher l\'état des données en continu</string>
<string name="settings_perm_battery">Optimisation de la batterie</string>
<string name="settings_perm_battery_desc">Ignorer l\'optimisation pour que la surveillance ne soit pas interrompue</string>
<string name="settings_perm_granted">Accordée</string>
<string name="settings_perm_missing">Manquante</string>
<string name="settings_save">Enregistrer</string>
<string name="settings_edit_date">Modifier</string>
<string name="settings_saved">Réglages enregistrés</string>
<!-- Whitelist -->
<string name="whitelist_title">Liste blanche</string>
<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>
<!-- Notification -->
<string name="notification_channel_name">Surveillance des données</string>
<string name="notification_channel_description">Affiche la consommation de données et l\'état de la coupure</string>
<string name="notification_title">DataControl</string>
<!-- Common -->
<string name="unit_mo">Mo</string>
<string name="unit_euro"></string>
<string name="common_cancel">Annuler</string>
<string name="common_confirm">Confirmer</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.DataControl" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@color/primary</item>
</style>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<exclude domain="sharedpref" path="." />
</full-backup-content>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<exclude domain="sharedpref" path="." />
</cloud-backup>
</data-extraction-rules>

7
build.gradle.kts Normal file
View File

@@ -0,0 +1,7 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.hilt.android) apply false
alias(libs.plugins.kotlin.kapt) apply false
}

5
gradle.properties Normal file
View File

@@ -0,0 +1,5 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.configuration-cache=false
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

50
gradle/libs.versions.toml Normal file
View File

@@ -0,0 +1,50 @@
[versions]
agp = "8.5.0"
kotlin = "1.9.24"
coreKtx = "1.13.1"
junit = "4.13.2"
junitVersion = "1.2.1"
espressoCore = "3.6.1"
composeBom = "2024.09.00"
activityCompose = "1.9.2"
lifecycleViewmodelCompose = "2.8.5"
lifecycleRuntimeCompose = "2.8.5"
lifecycleService = "2.8.5"
navigationCompose = "2.8.2"
hilt = "2.51.1"
hiltNavigationCompose = "1.2.0"
datastore = "1.1.1"
coroutines = "1.8.1"
workManager = "2.9.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeCompose" }
androidx-lifecycle-service = { group = "androidx.lifecycle", name = "lifecycle-service", version.ref = "lifecycleService" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
gradlew vendored Executable file
View File

@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

23
settings.gradle.kts Normal file
View File

@@ -0,0 +1,23 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "DataControl"
include(":app")

2
version.properties Normal file
View File

@@ -0,0 +1,2 @@
versionName=0.1.0
versionCode=1