"""Constants for the screen locker module.""" from __future__ import annotations from pathlib import Path from gatelock import default_runtime_dir from gatelock.log_integrity import DEFAULT_HMAC_KEY_FILE # Ephemeral runtime state (self-clears on reboot, same directory gatelock's # own Arbiter uses) -- not repo-tracked data, so it does not belong next to # log.json below. INSTANCE_LOCK_FILE = default_runtime_dir() / "screen_locker-instance.lock" # Single-sourced from gatelock so the literal key path can't drift. The key is # root:root 0644 ON PURPOSE, never 0600: leetcode-guard, book-guard and # anki-guard run as user timers and sign their ledgers with it, and this # locker signs log.json, rest_days.json and the workout ledger as the user. # Nothing in this repo may chmod it; root ownership keeps it from being # replaced, which is what the signatures rely on. HMAC_KEY_FILE = DEFAULT_HMAC_KEY_FILE # The workout ledger (earned_time.WORKOUT.ledger): one signed credit row per # verified RunnerUp TCX credit and per rest day, resolved at call time. WORKOUT_LEDGER_RELATIVE = Path(".local/share/workout_locker/ledger.json") PHONE_PENALTY_DELAY_DEMO = 10 PHONE_PENALTY_DELAY_PRODUCTION = 100 # Penalty added to phone-penalty timer when ADB / phone unavailable # (so unplugging the phone does not become an easy path to the manual log). NO_PHONE_EXTRA_LOCKOUT_SECONDS = 480 # extra 8 minutes on top of base # Manual (unverified) workout rate-limiting. Once either # window is exhausted the "Log Manual Workout" option disappears entirely. MANUAL_WORKOUT_BUDGET_PER_7_DAYS = 2 MANUAL_WORKOUT_BUDGET_PER_30_DAYS = 10 # A manual workout may be logged AHEAD for a date up to this many days from # today (never a past date); it earns its credit on that date, not before. MANUAL_WORKOUT_MAX_DAYS_AHEAD = 7 # Manual entries use the shared MIN_WORKOUT_DURATION_MINUTES bar below — # every workout type answers to the same minimum, whatever the sport. # Minimum chars for the "what was done" activity-details field. MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS = 40 # Minimum chars for each reflection field (what went well / to improve / feeling). MANUAL_WORKOUT_REFLECTION_MIN_CHARS = 20 MANUAL_WORKOUT_RPE_MIN = 1 MANUAL_WORKOUT_RPE_MAX = 10 ADB_TIMEOUT = 15 # Workout app JSON candidate paths on the phone, in the order the app prefers # when writing (see sync_service.dart). The app writes to the primary /sdcard/ # path first and only falls back to its app-external files dir if /sdcard/ is # not writable, so the locker must check both — primary first. WORKOUT_APP_JSON_REMOTES = ( "/sdcard/workout_result.json", "/storage/emulated/0/Android/data/com.kuhy.workout_app/files/workout_result.json", ) # Port the workout app's HTTP server listens on (no ADB/developer-options needed). WORKOUT_HTTP_PORT = 8765 # The ONE duration bar every activity answers to — weightlifting, manual # entries, runs and walks (summed moving time) alike. This is the ADVERTISED # number: it is the only one that may ever appear in a message the user reads. MIN_WORKOUT_DURATION_MINUTES = 30 # ...and this is the leeway that is deliberately INVISIBLE. A session is # actually accepted at MIN_WORKOUT_DURATION_MINUTES - this, so 25 minutes # passes while every string still says 30. That gap is intentional (the user # asked for it explicitly): do NOT "fix" the inconsistency by advertising the # real cutoff, and never interpolate WORKOUT_DURATION_ACCEPT_MINUTES into an # f-string. WORKOUT_DURATION_LEEWAY_MINUTES = 5 # Derived so the advertised bar and the accept bar cannot drift apart. # Comparisons use this; messages use MIN_WORKOUT_DURATION_MINUTES. Every # caller compares with `>=` — one operator across all workout types, so a # 25.0-minute session cannot pass one gate and fail another. WORKOUT_DURATION_ACCEPT_MINUTES = ( MIN_WORKOUT_DURATION_MINUTES - WORKOUT_DURATION_LEEWAY_MINUTES ) RUNNERUP_PACKAGES = ("org.runnerup", "org.runnerup.free") RUNNERUP_DB_SDCARD_TMP = "/sdcard/.runnerup_tmp_verification.db" # A day's activity (run or walk alike) qualifies on EITHER criterion — they are # an OR, not an AND. A short but long-lasting session counts, and so does a # fast 5 km that takes under the duration bar. Do not reinstate an AND here: # it would reject exactly the slow case this pair exists to allow. The # duration arm is the shared MIN_WORKOUT_DURATION_MINUTES above (moving time). MIN_RUN_DISTANCE_KM = 3.5 # inclusive: "as low as 3.5 km" # GPS distance tracking undershoots on tight turns/tree cover/urban canyons; # a run within 5% of the minimum is accepted rather than rejected on # measurement noise. This is SEPARATE from WORKOUT_DURATION_LEEWAY_MINUTES: # that one is a deliberate concession to the user, this one corrects a # measurement error. Duration now gets a leeway too, but for the other reason. RUNNERUP_DISTANCE_TOLERANCE = 0.05 MAX_CLOCK_SKEW_SECONDS = 300 # 5 minutes max time skew from NTP EARLY_BIRD_START_HOUR = 5 EARLY_BIRD_END_HOUR = 8 EARLY_BIRD_END_MINUTE = 30 HEAT_SKIP_TEMP_THRESHOLD: int = 33 # °C — above this the heat-skip dialog is offered HEAT_SKIP_CITY: str = "Warsaw" SHUTDOWN_CONFIG_FILE = Path("/etc/shutdown-schedule.conf") # Helper script path (relative to this file) ADJUST_SHUTDOWN_SCRIPT = Path(__file__).resolve().parent / "adjust_shutdown_schedule.sh" # JSON list of ISO date strings ("YYYY-MM-DD") for which the screen lock is skipped. SCHEDULED_SKIPS_FILE = Path(__file__).resolve().parent / "scheduled_skips.json" # State file tracking streak, skip credits, and early-bird extension weeks. EXTRA_BENEFITS_FILE = Path(__file__).resolve().parent / "extra_benefits_state.json" # State file storing the base (pre-bonus) shutdown hours and last reset date. SHUTDOWN_BASE_FILE = Path(__file__).resolve().parent / "shutdown_base.json" # HMAC-signed rest days, declared ahead (see _rest_day), and how far the grace # floor has lifted today's shutdown above the earned time (see _grace_floor). REST_DAY_FILE = Path(__file__).resolve().parent / "rest_days.json" REST_DAYS_PER_ISO_WEEK = 2 GRACE_STATE_FILE = Path(__file__).resolve().parent / "grace_floor.json" # Every GPS activity (run or walk, whatever its TCX sport) is judged the same # way: a day's activities count as the workout once their summed distance # reaches MIN_RUN_DISTANCE_KM OR their summed MOVING time reaches # WORKOUT_DURATION_ACCEPT_MINUTES. Moving = trackpoint pairs seen within # WALK_MAX_GAP_SECONDS and travelled at least this fast (slower is standing # still). A pair faster than MAX_MOVING_SPEED_MPS (25 km/h, the app's running # jump filter) is a vehicle or a GPS jump and counts neither moving time NOR # distance, for every activity. The Dart app mirrors the bounds. MIN_MOVING_SPEED_MPS = 0.5 MAX_MOVING_SPEED_MPS = 25 / 3.6 WALK_MAX_GAP_SECONDS = 30 # Workout records this PC must keep deleted in the sync store, pushed as # tombstones on every tick (see _sync_tombstones for why once is not enough). SYNC_TOMBSTONES_FILE = Path(__file__).resolve().parent / "sync_tombstones.json" # Future-dated manual workouts logged on THIS PC, re-published on every push # until their date (see _manual_pending for why a one-shot publish is erased). MANUAL_PENDING_FILE = Path(__file__).resolve().parent / "manual_pending.json" # Self-expiring marker: "logged in during today's early-bird window, still # waiting to see if a real workout shows up." Not a log.json entry — # it's a same-day pending flag, checked against its own "date" field. EARLY_BIRD_PENDING_FILE = Path(__file__).resolve().parent / "early_bird_pending.json" # The PC's copy of the phone's morning session, written HMAC-signed by # wake-alarm (`python -m wake_alarm._session --refresh`, every 2 min through # the morning). Read here, never fetched: wake-alarm is the only Firebase # reader. XDG state rather than a sibling checkout, because the previous # reader pointed at "../wake_alarm/wake_state.json" -- a path that stopped # existing when the repos split -- and returned "no skip" for a year without # a single error. Keep the path in step with wake_alarm/_constants.py. MORNING_SESSION_FILE = ( Path.home() / ".local" / "state" / "wake_alarm" / "morning_session.json" ) # A separate file from MORNING_SESSION_FILE: that one always describes # *today* and is overwritten at the date rollover, so it cannot carry a # marker forward to tomorrow. wake-alarm writes this one when a Tue/Wed/Thu # ring goes unanswered; today == penalty_date means today's relaxed-day # leniency is off. Keep the path in step with wake_alarm/_constants.py. WORKDAY_PENALTY_FILE = ( Path.home() / ".local" / "state" / "wake_alarm" / "workday_penalty.json" ) # Directories where RunnerUp writes per-activity TCX exports (File Synchronizer). # Listed in preference order; both resolve to the same path on most devices. RUNNERUP_EXPORT_DIRS: tuple[str, ...] = ( "/sdcard/Documents/RunnerUp", "/storage/emulated/0/Documents/RunnerUp", ) # Where RunnerUp's WebDAV synchroniser drops the same exports on this machine # (dufs, see ~/src/dufs-cloud), and where endurain-import moves them once # Endurain has them. Read straight off the disk, so a run is credited without # the phone being adb-reachable -- on 2026-09-16 the TCX landed here at 20:08 # and the phone was only plugged in at 20:25. Same filenames as the phone # export, optionally prefixed with the device name (RunnerUp_Pixel_6a_...). RUNNERUP_WEBDAV_DIRS: tuple[Path, ...] = ( Path.home() / "data" / "cloud" / "RunnerUp", Path.home() / "data" / "cloud" / "RunnerUp" / "processed", ) # --------------------------------------------------------------------------- # Sync (phone workout data) — built on the shared crdt-sync library, Firebase # RTDB only since 2026-10-09 (the shared ~/.config/crdt-sync/ credential). The # old GitHub repo kuhyx/syncs/screen-locker-sync is a frozen archive: nothing # reads or writes it. Every device pushes to devices//log.json; the # PC reads every device's log and pushes its OWN workouts (see _manual_push). # --------------------------------------------------------------------------- # The phone's pre-uuid device id. Still read (the phone may not have migrated # yet), but no longer the only path the reader knows about -- it discovers # every device directory. SYNC_PHONE_DEVICE_ID: str = "phone" # The id this machine pushed manual workouts under before per-install uuids. # Kept so the sync layer still recognises devices/pc/ as its own and does not # re-merge its own history as a peer's; drop to None once that path is gone. SYNC_LEGACY_DEVICE_ID: str | None = "pc" # This machine's sync id, minted once and persisted. A fixed "pc" collides as # soon as a second machine takes the same role, and a reinstall would inherit # the previous install's CRDT identity. SYNC_DEVICE_ID_FILE: Path = ( Path.home() / ".local" / "share" / "screen_locker" / ".device_id" ) # Revision cache for cross-device sync. Without it every push re-uploads and # every pull re-downloads regardless of change, which is the traffic the # Firebase free tier's monthly budget depends on not happening. SYNC_STATE_FILE: Path = ( Path.home() / ".local" / "share" / "screen_locker" / "sync_state.json" ) SYNC_TIMEOUT_SECONDS: float = 10.0 # Published while the locker has DECIDED to lock but is waiting for a # higher-ranked holder (wake_alarm) to release the screen. From outside the # process that state is indistinguishable from a hang: on 2026-08-30 the # locker sat in gatelock's queue for 2h58m and the only evidence anywhere was # a single INFO line at the moment it started waiting. QUEUE_STATE_FILE: Path = ( Path.home() / ".local" / "share" / "screen_locker" / "queue_state.json" ) # Sync runs from the morning routine, which is started right after boot and # right after resume — often seconds before networking is actually up. Such a # run used to fail outright, and reported it as a permissions problem # (2026-07-20: six workouts "FAILED" at 08:04 with the network still down, then # pushed fine by hand minutes later on the same token). So retry before giving # up. Exponential backoff from SYNC_RETRY_DELAY_SECONDS gives 2s + 4s + 8s = # ~14s of patience — enough for DHCP/DNS to come up, without making a genuinely # offline run drag the whole morning routine. SYNC_RETRY_ATTEMPTS: int = 4 SYNC_RETRY_DELAY_SECONDS: float = 2.0