Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1912c2056 |
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"
|
|
||||||
|
|
||||||
|
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 }
|
val playable = files.mapNotNull { it as? JsonObject }
|
||||||
.firstOrNull { file ->
|
.filter { file ->
|
||||||
val name = file.text("name").orEmpty().lowercase()
|
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 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,38 +34,55 @@ 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 connection = openConnection(url)
|
val criteria = buildList {
|
||||||
val body = try {
|
add("title=^$term")
|
||||||
if (connection.responseCode !in 200..299) {
|
add("author=$term")
|
||||||
throw IllegalStateException("LibriVox a répondu ${connection.responseCode}")
|
// « 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")}")
|
||||||
connection.inputStream.bufferedReader().readText()
|
|
||||||
} finally {
|
|
||||||
connection.disconnect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val books = json.parseToJsonElement(body).jsonObject["books"] as? JsonArray
|
// Les trois critères sont indépendants : les lancer en parallèle ramène l'attente
|
||||||
?: throw IllegalStateException("Aucun livre trouvé")
|
// à celle de la requête la plus lente, au lieu de leur somme.
|
||||||
|
coroutineScope {
|
||||||
books.mapNotNull { element ->
|
criteria.map { async { books(it) } }.awaitAll()
|
||||||
val book = element as? JsonObject ?: return@mapNotNull null
|
}.flatten().distinctBy { it.id }
|
||||||
val feed = book.text("url_rss") ?: return@mapNotNull null
|
|
||||||
CatalogItem(
|
|
||||||
id = feed,
|
|
||||||
title = book.text("title") ?: "Livre audio",
|
|
||||||
subtitle = book.authors(),
|
|
||||||
hint = "Livre audio",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun resolve(item: CatalogItem): Result<List<MediaEntry>> =
|
override suspend fun resolve(item: CatalogItem): Result<List<MediaEntry>> =
|
||||||
rss.import(item.id).map { it.episodes }
|
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 {
|
private fun JsonObject.authors(): String {
|
||||||
val authors = this["authors"] as? JsonArray ?: return "LibriVox"
|
val authors = this["authors"] as? JsonArray ?: return "LibriVox"
|
||||||
return authors.mapNotNull { it as? JsonObject }
|
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.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