""" ota.py — OTA update support for B24 Browser. Adapted directly from the working, protocol-correct implementation in the Messenger backend (messenger_backend_space2/app.py): same Expo Updates protocol contract (plain-JSON manifest, no code signing), same asset hashing scheme, same admin-upload flow. Scoped to Browser-back's own ota_db.py instead of sharing Messenger's database, and uploaded under a distinct "browser-ota/" path prefix so the two apps' OTA assets never collide even if they share the same HF dataset repo. Android-only for now, matching Messenger's actual current scope. """ import hashlib import base64 import uuid import io import json import zipfile import os from datetime import datetime, timezone import ota_db as db APP_UPDATE_DATASET_REPO = os.environ.get("APP_UPDATE_DATASET_REPO", "Brighton233j/APP_UPDATES") OTA_PATH_PREFIX = "browser-ota" # distinct from Messenger's "ota/" prefix in the same dataset repo def _asset_key(file_bytes): """Stable per-asset id (md5 of content) — mirrors the 'key' field Expo's own export tooling uses for on-device asset caching.""" return hashlib.md5(file_bytes).hexdigest() def _sha256_base64url(file_bytes): digest = hashlib.sha256(file_bytes).digest() return base64.urlsafe_b64encode(digest).decode("utf-8").rstrip("=") def _uuid_from_update_id(update_id): """Deterministic UUID derived from our own update_id, in the same spirit as Expo's reference server's convertSHA256HashToUUID().""" hex_digest = hashlib.sha256(update_id.encode("utf-8")).hexdigest() return str(uuid.UUID(hex_digest[:32])) def _content_type_for(rel_path): if rel_path.endswith(".js"): return "application/javascript" if rel_path.endswith(".png"): return "image/png" if rel_path.endswith(".jpg") or rel_path.endswith(".jpeg"): return "image/jpeg" if rel_path.endswith(".ttf") or rel_path.endswith(".otf"): return "font/ttf" if rel_path.endswith(".json"): return "application/json" return "application/octet-stream" def process_upload(file_storage, runtime_version, notes=None): """Takes the uploaded zip (dist/ folder from `npx expo export --platform android --output-dir dist`), uploads every asset to the HF dataset repo, and records the new manifest as the latest OTA update. Returns (result_dict, error_tuple) — exactly one of the two is None. error_tuple is (body_dict, status_code), ready to jsonify+return. """ from huggingface_hub import HfApi update_id = str(uuid.uuid4()) try: zf = zipfile.ZipFile(io.BytesIO(file_storage.read())) names = zf.namelist() meta_name = next((n for n in names if n.endswith("metadata.json")), None) if not meta_name: return None, ({"error": "metadata.json not found — zip the dist/ folder produced by expo export"}, 400) metadata = json.loads(zf.read(meta_name)) base_dir = meta_name[: -len("metadata.json")] api = HfApi(token=os.environ.get("HF_TOKEN") or os.environ.get("hf_token")) def upload_asset(rel_path): data = zf.read(base_dir + rel_path) path_in_repo = f"{OTA_PATH_PREFIX}/{runtime_version}/{update_id}/{rel_path}" api.upload_file( path_or_fileobj=data, path_in_repo=path_in_repo, repo_id=APP_UPDATE_DATASET_REPO, repo_type="dataset", ) url = f"https://huggingface.co/datasets/{APP_UPDATE_DATASET_REPO}/resolve/main/{path_in_repo}" return url, data android_meta = metadata["fileMetadata"]["android"] bundle_url, bundle_bytes = upload_asset(android_meta["bundle"]) launch_asset = { "hash": _sha256_base64url(bundle_bytes), "key": _asset_key(bundle_bytes), "contentType": "application/javascript", "url": bundle_url, } assets = [] for a in android_meta.get("assets", []): url, data = upload_asset(a["path"]) assets.append({ "hash": _sha256_base64url(data), "key": _asset_key(data), "contentType": _content_type_for(a["path"]), "url": url, }) db.set_latest_ota_update(update_id, runtime_version, json.dumps(launch_asset), json.dumps(assets), notes) except KeyError as e: return None, ({"error": f"Unexpected metadata.json shape, missing {e}"}, 400) except Exception as e: return None, ({"error": f"OTA upload failed: {e}"}, 500) return { "status": "updated", "update_id": update_id, "runtime_version": runtime_version, "asset_count": len(assets) + 1, }, None def build_manifest(protocol_version, platform, runtime_version, current_update_id): """Returns (manifest_dict, error_tuple) — exactly one is None. error_tuple is (body_dict, status_code), ready to jsonify+return.""" if platform != "android": return None, ({"error": "Unsupported platform. Expected android."}, 400) if not runtime_version: return None, ({"error": "No runtimeVersion provided."}, 400) update = db.get_latest_ota_update() if not update or update.get("runtime_version") != runtime_version: return None, ({"error": "No update available for this runtime version."}, 404) manifest_id = _uuid_from_update_id(update["update_id"]) if protocol_version == "1" and current_update_id == manifest_id: return None, ({"error": "No update available."}, 406) created_at = datetime.fromtimestamp(update["created_at"], tz=timezone.utc).isoformat() manifest = { "id": manifest_id, "createdAt": created_at, "runtimeVersion": runtime_version, "launchAsset": json.loads(update["launch_asset_json"]), "assets": json.loads(update["assets_json"]), "metadata": {}, "extra": {}, } return manifest, None