"""Phone workout verification mixin using ADB and StrongLifts.""" from __future__ import annotations from concurrent.futures import ( # pylint: disable=no-name-in-module ThreadPoolExecutor, as_completed, ) import contextlib import json import logging from pathlib import Path import shutil import socket import sqlite3 import subprocess import tempfile import time from screen_locker._constants import ( ADB_TIMEOUT, MIN_WORKOUT_DURATION_MINUTES, STRONGLIFTS_DB_REMOTE, ) from screen_locker._time_check import check_clock_skew _logger = logging.getLogger(__name__) class PhoneVerificationMixin: """Mixin providing phone-based workout verification via ADB.""" def _run_adb(self, args: list[str]) -> tuple[bool, str]: """Run an ADB command and return success flag and stdout.""" adb = shutil.which("adb") or "adb" # When multiple devices are connected (e.g. USB + wireless), pin to # the wireless device's serial to avoid "more than one device" errors. _discovery_cmds = {"devices", "connect", "disconnect", "kill-server"} serial = ( self._get_wireless_serial() if args and args[0] not in _discovery_cmds else None ) serial_args = ["-s", serial] if serial else [] try: result = subprocess.run( [adb, *serial_args, *args], capture_output=True, text=True, timeout=ADB_TIMEOUT, check=False, ) except (FileNotFoundError, OSError) as exc: _logger.warning("ADB not available: %s", exc) return False, "" except subprocess.TimeoutExpired: _logger.warning("ADB command timed out: %s", args) return False, "" return not result.returncode, result.stdout def _adb_shell( self, command: str, *, root: bool = False, ) -> tuple[bool, str]: """Run a shell command on the connected Android device.""" if root: return self._run_adb(["shell", "su", "-c", command]) return self._run_adb(["shell", command]) def _get_wireless_serial(self) -> str | None: """Return the serial (ip:port) of the first connected wireless ADB device. Used to pin ADB commands to the wireless device when multiple devices (e.g. USB cable + wireless debugging) are simultaneously connected. """ success, output = self._run_adb(["devices"]) if not success: return None for line in output.strip().split("\n")[1:]: parts = line.split() if parts and ":" in parts[0] and "device" in line and "offline" not in line: return parts[0] return None def _has_adb_device(self) -> bool: """Return True if adb devices shows at least one connected device.""" success, output = self._run_adb(["devices"]) if not success: return False lines = output.strip().split("\n")[1:] return any("device" in line and "offline" not in line for line in lines) def _try_adb_connect(self, address: str) -> bool: """Run adb connect to address. Returns True on success.""" _, output = self._run_adb(["connect", address]) lower = output.lower() return "connected" in lower and "unable" not in lower and "failed" not in lower def _get_local_subnet_prefix(self) -> str | None: """Detect the local /24 network prefix (e.g. '192.168.1').""" with ( contextlib.suppress(OSError), socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock, ): sock.connect(("8.8.8.8", 80)) return ".".join(sock.getsockname()[0].split(".")[:3]) return None def _try_wireless_reconnect(self) -> bool: """Scan local /24 subnet on port 5555 and attempt ADB connect to phone.""" prefix = self._get_local_subnet_prefix() if prefix is None: _logger.info("Could not determine local subnet for wireless scan") return False def probe(i: int) -> bool: ip = f"{prefix}.{i}" with ( contextlib.suppress(OSError), socket.create_connection((ip, 5555), timeout=0.5), ): if self._try_adb_connect(f"{ip}:5555"): return self._has_adb_device() return False _logger.info("Scanning %s.1-254:5555 for phone...", prefix) with ThreadPoolExecutor(max_workers=64) as executor: for future in as_completed( executor.submit(probe, i) for i in range(1, 255) ): if future.result(): return True return False def _is_phone_connected(self) -> bool: """Check if an Android device is connected via ADB. If no device is visible, attempts wireless reconnection using the stored phone IP/port config. USB-connected devices are detected automatically by adb devices without any extra steps. """ if self._has_adb_device(): return True _logger.info("No ADB device detected — attempting wireless reconnect...") return self._try_wireless_reconnect() def _pull_stronglifts_db(self) -> Path | None: """Pull StrongLifts database from phone to a local temp file. Returns: Path to the local copy, or None on failure. """ tmp = Path(tempfile.gettempdir()) / "stronglifts_check.db" success, _ = self._adb_shell( f"cat '{STRONGLIFTS_DB_REMOTE}' > /sdcard/_sl_tmp.db", root=True, ) if not success: return None ok, _ = self._run_adb(["pull", "/sdcard/_sl_tmp.db", str(tmp)]) if not ok: return None return tmp def _count_today_workouts(self, db_path: Path) -> int: """Count today's workouts in a local copy of StrongLifts DB. Args: db_path: Path to the locally-pulled StrongLifts database. Returns: Number of workouts started today (local time). """ try: conn = sqlite3.connect(str(db_path)) try: cursor = conn.execute( "SELECT COUNT(*) FROM workouts " "WHERE date(start / 1000, 'unixepoch', 'localtime') " "= date('now', 'localtime')", ) row = cursor.fetchone() return int(row[0]) if row else 0 finally: conn.close() except (sqlite3.Error, ValueError, TypeError): _logger.warning("Failed to query StrongLifts database") return 0 def _get_today_workout_duration_minutes(self, db_path: Path) -> float: """Get the total duration in minutes of today's workouts. Args: db_path: Path to the locally-pulled StrongLifts database. Returns: Total duration in minutes of all workouts started today. Returns 0.0 on any error or if no workouts found. """ try: conn = sqlite3.connect(str(db_path)) try: cursor = conn.execute( "SELECT SUM((finish - start) / 1000.0 / 60.0) " "FROM workouts " "WHERE date(start / 1000, 'unixepoch', 'localtime') " "= date('now', 'localtime') " "AND finish > start", ) row = cursor.fetchone() return float(row[0]) if row and row[0] is not None else 0.0 finally: conn.close() except (sqlite3.Error, ValueError, TypeError): _logger.warning("Failed to query workout duration") return 0.0 def _get_today_exercise_count(self, db_path: Path) -> int: """Count distinct exercises in today's workouts. Parses the JSON ``exercises`` column in the ``workouts`` table. Each workout row stores its exercises as a JSON array, not in a separate relational table. Args: db_path: Path to the locally-pulled StrongLifts database. Returns: Number of distinct exercises across today's workouts. Returns 0 on any error. """ try: conn = sqlite3.connect(str(db_path)) try: cursor = conn.execute( "SELECT exercises FROM workouts " "WHERE date(start / 1000, 'unixepoch', 'localtime') " "= date('now', 'localtime')", ) exercise_ids: set[str] = set() for (exercises_json,) in cursor: if not exercises_json: continue for ex in json.loads(exercises_json): ex_id = ex.get("id") or ex.get("name", "") if ex_id: exercise_ids.add(ex_id) return len(exercise_ids) finally: conn.close() except (sqlite3.Error, ValueError, TypeError, json.JSONDecodeError): _logger.warning("Failed to query exercise count") return 0 def _is_workout_finish_recent(self, db_path: Path) -> bool: """Check if the latest workout's finish time is recent. A fresh workout should have finished within the last 24 hours. This prevents using an old pre-prepared database dump while still accepting workouts done earlier the same day. Args: db_path: Path to the locally-pulled StrongLifts database. Returns: True if the latest finish time is within 24 hours of now. """ max_age_seconds = 24 * 3600 # accept same-day workouts try: conn = sqlite3.connect(str(db_path)) try: cursor = conn.execute( "SELECT MAX(finish) FROM workouts " "WHERE date(start / 1000, 'unixepoch', 'localtime') " "= date('now', 'localtime') " "AND finish > start", ) row = cursor.fetchone() if not row or row[0] is None: return False finish_epoch = int(row[0]) / 1000.0 return (time.time() - finish_epoch) < max_age_seconds finally: conn.close() except (sqlite3.Error, ValueError, TypeError): _logger.warning("Failed to query workout finish time") return False def _validate_workout_db( self, local_db: Path, ) -> tuple[str, str] | None: """Validate workout database has a recent, real workout. Returns: A (status, message) tuple if validation fails, or None if OK. """ count = self._count_today_workouts(local_db) if count <= 0: return "not_verified", "No workout found on phone today" if not self._is_workout_finish_recent(local_db): return ( "stale", "Workout finish time is too old. Did you actually work out today?", ) exercise_count = self._get_today_exercise_count(local_db) if exercise_count < 1: return ( "no_exercises", "No exercises found in today's workout. " "Log actual exercises in StrongLifts!", ) return None def _verify_phone_workout(self) -> tuple[str, str]: """Verify workout was recorded in StrongLifts on the phone. Returns: Tuple of (status, message) where status is one of: - "verified": Workout confirmed and >= minimum duration. - "too_short": Workout found but shorter than minimum. - "not_verified": Phone connected but no workout found. - "no_phone": No phone connected via ADB. - "error": Could not access StrongLifts database. - "stale": Workout finish time is not recent. - "no_exercises": Workout has no logged exercises. - "clock_tampered": System clock skew exceeds threshold. """ clock_ok, clock_msg = check_clock_skew() if not clock_ok: return "clock_tampered", clock_msg if not self._is_phone_connected(): return "no_phone", "No phone connected via ADB" local_db = self._pull_stronglifts_db() if local_db is None: return "error", "StrongLifts database not found on phone" db_error = self._validate_workout_db(local_db) if db_error is not None: return db_error duration = self._get_today_workout_duration_minutes(local_db) if duration < MIN_WORKOUT_DURATION_MINUTES: return ( "too_short", f"Workout too short! {duration:.0f} min logged, " f"need at least {MIN_WORKOUT_DURATION_MINUTES} min.", ) exercise_count = self._get_today_exercise_count(local_db) return ( "verified", f"Workout verified! ({self._count_today_workouts(local_db)}" f" session(s), {duration:.0f} min, " f"{exercise_count} exercise(s))", )