Recherche LibriVox réparée, téléchargements Archive fiabilisés, mises à jour intégrées (1.1.0)

Deux bugs signalés à l'usage, et la distribution hors magasin qui manquait.

LibriVox ne renvoyait jamais rien : l'API cherche les titres en correspondance
exacte sauf préfixe « ^ », et les auteurs sur le seul nom de famille. Les trois
critères sont interrogés en parallèle et dédoublonnés, et un 404 « aucun
résultat » n'est plus confondu avec une panne.

Les téléchargements Internet Archive échouaient pour trois raisons distinctes :
éléments en accès restreint répondant 401, filtre limité aux seuls .mp3/.mp4, et
choix du premier fichier plutôt que du plus petit dérivé. La taille de chaque
résultat est désormais affichée avant sélection, ce qui distingue une chanson
d'une émission de 500 Mo.

L'application étant distribuée hors magasin, elle surveille elle-même les
releases du dépôt, télécharge l'APK en Wi-Fi et propose l'installation, qui
reste un geste de l'utilisateur comme Android l'impose.
This commit is contained in:
tuxgyver committed 2026-09-11 20:57:58 +02:00
1 parent 992fe88cc4
commit 439bace379
17 files changed
+587 -36

No files matched your search

+1
View File
@@ -11,3 +11,4 @@ keystore.properties
*.jks
.gitea-token
Cachette-*.apk
update-token.properties
+22
View File
@@ -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é
+35 -1
View File
@@ -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
```
+1 -1
View File
@@ -1 +1 @@
1.0.0
1.1.0
+13
View File
@@ -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 {
+11
View File
@@ -5,6 +5,7 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
@@ -58,5 +59,15 @@
</intent-filter>
</service>
<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>
</application>
</manifest>
@@ -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)
}
}
@@ -27,9 +27,14 @@ class ArchiveOrgCatalog : Catalog {
override suspend fun search(query: String): Result<List<CatalogItem>> = 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<List<MediaEntry>> = 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. */
@@ -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. */
@@ -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<List<CatalogItem>> = 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<List<MediaEntry>> =
rss.import(item.id).map { it.episodes }
private fun books(criterion: String): List<CatalogItem> {
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 }
@@ -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,
@@ -149,6 +149,11 @@ fun SourcesScreen(
Text("Services tiers", style = MaterialTheme.typography.titleMedium)
}
item {
Spacer(Modifier.size(8.dp))
UpdateCard(container)
}
unavailableSourceCards(
listOf(
UnavailableSource(
@@ -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>(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") }
}
}
}
}
@@ -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<UpdateCheckWorker>(1, TimeUnit.DAYS)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED)
.build()
)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
request,
)
}
}
}
@@ -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)
}
}
@@ -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
}
}
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Seul le dossier des mises à jour est partagé, et uniquement avec l'installateur système. -->
<paths>
<cache-path name="updates" path="updates/" />
</paths>