fix : mise à jour in-app sans quitter l'application

- @capacitor/filesystem : téléchargement natif de l'APK (OkHttp)
- @capawesome-team/capacitor-file-opener : ouverture de l'installateur Android
- L'APK est écrit dans le cache de l'app puis ouvert via un Intent natif
- Plus de passage par le navigateur — tout se passe dans l'app
- file_paths.xml mis à jour pour couvrir le cache
This commit is contained in:
Mathis 2026-10-04 20:35:35 +02:00
parent 208ece19fc
commit 3ecd57119f
7 changed files with 150 additions and 48 deletions

39
package-lock.json generated
View file

@ -67,6 +67,43 @@
"tslib": "^2.1.0" "tslib": "^2.1.0"
} }
}, },
"node_modules/@capacitor/filesystem": {
"version": "8.1.4",
"resolved": "https://registry.npmjs.org/@capacitor/filesystem/-/filesystem-8.1.4.tgz",
"integrity": "sha512-8MXmt10cb+dGWoh8jUeO8on0U6iETQCX5k+fmdB278PZ3MYDEVGQo08+hroaOtUbK3+5stiRk4/K6QBHKepv0A==",
"license": "MIT",
"dependencies": {
"@capacitor/synapse": "^1.0.4"
},
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@capacitor/synapse": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz",
"integrity": "sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==",
"license": "ISC"
},
"node_modules/@capawesome-team/capacitor-file-opener": {
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/@capawesome-team/capacitor-file-opener/-/capacitor-file-opener-8.0.5.tgz",
"integrity": "sha512-Q5p9yv8nrpJbCAKCnjTQsCOJKEVZaxdq4K3JpbUtpTbRUMRZoEg+U4ZyVfF3wAo1WoHrQSR4ZI1x47pSSjj6fg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/capawesome-team/"
},
{
"type": "opencollective",
"url": "https://opencollective.com/capawesome"
}
],
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
@ -4671,6 +4708,8 @@
"@capacitor/android": "^8.5.2", "@capacitor/android": "^8.5.2",
"@capacitor/cli": "^8.5.2", "@capacitor/cli": "^8.5.2",
"@capacitor/core": "^8.5.2", "@capacitor/core": "^8.5.2",
"@capacitor/filesystem": "^8.1.4",
"@capawesome-team/capacitor-file-opener": "^8.0.5",
"@meteo-exam/shared": "*" "@meteo-exam/shared": "*"
}, },
"devDependencies": { "devDependencies": {

View file

@ -9,7 +9,8 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies { dependencies {
implementation project(':capacitor-filesystem')
implementation project(':capawesome-team-capacitor-file-opener')
} }

View file

@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android"> <paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." /> <files-path name="files" path="." />
<cache-path name="my_cache_images" path="." /> <cache-path name="cache" path="." />
<external-files-path name="external-files" path="." />
<external-cache-path name="external-cache" path="." />
<external-path name="external" path="." />
</paths> </paths>

View file

@ -1,3 +1,9 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN // DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android' include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../../node_modules/@capacitor/android/capacitor') project(':capacitor-android').projectDir = new File('../../node_modules/@capacitor/android/capacitor')
include ':capacitor-filesystem'
project(':capacitor-filesystem').projectDir = new File('../../node_modules/@capacitor/filesystem/android')
include ':capawesome-team-capacitor-file-opener'
project(':capawesome-team-capacitor-file-opener').projectDir = new File('../../node_modules/@capawesome-team/capacitor-file-opener/android')

View file

@ -13,6 +13,8 @@
"@capacitor/android": "^8.5.2", "@capacitor/android": "^8.5.2",
"@capacitor/cli": "^8.5.2", "@capacitor/cli": "^8.5.2",
"@capacitor/core": "^8.5.2", "@capacitor/core": "^8.5.2",
"@capacitor/filesystem": "^8.1.4",
"@capawesome-team/capacitor-file-opener": "^8.0.5",
"@meteo-exam/shared": "*" "@meteo-exam/shared": "*"
}, },
"devDependencies": { "devDependencies": {

View file

@ -1,17 +1,15 @@
import { h } from './dom' import { h } from './dom'
import type { UpdateInfo } from './update' import type { UpdateInfo } from './update'
import { currentAppVersion, dismissUpdate, downloadApk } from './update' import { currentAppVersion, dismissUpdate, downloadAndInstallApk } from './update'
import { showToast } from './toast' import { showToast } from './toast'
/** Modale de mise à jour affichée au démarrage quand une version plus récente existe. */ /** Modale de mise à jour : téléchargement + installation sans quitter l'app. */
export function showUpdateModal(info: UpdateInfo, onAfter: () => void): void { export function showUpdateModal(info: UpdateInfo, onAfter: () => void): void {
const overlay = h('div', { const overlay = h('div', {
class: 'fixed inset-0 z-[99] bg-slate-950/85 backdrop-blur-sm flex items-end sm:items-center justify-center p-0 sm:p-6', class: 'fixed inset-0 z-[99] bg-slate-950/85 backdrop-blur-sm flex items-end sm:items-center justify-center p-0 sm:p-6',
}) })
const close = (): void => { const close = (): void => overlay.remove()
overlay.remove()
}
const formatSize = (bytes: number | null): string => { const formatSize = (bytes: number | null): string => {
if (!bytes) return '' if (!bytes) return ''
@ -19,6 +17,41 @@ export function showUpdateModal(info: UpdateInfo, onAfter: () => void): void {
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo` return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`
} }
const statusText = h('p', { class: 'text-[11px] text-slate-400 font-semibold text-center mt-3 hidden' })
const downloadBtn = h(
'button',
{
class: 'w-full bg-sky-500 text-slate-950 text-sm font-extrabold py-3.5 rounded-xl active:scale-[0.98] transition-transform',
onclick: async () => {
if (!info.apkUrl) {
showToast('Aucune APK disponible pour le moment', 'info')
return
}
downloadBtn.disabled = true
downloadBtn.classList.add('opacity-60')
statusText.classList.remove('hidden')
statusText.textContent = 'Téléchargement en cours…'
try {
await downloadAndInstallApk(info.apkUrl)
statusText.textContent = 'Installation lancée — suivez les instructions Android.'
dismissUpdate(info.version)
showToast('Mise à jour prête à être installée', 'success')
close()
onAfter()
} catch (error) {
const msg = error instanceof Error ? error.message : 'Erreur inconnue'
statusText.textContent = `Échec : ${msg}`
showToast('Téléchargement impossible', 'reject')
downloadBtn.disabled = false
downloadBtn.classList.remove('opacity-60')
}
},
},
h('i', { class: 'fa-solid fa-download mr-2' }),
'Mettre à jour maintenant',
)
const panel = h( const panel = h(
'div', 'div',
{ class: 'w-full sm:max-w-md bg-slate-900 border border-sky-500/30 rounded-t-[2rem] sm:rounded-[2rem] p-6 shadow-2xl' }, { class: 'w-full sm:max-w-md bg-slate-900 border border-sky-500/30 rounded-t-[2rem] sm:rounded-[2rem] p-6 shadow-2xl' },
@ -39,30 +72,12 @@ export function showUpdateModal(info: UpdateInfo, onAfter: () => void): void {
), ),
info.apkSize info.apkSize
? h('p', { class: 'text-[10px] text-slate-500 font-semibold text-center mb-4' }, ? h('p', { class: 'text-[10px] text-slate-500 font-semibold text-center mb-4' },
`Taille du fichier : ${formatSize(info.apkSize)}`) `Taille : ${formatSize(info.apkSize)}`)
: null, : null,
h( h(
'div', 'div',
{ class: 'space-y-2.5' }, { class: 'space-y-2.5' },
h( downloadBtn,
'button',
{
class: 'w-full bg-sky-500 text-slate-950 text-sm font-extrabold py-3.5 rounded-xl active:scale-[0.98] transition-transform',
onclick: () => {
if (info.apkUrl) {
downloadApk(info.apkUrl)
showToast('Téléchargement lancé — ouvrez la notification pour installer', 'success')
dismissUpdate(info.version)
close()
onAfter()
} else {
showToast('Aucune APK disponible pour le moment', 'info')
}
},
},
h('i', { class: 'fa-solid fa-download mr-2' }),
'Télécharger et installer',
),
h( h(
'button', 'button',
{ {
@ -75,9 +90,10 @@ export function showUpdateModal(info: UpdateInfo, onAfter: () => void): void {
}, },
'Plus tard', 'Plus tard',
), ),
statusText,
), ),
h('p', { class: 'text-[10px] text-slate-600 font-semibold text-center mt-4' }, h('p', { class: 'text-[10px] text-slate-600 font-semibold text-center mt-4' },
'Android installera l’APK par-dessus l’ancienne version — vos données et votre session sont conservées.'), 'L’installation se fait dans l’app — vos données et votre session sont conservés.'),
) )
overlay.append(panel) overlay.append(panel)

View file

@ -1,4 +1,6 @@
import { Capacitor } from '@capacitor/core' import { Capacitor, CapacitorHttp } from '@capacitor/core'
import { Filesystem, Directory } from '@capacitor/filesystem'
import { FileOpener } from '@capawesome-team/capacitor-file-opener'
import { getBaseUrl } from './config' import { getBaseUrl } from './config'
export interface UpdateInfo { export interface UpdateInfo {
@ -23,8 +25,7 @@ function compareVersions(a: string, b: string): number {
/** /**
* Vérifie si une mise à jour est disponible sur le serveur. * Vérifie si une mise à jour est disponible sur le serveur.
* Ne retourne que sur Android natif (Capacitor) : la PWA iOS se met à jour * Seulement sur Android natif : la PWA iOS se met à jour en rechargeant.
* en rechargeant simplement la page.
*/ */
export async function checkForUpdate(): Promise<UpdateInfo | null> { export async function checkForUpdate(): Promise<UpdateInfo | null> {
if (!Capacitor.isNativePlatform() || Capacitor.getPlatform() !== 'android') { if (!Capacitor.isNativePlatform() || Capacitor.getPlatform() !== 'android') {
@ -40,17 +41,13 @@ export async function checkForUpdate(): Promise<UpdateInfo | null> {
if (!response.ok) return null if (!response.ok) return null
const remote = (await response.json()) as UpdateInfo const remote = (await response.json()) as UpdateInfo
// Le serveur est plus vieux ou à jour : rien à faire.
if (compareVersions(remote.version, localVersion) <= 0) return null if (compareVersions(remote.version, localVersion) <= 0) return null
// L'utilisateur a déjà ignoré cette version-là.
const dismissed = localStorage.getItem('meteo-exam.dismissed-update') const dismissed = localStorage.getItem('meteo-exam.dismissed-update')
if (dismissed === remote.version) return null if (dismissed === remote.version) return null
return remote return remote
} catch { } catch {
// Serveur injoignable : on ne bloque pas le démarrage de l'app.
return null return null
} }
} }
@ -62,17 +59,55 @@ export function dismissUpdate(version: string): void {
} }
/** /**
* Déclenche le téléchargement de l'APK. Sur Android, le WebView enregistre * Télécharge l'APK via le réseau natif (OkHttp), l'enregistre dans le cache
* le fichier et l'utilisateur ouvre la notification pour installer. * de l'app puis ouvre l'installateur Android — sans quitter l'application.
*/ */
export function downloadApk(apkUrl: string): void { export async function downloadAndInstallApk(apkUrl: string): Promise<void> {
const url = apkUrl.startsWith('http') ? apkUrl : `${getBaseUrl()}${apkUrl}` const url = apkUrl.startsWith('http') ? apkUrl : `${getBaseUrl()}${apkUrl}`
const anchor = document.createElement('a')
anchor.href = url // 1. Télécharger le binaire via CapacitorHttp (natif, pas de WebView).
anchor.download = 'meteo-exam.apk' const response = await CapacitorHttp.request({
anchor.rel = 'noopener' url,
anchor.style.display = 'none' method: 'GET',
document.body.append(anchor) responseType: 'arraybuffer',
anchor.click() })
anchor.remove()
if (response.status !== 200) {
throw new Error(`Téléchargement impossible (HTTP ${response.status})`)
}
// 2. Écrire le fichier dans le cache de l'app.
// CapacitorHttp renvoie les données en base64 pour responseType arraybuffer.
const data = response.data as string
const binary = atob(data)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
// Convertir en base64 pour Capacitor Filesystem.
let base64 = ''
const chunk = 0x8000
for (let i = 0; i < bytes.length; i += chunk) {
base64 += String.fromCharCode(...bytes.subarray(i, i + chunk))
}
const b64 = btoa(base64)
const fileName = `meteo-exam-${__APP_VERSION__}-update.apk`
await Filesystem.writeFile({
path: fileName,
data: b64,
directory: Directory.Cache,
})
// 3. Lire le chemin réel du fichier sur le système de fichiers.
const { uri } = await Filesystem.getUri({
path: fileName,
directory: Directory.Cache,
})
// 4. Ouvrir l'installateur Android avec ce fichier APK.
// Android affiche l'écran d'installation par-dessus l'app.
await FileOpener.openFile({
path: uri,
mimeType: 'application/vnd.android.package-archive',
})
} }