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:
parent
208ece19fc
commit
3ecd57119f
7 changed files with 150 additions and 48 deletions
39
package-lock.json
generated
39
package-lock.json
generated
|
|
@ -67,6 +67,43 @@
|
|||
"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": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||
|
|
@ -4671,6 +4708,8 @@
|
|||
"@capacitor/android": "^8.5.2",
|
||||
"@capacitor/cli": "^8.5.2",
|
||||
"@capacitor/core": "^8.5.2",
|
||||
"@capacitor/filesystem": "^8.1.4",
|
||||
"@capawesome-team/capacitor-file-opener": "^8.0.5",
|
||||
"@meteo-exam/shared": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ android {
|
|||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
|
||||
implementation project(':capacitor-filesystem')
|
||||
implementation project(':capawesome-team-capacitor-file-opener')
|
||||
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<external-path name="my_images" path="." />
|
||||
<cache-path name="my_cache_images" path="." />
|
||||
<files-path name="files" 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>
|
||||
|
|
@ -1,3 +1,9 @@
|
|||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
include ':capacitor-android'
|
||||
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')
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@
|
|||
"@capacitor/android": "^8.5.2",
|
||||
"@capacitor/cli": "^8.5.2",
|
||||
"@capacitor/core": "^8.5.2",
|
||||
"@capacitor/filesystem": "^8.1.4",
|
||||
"@capawesome-team/capacitor-file-opener": "^8.0.5",
|
||||
"@meteo-exam/shared": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -1,17 +1,15 @@
|
|||
import { h } from './dom'
|
||||
import type { UpdateInfo } from './update'
|
||||
import { currentAppVersion, dismissUpdate, downloadApk } from './update'
|
||||
import { currentAppVersion, dismissUpdate, downloadAndInstallApk } from './update'
|
||||
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 {
|
||||
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',
|
||||
})
|
||||
|
||||
const close = (): void => {
|
||||
overlay.remove()
|
||||
}
|
||||
const close = (): void => overlay.remove()
|
||||
|
||||
const formatSize = (bytes: number | null): string => {
|
||||
if (!bytes) return ''
|
||||
|
|
@ -19,6 +17,41 @@ export function showUpdateModal(info: UpdateInfo, onAfter: () => void): void {
|
|||
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(
|
||||
'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' },
|
||||
|
|
@ -39,30 +72,12 @@ export function showUpdateModal(info: UpdateInfo, onAfter: () => void): void {
|
|||
),
|
||||
info.apkSize
|
||||
? 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,
|
||||
h(
|
||||
'div',
|
||||
{ class: 'space-y-2.5' },
|
||||
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: () => {
|
||||
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',
|
||||
),
|
||||
downloadBtn,
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
|
|
@ -75,9 +90,10 @@ export function showUpdateModal(info: UpdateInfo, onAfter: () => void): void {
|
|||
},
|
||||
'Plus tard',
|
||||
),
|
||||
statusText,
|
||||
),
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
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.
|
||||
* Ne retourne que sur Android natif (Capacitor) : la PWA iOS se met à jour
|
||||
* en rechargeant simplement la page.
|
||||
* Seulement sur Android natif : la PWA iOS se met à jour en rechargeant.
|
||||
*/
|
||||
export async function checkForUpdate(): Promise<UpdateInfo | null> {
|
||||
if (!Capacitor.isNativePlatform() || Capacitor.getPlatform() !== 'android') {
|
||||
|
|
@ -40,17 +41,13 @@ export async function checkForUpdate(): Promise<UpdateInfo | null> {
|
|||
if (!response.ok) return null
|
||||
|
||||
const remote = (await response.json()) as UpdateInfo
|
||||
|
||||
// Le serveur est plus vieux ou à jour : rien à faire.
|
||||
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')
|
||||
if (dismissed === remote.version) return null
|
||||
|
||||
return remote
|
||||
} catch {
|
||||
// Serveur injoignable : on ne bloque pas le démarrage de l'app.
|
||||
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
|
||||
* le fichier et l'utilisateur ouvre la notification pour installer.
|
||||
* Télécharge l'APK via le réseau natif (OkHttp), l'enregistre dans le cache
|
||||
* 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 anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = 'meteo-exam.apk'
|
||||
anchor.rel = 'noopener'
|
||||
anchor.style.display = 'none'
|
||||
document.body.append(anchor)
|
||||
anchor.click()
|
||||
anchor.remove()
|
||||
|
||||
// 1. Télécharger le binaire via CapacitorHttp (natif, pas de WebView).
|
||||
const response = await CapacitorHttp.request({
|
||||
url,
|
||||
method: 'GET',
|
||||
responseType: 'arraybuffer',
|
||||
})
|
||||
|
||||
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',
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue