From c63cc45c5759f17bdf56c984373dfa0b0a5cca96 Mon Sep 17 00:00:00 2001 From: tuxgyver Date: Sun, 20 Sep 2026 12:07:37 +0200 Subject: [PATCH] =?UTF-8?q?release:=20v0.1.2=20=E2=80=94=20mise=20=C3=A0?= =?UTF-8?q?=20jour=20via=20Gitea,=20Pronote=20par=20d=C3=A9faut,=20total?= =?UTF-8?q?=20estim=C3=A9=20dans=20la=20notification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 7 + app/src/main/AndroidManifest.xml | 12 ++ .../com/jttools/datacontrol/DataControlApp.kt | 8 ++ .../jttools/datacontrol/data/AppSettings.kt | 11 +- .../datacontrol/data/SettingsRepository.kt | 15 ++ .../jttools/datacontrol/data/UpdateManager.kt | 131 ++++++++++++++++++ .../datacontrol/service/DataMonitorService.kt | 7 +- .../datacontrol/ui/about/AboutScreen.kt | 75 ++++++++++ app/src/main/res/values/strings.xml | 9 ++ app/src/main/res/xml/file_paths.xml | 4 + version.properties | 4 +- 11 files changed, 277 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/jttools/datacontrol/data/UpdateManager.kt create mode 100644 app/src/main/res/xml/file_paths.xml 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