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:
1 parent
992fe88cc4
commit
c1912c2056
17 files changed
+580
-29
No files matched your search
@@ -11,3 +11,4 @@ keystore.properties
|
|||||||
*.jks
|
*.jks
|
||||||
.gitea-token
|
.gitea-token
|
||||||
Cachette-*.apk
|
Cachette-*.apk
|
||||||
|
update-token.properties
|
||||||
@@ -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/)
|
Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/)
|
||||||
et le versionnage [SemVer](https://semver.org/lang/fr/).
|
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
|
## [1.0.0] — 2026-09-11
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
[](https://kotlinlang.org)
|
[](https://kotlinlang.org)
|
||||||
[](https://developer.android.com/jetpack/compose)
|
[](https://developer.android.com/jetpack/compose)
|
||||||
[](https://developer.android.com/media/media3)
|
[](https://developer.android.com/media/media3)
|
||||||
[](VERSION)
|
[](VERSION)
|
||||||
[](#-comment-ça-marche)
|
[](#-comment-ça-marche)
|
||||||
|
|
||||||
Cachette est un lecteur audio et vidéo pensé pour les trajets, les tunnels, les avions et les
|
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é |
|
| 🎛️ **Lecture en arrière-plan** | Notification système, casque, Bluetooth, écran verrouillé |
|
||||||
| 🌙 **Thème clair et sombre** | Material 3, palette de marque, contraste garanti |
|
| 🌙 **Thème clair et sombre** | Material 3, palette de marque, contraste garanti |
|
||||||
| 🔁 **Reprise automatique** | Un téléchargement coupé repart tout seul via WorkManager |
|
| 🔁 **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 |
|
| `INTERNET` | Télécharger ce que vous avez choisi de garder |
|
||||||
| `POST_NOTIFICATIONS` | Commandes de lecture et progression des téléchargements |
|
| `POST_NOTIFICATIONS` | Commandes de lecture et progression des téléchargements |
|
||||||
| `FOREGROUND_SERVICE_*` | Continuer la lecture et les téléchargements écran éteint |
|
| `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
|
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
|
(`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
|
## 🗂️ Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -183,6 +216,7 @@ app/src/main/java/fr/tuxgyver/cachette/
|
|||||||
├── sources/ Import URL et RSS, catalogues Internet Archive et LibriVox
|
├── sources/ Import URL et RSS, catalogues Internet Archive et LibriVox
|
||||||
├── offline/ Cache Media3, gestionnaire et service de téléchargement
|
├── offline/ Cache Media3, gestionnaire et service de téléchargement
|
||||||
├── playback/ MediaSessionService, ExoPlayer, contrôleur d'interface
|
├── 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
|
└── ui/ Compose — thème, bibliothèque, hors ligne, sources, parcours, lecteur
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,18 @@ android {
|
|||||||
val (major, minor, patch) = appVersion.split(".").map(String::toInt)
|
val (major, minor, patch) = appVersion.split(".").map(String::toInt)
|
||||||
versionName = appVersion
|
versionName = appVersion
|
||||||
versionCode = major * 10000 + minor * 100 + patch
|
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 {
|
buildTypes {
|
||||||
@@ -55,6 +67,7 @@ android {
|
|||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
}
|
}
|
||||||
|
|
||||||
packaging {
|
packaging {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<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" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
@@ -58,5 +59,15 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</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>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -14,6 +14,8 @@ import fr.tuxgyver.cachette.sources.CatalogId
|
|||||||
import fr.tuxgyver.cachette.sources.LibriVoxCatalog
|
import fr.tuxgyver.cachette.sources.LibriVoxCatalog
|
||||||
import fr.tuxgyver.cachette.sources.RssImporter
|
import fr.tuxgyver.cachette.sources.RssImporter
|
||||||
import fr.tuxgyver.cachette.sources.UrlImporter
|
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.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -43,6 +45,8 @@ class AppContainer(context: Context) {
|
|||||||
).associateBy { it.id }
|
).associateBy { it.id }
|
||||||
|
|
||||||
val player = PlayerController(context, scope) { id -> library.entry(id) }
|
val player = PlayerController(context, scope) { id -> library.entry(id) }
|
||||||
|
|
||||||
|
val updates = UpdateRepository(context, BuildConfig.VERSION_NAME)
|
||||||
}
|
}
|
||||||
|
|
||||||
class CachetteApp : Application() {
|
class CachetteApp : Application() {
|
||||||
@@ -53,5 +57,6 @@ class CachetteApp : Application() {
|
|||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
container = AppContainer(this)
|
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) {
|
override suspend fun search(query: String): Result<List<CatalogItem>> = withContext(Dispatchers.IO) {
|
||||||
runCatching {
|
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" +
|
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"
|
"&rows=30&page=1&output=json"
|
||||||
|
|
||||||
val body = fetch(url)
|
val body = fetch(url)
|
||||||
@@ -46,6 +51,7 @@ class ArchiveOrgCatalog : Catalog {
|
|||||||
subtitle = doc.text("creator") ?: "Internet Archive",
|
subtitle = doc.text("creator") ?: "Internet Archive",
|
||||||
artworkUri = "https://archive.org/services/img/$identifier",
|
artworkUri = "https://archive.org/services/img/$identifier",
|
||||||
hint = if (doc.text("mediatype") == "movies") "Vidéo" else "Audio",
|
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) {
|
override suspend fun resolve(item: CatalogItem): Result<List<MediaEntry>> = withContext(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
val metadata = json.parseToJsonElement(fetch("https://archive.org/metadata/${item.id}")).jsonObject
|
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"
|
|
||||||
|
|
||||||
val playable = files.mapNotNull { it as? JsonObject }
|
if (metadata["metadata"]?.jsonObject?.text("access-restricted-item") == "true") {
|
||||||
.firstOrNull { file ->
|
throw IllegalStateException("« ${item.title} » est en accès restreint sur archive.org")
|
||||||
val name = file.text("name").orEmpty().lowercase()
|
|
||||||
if (wantsVideo) name.endsWith(".mp4") else name.endsWith(".mp3")
|
|
||||||
}
|
}
|
||||||
?: throw IllegalStateException("Aucun fichier lisible pour « ${item.title} »")
|
|
||||||
|
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 }
|
||||||
|
.filter { file ->
|
||||||
|
val name = file.text("name").orEmpty().lowercase()
|
||||||
|
extensions.any { name.endsWith(it) }
|
||||||
|
}
|
||||||
|
.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 name = playable.text("name")!!
|
||||||
val seconds = playable.text("length")?.substringBefore('.')?.toLongOrNull() ?: 0L
|
val seconds = playable.text("length")?.substringBefore('.')?.toLongOrNull() ?: 0L
|
||||||
@@ -74,7 +91,7 @@ class ArchiveOrgCatalog : Catalog {
|
|||||||
kind = if (wantsVideo) MediaKind.VIDEO else MediaKind.AUDIO,
|
kind = if (wantsVideo) MediaKind.VIDEO else MediaKind.AUDIO,
|
||||||
title = item.title,
|
title = item.title,
|
||||||
subtitle = item.subtitle,
|
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,
|
artworkUri = item.artworkUri,
|
||||||
durationMs = seconds * 1000,
|
durationMs = seconds * 1000,
|
||||||
addedAt = System.currentTimeMillis(),
|
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 {
|
private fun fetch(url: String): String {
|
||||||
val connection = openConnection(url)
|
val connection = openConnection(url)
|
||||||
return try {
|
return try {
|
||||||
@@ -94,6 +117,13 @@ class ArchiveOrgCatalog : Catalog {
|
|||||||
connection.disconnect()
|
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. */
|
/** 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 subtitle: String,
|
||||||
val artworkUri: String? = null,
|
val artworkUri: String? = null,
|
||||||
val hint: String = "",
|
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. */
|
/** 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 fr.tuxgyver.cachette.model.MediaEntry
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonArray
|
import kotlinx.serialization.json.JsonArray
|
||||||
@@ -12,8 +15,16 @@ import java.net.URLEncoder
|
|||||||
/**
|
/**
|
||||||
* Recherche dans LibriVox.
|
* Recherche dans LibriVox.
|
||||||
*
|
*
|
||||||
* Chaque livre publie un flux RSS : la résolution réutilise donc l'import de podcast plutôt que
|
* L'API a deux pièges, tous deux vérifiés contre le service réel :
|
||||||
* de redécrire un parcours de chapitres.
|
* 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 {
|
class LibriVoxCatalog(private val rss: RssImporter) : Catalog {
|
||||||
|
|
||||||
@@ -23,37 +34,54 @@ class LibriVoxCatalog(private val rss: RssImporter) : Catalog {
|
|||||||
|
|
||||||
override suspend fun search(query: String): Result<List<CatalogItem>> = withContext(Dispatchers.IO) {
|
override suspend fun search(query: String): Result<List<CatalogItem>> = withContext(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
val url = "https://librivox.org/api/feed/audiobooks/?format=json&limit=25" +
|
val cleaned = query.trim()
|
||||||
"&title=${URLEncoder.encode(query, "UTF-8")}"
|
val term = URLEncoder.encode(cleaned, "UTF-8")
|
||||||
|
val words = cleaned.split(Regex("\\s+")).filter { it.isNotBlank() }
|
||||||
|
|
||||||
|
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")}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 connection = openConnection(url)
|
||||||
val body = try {
|
val body = try {
|
||||||
if (connection.responseCode !in 200..299) {
|
when (connection.responseCode) {
|
||||||
throw IllegalStateException("LibriVox a répondu ${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}")
|
||||||
}
|
}
|
||||||
connection.inputStream.bufferedReader().readText()
|
|
||||||
} finally {
|
} finally {
|
||||||
connection.disconnect()
|
connection.disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
val books = json.parseToJsonElement(body).jsonObject["books"] as? JsonArray
|
val books = json.parseToJsonElement(body).jsonObject["books"] as? JsonArray ?: return emptyList()
|
||||||
?: throw IllegalStateException("Aucun livre trouvé")
|
return books.mapNotNull { element ->
|
||||||
|
|
||||||
books.mapNotNull { element ->
|
|
||||||
val book = element as? JsonObject ?: return@mapNotNull null
|
val book = element as? JsonObject ?: return@mapNotNull null
|
||||||
val feed = book.text("url_rss") ?: return@mapNotNull null
|
val feed = book.text("url_rss") ?: return@mapNotNull null
|
||||||
CatalogItem(
|
CatalogItem(
|
||||||
id = feed,
|
id = feed,
|
||||||
title = book.text("title") ?: "Livre audio",
|
title = book.text("title") ?: "Livre audio",
|
||||||
subtitle = book.authors(),
|
subtitle = book.authors(),
|
||||||
hint = "Livre audio",
|
hint = book.text("totaltime")?.let { "Livre audio · $it" } ?: "Livre audio",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun resolve(item: CatalogItem): Result<List<MediaEntry>> =
|
|
||||||
rss.import(item.id).map { it.episodes }
|
|
||||||
|
|
||||||
private fun JsonObject.authors(): String {
|
private fun JsonObject.authors(): String {
|
||||||
val authors = this["authors"] as? JsonArray ?: return "LibriVox"
|
val authors = this["authors"] as? JsonArray ?: return "LibriVox"
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import fr.tuxgyver.cachette.model.MediaKind
|
|||||||
import fr.tuxgyver.cachette.sources.CatalogId
|
import fr.tuxgyver.cachette.sources.CatalogId
|
||||||
import fr.tuxgyver.cachette.sources.CatalogItem
|
import fr.tuxgyver.cachette.sources.CatalogItem
|
||||||
import fr.tuxgyver.cachette.ui.components.Artwork
|
import fr.tuxgyver.cachette.ui.components.Artwork
|
||||||
|
import fr.tuxgyver.cachette.ui.components.formatBytes
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -237,7 +238,11 @@ private fun CatalogRow(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
Text(
|
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,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
|
|||||||
@@ -149,6 +149,11 @@ fun SourcesScreen(
|
|||||||
Text("Services tiers", style = MaterialTheme.typography.titleMedium)
|
Text("Services tiers", style = MaterialTheme.typography.titleMedium)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
UpdateCard(container)
|
||||||
|
}
|
||||||
|
|
||||||
unavailableSourceCards(
|
unavailableSourceCards(
|
||||||
listOf(
|
listOf(
|
||||||
UnavailableSource(
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
Reference in new issue
Block a user