diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2cac141..50464c3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## v0.1.2 – 2026-09-20
+
+- Mise à jour depuis les Releases Gitea : bouton « Rechercher une mise à jour » dans l'onglet À propos (téléchargement + installation de l'APK).
+- Notification : le total estimé apparaît aussi dans la vue repliée.
+- Pronote (`com.indexeducation.pronote`) est dans la liste blanche par défaut (données mobiles et Wi-Fi).
+- Réinitialisation unique du mot de passe de verrouillage au prochain lancement.
+
## v0.1.1 – 2026-08-30
- `make publish`/`make deploy` : publie l'APK signé comme asset d'une Release Gitea (tag + notes extraites du CHANGELOG), sur le même modèle que les autres apps Android du même auteur.
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1222356..0c5c857 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -9,6 +9,7 @@
+
@@ -49,6 +50,17 @@
android:exported="false"
android:foregroundServiceType="dataSync" />
+
+
+
+
+
(15, TimeUnit.MINUTES).build()
diff --git a/app/src/main/java/com/jttools/datacontrol/data/AppSettings.kt b/app/src/main/java/com/jttools/datacontrol/data/AppSettings.kt
index 2b2e5d3..c16d45f 100644
--- a/app/src/main/java/com/jttools/datacontrol/data/AppSettings.kt
+++ b/app/src/main/java/com/jttools/datacontrol/data/AppSettings.kt
@@ -14,8 +14,8 @@ data class AppSettings(
val autoResetEnabled: Boolean = true,
val cutoffIntervalMinutes: Int = 15,
val baselineMobileBytesAtReset: Long = 0L,
- val whitelistedPackages: Set = emptySet(),
- val whitelistedPackagesWifi: Set = emptySet(),
+ val whitelistedPackages: Set = DEFAULT_WHITELIST,
+ val whitelistedPackagesWifi: Set = DEFAULT_WHITELIST,
val wifiFilterEnabled: Boolean = false,
val showSystemApps: Boolean = false,
val passwordHash: String? = null,
@@ -38,6 +38,13 @@ data class AppSettings(
}
return next
}
+
+ companion object {
+ const val PRONOTE_PACKAGE = "com.indexeducation.pronote"
+
+ /** Applications autorisées par défaut (tant que l'utilisateur n'a pas modifié la liste blanche). */
+ val DEFAULT_WHITELIST: Set = setOf(PRONOTE_PACKAGE)
+ }
}
/** État calculé de la consommation, dérivé des réglages + de la mesure de données réelle. */
diff --git a/app/src/main/java/com/jttools/datacontrol/data/SettingsRepository.kt b/app/src/main/java/com/jttools/datacontrol/data/SettingsRepository.kt
index 2e5ea2c..6b9c79f 100644
--- a/app/src/main/java/com/jttools/datacontrol/data/SettingsRepository.kt
+++ b/app/src/main/java/com/jttools/datacontrol/data/SettingsRepository.kt
@@ -38,6 +38,7 @@ class SettingsRepository @Inject constructor(
val SHOW_SYSTEM_APPS = booleanPreferencesKey("show_system_apps")
val PASSWORD_HASH = stringPreferencesKey("password_hash")
val PASSWORD_SALT = stringPreferencesKey("password_salt")
+ val PASSWORD_RESET_V1_DONE = booleanPreferencesKey("password_reset_v1_done")
val CUTOFF_ANCHOR_MILLIS = longPreferencesKey("cutoff_anchor_millis")
val CUTOFF_IN_PULSE = booleanPreferencesKey("cutoff_in_pulse")
val CUTOFF_PULSE_START_MILLIS = longPreferencesKey("cutoff_pulse_start_millis")
@@ -152,6 +153,20 @@ class SettingsRepository @Inject constructor(
}
}
+ /**
+ * Réinitialisation unique (v0.1.2) du mot de passe de verrouillage : le supprime une seule fois,
+ * puis mémorise qu'elle a eu lieu pour ne pas effacer un mot de passe défini ensuite.
+ */
+ suspend fun resetPasswordOnce() {
+ context.dataStore.edit {
+ if (it[Keys.PASSWORD_RESET_V1_DONE] != true) {
+ it.remove(Keys.PASSWORD_HASH)
+ it.remove(Keys.PASSWORD_SALT)
+ it[Keys.PASSWORD_RESET_V1_DONE] = true
+ }
+ }
+ }
+
/** Vrai si aucun mot de passe n'est défini, ou si [password] correspond au mot de passe stocké. */
suspend fun verifyPassword(password: String): Boolean {
val prefs = context.dataStore.data.first()
diff --git a/app/src/main/java/com/jttools/datacontrol/data/UpdateManager.kt b/app/src/main/java/com/jttools/datacontrol/data/UpdateManager.kt
new file mode 100644
index 0000000..0518621
--- /dev/null
+++ b/app/src/main/java/com/jttools/datacontrol/data/UpdateManager.kt
@@ -0,0 +1,131 @@
+package com.jttools.datacontrol.data
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.provider.Settings
+import androidx.core.content.FileProvider
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.json.JSONObject
+import java.io.File
+import java.net.HttpURLConnection
+import java.net.URL
+
+/**
+ * Mise à jour via les Releases Gitea du dépôt (dernière release publiée + son asset .apk).
+ * Le dépôt est public : aucun jeton n'est nécessaire.
+ */
+class UpdateManager(private val context: Context, private val currentVersion: String) {
+
+ data class UpdateInfo(val versionName: String, val apkUrl: String, val changelog: String)
+
+ sealed interface CheckResult {
+ data object UpToDate : CheckResult
+ data class Available(val info: UpdateInfo) : CheckResult
+ data class Error(val message: String) : CheckResult
+ }
+
+ suspend fun check(): CheckResult = withContext(Dispatchers.IO) {
+ try {
+ val conn = open(RELEASES_URL)
+ try {
+ if (conn.responseCode != HttpURLConnection.HTTP_OK) {
+ return@withContext CheckResult.Error("HTTP ${conn.responseCode}")
+ }
+ val json = JSONObject(conn.inputStream.bufferedReader().use { it.readText() })
+ val version = json.optString("tag_name").removePrefix("v")
+ if (version.isBlank()) return@withContext CheckResult.Error("Release sans tag")
+
+ val assets = json.optJSONArray("assets")
+ var apkUrl = ""
+ if (assets != null) {
+ for (i in 0 until assets.length()) {
+ val asset = assets.getJSONObject(i)
+ if (asset.optString("name").endsWith(".apk")) {
+ apkUrl = asset.optString("browser_download_url")
+ break
+ }
+ }
+ }
+ when {
+ !isNewer(version, currentVersion) -> CheckResult.UpToDate
+ apkUrl.isBlank() -> CheckResult.Error("Aucun APK dans la release v$version")
+ else -> CheckResult.Available(UpdateInfo(version, apkUrl, json.optString("body")))
+ }
+ } finally {
+ conn.disconnect()
+ }
+ } catch (e: Exception) {
+ CheckResult.Error(e.message ?: e.javaClass.simpleName)
+ }
+ }
+
+ /** Télécharge l'APK dans le cache ; [onProgress] reçoit 0-100. Retourne le fichier ou lève une exception. */
+ suspend fun download(info: UpdateInfo, onProgress: (Int) -> Unit): File = withContext(Dispatchers.IO) {
+ val dir = File(context.cacheDir, "updates").apply { mkdirs() }
+ dir.listFiles()?.forEach { it.delete() }
+ val file = File(dir, "DataControl-${info.versionName}.apk")
+ val conn = open(info.apkUrl)
+ try {
+ if (conn.responseCode != HttpURLConnection.HTTP_OK) error("HTTP ${conn.responseCode}")
+ val total = conn.contentLengthLong
+ var done = 0L
+ conn.inputStream.use { input ->
+ file.outputStream().use { output ->
+ val buf = ByteArray(32 * 1024)
+ var n: Int
+ while (input.read(buf).also { n = it } != -1) {
+ output.write(buf, 0, n)
+ done += n
+ if (total > 0) onProgress((done * 100 / total).toInt())
+ }
+ }
+ }
+ } finally {
+ conn.disconnect()
+ }
+ file
+ }
+
+ /** Vrai si l'installation a été lancée ; faux si l'utilisateur doit d'abord autoriser l'installation d'APK. */
+ fun install(file: File): Boolean {
+ if (!context.packageManager.canRequestPackageInstalls()) {
+ context.startActivity(
+ Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}"))
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ )
+ return false
+ }
+ val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
+ context.startActivity(
+ Intent(Intent.ACTION_VIEW)
+ .setDataAndType(uri, "application/vnd.android.package-archive")
+ .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
+ )
+ return true
+ }
+
+ private fun open(url: String): HttpURLConnection =
+ (URL(url).openConnection() as HttpURLConnection).apply {
+ connectTimeout = 15_000
+ readTimeout = 30_000
+ setRequestProperty("User-Agent", "DataControl/$currentVersion")
+ setRequestProperty("Accept", "application/json, application/octet-stream")
+ }
+
+ companion object {
+ const val RELEASES_URL = "https://git.tuxtech.fr/api/v1/repos/tuxgyver/DataControl/releases/latest"
+
+ fun isNewer(remote: String, current: String): Boolean {
+ val r = remote.split(".").map { it.toIntOrNull() ?: 0 }
+ val c = current.split(".").map { it.toIntOrNull() ?: 0 }
+ for (i in 0 until maxOf(r.size, c.size)) {
+ val rv = r.getOrElse(i) { 0 }
+ val cv = c.getOrElse(i) { 0 }
+ if (rv != cv) return rv > cv
+ }
+ return false
+ }
+ }
+}
diff --git a/app/src/main/java/com/jttools/datacontrol/service/DataMonitorService.kt b/app/src/main/java/com/jttools/datacontrol/service/DataMonitorService.kt
index 2a84563..b404b37 100644
--- a/app/src/main/java/com/jttools/datacontrol/service/DataMonitorService.kt
+++ b/app/src/main/java/com/jttools/datacontrol/service/DataMonitorService.kt
@@ -274,8 +274,11 @@ class DataMonitorService : LifecycleService() {
} 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 usageText = "$emoji $stateLabel · ${snapshot.consumedMo}/${snapshot.planLimitMo} Mo ($percentText)"
+ val costText = "${getString(R.string.dashboard_cost_total)} : $priceText €"
+ // Le total est aussi dans la vue repliée : sinon il n'est visible qu'une fois la notification dépliée.
+ val contentText = "$usageText · $priceText €"
+ val bigText = "$usageText\n$costText"
val openApp = PendingIntent.getActivity(
this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE
diff --git a/app/src/main/java/com/jttools/datacontrol/ui/about/AboutScreen.kt b/app/src/main/java/com/jttools/datacontrol/ui/about/AboutScreen.kt
index 473cfd1..76d0972 100644
--- a/app/src/main/java/com/jttools/datacontrol/ui/about/AboutScreen.kt
+++ b/app/src/main/java/com/jttools/datacontrol/ui/about/AboutScreen.kt
@@ -11,13 +11,20 @@ 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.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
@@ -28,6 +35,8 @@ import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap
import com.jttools.datacontrol.R
+import com.jttools.datacontrol.data.UpdateManager
+import kotlinx.coroutines.launch
@Composable
fun AboutScreen() {
@@ -46,6 +55,13 @@ fun AboutScreen() {
}.getOrNull()
}
+ val updateManager = remember(versionName) { UpdateManager(context.applicationContext, versionName) }
+ val scope = rememberCoroutineScope()
+ var updateStatus by remember { mutableStateOf(null) }
+ var available by remember { mutableStateOf(null) }
+ var busy by remember { mutableStateOf(false) }
+ var progress by remember { mutableIntStateOf(-1) }
+
Column(
modifier = Modifier
.fillMaxSize()
@@ -83,6 +99,65 @@ fun AboutScreen() {
}
}
+ Card(shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(2.dp)) {
+ Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ Text(stringResource(R.string.update_section), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
+ HorizontalDivider()
+ val info = available
+ val statusText = when {
+ progress >= 0 -> stringResource(R.string.update_downloading, progress)
+ updateStatus != null -> updateStatus!!
+ info != null -> stringResource(R.string.update_available, info.versionName)
+ else -> null
+ }
+ if (statusText != null) Text(statusText, style = MaterialTheme.typography.bodyMedium)
+ if (info != null && progress < 0 && info.changelog.isNotBlank()) {
+ Text(info.changelog, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ if (progress >= 0) {
+ LinearProgressIndicator(progress = { progress / 100f }, modifier = Modifier.fillMaxWidth())
+ }
+ val needPermission = stringResource(R.string.update_need_permission)
+ val upToDate = stringResource(R.string.update_up_to_date)
+ val errorFmt = stringResource(R.string.update_error, "%s")
+ Button(
+ enabled = !busy,
+ onClick = {
+ busy = true
+ scope.launch {
+ if (info == null) {
+ updateStatus = null
+ when (val r = updateManager.check()) {
+ is UpdateManager.CheckResult.UpToDate -> updateStatus = upToDate
+ is UpdateManager.CheckResult.Available -> available = r.info
+ is UpdateManager.CheckResult.Error -> updateStatus = errorFmt.replace("%s", r.message)
+ }
+ } else {
+ updateStatus = null
+ progress = 0
+ try {
+ val file = updateManager.download(info) { progress = it }
+ if (!updateManager.install(file)) updateStatus = needPermission
+ } catch (e: Exception) {
+ updateStatus = errorFmt.replace("%s", e.message ?: e.javaClass.simpleName)
+ }
+ progress = -1
+ }
+ busy = false
+ }
+ }
+ ) {
+ Text(
+ when {
+ busy && info == null -> stringResource(R.string.update_checking)
+ info != null -> stringResource(R.string.update_download_install)
+ else -> stringResource(R.string.update_check)
+ }
+ )
+ }
+ }
+ }
+
Card(shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(2.dp)) {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(stringResource(R.string.about_section_description), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index a307d92..63033e1 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -89,6 +89,15 @@
DataControl 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.
Fonctionnement
Le filtrage repose sur un pare-feu local basé sur VpnService (sans root) : le trafic des applications non autorisées est capturé dans un tunnel VPN local puis silencieusement ignoré, tandis que les applications de la liste blanche continuent de communiquer normalement.
+ Mise à jour
+ Rechercher une mise à jour
+ Recherche en cours…
+ Vous avez la dernière version.
+ Version %1$s disponible
+ Télécharger et installer
+ Téléchargement… %1$d %%
+ Autorisez l\'installation d\'applications pour DataControl, puis relancez la mise à jour.
+ Échec : %1$s
Auteur
JT-Tools — Johnny
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..c9cc257
--- /dev/null
+++ b/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/version.properties b/version.properties
index 480d727..624c632 100644
--- a/version.properties
+++ b/version.properties
@@ -1,2 +1,2 @@
-versionName=0.1.1
-versionCode=2
+versionName=0.1.2
+versionCode=3