From 3ecd57119f8151071e95f5f908014c81786dc879 Mon Sep 17 00:00:00 2001 From: Mathis Date: Sun, 4 Oct 2026 20:35:35 +0200 Subject: [PATCH] =?UTF-8?q?fix=20:=20mise=20=C3=A0=20jour=20in-app=20sans?= =?UTF-8?q?=20quitter=20l'application?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @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 --- package-lock.json | 39 ++++++++++ web/android/app/capacitor.build.gradle | 3 +- .../app/src/main/res/xml/file_paths.xml | 9 ++- web/android/capacitor.settings.gradle | 6 ++ web/package.json | 2 + web/src/update-modal.ts | 68 +++++++++++------- web/src/update.ts | 71 ++++++++++++++----- 7 files changed, 150 insertions(+), 48 deletions(-) diff --git a/package-lock.json b/package-lock.json index 345a84b..947e47e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/web/android/app/capacitor.build.gradle b/web/android/app/capacitor.build.gradle index bbfb44f..460f62e 100644 --- a/web/android/app/capacitor.build.gradle +++ b/web/android/app/capacitor.build.gradle @@ -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') } diff --git a/web/android/app/src/main/res/xml/file_paths.xml b/web/android/app/src/main/res/xml/file_paths.xml index bd0c4d8..adbf50d 100644 --- a/web/android/app/src/main/res/xml/file_paths.xml +++ b/web/android/app/src/main/res/xml/file_paths.xml @@ -1,5 +1,8 @@ - - - \ No newline at end of file + + + + + + diff --git a/web/android/capacitor.settings.gradle b/web/android/capacitor.settings.gradle index 874b109..bd70a56 100644 --- a/web/android/capacitor.settings.gradle +++ b/web/android/capacitor.settings.gradle @@ -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') diff --git a/web/package.json b/web/package.json index 1b6282b..294215e 100644 --- a/web/package.json +++ b/web/package.json @@ -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": { diff --git a/web/src/update-modal.ts b/web/src/update-modal.ts index 10894db..e2f3ccf 100644 --- a/web/src/update-modal.ts +++ b/web/src/update-modal.ts @@ -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) diff --git a/web/src/update.ts b/web/src/update.ts index c1d4fe9..334fc18 100644 --- a/web/src/update.ts +++ b/web/src/update.ts @@ -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 { if (!Capacitor.isNativePlatform() || Capacitor.getPlatform() !== 'android') { @@ -40,17 +41,13 @@ export async function checkForUpdate(): Promise { 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 { 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', + }) }