diff --git a/.gitignore b/.gitignore index 02ca64e..1ca7e92 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ keystore.properties *.jks .gitea-token Cachette-*.apk +update-token.properties diff --git a/CHANGELOG.md b/CHANGELOG.md index f874e87..4cab916 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,28 @@ Toutes les évolutions notables de Cachette sont documentées ici. Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le versionnage [SemVer](https://semver.org/lang/fr/). +## [1.1.0] — 2026-09-11 + +### Ajouté + +- Mise à jour intégrée : Cachette surveille les releases du dépôt, télécharge l'APK en Wi-Fi + et propose l'installation. Vérification quotidienne en tâche de fond, et à la demande depuis + l'écran Sources, qui affiche aussi la version installée. +- La taille approximative de chaque résultat Internet Archive est affichée avant sélection : un + élément de 500 Mo ne se laisse plus confondre avec une chanson. + +### Corrigé + +- Recherche LibriVox qui ne renvoyait rien. L'API cherche les titres en correspondance exacte + sauf si la requête est préfixée par `^`, et les auteurs sur le seul nom de famille : « Jules + Verne » n'atteignait donc ni l'un ni l'autre. Les trois critères sont désormais interrogés en + parallèle et dédoublonnés, et un « aucun résultat » (HTTP 404) n'est plus traité comme une panne. +- Téléchargements Internet Archive qui échouaient ou n'aboutissaient pas. Trois causes distinctes : + les éléments en accès restreint répondaient 401 et sont maintenant écartés dès la recherche ; + seuls les fichiers `.mp3` et `.mp4` étaient acceptés, ce qui excluait des éléments ne proposant + que du `.m4a` ou de l'`.ogg` ; et le fichier retenu était le premier trouvé, souvent l'original + non compressé — c'est désormais le plus petit dérivé, soit 9 Mo au lieu de 30 sur un cas mesuré. + ## [1.0.0] — 2026-09-11 ### Ajouté diff --git a/README.md b/README.md index 5b29fb5..b316dc5 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Kotlin](https://img.shields.io/badge/Kotlin-2.1.0-7F52FF?logo=kotlin&logoColor=white)](https://kotlinlang.org) [![Compose](https://img.shields.io/badge/Jetpack%20Compose-Material%203-4285F4?logo=jetpackcompose&logoColor=white)](https://developer.android.com/jetpack/compose) [![Media3](https://img.shields.io/badge/Media3-1.11.1-FF6F00?logo=googleplay&logoColor=white)](https://developer.android.com/media/media3) -[![Version](https://img.shields.io/badge/Version-1.0.0-6B4EFF)](VERSION) +[![Version](https://img.shields.io/badge/Version-1.1.0-6B4EFF)](VERSION) [![Hors ligne](https://img.shields.io/badge/Hors%20ligne-100%25-3FD8C0)](#-comment-ça-marche) Cachette est un lecteur audio et vidéo pensé pour les trajets, les tunnels, les avions et les @@ -37,6 +37,7 @@ sans la moindre connexion. | 🎛️ **Lecture en arrière-plan** | Notification système, casque, Bluetooth, écran verrouillé | | 🌙 **Thème clair et sombre** | Material 3, palette de marque, contraste garanti | | 🔁 **Reprise automatique** | Un téléchargement coupé repart tout seul via WorkManager | +| 🆕 **Mise à jour intégrée** | Surveille les releases du dépôt, télécharge l'APK en Wi-Fi et propose l'installation | --- @@ -162,6 +163,7 @@ Gradle, le tag git et les notes de release suivront seuls. | `INTERNET` | Télécharger ce que vous avez choisi de garder | | `POST_NOTIFICATIONS` | Commandes de lecture et progression des téléchargements | | `FOREGROUND_SERVICE_*` | Continuer la lecture et les téléchargements écran éteint | +| `REQUEST_INSTALL_PACKAGES` | Proposer l'installation d'une mise à jour téléchargée | Aucune donnée ne quitte l'appareil : pas de compte, pas de télémétrie, pas de sauvegarde cloud (`dataExtractionRules` exclut explicitement le contenu mis en cache). Les seules connexions @@ -172,6 +174,37 @@ sortantes sont celles des URL que vous ajoutez et des catalogues que vous interr --- +## 🆕 Mises à jour + +Cachette étant distribuée hors magasin, elle surveille elle-même les releases du dépôt. + +``` +Release publiée ──► vérification quotidienne (WorkManager, Wi-Fi) + │ + ├─ version plus récente ? ──► téléchargement de l'APK + │ │ + └─ sinon : rien ▼ + notification « prête à installer » + │ + installation = geste utilisateur +``` + +L'installation reste un geste explicite : Android l'impose à toute application hors magasin. +L'écran **Sources** porte la même vérification à la demande, et affiche la version installée. + +Le dépôt étant privé, la lecture des releases demande un jeton. Il est lu depuis +`update-token.properties` (ignoré par git) et injecté dans `BuildConfig` à la compilation : + +```properties +giteaUpdateToken=… +``` + +> ⚠️ Un jeton embarqué dans un APK est extractible. Celui-ci est volontairement limité à la portée +> `read:repository` et révocable depuis Gitea. Il donne un accès en lecture aux dépôts du compte — +> ne pas lui accorder davantage. + +--- + ## 🗂️ Structure ``` @@ -183,6 +216,7 @@ app/src/main/java/fr/tuxgyver/cachette/ ├── sources/ Import URL et RSS, catalogues Internet Archive et LibriVox ├── offline/ Cache Media3, gestionnaire et service de téléchargement ├── playback/ MediaSessionService, ExoPlayer, contrôleur d'interface +├── update/ Vérification des releases, téléchargement, notification └── ui/ Compose — thème, bibliothèque, hors ligne, sources, parcours, lecteur ``` diff --git a/VERSION b/VERSION index 3eefcb9..9084fa2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.0 +1.1.0 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 45c1803..f6210e7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -22,6 +22,18 @@ android { val (major, minor, patch) = appVersion.split(".").map(String::toInt) versionName = appVersion versionCode = major * 10000 + minor * 100 + patch + + // Le dépôt de publication est privé : la vérification des mises à jour a besoin d'un + // jeton en lecture seule, lu depuis un fichier local ignoré par git, jamais codé en dur. + val updateTokenFile = rootProject.file("update-token.properties") + val updateToken = if (updateTokenFile.exists()) { + Properties().apply { updateTokenFile.inputStream().use { load(it) } } + .getProperty("giteaUpdateToken", "") + } else { + "" + } + buildConfigField("String", "GITEA_UPDATE_TOKEN", "\"$updateToken\"") + buildConfigField("String", "UPDATE_API", "\"https://git.tuxtech.fr/api/v1/repos/tuxgyver/Cachette\"") } buildTypes { @@ -55,6 +67,7 @@ android { buildFeatures { compose = true + buildConfig = true } packaging { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e8b75ee..eb2751f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + @@ -58,5 +59,15 @@ + + + + diff --git a/app/src/main/java/fr/tuxgyver/cachette/CachetteApp.kt b/app/src/main/java/fr/tuxgyver/cachette/CachetteApp.kt index 1a38246..435a07f 100644 --- a/app/src/main/java/fr/tuxgyver/cachette/CachetteApp.kt +++ b/app/src/main/java/fr/tuxgyver/cachette/CachetteApp.kt @@ -14,6 +14,8 @@ import fr.tuxgyver.cachette.sources.CatalogId import fr.tuxgyver.cachette.sources.LibriVoxCatalog import fr.tuxgyver.cachette.sources.RssImporter import fr.tuxgyver.cachette.sources.UrlImporter +import fr.tuxgyver.cachette.update.UpdateCheckWorker +import fr.tuxgyver.cachette.update.UpdateRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -43,6 +45,8 @@ class AppContainer(context: Context) { ).associateBy { it.id } val player = PlayerController(context, scope) { id -> library.entry(id) } + + val updates = UpdateRepository(context, BuildConfig.VERSION_NAME) } class CachetteApp : Application() { @@ -53,5 +57,6 @@ class CachetteApp : Application() { override fun onCreate() { super.onCreate() container = AppContainer(this) + UpdateCheckWorker.schedule(this) } } diff --git a/app/src/main/java/fr/tuxgyver/cachette/sources/ArchiveOrgCatalog.kt b/app/src/main/java/fr/tuxgyver/cachette/sources/ArchiveOrgCatalog.kt index 34f9256..327a596 100644 --- a/app/src/main/java/fr/tuxgyver/cachette/sources/ArchiveOrgCatalog.kt +++ b/app/src/main/java/fr/tuxgyver/cachette/sources/ArchiveOrgCatalog.kt @@ -27,9 +27,14 @@ class ArchiveOrgCatalog : Catalog { override suspend fun search(query: String): Result> = withContext(Dispatchers.IO) { runCatching { - val q = URLEncoder.encode("($query) AND mediatype:(audio OR movies)", "UTF-8") + // Les éléments « access-restricted » répondent 401 au téléchargement : on les écarte + // dès la recherche plutôt que de les proposer puis d'échouer. + val q = URLEncoder.encode( + "($query) AND mediatype:(audio OR movies) AND NOT access-restricted-item:true", + "UTF-8", + ) val url = "https://archive.org/advancedsearch.php?q=$q" + - "&fl%5B%5D=identifier&fl%5B%5D=title&fl%5B%5D=creator&fl%5B%5D=mediatype" + + "&fl%5B%5D=identifier&fl%5B%5D=title&fl%5B%5D=creator&fl%5B%5D=mediatype&fl%5B%5D=item_size" + "&rows=30&page=1&output=json" val body = fetch(url) @@ -46,6 +51,7 @@ class ArchiveOrgCatalog : Catalog { subtitle = doc.text("creator") ?: "Internet Archive", artworkUri = "https://archive.org/services/img/$identifier", hint = if (doc.text("mediatype") == "movies") "Vidéo" else "Audio", + sizeBytes = doc.text("item_size")?.toLongOrNull(), ) } } @@ -54,15 +60,26 @@ class ArchiveOrgCatalog : Catalog { override suspend fun resolve(item: CatalogItem): Result> = withContext(Dispatchers.IO) { runCatching { val metadata = json.parseToJsonElement(fetch("https://archive.org/metadata/${item.id}")).jsonObject - val files = metadata["files"] as? JsonArray ?: JsonArray(emptyList()) - val wantsVideo = item.hint == "Vidéo" + if (metadata["metadata"]?.jsonObject?.text("access-restricted-item") == "true") { + throw IllegalStateException("« ${item.title} » est en accès restreint sur archive.org") + } + + val wantsVideo = item.hint == "Vidéo" + val extensions = if (wantsVideo) VIDEO_EXTENSIONS else AUDIO_EXTENSIONS + val files = (metadata["files"] as? JsonArray).orEmpty() + + // Un élément expose souvent l'original et ses dérivés. Le plus petit est le dérivé + // compressé : c'est celui qu'on veut sur un téléphone. val playable = files.mapNotNull { it as? JsonObject } - .firstOrNull { file -> + .filter { file -> val name = file.text("name").orEmpty().lowercase() - if (wantsVideo) name.endsWith(".mp4") else name.endsWith(".mp3") + extensions.any { name.endsWith(it) } } - ?: throw IllegalStateException("Aucun fichier lisible pour « ${item.title} »") + .minByOrNull { it.text("size")?.toLongOrNull() ?: Long.MAX_VALUE } + ?: throw IllegalStateException( + "Aucun fichier ${if (wantsVideo) "vidéo" else "audio"} lisible dans « ${item.title} »" + ) val name = playable.text("name")!! val seconds = playable.text("length")?.substringBefore('.')?.toLongOrNull() ?: 0L @@ -74,7 +91,7 @@ class ArchiveOrgCatalog : Catalog { kind = if (wantsVideo) MediaKind.VIDEO else MediaKind.AUDIO, title = item.title, subtitle = item.subtitle, - uri = "https://archive.org/download/${item.id}/${URLEncoder.encode(name, "UTF-8").replace("+", "%20")}", + uri = "https://archive.org/download/${item.id}/${encodePath(name)}", artworkUri = item.artworkUri, durationMs = seconds * 1000, addedAt = System.currentTimeMillis(), @@ -83,6 +100,12 @@ class ArchiveOrgCatalog : Catalog { } } + /** Encode le nom de fichier sans toucher aux séparateurs de chemin. */ + private fun encodePath(name: String): String = + name.split('/').joinToString("/") { + URLEncoder.encode(it, "UTF-8").replace("+", "%20") + } + private fun fetch(url: String): String { val connection = openConnection(url) return try { @@ -94,6 +117,13 @@ class ArchiveOrgCatalog : Catalog { connection.disconnect() } } + + private companion object { + val AUDIO_EXTENSIONS = listOf(".mp3", ".m4a", ".ogg", ".opus", ".flac", ".wav") + val VIDEO_EXTENSIONS = listOf(".mp4", ".m4v", ".webm", ".ogv") + + fun JsonArray?.orEmpty(): JsonArray = this ?: JsonArray(emptyList()) + } } /** archive.org renvoie tantôt une chaîne, tantôt un tableau : on prend la première valeur utile. */ diff --git a/app/src/main/java/fr/tuxgyver/cachette/sources/Catalog.kt b/app/src/main/java/fr/tuxgyver/cachette/sources/Catalog.kt index 8a1c8dd..75fe7be 100644 --- a/app/src/main/java/fr/tuxgyver/cachette/sources/Catalog.kt +++ b/app/src/main/java/fr/tuxgyver/cachette/sources/Catalog.kt @@ -9,6 +9,8 @@ data class CatalogItem( val subtitle: String, val artworkUri: String? = null, val hint: String = "", + /** Taille approximative de l'élément, quand le catalogue la connaît avant résolution. */ + val sizeBytes: Long? = null, ) /** Catalogue public interrogeable sans compte ni clé d'API. */ diff --git a/app/src/main/java/fr/tuxgyver/cachette/sources/LibriVoxCatalog.kt b/app/src/main/java/fr/tuxgyver/cachette/sources/LibriVoxCatalog.kt index 51b689a..5e04ca3 100644 --- a/app/src/main/java/fr/tuxgyver/cachette/sources/LibriVoxCatalog.kt +++ b/app/src/main/java/fr/tuxgyver/cachette/sources/LibriVoxCatalog.kt @@ -2,6 +2,9 @@ package fr.tuxgyver.cachette.sources import fr.tuxgyver.cachette.model.MediaEntry import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray @@ -12,8 +15,16 @@ import java.net.URLEncoder /** * Recherche dans LibriVox. * - * Chaque livre publie un flux RSS : la résolution réutilise donc l'import de podcast plutôt que - * de redécrire un parcours de chapitres. + * L'API a deux pièges, tous deux vérifiés contre le service réel : + * le champ `title` fait une correspondance **exacte** sauf s'il est préfixé par `^` + * (`title=Alice` ne donne rien, `title=^Alice` donne vingt-trois livres), et le champ `author` + * ne matche que le **nom de famille** (`author=Jules Verne` ne donne rien, `author=Verne` si). + * Un « aucun résultat » se présente enfin sous la forme d'un HTTP 404, pas d'une liste vide. + * + * D'où trois interrogations combinées et dédoublonnées, plutôt qu'une seule qui échouerait. + * + * Chaque livre publie un flux RSS : la résolution réutilise l'import de podcast plutôt que de + * redécrire un parcours de chapitres. */ class LibriVoxCatalog(private val rss: RssImporter) : Catalog { @@ -23,38 +34,55 @@ class LibriVoxCatalog(private val rss: RssImporter) : Catalog { override suspend fun search(query: String): Result> = withContext(Dispatchers.IO) { runCatching { - val url = "https://librivox.org/api/feed/audiobooks/?format=json&limit=25" + - "&title=${URLEncoder.encode(query, "UTF-8")}" + val cleaned = query.trim() + val term = URLEncoder.encode(cleaned, "UTF-8") + val words = cleaned.split(Regex("\\s+")).filter { it.isNotBlank() } - val connection = openConnection(url) - val body = try { - if (connection.responseCode !in 200..299) { - throw IllegalStateException("LibriVox a répondu ${connection.responseCode}") - } - connection.inputStream.bufferedReader().readText() - } finally { - connection.disconnect() + val criteria = buildList { + add("title=^$term") + add("author=$term") + // « Jules Verne » ne matche que sur « Verne » : on retente avec le nom de famille. + if (words.size > 1) add("author=${URLEncoder.encode(words.last(), "UTF-8")}") } - val books = json.parseToJsonElement(body).jsonObject["books"] as? JsonArray - ?: throw IllegalStateException("Aucun livre trouvé") - - books.mapNotNull { element -> - val book = element as? JsonObject ?: return@mapNotNull null - val feed = book.text("url_rss") ?: return@mapNotNull null - CatalogItem( - id = feed, - title = book.text("title") ?: "Livre audio", - subtitle = book.authors(), - hint = "Livre audio", - ) - } + // Les trois critères sont indépendants : les lancer en parallèle ramène l'attente + // à celle de la requête la plus lente, au lieu de leur somme. + coroutineScope { + criteria.map { async { books(it) } }.awaitAll() + }.flatten().distinctBy { it.id } } } override suspend fun resolve(item: CatalogItem): Result> = rss.import(item.id).map { it.episodes } + private fun books(criterion: String): List { + val url = "https://librivox.org/api/feed/audiobooks/?format=json&limit=25&$criterion" + val connection = openConnection(url) + val body = try { + when (connection.responseCode) { + // LibriVox répond 404 quand le critère ne donne rien : ce n'est pas une panne. + 404 -> return emptyList() + in 200..299 -> connection.inputStream.bufferedReader().readText() + else -> throw IllegalStateException("LibriVox a répondu ${connection.responseCode}") + } + } finally { + connection.disconnect() + } + + val books = json.parseToJsonElement(body).jsonObject["books"] as? JsonArray ?: return emptyList() + return books.mapNotNull { element -> + val book = element as? JsonObject ?: return@mapNotNull null + val feed = book.text("url_rss") ?: return@mapNotNull null + CatalogItem( + id = feed, + title = book.text("title") ?: "Livre audio", + subtitle = book.authors(), + hint = book.text("totaltime")?.let { "Livre audio · $it" } ?: "Livre audio", + ) + } + } + private fun JsonObject.authors(): String { val authors = this["authors"] as? JsonArray ?: return "LibriVox" return authors.mapNotNull { it as? JsonObject } diff --git a/app/src/main/java/fr/tuxgyver/cachette/ui/browse/CatalogBrowseScreen.kt b/app/src/main/java/fr/tuxgyver/cachette/ui/browse/CatalogBrowseScreen.kt index 495849e..d59dbee 100644 --- a/app/src/main/java/fr/tuxgyver/cachette/ui/browse/CatalogBrowseScreen.kt +++ b/app/src/main/java/fr/tuxgyver/cachette/ui/browse/CatalogBrowseScreen.kt @@ -49,6 +49,7 @@ import fr.tuxgyver.cachette.model.MediaKind import fr.tuxgyver.cachette.sources.CatalogId import fr.tuxgyver.cachette.sources.CatalogItem import fr.tuxgyver.cachette.ui.components.Artwork +import fr.tuxgyver.cachette.ui.components.formatBytes import kotlinx.coroutines.launch /** @@ -237,7 +238,11 @@ private fun CatalogRow( overflow = TextOverflow.Ellipsis, ) Text( - text = listOf(item.subtitle, item.hint).filter { it.isNotBlank() }.joinToString(" · "), + text = listOfNotNull( + item.subtitle.takeIf { it.isNotBlank() }, + item.hint.takeIf { it.isNotBlank() }, + item.sizeBytes?.let { "~${formatBytes(it)}" }, + ).joinToString(" · "), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, diff --git a/app/src/main/java/fr/tuxgyver/cachette/ui/sources/SourcesScreen.kt b/app/src/main/java/fr/tuxgyver/cachette/ui/sources/SourcesScreen.kt index f03fa87..3f18656 100644 --- a/app/src/main/java/fr/tuxgyver/cachette/ui/sources/SourcesScreen.kt +++ b/app/src/main/java/fr/tuxgyver/cachette/ui/sources/SourcesScreen.kt @@ -149,6 +149,11 @@ fun SourcesScreen( Text("Services tiers", style = MaterialTheme.typography.titleMedium) } + item { + Spacer(Modifier.size(8.dp)) + UpdateCard(container) + } + unavailableSourceCards( listOf( UnavailableSource( diff --git a/app/src/main/java/fr/tuxgyver/cachette/ui/sources/UpdateCard.kt b/app/src/main/java/fr/tuxgyver/cachette/ui/sources/UpdateCard.kt new file mode 100644 index 0000000..ed4da84 --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/ui/sources/UpdateCard.kt @@ -0,0 +1,137 @@ +package fr.tuxgyver.cachette.ui.sources + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.SystemUpdate +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.platform.LocalContext +import androidx.compose.ui.unit.dp +import fr.tuxgyver.cachette.AppContainer +import fr.tuxgyver.cachette.BuildConfig +import fr.tuxgyver.cachette.update.UpdateInfo +import fr.tuxgyver.cachette.update.UpdateNotifier +import kotlinx.coroutines.launch +import java.io.File + +private sealed interface UpdateUiState { + data object Idle : UpdateUiState + data object Checking : UpdateUiState + data object UpToDate : UpdateUiState + data class Available(val info: UpdateInfo) : UpdateUiState + data class Downloading(val info: UpdateInfo) : UpdateUiState + data class Ready(val info: UpdateInfo, val apk: File) : UpdateUiState + data class Failed(val message: String) : UpdateUiState +} + +/** + * Vérification manuelle des mises à jour. + * + * La vérification automatique tourne une fois par jour en tâche de fond ; cette carte donne le + * même résultat à la demande, et rend visible la version installée. + */ +@Composable +fun UpdateCard(container: AppContainer) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var state by remember { mutableStateOf(UpdateUiState.Idle) } + + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.large, + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(20.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Rounded.SystemUpdate, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.size(12.dp)) + Column(Modifier.weight(1f)) { + Text("Mises à jour", style = MaterialTheme.typography.titleMedium) + Text( + text = "Version installée ${BuildConfig.VERSION_NAME}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(Modifier.size(10.dp)) + + val current = state + Text( + text = when (current) { + UpdateUiState.Idle -> + "Cachette regarde chaque jour si une nouvelle version a été publiée, et la télécharge en Wi-Fi." + UpdateUiState.Checking -> "Vérification en cours…" + UpdateUiState.UpToDate -> "Vous avez déjà la dernière version." + is UpdateUiState.Available -> "Version ${current.info.version} disponible." + is UpdateUiState.Downloading -> "Téléchargement de la version ${current.info.version}…" + is UpdateUiState.Ready -> "Version ${current.info.version} téléchargée, prête à installer." + is UpdateUiState.Failed -> current.message + }, + style = MaterialTheme.typography.bodyMedium, + color = if (current is UpdateUiState.Failed) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.size(12.dp)) + + when (current) { + is UpdateUiState.Ready -> Button( + onClick = { UpdateNotifier.install(context, current.apk) } + ) { Text("Installer") } + + is UpdateUiState.Available -> Button(onClick = { + state = UpdateUiState.Downloading(current.info) + scope.launch { + runCatching { container.updates.downloadApk(current.info) }.fold( + onSuccess = { state = UpdateUiState.Ready(current.info, it) }, + onFailure = { + state = UpdateUiState.Failed(it.message ?: "Téléchargement impossible") + }, + ) + } + }) { Text("Télécharger") } + + UpdateUiState.Checking, is UpdateUiState.Downloading -> + CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(24.dp)) + + else -> Button(onClick = { + state = UpdateUiState.Checking + scope.launch { + runCatching { container.updates.checkForUpdate() }.fold( + onSuccess = { info -> + state = if (info == null) UpdateUiState.UpToDate + else UpdateUiState.Available(info) + }, + onFailure = { + state = UpdateUiState.Failed(it.message ?: "Vérification impossible") + }, + ) + } + }) { Text("Rechercher une mise à jour") } + } + } + } +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/update/UpdateCheckWorker.kt b/app/src/main/java/fr/tuxgyver/cachette/update/UpdateCheckWorker.kt new file mode 100644 index 0000000..f476778 --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/update/UpdateCheckWorker.kt @@ -0,0 +1,53 @@ +package fr.tuxgyver.cachette.update + +import android.content.Context +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import fr.tuxgyver.cachette.CachetteApp +import java.util.concurrent.TimeUnit + +/** + * Vérifie chaque jour la dernière release du dépôt, télécharge l'APK si une version plus récente + * existe, puis notifie. L'installation reste un geste de l'utilisateur, imposé par Android. + */ +class UpdateCheckWorker( + context: Context, + params: WorkerParameters, +) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + val repository = (applicationContext as CachetteApp).container.updates + val update = runCatching { repository.checkForUpdate() }.getOrNull() + ?: return Result.success() + val apk = runCatching { repository.downloadApk(update) }.getOrNull() + ?: return Result.retry() + UpdateNotifier.notifyReadyToInstall(applicationContext, update.version, apk) + return Result.success() + } + + companion object { + private const val WORK_NAME = "cachette-update-check" + + /** Une vérification par jour, sur réseau non facturé à l'usage. */ + fun schedule(context: Context) { + val request = PeriodicWorkRequestBuilder(1, TimeUnit.DAYS) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.UNMETERED) + .build() + ) + .build() + + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + request, + ) + } + } +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/update/UpdateNotifier.kt b/app/src/main/java/fr/tuxgyver/cachette/update/UpdateNotifier.kt new file mode 100644 index 0000000..0dcd340 --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/update/UpdateNotifier.kt @@ -0,0 +1,79 @@ +package fr.tuxgyver.cachette.update + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import fr.tuxgyver.cachette.R +import java.io.File + +/** Prévient que la mise à jour est téléchargée et ouvre l'installateur système d'un appui. */ +object UpdateNotifier { + + private const val CHANNEL_ID = "cachette_updates" + private const val NOTIFICATION_ID = 4301 + + fun notifyReadyToInstall(context: Context, version: String, apk: File) { + createChannel(context) + + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apk) + val install = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "application/vnd.android.package-archive") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) + } + val pending = PendingIntent.getActivity( + context, + 0, + install, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_offline) + .setContentTitle("Cachette $version est prête") + .setContentText("Appuyez pour installer la mise à jour.") + .setContentIntent(pending) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .build() + + // Sans la permission de notifier, le téléchargement reste valable : l'écran Sources + // propose la même installation. On s'abstient donc plutôt que de planter. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != + PackageManager.PERMISSION_GRANTED + ) { + return + } + NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notification) + } + + /** Ouvre l'installateur système pour un APK déjà téléchargé. */ + fun install(context: Context, apk: File) { + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apk) + context.startActivity( + Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "application/vnd.android.package-archive") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) + } + ) + } + + private fun createChannel(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val channel = NotificationChannel( + CHANNEL_ID, + "Mises à jour", + NotificationManager.IMPORTANCE_DEFAULT, + ).apply { description = "Disponibilité d'une nouvelle version de Cachette" } + context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + } +} diff --git a/app/src/main/java/fr/tuxgyver/cachette/update/UpdateRepository.kt b/app/src/main/java/fr/tuxgyver/cachette/update/UpdateRepository.kt new file mode 100644 index 0000000..06a5457 --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/cachette/update/UpdateRepository.kt @@ -0,0 +1,121 @@ +package fr.tuxgyver.cachette.update + +import android.content.Context +import fr.tuxgyver.cachette.BuildConfig +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.io.File +import java.io.FileOutputStream +import java.net.HttpURLConnection +import java.net.URL + +data class UpdateInfo( + val version: String, + val notes: String, + val downloadUrl: String, + val apkFileName: String, +) + +/** + * Vérifie la dernière release publiée sur le dépôt et en télécharge l'APK. + * + * Le dépôt étant privé, chaque appel est authentifié par un jeton en lecture seule injecté à la + * compilation. L'installation elle-même reste un geste de l'utilisateur : Android l'impose à + * toute application distribuée hors magasin. + */ +class UpdateRepository( + private val context: Context, + private val currentVersion: String, +) { + private val json = Json { ignoreUnknownKeys = true } + + suspend fun checkForUpdate(): UpdateInfo? = withContext(Dispatchers.IO) { + if (BuildConfig.GITEA_UPDATE_TOKEN.isBlank()) return@withContext null + + val body = runCatching { fetch("${BuildConfig.UPDATE_API}/releases/latest") }.getOrNull() + ?: return@withContext null + + val release = json.parseToJsonElement(body).jsonObject + val remote = release["tag_name"]?.jsonPrimitive?.content?.removePrefix("v") + ?: return@withContext null + if (!isNewer(remote, currentVersion)) return@withContext null + + val apk = (release["assets"] as? JsonArray) + ?.mapNotNull { it as? JsonObject } + ?.firstOrNull { it["name"]?.jsonPrimitive?.content?.endsWith(".apk") == true } + ?: return@withContext null + + UpdateInfo( + version = remote, + notes = release["body"]?.jsonPrimitive?.content.orEmpty(), + downloadUrl = apk["browser_download_url"]?.jsonPrimitive?.content ?: return@withContext null, + apkFileName = apk["name"]?.jsonPrimitive?.content ?: "cachette-$remote.apk", + ) + } + + /** Télécharge l'APK dans le cache de l'application et renvoie le fichier prêt à installer. */ + suspend fun downloadApk(update: UpdateInfo): File = withContext(Dispatchers.IO) { + val directory = File(context.cacheDir, "updates").apply { mkdirs() } + // Une version précédemment téléchargée n'a plus lieu d'être une fois la suivante demandée. + directory.listFiles()?.forEach { if (it.name != update.apkFileName) it.delete() } + + val target = File(directory, update.apkFileName) + val partial = File(directory, "${update.apkFileName}.part") + + val connection = authenticated(update.downloadUrl) + try { + if (connection.responseCode !in 200..299) { + throw IllegalStateException("Téléchargement refusé (${connection.responseCode})") + } + connection.inputStream.use { input -> + FileOutputStream(partial).use { output -> input.copyTo(output) } + } + } finally { + connection.disconnect() + } + + // Renommage final : un fichier .apk présent est donc toujours un fichier complet. + if (target.exists()) target.delete() + check(partial.renameTo(target)) { "Impossible de finaliser le téléchargement" } + target + } + + private fun fetch(url: String): String { + val connection = authenticated(url) + return try { + if (connection.responseCode !in 200..299) { + throw IllegalStateException("Le dépôt a répondu ${connection.responseCode}") + } + connection.inputStream.bufferedReader().readText() + } finally { + connection.disconnect() + } + } + + private fun authenticated(url: String): HttpURLConnection = + (URL(url).openConnection() as HttpURLConnection).apply { + requestMethod = "GET" + instanceFollowRedirects = true + connectTimeout = 15_000 + readTimeout = 60_000 + setRequestProperty("User-Agent", "Cachette/$currentVersion (Android)") + setRequestProperty("Authorization", "token ${BuildConfig.GITEA_UPDATE_TOKEN}") + } + + /** Comparaison numérique champ à champ : « 1.10.0 » est plus récent que « 1.9.0 ». */ + private fun isNewer(remote: String, current: String): Boolean { + val r = remote.split('.').mapNotNull { it.toIntOrNull() } + val c = current.split('.').mapNotNull { it.toIntOrNull() } + for (i in 0 until maxOf(r.size, c.size)) { + val remotePart = r.getOrElse(i) { 0 } + val currentPart = c.getOrElse(i) { 0 } + if (remotePart != currentPart) return remotePart > currentPart + } + return false + } +} 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..d5d1162 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + +