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:
tuxgyverandClaude Sonnet 5 committed 2026-09-20 12:07:37 +02:00
1 parent 4aaf6efe52
commit c63cc45c57
11 files changed
+277 -6

No files matched your search

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