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 c1912c2056
17 files changed
+587 -36

No files matched your search

+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>