release: v0.1.2 — mise à jour via Gitea, Pronote par défaut, total estimé dans la notification
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4aaf6efe52
commit
c63cc45c57
11 files changed
+277
-6
No files matched your search
@@ -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.
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<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" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<!-- 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" />
|
||||
@@ -49,6 +50,17 @@
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<!-- FileProvider pour installer l'APK de mise à jour téléchargé -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<receiver
|
||||
android:name=".service.BootReceiver"
|
||||
android:exported="true"
|
||||
|
||||
@@ -4,15 +4,23 @@ import android.app.Application
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import com.jttools.datacontrol.data.SettingsRepository
|
||||
import com.jttools.datacontrol.service.DataMonitorRestartWorker
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidApp
|
||||
class DataControlApp : Application() {
|
||||
|
||||
@Inject lateinit var settingsRepository: SettingsRepository
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
CoroutineScope(Dispatchers.IO).launch { settingsRepository.resetPasswordOnce() }
|
||||
// Filet de sécurité : relance périodiquement DataMonitorService s'il a été tué en arrière-plan.
|
||||
// enqueueUniquePeriodicWork + KEEP : sans effet si déjà planifié, donc sûr à appeler à chaque démarrage.
|
||||
val request = PeriodicWorkRequestBuilder<DataMonitorRestartWorker>(15, TimeUnit.MINUTES).build()
|
||||
|
||||
@@ -14,8 +14,8 @@ data class AppSettings(
|
||||
val autoResetEnabled: Boolean = true,
|
||||
val cutoffIntervalMinutes: Int = 15,
|
||||
val baselineMobileBytesAtReset: Long = 0L,
|
||||
val whitelistedPackages: Set<String> = emptySet(),
|
||||
val whitelistedPackagesWifi: Set<String> = emptySet(),
|
||||
val whitelistedPackages: Set<String> = DEFAULT_WHITELIST,
|
||||
val whitelistedPackagesWifi: Set<String> = 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<String> = setOf(PRONOTE_PACKAGE)
|
||||
}
|
||||
}
|
||||
|
||||
/** État calculé de la consommation, dérivé des réglages + de la mesure de données réelle. */
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<String?>(null) }
|
||||
var available by remember { mutableStateOf<UpdateManager.UpdateInfo?>(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)
|
||||
|
||||
@@ -89,6 +89,15 @@
|
||||
<string name="about_description">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.</string>
|
||||
<string name="about_section_how">Fonctionnement</string>
|
||||
<string name="about_how_it_works">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.</string>
|
||||
<string name="update_section">Mise à jour</string>
|
||||
<string name="update_check">Rechercher une mise à jour</string>
|
||||
<string name="update_checking">Recherche en cours…</string>
|
||||
<string name="update_up_to_date">Vous avez la dernière version.</string>
|
||||
<string name="update_available">Version %1$s disponible</string>
|
||||
<string name="update_download_install">Télécharger et installer</string>
|
||||
<string name="update_downloading">Téléchargement… %1$d %%</string>
|
||||
<string name="update_need_permission">Autorisez l\'installation d\'applications pour DataControl, puis relancez la mise à jour.</string>
|
||||
<string name="update_error">Échec : %1$s</string>
|
||||
<string name="about_author">Auteur</string>
|
||||
<string name="about_author_value">JT-Tools — Johnny</string>
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path name="updates" path="updates/" />
|
||||
</paths>
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
versionName=0.1.1
|
||||
versionCode=2
|
||||
versionName=0.1.2
|
||||
versionCode=3
|
||||
Reference in new issue
Block a user