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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user