# -*- coding: utf-8 -*- """ update_client.py - mises à jour automatiques pour une appli Kivy / Android. (Généré par station_update.py : même contenu que http:///update_client.py) INTÉGRATION from update_client import UpdateClient, setup_client # dans AppV6.build() s.update_client = UpdateClient(server_url="https://shinjidai-station.fly.dev", app_name="Shinjidai Voice") # current_version : facultatif, lu tout seul setup_client(s.update_client) # (V1.1 : revérifie aussi toutes les 30 min tant que l'appli est ouverte) # dans AppV6._boot() s.update_client.check_and_update_async() BUILD (buildozer.spec) -> automatique : python station_update.py --patch-spec buildozer.spec android.permissions = INTERNET, REQUEST_INSTALL_PACKAGES, UPDATE_PACKAGES_WITHOUT_USER_ACTION requirements = python3,kivy,pyjnius,requests,urllib3,idna,charset-normalizer,certifi SANS REQUEST_INSTALL_PACKAGES dans l'APK INSTALLÉ, Android refuse toute installation (écran des réglages vide). L'APK mis à jour doit être signé avec la MÊME clé que l'appli installée et avoir un versionCode supérieur (la station lit le vrai versionCode dans l'APK publié et le compare à celui de l'appli installée). TEST SUR PC (affiche les popups, sans installer) : python update_client.py http://127.0.0.1:8000 1 """ import hashlib import json import os import re import ssl import tempfile import threading import time import traceback from urllib.parse import urlencode from urllib.request import Request, urlopen try: import requests except Exception: # pas de requests -> repli sur urllib requests = None from kivy.clock import Clock, mainthread from kivy.graphics import Color, Ellipse, Rectangle, RoundedRectangle from kivy.metrics import dp, sp from kivy.properties import NumericProperty from kivy.uix.boxlayout import BoxLayout from kivy.uix.button import Button from kivy.uix.floatlayout import FloatLayout from kivy.uix.label import Label from kivy.uix.scrollview import ScrollView from kivy.uix.widget import Widget from kivy.utils import platform __version__ = "1.5.0" __all__ = ["UpdateClient", "UpdatePopup", "PermissionGuidePopup", "install_update", "setup_client", "can_install_unknown_apps", "open_unknown_sources_settings"] IS_ANDROID = platform == "android" USE_EMOJI = False # True seulement si votre appli charge une police avec emojis (Roboto n'en a pas) # Palette (contraste élevé, lisible par tous) C_CARD = (0.075, 0.11, 0.20, 1) C_PANEL = (0.04, 0.07, 0.14, 1) C_TEXT = (0.93, 0.95, 1.0, 1) C_MUTED = (0.66, 0.73, 0.86, 1) C_ACCENT = (0.25, 0.85, 0.97, 1) C_ON_ACCENT = (0.02, 0.07, 0.13, 1) C_SECOND = (0.17, 0.24, 0.40, 1) C_WARN = (1.0, 0.72, 0.28, 1) C_ERR = (1.0, 0.45, 0.45, 1) C_TRACK = (0.13, 0.19, 0.33, 1) _UA = {"User-Agent": "KJ-UpdateClient/" + __version__} def log(*a): print("[update]", *a) # ============================================================================= # Android : permission "applis inconnues" + installation # ============================================================================= def _activity(): from jnius import autoclass return autoclass("org.kivy.android.PythonActivity").mActivity def _sdk_int(): from jnius import autoclass return int(autoclass("android.os.Build$VERSION").SDK_INT) def _installed_info(): """(paquet, versionCode, versionName) de l'APK INSTALLÉ. (None, None, None) hors Android ou si illisible.""" if not IS_ANDROID: return None, None, None try: act = _activity() pkg = act.getPackageName() pi = act.getPackageManager().getPackageInfo(pkg, 0) try: code = int(pi.getLongVersionCode()) # Android 9+ except Exception: code = int(pi.versionCode) return str(pkg), code, str(pi.versionName or "") except Exception: traceback.print_exc() return None, None, None def declares_install_permission(): """L'APK INSTALLÉ déclare-t-il REQUEST_INSTALL_PACKAGES ? True / False / None (inconnu). Sans elle, canRequestPackageInstalls() reste FAUX pour toujours et l'écran Android « Installer des applis inconnues » n'affiche même pas l'appli : la mise à jour ne peut JAMAIS s'installer (cause n°1 des échecs).""" if not IS_ANDROID: return True try: act = _activity() pi = act.getPackageManager().getPackageInfo(act.getPackageName(), 4096) # GET_PERMISSIONS perms = pi.requestedPermissions if perms is None: return False return "android.permission.REQUEST_INSTALL_PACKAGES" in [str(p) for p in perms] except Exception: traceback.print_exc() return None def can_install_unknown_apps(): """True si l'appli a le droit d'installer des APK (toujours True hors Android).""" if not IS_ANDROID: return True try: if _sdk_int() < 26: return True return bool(_activity().getPackageManager().canRequestPackageInstalls()) except Exception: traceback.print_exc() return True # impossible de savoir : on tente quand même def open_unknown_sources_settings(): """Ouvre directement l'écran Android « Installer des applis inconnues » pour cette appli.""" if not IS_ANDROID: return False from jnius import autoclass Intent = autoclass("android.content.Intent") Uri = autoclass("android.net.Uri") Settings = autoclass("android.provider.Settings") act = _activity() try: it = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + act.getPackageName())) act.startActivity(it) return True except Exception: try: act.startActivity(Intent(Settings.ACTION_SECURITY_SETTINGS)) return True except Exception: traceback.print_exc() return False def _install_via_fileprovider(apk_path): """Méthode 1 : Intent ACTION_VIEW + FileProvider (si l'appli en déclare un).""" from jnius import autoclass Intent = autoclass("android.content.Intent") File = autoclass("java.io.File") act = _activity() pkg = act.getPackageName() pm = act.getPackageManager() FileProvider = None for cls in ("androidx.core.content.FileProvider", "android.support.v4.content.FileProvider"): try: FileProvider = autoclass(cls) break except Exception: pass if FileProvider is None: raise RuntimeError("FileProvider absent de l'APK") uri, last = None, None for auth in (pkg + ".fileprovider", pkg + ".provider", pkg + ".fileProvider", pkg + ".files"): try: if pm.resolveContentProvider(auth, 0) is None: continue uri = FileProvider.getUriForFile(act, auth, File(apk_path)) break except Exception as e: last = e if uri is None: raise RuntimeError("aucun FileProvider utilisable (%s)" % (last or "non déclaré")) it = Intent(Intent.ACTION_VIEW) it.setDataAndType(uri, "application/vnd.android.package-archive") it.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_NEW_TASK) if it.resolveActivity(pm) is None: raise RuntimeError("aucun installateur de paquets sur ce téléphone") act.startActivity(it) def _install_hint(msg): """Traduit le message technique du PackageInstaller en cause + remède (les 3 causes classiques).""" m = (msg or "").upper() if "SIGNATURE" in m or "UPDATE_INCOMPATIBLE" in m or "CERTIFICATE" in m: return "L'APK n'est pas signé avec la MÊME clé que l'appli installée. Recompilez toujours avec le même keystore." if "DOWNGRADE" in m: return "Le versionCode de l'APK est inférieur à celui de l'appli installée." if "STORAGE" in m: return "Pas assez d'espace libre sur le téléphone." if "PACKAGE_NAME" in m or "INSTALL_FAILED_INVALID_APK" in m and "PACKAGE" in m: return "Ce n'est pas le même paquet que l'appli installée (ce ne serait pas une mise à jour)." if "INVALID" in m or "PARSE" in m or "NOT_APK" in m: return "APK invalide ou incomplet." return "" _SESSION_ACTION = "kj.update.INSTALL_COMMIT" _session_bound = False _session_seen = [0.0] # instant de la dernière réponse du PackageInstaller (sert au chien de garde) _ACTIVE = None # UpdateClient courant (pour remonter les erreurs d'installation) def _bind_session_listener(): global _session_bound if _session_bound or not IS_ANDROID: return from android import activity as android_activity android_activity.bind(on_new_intent=_on_new_intent) _session_bound = True def _on_new_intent(intent): """Réponse du PackageInstaller : on lance l'écran de confirmation Android ou on signale l'erreur.""" try: if intent.getAction() != _SESSION_ACTION: return from jnius import autoclass, cast PI = autoclass("android.content.pm.PackageInstaller") Intent = autoclass("android.content.Intent") status = intent.getIntExtra(PI.EXTRA_STATUS, -999) _session_seen[0] = time.time() if status == PI.STATUS_PENDING_USER_ACTION: confirm = cast("android.content.Intent", intent.getParcelableExtra(Intent.EXTRA_INTENT)) confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) _activity().startActivity(confirm) elif status != PI.STATUS_SUCCESS: msg = intent.getStringExtra(PI.EXTRA_STATUS_MESSAGE) or "code %s" % status hint = _install_hint(msg) log("installation refusée :", msg) if _ACTIVE is not None and status != PI.STATUS_FAILURE_ABORTED: _ACTIVE._fail("Installation impossible : %s" % ((hint + " (" + msg + ")") if hint else msg)) elif _ACTIVE is not None: _ACTIVE._fail("Installation annulée.") except Exception: traceback.print_exc() def _install_via_session(apk_path): """Méthode 2 (sans FileProvider) : PackageInstaller (Android 10 -> 15).""" from jnius import autoclass _bind_session_listener() Intent = autoclass("android.content.Intent") PendingIntent = autoclass("android.app.PendingIntent") FileInputStream = autoclass("java.io.FileInputStream") Params = autoclass("android.content.pm.PackageInstaller$SessionParams") act = _activity() installer = act.getPackageManager().getPackageInstaller() size = os.path.getsize(apk_path) params = Params(Params.MODE_FULL_INSTALL) params.setSize(size) try: params.setAppPackageName(act.getPackageName()) # refuse tout APK d'un AUTRE paquet (ce ne serait pas une mise à jour) except Exception: pass if _sdk_int() >= 31: try: # Android 12+ : installation sans confirmation SI Android l'accepte (sinon : confirmation habituelle) params.setRequireUserAction(Params.USER_ACTION_NOT_REQUIRED) except Exception: pass sid = installer.createSession(params) session = installer.openSession(sid) try: out = session.openWrite("update.apk", 0, size) fis = FileInputStream(apk_path) try: try: autoclass("android.os.FileUtils").copy(fis, out) except Exception: # repli : copie manuelle with open(apk_path, "rb") as f: while True: chunk = f.read(65536) if not chunk: break out.write(bytearray(chunk), 0, len(chunk)) session.fsync(out) finally: fis.close() out.close() it = Intent(act, act.getClass()) it.setAction(_SESSION_ACTION) it.setPackage(act.getPackageName()) it.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) flags = PendingIntent.FLAG_UPDATE_CURRENT if _sdk_int() >= 31: flags |= PendingIntent.FLAG_MUTABLE pi = PendingIntent.getActivity(act, int(sid), it, flags) t_commit = time.time() _session_seen[0] = 0.0 session.commit(pi.getIntentSender()) # chien de garde : si Android n'a rien répondu en 15 s (écouteur on_new_intent jamais appelé), on le dit au lieu de rester muet def _watch(dt): if _session_seen[0] < t_commit and _ACTIVE is not None: _ACTIVE._fail("Android n'a pas répondu à la demande d'installation. Réessayez ; si cela se répète, " "installez le fichier téléchargé à la main (Fichiers > Android > data > > files > updates).") Clock.schedule_once(_watch, 15) except Exception: try: session.abandon() except Exception: pass raise finally: try: session.close() except Exception: pass def install_update(apk_path): """Lance l'installation d'un APK. Retourne (ok, code, message). code : "ok" | "permission" | "no_manifest_permission" | "missing" | "not_android" | "error".""" if not apk_path or not os.path.isfile(apk_path): return False, "missing", "Fichier de mise à jour introuvable." if not IS_ANDROID: return False, "not_android", "L'installation n'est possible que sur Android." if declares_install_permission() is False: return False, "no_manifest_permission", ("Cette appli a été compilée SANS la permission REQUEST_INSTALL_PACKAGES : Android refusera " "toujours l'installation. Ajoutez-la dans buildozer.spec (python station_update.py " "--patch-spec buildozer.spec), recompilez et réinstallez UNE fois à la main.") if not can_install_unknown_apps(): return False, "permission", "Autorisation « applis inconnues » manquante." errors = [] for fn in (_install_via_fileprovider, _install_via_session): try: fn(apk_path) log("installation lancée via", fn.__name__) return True, "ok", "Installation lancée." except Exception as e: log(fn.__name__, "a échoué :", e) errors.append("%s" % e) return False, "error", "Installation impossible (%s)" % " | ".join(errors) # ============================================================================= # Réseau # ============================================================================= def _ssl_ctx(): try: import certifi return ssl.create_default_context(cafile=certifi.where()) except Exception: return ssl.create_default_context() def _get_json(url, timeout): if requests is not None: r = requests.get(url, timeout=timeout, headers=_UA) r.raise_for_status() return r.json() with urlopen(Request(url, headers=_UA), timeout=timeout, context=_ssl_ctx()) as r: return json.loads(r.read().decode("utf-8")) def _download(url, dest, progress=None, timeout=30, total_hint=0, tries=4): """Télécharge url -> dest avec REPRISE (en-tête Range) et plusieurs tentatives : sur mobile, une coupure de réseau ne recommence plus à zéro. Retourne (sha256, octets).""" part = dest + ".part" last = None for attempt in range(1, tries + 1): try: have = os.path.getsize(part) if os.path.exists(part) else 0 headers = dict(_UA) if have: headers["Range"] = "bytes=%d-" % have if requests is not None: resp = requests.get(url, stream=True, timeout=(10, timeout), headers=headers) status, closer = resp.status_code, resp.close if status == 416: # .part déjà complet closer() break resp.raise_for_status() chunks = resp.iter_content(65536) clen = int(resp.headers.get("Content-Length") or 0) else: resp = urlopen(Request(url, headers=headers), timeout=timeout, context=_ssl_ctx()) status, closer = getattr(resp, "status", 200), resp.close chunks = iter(lambda: resp.read(65536), b"") clen = int(resp.headers.get("Content-Length") or 0) append = bool(have) and status == 206 if not append: have = 0 # le serveur ne gère pas Range : on repart de zéro total = (have + clen) if clen else int(total_hint or 0) done = have try: with open(part, "ab" if append else "wb") as f: for chunk in chunks: if not chunk: continue f.write(chunk) done += len(chunk) if progress: progress(done, total) finally: closer() if total and done != total: raise IOError("Téléchargement incomplet (%d / %d octets)." % (done, total)) break except Exception as e: last = e log("téléchargement interrompu (essai %d/%d) : %s" % (attempt, tries, e)) sc = getattr(getattr(e, "response", None), "status_code", None) if (sc is not None and 400 <= sc < 500 and sc != 416) or attempt == tries: raise time.sleep(2 * attempt) sha = hashlib.sha256() n = 0 with open(part, "rb") as f: for chunk in iter(lambda: f.read(1 << 20), b""): sha.update(chunk) n += len(chunk) if total_hint and n != int(total_hint): os.remove(part) raise IOError("Fichier reçu incomplet (%d / %d octets). Réessayez." % (n, int(total_hint))) os.replace(part, dest) return sha.hexdigest(), n def _friendly(e): s = str(e) low = s.lower() if any(k in low for k in ("resolve", "connection", "timed out", "timeout", "unreachable", "network")): return "Pas de connexion Internet. Vérifiez votre réseau et réessayez." return s or e.__class__.__name__ def _vkey(v): parts = [int(p) for p in re.findall(r"\d+", str(v))][:4] return tuple(parts + [0] * (4 - len(parts))) def _mb(n): return "%.1f Mo" % (float(n or 0) / 1048576.0) def _format_notes(n): lines = [str(x) for x in n] if isinstance(n, (list, tuple)) else str(n or "").splitlines() out = [] for l in lines: l = l.strip() if not l: continue out.append("\u2022 " + (l[1:].strip() if l[0] in "\u2022-*" else l)) return "\n".join(out) or "\u2022 Améliorations et corrections." def _updates_dir(): base = None if IS_ANDROID: try: ext = _activity().getExternalFilesDir(None) base = (ext or _activity().getFilesDir()).getAbsolutePath() except Exception: base = None if not base: from kivy.app import App app = App.get_running_app() base = getattr(app, "user_data_dir", None) or tempfile.gettempdir() d = os.path.join(base, "updates") os.makedirs(d, exist_ok=True) return d def _state_path(): return os.path.join(_updates_dir(), "state.json") def _state_load(): try: with open(_state_path(), "r", encoding="utf-8") as f: d = json.load(f) return d if isinstance(d, dict) else {} except Exception: return {} def _state_save(st): try: with open(_state_path(), "w", encoding="utf-8") as f: json.dump(st, f) except Exception: pass _sha_cache = {} def _installed_sha(): """SHA-256 de l'APK INSTALLÉ (base.apk). Sert à reconnaître une nouvelle build qui a le MÊME versionCode que l'installée : plus besoin de toucher au numéro à la main. Mis en cache (chemin, taille, date). None hors Android / si illisible.""" if not IS_ANDROID: return None try: path = str(_activity().getApplicationInfo().sourceDir) stt = os.stat(path) key = (path, stt.st_size, int(stt.st_mtime)) if key not in _sha_cache: h = hashlib.sha256() with open(path, "rb") as f: for chunk in iter(lambda: f.read(1 << 20), b""): h.update(chunk) _sha_cache.clear() _sha_cache[key] = h.hexdigest() return _sha_cache[key] except Exception: traceback.print_exc() return None def _cleanup(folder, keep=None): try: for n in os.listdir(folder): if n.startswith("update_") and not (keep and n.startswith(keep)): os.remove(os.path.join(folder, n)) except Exception: pass # ============================================================================= # Widgets Kivy (grands, contrastés, accessibles) # ============================================================================= def _rounded(widget, color, radius=18): with widget.canvas.before: Color(*color) r = RoundedRectangle(pos=widget.pos, size=widget.size, radius=[dp(radius)]) widget.bind(pos=lambda w, v: setattr(r, "pos", v), size=lambda w, v: setattr(r, "size", v)) def _label(text, size=18, color=C_TEXT, bold=False, halign="left"): lb = Label(text=text, font_size=sp(size), color=color, bold=bold, halign=halign, valign="middle", size_hint_y=None) lb.bind(width=lambda w, v: setattr(w, "text_size", (v, None))) lb.bind(texture_size=lambda w, v: setattr(w, "height", v[1] + dp(6))) return lb class BigButton(Button): def __init__(self, text, color=C_ACCENT, text_color=C_ON_ACCENT, font=22, height=72, **kw): super().__init__(text=text, background_normal="", background_down="", background_color=(0, 0, 0, 0), color=text_color, bold=True, font_size=sp(font), halign="center", valign="middle", size_hint_y=None, height=dp(height), **kw) self._base = color with self.canvas.before: self._col = Color(*color) self._rect = RoundedRectangle(pos=self.pos, size=self.size, radius=[dp(18)]) self.bind(pos=self._sync, size=self._sync, state=self._tint, disabled=self._tint, width=lambda w, v: setattr(w, "text_size", (v - dp(20), None))) def _sync(self, *a): self._rect.pos, self._rect.size = self.pos, self.size def _tint(self, *a): if self.disabled: self._col.rgba = (0.22, 0.28, 0.40, 1) elif self.state == "down": self._col.rgba = tuple(c * 0.72 for c in self._base[:3]) + (1,) else: self._col.rgba = self._base class RoundedProgress(Widget): value = NumericProperty(0) # 0..100 def __init__(self, **kw): super().__init__(size_hint_y=None, height=0, opacity=0, **kw) self.bind(pos=self._draw, size=self._draw, value=self._draw) def _draw(self, *a): self.canvas.clear() h = self.height with self.canvas: Color(*C_TRACK) RoundedRectangle(pos=self.pos, size=self.size, radius=[h / 2.0]) if self.value > 0: Color(*C_ACCENT) w = max(h, self.width * min(100.0, self.value) / 100.0) RoundedRectangle(pos=self.pos, size=(w, h), radius=[h / 2.0]) class _Overlay(FloatLayout): """Fenêtre modale légère posée sur la Window (aucune dépendance au thème Kivy).""" __events__ = ("on_dismiss",) def __init__(self, cancellable=True, **kw): super().__init__(**kw) self.cancellable = cancellable self.opened = False with self.canvas.before: Color(0, 0, 0, 0.80) self._dim = Rectangle(pos=self.pos, size=self.size) self.bind(pos=self._sync, size=self._sync) self.card = BoxLayout(orientation="vertical", size_hint=(0.92, None), size_hint_max_x=dp(540), pos_hint={"center_x": 0.5, "center_y": 0.5}, padding=dp(22), spacing=dp(12)) self.card.bind(minimum_height=self.card.setter("height")) _rounded(self.card, C_CARD, 26) self.add_widget(self.card) def _sync(self, *a): self._dim.pos, self._dim.size = self.pos, self.size def on_touch_down(self, touch): super().on_touch_down(touch) return True def on_touch_move(self, touch): super().on_touch_move(touch) return True def on_touch_up(self, touch): super().on_touch_up(touch) return True def open(self): if self.opened: return from kivy.core.window import Window self.opened = True Window.add_widget(self) Window.bind(on_keyboard=self._on_key) def dismiss(self): if not self.opened: return from kivy.core.window import Window self.opened = False Window.unbind(on_keyboard=self._on_key) Window.remove_widget(self) self.dispatch("on_dismiss") def on_dismiss(self): pass def _on_key(self, win, key, *a): if key == 27: # bouton Retour if self.cancellable: self.dismiss() return True return False class UpdatePopup(_Overlay): """Popup « Mise à jour disponible » : version, notes, progression, gros bouton.""" def __init__(self, client, info, **kw): self.client, self.info = client, info self.mandatory = bool(info.get("mandatory")) super().__init__(cancellable=not self.mandatory, **kw) c = self.card c.add_widget(_label(("\U0001F514 " if USE_EMOJI else "") + "Mise à jour disponible", 27, C_TEXT, True)) cur = client.current_name or client.installed()[2] or client.current_version or "?" new = info.get("version_name") or info.get("version") c.add_widget(_label("Version %s \u2192 Version %s" % (cur, new), 21, C_ACCENT, True)) if self.mandatory: c.add_widget(_label("Cette mise à jour est obligatoire.", 18, C_WARN, True)) box = BoxLayout(orientation="vertical", size_hint_y=None, padding=dp(14), spacing=dp(6)) box.bind(minimum_height=box.setter("height")) _rounded(box, C_PANEL, 16) box.add_widget(_label("Notes de version :", 17, C_MUTED, True)) sv = ScrollView(size_hint_y=None, height=dp(60), bar_width=dp(4), do_scroll_x=False) notes = _label(_format_notes(info.get("notes")), 19, C_TEXT) notes.bind(height=lambda w, v: setattr(sv, "height", min(v, dp(150)))) sv.add_widget(notes) box.add_widget(sv) c.add_widget(box) if info.get("size"): c.add_widget(_label("Téléchargement : %s" % _mb(info["size"]), 17, C_MUTED)) self.bar = RoundedProgress() c.add_widget(self.bar) self.status = _label("", 19, C_MUTED, halign="center") c.add_widget(self.status) self.btn = BigButton("METTRE À JOUR MAINTENANT", on_release=lambda *_: client.begin_update()) c.add_widget(self.btn) self.later = None if not self.mandatory: self.later = BigButton("Plus tard", color=C_SECOND, text_color=C_TEXT, font=20, height=58, on_release=lambda *_: self.dismiss()) c.add_widget(self.later) def show_bar(self): self.bar.height, self.bar.opacity = dp(18), 1 def set_progress(self, done, total): self.show_bar() if total: pct = done * 100.0 / total self.bar.value = pct self.set_status("%d %% (%s / %s)" % (pct, _mb(done), _mb(total))) else: self.set_status(_mb(done)) def set_status(self, text, error=False): self.status.text = text self.status.color = C_ERR if error else C_MUTED def set_busy(self, busy, label=None): self.btn.disabled = busy self.btn.text = label or ("TÉLÉCHARGEMENT…" if busy else "METTRE À JOUR MAINTENANT") if self.later is not None: self.later.disabled = busy def _step(n, text): row = BoxLayout(orientation="horizontal", size_hint_y=None, spacing=dp(14)) badge = Label(text=str(n), bold=True, font_size=sp(24), color=C_ON_ACCENT, size_hint=(None, None), size=(dp(46), dp(46)), pos_hint={"center_y": 0.5}) with badge.canvas.before: Color(*C_ACCENT) e = Ellipse(pos=badge.pos, size=badge.size) badge.bind(pos=lambda w, v: setattr(e, "pos", v), size=lambda w, v: setattr(e, "size", v)) lb = _label(text, 20, C_TEXT) lb.bind(height=lambda w, v: setattr(row, "height", max(v, dp(46)))) row.add_widget(badge) row.add_widget(lb) return row class PermissionGuidePopup(_Overlay): """Guide pas à pas pour autoriser « Installer des applis inconnues » (une seule fois).""" def __init__(self, client, on_granted, on_cancel=None, **kw): super().__init__(cancellable=True, **kw) self.client, self.on_granted, self.on_cancel = client, on_granted, on_cancel self._ev, self._done = None, False c = self.card c.add_widget(_label("Une dernière autorisation", 26, C_TEXT, True)) c.add_widget(_label("Pour recevoir les mises à jour automatiquement, autorisez cette application " "à installer ses mises à jour. Vous ne le ferez qu'une seule fois.", 19, C_MUTED)) c.add_widget(_step(1, "Touchez le gros bouton « Ouvrir les paramètres » ci-dessous.")) c.add_widget(_step(2, "Activez l'interrupteur « Autoriser depuis cette source ».")) c.add_widget(_step(3, "Revenez ici avec la flèche Retour de votre téléphone.")) self.msg = _label("", 18, C_WARN, halign="center") c.add_widget(self.msg) c.add_widget(BigButton("OUVRIR LES PARAMÈTRES", on_release=self._open_settings)) c.add_widget(BigButton("J'AI DÉJÀ AUTORISÉ", color=C_SECOND, text_color=C_TEXT, font=20, height=62, on_release=self._check_now)) c.add_widget(BigButton("Annuler", color=C_CARD, text_color=C_MUTED, font=18, height=48, on_release=lambda *_: self.dismiss())) def open(self): super().open() self._ev = Clock.schedule_interval(self._poll, 1.0) # détecte le retour de l'utilisateur def on_dismiss(self): if self._ev is not None: self._ev.cancel() self._ev = None if not self._done and self.on_cancel: self.on_cancel() def _open_settings(self, *a): if not open_unknown_sources_settings(): self.msg.text = "Ouvrez Réglages > Applications > Accès spécial > Installer des applis inconnues." def _poll(self, dt): if can_install_unknown_apps(): self._granted() def _check_now(self, *a): if can_install_unknown_apps(): self._granted() else: self.msg.text = "Autorisation non détectée. Touchez « Ouvrir les paramètres »." def _granted(self): if self._done: return self._done = True self.dismiss() self.on_granted() # ============================================================================= # Client # ============================================================================= class UpdateClient: def __init__(self, server_url, current_version=None, app_name="Application", app_id=None, current_name=None, timeout=15): # current_version=None (conseillé) : le versionCode de l'APK INSTALLÉ est lu tout seul (rien à incrémenter à la main) self.server_url = (server_url or "").rstrip("/") self.current_version = current_version self.current_name = current_name # texte affiché pour la version actuelle (sinon le code) self.app_name = app_name self.app_id = app_id # None = application "default" de la station self.timeout = timeout self.info = None self.popup = None self.guide = None self.last_error = "" self._busy = False self._checking = False self._apk_ready = None self._last_ui = 0.0 self._recheck_ev = None def _base(self, force=False): """V1.5 : adresse de la station à utiliser MAINTENANT. Si l'APK embarque station_hot avec un relais (station sur un PC, tunnel qui change à chaque lancement), c'est l'annonce SIGNÉE de la station qui fait foi ; sinon (ou relais muet sans adresse connue) : server_url. Ne lève jamais.""" try: import station_hot a = station_hot.resolve_addr(force=force) if a: return a.rstrip("/") except Exception: pass return self.server_url # ---- état de l'appli installée ------------------------------------------------- def installed(self): """(paquet, versionCode, versionName) de l'appli installée (cache).""" if not hasattr(self, "_inst"): self._inst = _installed_info() return self._inst def diagnose(self): """Liste de lignes lisibles : tout ce qui peut empêcher une mise à jour (écrite aussi dans logcat).""" pkg, code, name = self.installed() lines = ["station : %s%s" % (self._base(), "" if self._base() == self.server_url else " (adresse annoncée par la station ; intégrée : %s)" % self.server_url), "appli installée : %s versionCode=%s versionName=%s" % (pkg, code, name), "permission REQUEST_INSTALL_PACKAGES déclarée : %s" % declares_install_permission(), "droit « applis inconnues » accordé : %s" % can_install_unknown_apps()] try: d = _get_json(self._base() + "/version.json?" + urlencode({"p": "android"}), self.timeout) lines.append("station joignable : oui — publié : v%s (versionCode=%s, paquet=%s)" % (d.get("version"), d.get("version_code"), d.get("package"))) except Exception as e: lines.append("station joignable : NON (%s)" % _friendly(e)) for l in lines: log("diagnostic |", l) return lines # ---- préparation (appelée par setup_client) --------------------------------- def _prepare(self): global _ACTIVE _ACTIVE = self try: _cleanup(_updates_dir()) # supprime l'APK d'une mise à jour déjà installée sh = _installed_sha() st = _state_load() if sh and sh.lower() in st.get("attempts", {}): # la build tentée est bien celle qui tourne : compteur remis à zéro del st["attempts"][sh.lower()] _state_save(st) _bind_session_listener() pkg, code, name = self.installed() log("appli installée :", pkg, "versionCode", code, name, "| permission d'installation déclarée :", declares_install_permission()) except Exception: traceback.print_exc() # ---- vérification ------------------------------------------------------------- def check(self): """Bloquant. Retourne le dict d'infos si une version plus récente existe, sinon None.""" pkg, code, _name = self.installed() params = {"p": "android"} if self.current_version is not None: params["v"] = self.current_version if code is not None: params["code"] = code if pkg: params["pkg"] = pkg if self.app_id: params["app"] = self.app_id base = self._base() try: data = _get_json(base + "/version.json?" + urlencode(params), self.timeout) except Exception: b2 = self._base(force=True) # V1.5 : l'adresse mémorisée ne répond plus (tunnel relancé) : on relit l'annonce et on réessaie une fois if b2 == base: raise base = b2 data = _get_json(base + "/version.json?" + urlencode(params), self.timeout) if not data.get("available", True) or not data.get("version"): return None if pkg and data.get("package") and data["package"] != pkg: self.last_error = "La station publie le paquet « %s » mais cette appli est « %s » : mise à jour ignorée (autre appli ?)." % (data["package"], pkg) log(self.last_error) return None if code is not None and data.get("version_code"): # fiable : vrais versionCode (APK installé / APK publié) srv_code, inst_code = int(data["version_code"]), int(code) if srv_code < inst_code: return None # Android refuse de revenir en arrière if srv_code == inst_code: # même numéro : on compare les APK eux-mêmes (empreinte SHA-256) sha_pub, sha_inst = (data.get("sha256") or "").lower(), _installed_sha() if not sha_pub or not sha_inst or sha_pub == sha_inst.lower(): return None if int(_state_load().get("attempts", {}).get(sha_pub, 0)) >= 3: # déjà tentée 3 fois sans effet : installateur qui change l'APK log("même versionCode, mais cette build a déjà été installée 3 fois sans changer l'APK : plus de proposition") return None elif self.current_version is not None: if _vkey(data["version"]) <= _vkey(self.current_version): return None else: log("impossible de comparer les versions : indiquez current_version=… (ou publiez un APK dont le manifeste est lisible)") return None data["_apk_url"] = (base + data["apk_path"]) if data.get("apk_path") else data.get("apk_url") return data if data["_apk_url"] else None def check_and_update_async(self): """À appeler au démarrage : thread séparé, silencieux si pas de réseau.""" if self._checking or self._busy or self.popup is not None: return self._checking = True threading.Thread(target=self._check_worker, daemon=True).start() def _check_worker(self): try: info = self.check() if info: log("mise à jour disponible :", info.get("version_name") or info.get("version")) self._show_update(info) except Exception as e: self.last_error = _friendly(e) log("vérification impossible :", self.last_error) finally: self._checking = False self._check_code() def _check_code(self): """V1.3 : si station_hot.py est présent dans l'appli, récupère aussi (en silence) le code Python signé le plus récent. Il devient actif au prochain lancement ; sans station_hot.py, cette étape est ignorée.""" try: import station_hot except ImportError: return try: station_hot.configure(server_url=self.server_url, app_id=self.app_id) c = station_hot.check_code(self.installed()[1]) if c: station_hot.fetch_code(c) except Exception as e: log("code Python :", _friendly(e)) # ---- interface ---------------------------------------------------------------- @mainthread def _show_update(self, info): if self.popup is not None: return self.info = info self._apk_ready = None self.popup = UpdatePopup(self, info) self.popup.bind(on_dismiss=lambda *_: setattr(self, "popup", None)) self.popup.open() @mainthread def _show_guide(self, after): if self.guide is not None: return self.guide = PermissionGuidePopup(self, after, on_cancel=self._guide_cancelled) self.guide.bind(on_dismiss=lambda *_: setattr(self, "guide", None)) self.guide.open() def _guide_cancelled(self): self._busy = False self._ui(lambda p: p.set_busy(False)) @mainthread def _ui(self, fn): if self.popup is not None: try: fn(self.popup) except Exception: traceback.print_exc() # ---- flux de mise à jour -------------------------------------------------------- def begin_update(self): """Bouton « Mettre à jour » : permission -> téléchargement -> installation.""" if self._busy or not self.info: return if self._apk_ready and os.path.isfile(self._apk_ready): return self._install_async(self._apk_ready) if declares_install_permission() is False: # l'écran des réglages ne pourrait jamais accorder le droit : on le dit clairement return self._fail( "Cette appli a été compilée SANS la permission REQUEST_INSTALL_PACKAGES. Ajoutez-la dans buildozer.spec " "(python station_update.py --patch-spec buildozer.spec), recompilez et réinstallez UNE fois à la main.") if not can_install_unknown_apps(): return self._show_guide(self.begin_update) self._busy = True self._ui(lambda p: (p.set_busy(True), p.set_status("Connexion au serveur…"))) threading.Thread(target=self._download_worker, daemon=True).start() def _on_progress(self, done, total): now = time.time() if now - self._last_ui < 0.12 and done != total: return self._last_ui = now self._ui(lambda p: p.set_progress(done, total)) def _download_worker(self): info = self.info try: folder = _updates_dir() dest = os.path.join(folder, "update_%s.apk" % info.get("version")) _cleanup(folder, keep=os.path.basename(dest)) # garde le .part de CETTE version (reprise après « RÉESSAYER ») sha, _n = _download(info["_apk_url"], dest, self._on_progress, self.timeout, info.get("size") or 0) expected = (info.get("sha256") or "").lower() if expected and sha.lower() != expected: os.remove(dest) raise IOError("Le fichier téléchargé est corrompu. Réessayez.") self._apk_ready = dest self._ui(lambda p: p.set_status("Téléchargement terminé. Installation…")) self._do_install(dest) except Exception as e: traceback.print_exc() self._fail(_friendly(e)) def _install_async(self, path): self._busy = True self._ui(lambda p: (p.set_busy(True, "INSTALLATION…"), p.set_status("Préparation de l'installation…"))) threading.Thread(target=self._do_install, args=(path,), daemon=True).start() def _do_install(self, path): try: ok, code, msg = install_update(path) except Exception as e: traceback.print_exc() ok, code, msg = False, "error", str(e) if ok: self._count_attempt() self._busy = False self._ui(lambda p: (p.set_busy(False, "INSTALLER"), p.set_status("Suivez les instructions de votre téléphone pour terminer."))) elif code == "permission": self._busy = False self._ui(lambda p: p.set_busy(False)) self._show_guide(lambda: self._install_async(path)) else: self._fail(msg) def _count_attempt(self): """V1.4 : une tentative = l'installateur d'Android a VRAIMENT été ouvert (garde-fou contre une boucle de mises à jour). Avant, chaque appui sur « Mettre à jour » comptait, même un téléchargement coupé par le réseau : après 3 coupures la build n'était plus jamais proposée.""" try: sha = ((self.info or {}).get("sha256") or "").lower() if sha: st = _state_load() st.setdefault("attempts", {})[sha] = int(st.get("attempts", {}).get(sha, 0)) + 1 _state_save(st) except Exception: pass def check_periodically(self, minutes=30): """Revérifie toutes les `minutes` tant que l'appli est ouverte (en plus du démarrage).""" if self._recheck_ev is not None: self._recheck_ev.cancel() self._recheck_ev = Clock.schedule_interval(lambda dt: self.check_and_update_async(), max(5, int(minutes)) * 60) def _fail(self, msg): self._busy = False self.last_error = msg log("échec :", msg) self._ui(lambda p: (p.set_status(msg, error=True), p.set_busy(False, "RÉESSAYER" if not self._apk_ready else "INSTALLER"))) def setup_client(client_or_app, server_url=None, current_version=None, app_name=None, auto_check=None, recheck_minutes=30): """Initialise le client sans bloquer le démarrage. setup_client(client) -> prépare seulement (vous appelez check_and_update_async() dans _boot) setup_client(app, "https://...") -> crée app.update_client et lance la vérification en arrière-plan """ if isinstance(client_or_app, UpdateClient): client = client_or_app auto = bool(auto_check) else: if not server_url: raise ValueError("setup_client(app, server_url) : server_url est obligatoire") client = UpdateClient(server_url, current_version, app_name or getattr(client_or_app, "title", None) or "Application") try: client_or_app.update_client = client except Exception: pass try: # V1.4 : à chaque retour dans l'appli (après un passage en arrière-plan) on revérifie, au plus une fois par minute def _on_resume(*_a, _c=client): if time.time() - getattr(_c, "_resume_t", 0.0) > 60: _c._resume_t = time.time() _c.check_and_update_async() client_or_app.bind(on_resume=_on_resume) except Exception: pass auto = True if auto_check is None else bool(auto_check) if not client.server_url: raise ValueError("UpdateClient : server_url est vide") client._prepare() if auto: client.check_and_update_async() if recheck_minutes: client.check_periodically(recheck_minutes) return client if __name__ == "__main__": # démo sur PC : python update_client.py http://127.0.0.1:8000 1 import sys from kivy.app import App class _Demo(App): def build(self): url = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8000" ver = int(sys.argv[2]) if len(sys.argv) > 2 else None self.uc = UpdateClient(url, ver, "Démo") setup_client(self.uc) return Label(text="Démo update_client : en attente de la station…") def on_start(self): self.uc.check_and_update_async() _Demo().run()