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
+587
-36
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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