#!/usr/bin/env python3 from __future__ import annotations import argparse import json import os import re import shutil import sqlite3 import subprocess import sys import urllib.error import urllib.parse import urllib.request from datetime import datetime from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] GRADLE_FILE = REPO_ROOT / "android" / "app" / "build.gradle" JAVA_HOME = "/Applications/Android Studio.app/Contents/jbr/Contents/Home" API_BASE_URL = "https://iwanttoheal.phenomrom.com" GITEA_URL = "https://git.whoagland.com" GITEA_OWNER = "phenom" GITEA_REPO = "i-want-to-heal" GITEA_TOKEN = "ed2db3fd54546e9658377d0551b3fc3961583f1d" BRANCH = "main" TRUENAS_PATH = Path("/mnt/usbssds/apps/iwanttoheal/app") PLAYER_DATA_TABLES = [ "encounter_loot_roll_items", "encounter_loot_rolls", "dungeon_runs", "character_pvp_stats", "character_boss_stats", "character_inventory", "character_talents", "character_ability_slots", "action_encounter_loot_rolls", "action_dungeon_runs", "action_character_inventory", "action_gear_items", "action_characters", "sessions", "characters", "accounts", ] def run(args: list[str], *, cwd: Path, env: dict[str, str] | None = None) -> None: print(f"$ {' '.join(args)}", flush=True) subprocess.run(args, cwd=cwd, env=env, check=True) def parse_args(argv: list[str]) -> argparse.Namespace: parser = argparse.ArgumentParser(description="Build and publish an Android release.") parser.add_argument( "--reset-player-data", action="store_true", help="After release, back up the database and delete all accounts, characters, sessions, runs, loot, inventory, and PvP stats.", ) parser.add_argument( "--reset-db", type=Path, help="SQLite game.db path to reset. Defaults to mounted TrueNAS data/game.db when present, otherwise local data/game.db.", ) parser.add_argument( "--yes-reset", action="store_true", help="Skip the RESET confirmation when used with --reset-player-data.", ) args = parser.parse_args(argv) if args.yes_reset and not args.reset_player_data: parser.error("--yes-reset requires --reset-player-data") return args def prompt_version() -> str: current = GRADLE_FILE.read_text() match = re.search(r'versionName\s+"([^"]+)"', current) current_version = match.group(1) if match else "" suffix = f" [{current_version}]" if current_version else "" version = input(f"Version{suffix}: ").strip() or current_version if not re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?", version): raise SystemExit("Version must look like 1.1.2") return version def update_gradle_version(version: str) -> None: text = GRADLE_FILE.read_text() code_match = re.search(r"versionCode\s+(\d+)", text) if not code_match: raise SystemExit(f"Could not find versionCode in {GRADLE_FILE}") next_code = int(code_match.group(1)) + 1 text = re.sub(r"versionCode\s+\d+", f"versionCode {next_code}", text, count=1) text = re.sub(r'versionName\s+"[^"]+"', f'versionName "{version}"', text, count=1) GRADLE_FILE.write_text(text) print(f"Android version: {version}, versionCode: {next_code}") def build_apk(version: str) -> Path: update_gradle_version(version) env = os.environ.copy() env["JAVA_HOME"] = JAVA_HOME env["PATH"] = f"{JAVA_HOME}/bin:{env.get('PATH', '')}" env["VITE_API_BASE_URL"] = API_BASE_URL run(["npm", "run", "android:sync"], cwd=REPO_ROOT, env=env) run(["./gradlew", "clean", "assembleDebug"], cwd=REPO_ROOT / "android", env=env) source = REPO_ROOT / "android" / "app" / "build" / "outputs" / "apk" / "debug" / "app-debug.apk" apk = REPO_ROOT / f"IWantToHeal-Thor-v{version}.apk" shutil.copy2(source, apk) print(f"APK: {apk}") return apk def commit_and_push(version: str) -> None: run(["git", "add", "."], cwd=REPO_ROOT) run(["git", "commit", "-m", f"Android build v{version}"], cwd=REPO_ROOT) run(["git", "push", "origin", BRANCH], cwd=REPO_ROOT) def request_json(method: str, path: str, token: str, data: dict | None = None) -> dict: body = json.dumps(data).encode("utf-8") if data is not None else None headers = {"Authorization": f"token {token}", "Content-Type": "application/json"} req = urllib.request.Request(GITEA_URL + path, data=body, headers=headers, method=method) try: with urllib.request.urlopen(req) as resp: return json.loads(resp.read().decode("utf-8")) except urllib.error.HTTPError as exc: detail = exc.read().decode("utf-8", "replace") raise SystemExit(f"Gitea {method} failed ({exc.code}): {detail}") from exc def upload_asset(path: str, token: str, apk: Path) -> dict: boundary = "----iwanttoheal-release-boundary" body = ( f"--{boundary}\r\n" f'Content-Disposition: form-data; name="attachment"; filename="{apk.name}"\r\n' "Content-Type: application/vnd.android.package-archive\r\n\r\n" ).encode("utf-8") body += apk.read_bytes() body += f"\r\n--{boundary}--\r\n".encode("utf-8") headers = { "Authorization": f"token {token}", "Content-Type": f"multipart/form-data; boundary={boundary}", } req = urllib.request.Request(GITEA_URL + path, data=body, headers=headers, method="POST") try: with urllib.request.urlopen(req) as resp: return json.loads(resp.read().decode("utf-8")) except urllib.error.HTTPError as exc: detail = exc.read().decode("utf-8", "replace") raise SystemExit(f"Gitea asset upload failed ({exc.code}): {detail}") from exc def create_gitea_release(version: str, apk: Path) -> None: token = GITEA_TOKEN.strip() if not token or token == "PASTE_YOUR_GITEA_TOKEN_HERE": raise SystemExit("Set GITEA_TOKEN near top of scripts/release_game_cli.py") repo_path = f"/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}" release = request_json( "POST", repo_path + "/releases", token, { "tag_name": f"v{version}", "target_commitish": BRANCH, "name": f"v{version}", "body": f"I Want to Heal Android build v{version}", "draft": False, "prerelease": False, }, ) release_id = release.get("id") if not release_id: raise SystemExit(f"Gitea release missing id: {release}") asset_name = urllib.parse.quote(apk.name) upload_asset(f"{repo_path}/releases/{release_id}/assets?name={asset_name}", token, apk) print(f"Gitea release uploaded: v{version}") def update_truenas() -> None: if TRUENAS_PATH.exists(): run(["git", "pull"], cwd=TRUENAS_PATH) print("Restart app in TrueNAS UI.") return print("TrueNAS path not mounted here. Run on TrueNAS:") print(f"cd {TRUENAS_PATH}") print("git pull") print("Then restart app in TrueNAS UI.") def default_reset_database_path() -> Path: truenas_database = TRUENAS_PATH / "data" / "game.db" if truenas_database.exists(): return truenas_database return REPO_ROOT / "data" / "game.db" def table_exists(database: sqlite3.Connection, table: str) -> bool: return database.execute( "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", (table,), ).fetchone() is not None def count_rows(database: sqlite3.Connection, table: str) -> int: if not table_exists(database, table): return 0 return int(database.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0]) def backup_database(database_path: Path) -> Path: backup_dir = database_path.parent / "backups" backup_dir.mkdir(parents=True, exist_ok=True) timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") backup_path = backup_dir / f"{database_path.stem}-pre-player-reset-{timestamp}{database_path.suffix}" source = sqlite3.connect(database_path) try: destination = sqlite3.connect(backup_path) try: source.backup(destination) finally: destination.close() finally: source.close() return backup_path def reset_player_data(database_path: Path, *, assume_yes: bool) -> None: database_path = database_path.expanduser().resolve() if not database_path.exists(): raise SystemExit(f"Database not found: {database_path}") if not assume_yes: print(f"Reset target: {database_path}") confirmation = input("Type RESET to delete all accounts/characters/player data: ").strip() if confirmation != "RESET": raise SystemExit("Player data reset cancelled.") backup_path = backup_database(database_path) database = sqlite3.connect(database_path) try: before = { "accounts": count_rows(database, "accounts"), "characters": count_rows(database, "characters"), "action_characters": count_rows(database, "action_characters"), "dungeon_runs": count_rows(database, "dungeon_runs"), "encounter_loot_rolls": count_rows(database, "encounter_loot_rolls"), } database.execute("PRAGMA foreign_keys = ON") database.execute("BEGIN") try: for table in PLAYER_DATA_TABLES: if table_exists(database, table): database.execute(f'DELETE FROM "{table}"') database.execute("COMMIT") except Exception: database.execute("ROLLBACK") raise after_accounts = count_rows(database, "accounts") after_characters = count_rows(database, "characters") if after_accounts or after_characters: raise SystemExit( f"Player data reset incomplete: {after_accounts} accounts, {after_characters} characters remain." ) print(f"Database backup: {backup_path}") print( "Player data reset: " f"{before['accounts']} accounts, " f"{before['characters']} characters, " f"{before['action_characters']} action characters, " f"{before['dungeon_runs']} dungeon runs, " f"{before['encounter_loot_rolls']} loot rolls removed." ) finally: database.close() def main(argv: list[str] | None = None) -> int: args = parse_args(sys.argv[1:] if argv is None else argv) os.chdir(REPO_ROOT) version = prompt_version() apk = build_apk(version) commit_and_push(version) create_gitea_release(version, apk) update_truenas() if args.reset_player_data: reset_player_data(args.reset_db or default_reset_database_path(), assume_yes=args.yes_reset) print("Done.") return 0 if __name__ == "__main__": raise SystemExit(main())