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 @@
[](https://kotlinlang.org)
[](https://developer.android.com/jetpack/compose)
[](https://developer.android.com/media/media3)
-[](VERSION)
+[](VERSION)
[](#-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 @@
+
+
+
+
+