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',
+ })
}