mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-11 18:14:04 +01:00
Discover alarm device directories instead of reading a fixed phone path
The PC read one hardcoded path, wake-alarm-sync/devices/phone/alarm.json, and never parsed the HLC. The phone's device id is migrating from the fixed constant "phone" to a per-install uuid (a role constant collides as soon as a second install takes the same role, and a reinstall inherits the previous install's CRDT identity). A reader pinned to the old path would have stopped seeing the alarm the moment the phone migrated, fallen through to the cache and then to FALLBACK_ALARM_HOUR -- i.e. woken at the wrong time, silently. So the PC half ships first, and this commit is that half plus the phone change it makes safe: - _fetch_firebase_alarm() lists SYNC_DEVICES_PREFIX, reads every device's alarm.json, and picks the newest by the hour field's HLC rather than by listing order. device_id breaks an exact tie so two devices stamped in the same millisecond don't alternate between ticks; a device whose payload doesn't parse is skipped rather than allowed to win with a bad value. - SYNC_ALARM_PATH stays as a last-resort read for a backend that lists nothing, so an un-migrated install keeps working. The GitHub mirror still reads the single fixed path. - The phone persists a uuid under SharedPreferences 'crdt.nodeId' (the key todo and home_inventory already use) and pushes under it. deviceId is now an instance field, injectable so tests don't touch real prefs. Parsing and ranking moved to _alarm_payload.py: _sync.py was over the repo's 500-line cap, and the wire format deserves testing without network or disk -- a misparse here doesn't raise, it just resolves to the wrong wake-up time. Verified against the live RTDB, not just mocks: seeded a uuid-named device directory and confirmed the reader discovered it, read it and ranked it -- losing to the phone with an old stamp, winning with a new one (03:15 vs the real 07:45). Test device deleted; --show returns 07:45 and the cache is intact. 354 tests, 100% branch coverage. Phone app: 10 tests, analyzer clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GZH89WEqAjuJT8WZdqc7AP
This commit is contained in:
parent
2cdb642e84
commit
03392e7ea5
21
CLAUDE.md
21
CLAUDE.md
@ -76,11 +76,28 @@ auto-start.
|
||||
`_sync.py`'s local cache instead of the remote file, so there is no second
|
||||
remote reader to keep in step.
|
||||
|
||||
- Source of truth: `wake-alarm-sync/devices/phone/alarm.json`, written by
|
||||
`phone_app/` as a `crdt_sync` log. The value lives at
|
||||
- Source of truth: a `crdt_sync` log written by `phone_app/` under
|
||||
`wake-alarm-sync/devices/<device-id>/alarm.json`. The value lives at
|
||||
`.alarm.fields.alarm_hour[0]` / `.alarm_minute[0]`. **The same logical path
|
||||
on both backends** — Firebase's namespace mirrors the repo layout — so only
|
||||
the transport differs, never the parse.
|
||||
- **The Firebase reader discovers device directories; it is not pinned to
|
||||
`devices/phone/`.** The phone's device id is migrating from the fixed
|
||||
constant `phone` to a per-install uuid, and a reader pinned to the old path
|
||||
would silently stop seeing the alarm the moment the phone migrated — then
|
||||
fall back to `FALLBACK_ALARM_HOUR`, i.e. wake at the wrong time. So
|
||||
`_fetch_firebase_alarm()` lists `SYNC_DEVICES_PREFIX`, reads every device's
|
||||
`alarm.json`, and picks the newest by the hour field's **HLC** (not by
|
||||
listing order), with `device_id` breaking exact ties so two devices stamped
|
||||
in the same millisecond don't alternate between ticks. A device whose
|
||||
payload doesn't parse is skipped rather than allowed to win.
|
||||
`SYNC_ALARM_PATH` remains as a last-resort read for a backend that lists
|
||||
nothing — keep it until every install has migrated. The **GitHub** fallback
|
||||
still reads the single fixed path; it is the mirror, not the primary.
|
||||
- Payload parsing/ranking lives in `_alarm_payload.py`, split out of `_sync.py`
|
||||
so the wire format is testable without network or disk (and to keep both
|
||||
files under the 500-line cap). `_sync.py` owns fetching, caching and
|
||||
fallback; `_alarm_payload.py` owns "what did the phone actually say".
|
||||
- **Firebase first, GitHub as the cutover mirror** (`fetch_remote_alarm()` is
|
||||
just `_fetch_firebase_alarm() or _fetch_github_alarm()`). Retiring the
|
||||
mirror is deleting the GitHub branch plus `SYNC_API_BASE`/`SYNC_REPO_*`.
|
||||
|
||||
@ -5,6 +5,8 @@ import 'package:crdt_sync/crdt_sync_io.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:wake_alarm_sync/services/firebase_backend.dart';
|
||||
|
||||
/// The alarm time this device holds, as read from or written to the store.
|
||||
@ -15,18 +17,24 @@ typedef Alarm = ({int hour, int minute});
|
||||
///
|
||||
/// The phone is the **sole writer**: it stores the chosen alarm locally in a
|
||||
/// [LogStore] (one HLC-stamped [Record], id [_recordId]) and pushes that log
|
||||
/// to `wake-alarm-sync/devices/phone/alarm.json` — Firebase first, mirrored
|
||||
/// to the private `kuhyx/syncs` repo during the cutover (see [syncBackend]).
|
||||
/// The path is deliberately identical on both backends, so only the transport
|
||||
/// differs.
|
||||
/// to `wake-alarm-sync/devices/<deviceId>/alarm.json` — Firebase first,
|
||||
/// mirrored to the private `kuhyx/syncs` repo during the cutover (see
|
||||
/// [syncBackend]). The path is deliberately identical on both backends, so
|
||||
/// only the transport differs.
|
||||
///
|
||||
/// [deviceId] is a per-install uuid, not the fixed `phone` constant this app
|
||||
/// shipped with ([legacyDeviceId]). The PC reader lists device directories
|
||||
/// and ranks their logs by HLC rather than reading one hardcoded path, so a
|
||||
/// migrating install is picked up automatically and the newest alarm wins
|
||||
/// regardless of which directory it landed in.
|
||||
///
|
||||
/// The PC side is read-only (`wake_alarm/_sync.py`, which tries Firebase then
|
||||
/// GitHub, caching the result for `shutdown-wrapper.sh`). There is no
|
||||
/// pull/merge here: the phone is the only writer, so its local store is
|
||||
/// always the source of truth.
|
||||
class AlarmSyncService {
|
||||
/// Wraps an already-loaded log store.
|
||||
AlarmSyncService(this._store);
|
||||
/// Wraps an already-loaded log store pushing under [deviceId].
|
||||
AlarmSyncService(this._store, {required this.deviceId});
|
||||
|
||||
final LogStore _store;
|
||||
|
||||
@ -36,24 +44,63 @@ class AlarmSyncService {
|
||||
/// Repo name within [owner] that holds every app's `*-sync/` data.
|
||||
static const repo = 'syncs';
|
||||
|
||||
/// This device's id in the `devices/<id>/` layout. The phone is the only
|
||||
/// writer, so a constant id is enough (HLCs still order re-installs).
|
||||
static const deviceId = 'phone';
|
||||
/// The device id this install used before migrating to a persisted uuid.
|
||||
///
|
||||
/// The PC reader still reads `devices/<legacyDeviceId>/` as a last resort,
|
||||
/// so an install that has not migrated keeps working; see
|
||||
/// `wake_alarm/_constants.py`'s `SYNC_ALARM_PATH`.
|
||||
static const legacyDeviceId = 'phone';
|
||||
|
||||
/// SharedPreferences key holding this install's persisted device id.
|
||||
///
|
||||
/// Named to match the sibling apps (`todo`, `home_inventory`), which store
|
||||
/// the same thing under the same key.
|
||||
static const kDeviceIdKey = 'crdt.nodeId';
|
||||
|
||||
/// This device's id in the `devices/<id>/` layout.
|
||||
///
|
||||
/// A per-install uuid rather than the old fixed `phone` constant: two
|
||||
/// installs sharing an id overwrite each other's pushed file on every
|
||||
/// tick, and a reinstall would silently inherit the previous install's
|
||||
/// CRDT identity. The PC reader discovers device directories, so it finds
|
||||
/// whatever uuid this turns out to be.
|
||||
final String deviceId;
|
||||
|
||||
String get _remotePath => 'wake-alarm-sync/devices/$deviceId/alarm.json';
|
||||
|
||||
static const _remotePath = 'wake-alarm-sync/devices/$deviceId/alarm.json';
|
||||
static const _recordId = 'alarm';
|
||||
static const _hourField = 'alarm_hour';
|
||||
static const _minuteField = 'alarm_minute';
|
||||
|
||||
/// Returns this install's device id, minting and persisting one if absent.
|
||||
///
|
||||
/// Migrating installs keep publishing real alarms throughout: the id only
|
||||
/// changes which directory the push lands in, and the PC ranks every
|
||||
/// device's log by HLC, so the newest alarm wins whichever path it is under.
|
||||
static Future<String> loadDeviceId() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final existing = prefs.getString(kDeviceIdKey);
|
||||
if (existing != null && existing.isNotEmpty) return existing;
|
||||
final minted = const Uuid().v4();
|
||||
await prefs.setString(kDeviceIdKey, minted);
|
||||
return minted;
|
||||
}
|
||||
|
||||
/// Opens the on-disk alarm store.
|
||||
///
|
||||
/// [persistence] is injectable so tests can supply an in-memory backend
|
||||
/// instead of touching the real app-documents directory.
|
||||
static Future<AlarmSyncService> open({LogPersistence? persistence}) async {
|
||||
/// instead of touching the real app-documents directory. [deviceId] is
|
||||
/// injectable for the same reason; production resolves it via
|
||||
/// [loadDeviceId].
|
||||
static Future<AlarmSyncService> open({
|
||||
LogPersistence? persistence,
|
||||
String? deviceId,
|
||||
}) async {
|
||||
final backend = persistence ?? await _defaultPersistence();
|
||||
final store = LogStore(persistence: backend, nodeId: deviceId);
|
||||
final id = deviceId ?? await loadDeviceId();
|
||||
final store = LogStore(persistence: backend, nodeId: id);
|
||||
await store.load();
|
||||
return AlarmSyncService(store);
|
||||
return AlarmSyncService(store, deviceId: id);
|
||||
}
|
||||
|
||||
static Future<LogPersistence> _defaultPersistence() async {
|
||||
|
||||
@ -138,6 +138,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fixnum
|
||||
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@ -630,6 +638,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.5"
|
||||
uuid:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: uuid
|
||||
sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.6.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@ -23,6 +23,7 @@ dependencies:
|
||||
path_provider: ^2.1.0
|
||||
shared_preferences: ^2.3.0
|
||||
url_launcher: ^6.3.0
|
||||
uuid: ^4.6.0
|
||||
|
||||
dev_dependencies:
|
||||
# Generates the Android launcher icons from assets/icon/ (see
|
||||
|
||||
@ -4,9 +4,12 @@ import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart' as http_testing;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:wake_alarm_sync/services/alarm_sync_service.dart';
|
||||
|
||||
const _alarmPath = 'wake-alarm-sync/devices/phone/alarm.json';
|
||||
const _deviceId = 'test-device-uuid';
|
||||
const _alarmPath = 'wake-alarm-sync/devices/$_deviceId/alarm.json';
|
||||
|
||||
http.Response _response(int statusCode, [Object? jsonBody]) =>
|
||||
http.Response(jsonEncode(jsonBody ?? {}), statusCode);
|
||||
@ -56,23 +59,35 @@ Map<String, dynamic> _decodePush(_PutCall call) =>
|
||||
|
||||
void main() {
|
||||
test('currentAlarm is null before anything is set', () async {
|
||||
final service = await AlarmSyncService.open(persistence: _MemPersistence());
|
||||
final service = await AlarmSyncService.open(
|
||||
persistence: _MemPersistence(),
|
||||
deviceId: _deviceId,
|
||||
);
|
||||
expect(service.currentAlarm, isNull);
|
||||
});
|
||||
|
||||
test('setLocal stores the alarm locally and survives a reopen', () async {
|
||||
final persistence = _MemPersistence();
|
||||
final service = await AlarmSyncService.open(persistence: persistence);
|
||||
final service = await AlarmSyncService.open(
|
||||
persistence: persistence,
|
||||
deviceId: _deviceId,
|
||||
);
|
||||
await service.setLocal(6, 30);
|
||||
expect(service.currentAlarm, (hour: 6, minute: 30));
|
||||
|
||||
// The same persistence backend rehydrates the alarm on the next open.
|
||||
final reopened = await AlarmSyncService.open(persistence: persistence);
|
||||
final reopened = await AlarmSyncService.open(
|
||||
persistence: persistence,
|
||||
deviceId: _deviceId,
|
||||
);
|
||||
expect(reopened.currentAlarm, (hour: 6, minute: 30));
|
||||
});
|
||||
|
||||
test('push PUTs the alarm log to the phone device path', () async {
|
||||
final service = await AlarmSyncService.open(persistence: _MemPersistence());
|
||||
final service = await AlarmSyncService.open(
|
||||
persistence: _MemPersistence(),
|
||||
deviceId: _deviceId,
|
||||
);
|
||||
await service.setLocal(7, 15);
|
||||
final (:httpClient, :putCalls) = _mockGitHub();
|
||||
|
||||
@ -88,7 +103,10 @@ void main() {
|
||||
});
|
||||
|
||||
test('push is a network round-trip that swallows a server error', () async {
|
||||
final service = await AlarmSyncService.open(persistence: _MemPersistence());
|
||||
final service = await AlarmSyncService.open(
|
||||
persistence: _MemPersistence(),
|
||||
deviceId: _deviceId,
|
||||
);
|
||||
await service.setLocal(9, 0);
|
||||
final client = http_testing.MockClient(
|
||||
(request) async => http.Response('', 500),
|
||||
@ -101,4 +119,63 @@ void main() {
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
});
|
||||
|
||||
group('device id', () {
|
||||
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||
|
||||
test('mints and persists a uuid on first run', () async {
|
||||
final minted = await AlarmSyncService.loadDeviceId();
|
||||
|
||||
expect(Uuid.isValidUUID(fromString: minted), isTrue);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
expect(prefs.getString(AlarmSyncService.kDeviceIdKey), minted);
|
||||
});
|
||||
|
||||
test('returns the same id on every later run', () async {
|
||||
final first = await AlarmSyncService.loadDeviceId();
|
||||
final second = await AlarmSyncService.loadDeviceId();
|
||||
|
||||
expect(second, first);
|
||||
});
|
||||
|
||||
test('adopts an id already stored under the shared key', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
AlarmSyncService.kDeviceIdKey: 'pre-existing-id',
|
||||
});
|
||||
|
||||
expect(await AlarmSyncService.loadDeviceId(), 'pre-existing-id');
|
||||
});
|
||||
|
||||
test('treats a blank stored id as absent and mints a new one', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
AlarmSyncService.kDeviceIdKey: '',
|
||||
});
|
||||
|
||||
expect(await AlarmSyncService.loadDeviceId(), isNotEmpty);
|
||||
});
|
||||
|
||||
test('pushes under the resolved uuid, not the legacy phone path', () async {
|
||||
final service = await AlarmSyncService.open(
|
||||
persistence: _MemPersistence(),
|
||||
deviceId: 'uuid-abc',
|
||||
);
|
||||
await service.setLocal(6, 30);
|
||||
final paths = <String>[];
|
||||
final client = http_testing.MockClient((request) async {
|
||||
paths.add(request.url.path);
|
||||
return _response(200);
|
||||
});
|
||||
|
||||
await service.push('tok', httpClient: client);
|
||||
|
||||
expect(paths.any((path) => path.contains('devices/uuid-abc/')), isTrue);
|
||||
expect(
|
||||
paths.any(
|
||||
(path) =>
|
||||
path.contains('devices/${AlarmSyncService.legacyDeviceId}/'),
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
173
wake_alarm/_alarm_payload.py
Normal file
173
wake_alarm/_alarm_payload.py
Normal file
@ -0,0 +1,173 @@
|
||||
"""Parse the alarm out of the ``crdt_sync`` log the phone publishes.
|
||||
|
||||
Split out of ``_sync.py`` so that module keeps to fetching, caching and
|
||||
fallback: everything here is pure, takes text or already-parsed JSON, and
|
||||
touches neither the network nor the disk. That makes the wire format testable
|
||||
on its own, which matters because a misparse here does not raise — it
|
||||
silently resolves to the wrong wake-up time.
|
||||
|
||||
The published shape is one ``crdt_sync`` record::
|
||||
|
||||
{"alarm": {"fields": {"alarm_hour": [7, "<hlc>"], "alarm_minute": [30, "<hlc>"]}}}
|
||||
|
||||
Every field is a ``(value, HLC)`` pair. Ranking across devices uses the HLC
|
||||
rather than the value, so the alarm that wins is the one *set* most recently.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from wake_alarm._constants import MAX_HOUR, MAX_MINUTE, MIN_HOUR, MIN_MINUTE
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Keys inside the crdt_sync log the phone writes.
|
||||
RECORD_ID = "alarm"
|
||||
HOUR_FIELD = "alarm_hour"
|
||||
MINUTE_FIELD = "alarm_minute"
|
||||
|
||||
# A crdt_sync field is a (value, HLC) pair.
|
||||
_HLC_PAIR_LENGTH = 2
|
||||
|
||||
|
||||
def in_range(hour: int, minute: int) -> bool:
|
||||
"""Check that an (hour, minute) pair is a real wall-clock time.
|
||||
|
||||
Guards both readers: a malformed sync payload and a corrupted cache must
|
||||
never become a fire time.
|
||||
|
||||
Args:
|
||||
hour: Candidate hour.
|
||||
minute: Candidate minute.
|
||||
|
||||
Returns:
|
||||
True if both values are within a 24-hour clock.
|
||||
"""
|
||||
return MIN_HOUR <= hour <= MAX_HOUR and MIN_MINUTE <= minute <= MAX_MINUTE
|
||||
|
||||
|
||||
def _field_pair(log: dict[str, Any], field: str) -> list[Any] | None:
|
||||
"""Return the raw ``(value, HLC)`` pair for [field], if well-shaped.
|
||||
|
||||
Args:
|
||||
log: The parsed log JSON.
|
||||
field: Field name to look up.
|
||||
|
||||
Returns:
|
||||
The pair as a list, or None if any level of the shape is unexpected.
|
||||
"""
|
||||
record = log.get(RECORD_ID)
|
||||
if not isinstance(record, dict):
|
||||
return None
|
||||
fields = record.get("fields")
|
||||
if not isinstance(fields, dict):
|
||||
return None
|
||||
pair = fields.get(field)
|
||||
return pair if isinstance(pair, list) and pair else None
|
||||
|
||||
|
||||
def extract_field(log: dict[str, Any], field: str) -> int | None:
|
||||
"""Pull one integer field out of the phone's crdt_sync log.
|
||||
|
||||
Args:
|
||||
log: The parsed log JSON.
|
||||
field: Field name to extract (e.g. ``alarm_hour``).
|
||||
|
||||
Returns:
|
||||
The integer value, or None if anything about the shape is unexpected.
|
||||
"""
|
||||
pair = _field_pair(log, field)
|
||||
if pair is None:
|
||||
return None
|
||||
value = pair[0]
|
||||
# bool is an int subclass; an alarm hour of True would be nonsense.
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def extract_hlc(log: dict[str, Any], field: str) -> str:
|
||||
"""Pull one field's HLC stamp out of the phone's crdt_sync log.
|
||||
|
||||
HLC strings are ``<iso>-<counter>-<deviceId>`` and sort lexicographically
|
||||
in timestamp order, which is what lets [newest_alarm] rank two devices'
|
||||
publications against each other.
|
||||
|
||||
Args:
|
||||
log: The parsed log JSON.
|
||||
field: Field name whose stamp to read.
|
||||
|
||||
Returns:
|
||||
The HLC string, or ``""`` if absent or the wrong shape -- which sorts
|
||||
below every real stamp, so a device publishing a malformed pair never
|
||||
outranks one publishing a good pair.
|
||||
"""
|
||||
pair = _field_pair(log, field)
|
||||
if pair is None or len(pair) < _HLC_PAIR_LENGTH:
|
||||
return ""
|
||||
hlc = pair[1]
|
||||
return hlc if isinstance(hlc, str) else ""
|
||||
|
||||
|
||||
def parse_log(raw: str) -> tuple[int, int] | None:
|
||||
"""Parse one device's published log into an (hour, minute) pair.
|
||||
|
||||
Args:
|
||||
raw: The raw JSON text returned by the backend.
|
||||
|
||||
Returns:
|
||||
The (hour, minute) pair, or None if the payload is malformed or the
|
||||
values are out of range.
|
||||
"""
|
||||
try:
|
||||
log = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
_logger.warning("Synced alarm is not valid JSON")
|
||||
return None
|
||||
if not isinstance(log, dict):
|
||||
return None
|
||||
hour = extract_field(log, HOUR_FIELD)
|
||||
minute = extract_field(log, MINUTE_FIELD)
|
||||
if hour is None or minute is None:
|
||||
_logger.warning("Synced alarm is missing hour/minute fields")
|
||||
return None
|
||||
if not in_range(hour, minute):
|
||||
_logger.warning("Synced alarm out of range: %d:%d", hour, minute)
|
||||
return None
|
||||
return hour, minute
|
||||
|
||||
|
||||
def newest_alarm(published: list[tuple[str, str]]) -> tuple[int, int] | None:
|
||||
"""Pick the most recently published alarm out of every device's log.
|
||||
|
||||
Ranked by the hour field's HLC stamp, which orders by wall time and then
|
||||
by counter -- so the winner is the device that set the alarm last, not
|
||||
whichever directory the backend happened to list first. A device whose
|
||||
payload does not parse is skipped rather than being allowed to win with a
|
||||
malformed value.
|
||||
|
||||
Args:
|
||||
published: ``(device_id, raw_json)`` for every device that has
|
||||
published, in any order.
|
||||
|
||||
Returns:
|
||||
The newest (hour, minute) pair, or None if no device published a
|
||||
usable one.
|
||||
"""
|
||||
ranked: list[tuple[str, str, tuple[int, int]]] = []
|
||||
for device_id, raw in published:
|
||||
parsed = parse_log(raw)
|
||||
if parsed is None:
|
||||
_logger.info("Ignoring unusable alarm published by %r", device_id)
|
||||
continue
|
||||
ranked.append((extract_hlc(json.loads(raw), HOUR_FIELD), device_id, parsed))
|
||||
if not ranked:
|
||||
return None
|
||||
# device_id breaks an exact HLC tie deterministically, so two devices
|
||||
# stamped in the same millisecond don't alternate between ticks.
|
||||
hlc, winner, alarm = max(ranked, key=lambda entry: (entry[0], entry[1]))
|
||||
_logger.info("Using the alarm published by %r (hlc %s)", winner, hlc or "none")
|
||||
return alarm
|
||||
@ -94,11 +94,23 @@ ALARM_CACHE_FILE: Path = Path.home() / ".config" / "wake_alarm" / "alarm_cache.j
|
||||
|
||||
# The phone's path to its published alarm. Deliberately the *same* logical
|
||||
# path on both backends — Firebase's namespace mirrors the repo layout — so
|
||||
# only the transport changes, never the parse. The phone is the sole writer
|
||||
# under a fixed device id, so one read of its alarm.json is enough: no device
|
||||
# discovery or HLC ordering here.
|
||||
# only the transport changes, never the parse.
|
||||
#
|
||||
# This is the *legacy* path: the phone used to be the sole writer under the
|
||||
# fixed device id "phone". It still is in practice, but the id is migrating
|
||||
# to a per-install uuid, so the reader discovers device directories under
|
||||
# SYNC_DEVICES_PREFIX and falls back to this path for a phone that has not
|
||||
# yet published under its uuid. Keep reading it until every install has
|
||||
# migrated; deleting it early means a stale phone's alarm silently stops
|
||||
# being seen, and the trigger falls back to FALLBACK_ALARM_HOUR.
|
||||
SYNC_ALARM_PATH: str = "wake-alarm-sync/devices/phone/alarm.json"
|
||||
|
||||
# The directory holding one subdirectory per publishing device.
|
||||
SYNC_DEVICES_PREFIX: str = "wake-alarm-sync/devices"
|
||||
|
||||
# The file each device publishes its alarm as, inside its own directory.
|
||||
SYNC_ALARM_FILENAME: str = "alarm.json"
|
||||
|
||||
# Names this app's credential cache under ~/.local/share/crdt-sync/, keeping
|
||||
# its Firebase refresh token separate from the sibling apps'.
|
||||
SYNC_APP_NAME: str = "wake_alarm"
|
||||
|
||||
@ -28,22 +28,20 @@ import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from wake_alarm._alarm_payload import in_range, newest_alarm, parse_log
|
||||
from wake_alarm._constants import (
|
||||
ALARM_CACHE_FILE,
|
||||
ALARM_CACHE_MAX_AGE_SECONDS,
|
||||
FALLBACK_ALARM_HOUR,
|
||||
MAX_HOUR,
|
||||
MAX_MINUTE,
|
||||
MIN_HOUR,
|
||||
MIN_MINUTE,
|
||||
SYNC_ALARM_FILENAME,
|
||||
SYNC_ALARM_PATH,
|
||||
SYNC_API_BASE,
|
||||
SYNC_APP_NAME,
|
||||
SYNC_CONFIG_FILE,
|
||||
SYNC_DEVICES_PREFIX,
|
||||
SYNC_HTTP_TIMEOUT_SECONDS,
|
||||
SYNC_REPO_NAME,
|
||||
SYNC_REPO_OWNER,
|
||||
@ -67,11 +65,6 @@ except ImportError:
|
||||
_FIREBASE_AVAILABLE = False
|
||||
_logger.info("crdt_sync is not installed; reading the alarm from GitHub")
|
||||
|
||||
# Keys inside the crdt_sync log the phone writes.
|
||||
_RECORD_ID = "alarm"
|
||||
_HOUR_FIELD = "alarm_hour"
|
||||
_MINUTE_FIELD = "alarm_minute"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Alarm:
|
||||
@ -94,22 +87,6 @@ class Alarm:
|
||||
return f"{self.hour:02d}:{self.minute:02d} ({self.source})"
|
||||
|
||||
|
||||
def _in_range(hour: int, minute: int) -> bool:
|
||||
"""Check that an (hour, minute) pair is a real wall-clock time.
|
||||
|
||||
Guards both readers: a malformed sync payload and a corrupted cache must
|
||||
never become a fire time.
|
||||
|
||||
Args:
|
||||
hour: Candidate hour.
|
||||
minute: Candidate minute.
|
||||
|
||||
Returns:
|
||||
True if both values are within a 24-hour clock.
|
||||
"""
|
||||
return MIN_HOUR <= hour <= MAX_HOUR and MIN_MINUTE <= minute <= MAX_MINUTE
|
||||
|
||||
|
||||
def _read_token_config() -> str | None:
|
||||
"""Read the token from this package's own JSON config.
|
||||
|
||||
@ -162,65 +139,15 @@ def _read_token() -> str | None:
|
||||
return _read_token_config() or _read_token_fallback()
|
||||
|
||||
|
||||
def _extract_field(log: dict[str, Any], field: str) -> int | None:
|
||||
"""Pull one integer field out of the phone's crdt_sync log.
|
||||
|
||||
The stored shape is ``{"alarm": {"fields": {"<field>": [value, "<hlc>"]}}}``
|
||||
— each field is a (value, HLC) pair, so the value is element 0.
|
||||
|
||||
Args:
|
||||
log: The parsed log JSON.
|
||||
field: Field name to extract (e.g. ``alarm_hour``).
|
||||
|
||||
Returns:
|
||||
The integer value, or None if anything about the shape is unexpected.
|
||||
"""
|
||||
record = log.get(_RECORD_ID)
|
||||
if not isinstance(record, dict):
|
||||
return None
|
||||
fields = record.get("fields")
|
||||
if not isinstance(fields, dict):
|
||||
return None
|
||||
pair = fields.get(field)
|
||||
# bool is an int subclass; an alarm hour of True would be nonsense.
|
||||
if not isinstance(pair, list) or not pair:
|
||||
return None
|
||||
value = pair[0]
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def _parse_log(raw: str) -> tuple[int, int] | None:
|
||||
"""Parse the phone's log into an (hour, minute) pair.
|
||||
|
||||
Args:
|
||||
raw: The raw JSON text returned by the Contents API.
|
||||
|
||||
Returns:
|
||||
The (hour, minute) pair, or None if the payload is malformed or the
|
||||
values are out of range.
|
||||
"""
|
||||
try:
|
||||
log = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
_logger.warning("Synced alarm is not valid JSON")
|
||||
return None
|
||||
if not isinstance(log, dict):
|
||||
return None
|
||||
hour = _extract_field(log, _HOUR_FIELD)
|
||||
minute = _extract_field(log, _MINUTE_FIELD)
|
||||
if hour is None or minute is None:
|
||||
_logger.warning("Synced alarm is missing hour/minute fields")
|
||||
return None
|
||||
if not _in_range(hour, minute):
|
||||
_logger.warning("Synced alarm out of range: %d:%d", hour, minute)
|
||||
return None
|
||||
return hour, minute
|
||||
|
||||
|
||||
def _fetch_firebase_alarm() -> tuple[int, int] | None:
|
||||
"""Fetch the alarm the phone last pushed to Firebase.
|
||||
"""Fetch the newest alarm any device has pushed to Firebase.
|
||||
|
||||
Discovers device directories rather than reading one fixed path: the
|
||||
phone's device id is migrating from the constant "phone" to a per-install
|
||||
uuid, and a reader pinned to the old path would stop seeing the alarm the
|
||||
moment the phone migrated — falling back to FALLBACK_ALARM_HOUR, i.e.
|
||||
waking the user at the wrong time. Listing covers both ids, and
|
||||
SYNC_ALARM_PATH remains as a last resort for a backend that cannot list.
|
||||
|
||||
Returns None — never raises — for every failure mode, including
|
||||
``crdt_sync`` not being importable on this interpreter, so the caller can
|
||||
@ -240,15 +167,26 @@ def _fetch_firebase_alarm() -> tuple[int, int] | None:
|
||||
|
||||
try:
|
||||
client = firebase_client_for(SYNC_APP_NAME)
|
||||
raw = client.get_file_text(SYNC_ALARM_PATH)
|
||||
published: list[tuple[str, str]] = []
|
||||
for device_id in client.list_directory(SYNC_DEVICES_PREFIX):
|
||||
raw = client.get_file_text(
|
||||
f"{SYNC_DEVICES_PREFIX}/{device_id}/{SYNC_ALARM_FILENAME}"
|
||||
)
|
||||
if raw is not None:
|
||||
published.append((device_id, raw))
|
||||
if not published:
|
||||
# No directories listed (or none held a file): still try the
|
||||
# legacy fixed path, so a phone that has not migrated is read.
|
||||
raw = client.get_file_text(SYNC_ALARM_PATH)
|
||||
if raw is None:
|
||||
_logger.info("No alarm published to Firebase yet")
|
||||
return None
|
||||
published.append(("phone", raw))
|
||||
except (ConfigError, FirebaseAuthError, RemoteSyncError, OSError) as exc:
|
||||
_logger.info("Cannot fetch the alarm from Firebase: %s", exc)
|
||||
return None
|
||||
|
||||
if raw is None:
|
||||
_logger.info("No alarm published to Firebase yet")
|
||||
return None
|
||||
return _parse_log(raw)
|
||||
return newest_alarm(published)
|
||||
|
||||
|
||||
def _fetch_github_alarm() -> tuple[int, int] | None:
|
||||
@ -300,7 +238,7 @@ def _fetch_github_alarm() -> tuple[int, int] | None:
|
||||
_logger.info("Cannot fetch synced alarm: %s", exc)
|
||||
return None
|
||||
|
||||
return _parse_log(raw)
|
||||
return parse_log(raw)
|
||||
|
||||
|
||||
def fetch_remote_alarm() -> tuple[int, int] | None:
|
||||
@ -337,7 +275,7 @@ def _read_cache() -> tuple[int, int, float] | None:
|
||||
return None
|
||||
if not isinstance(fetched_at, (int, float)):
|
||||
return None
|
||||
if not _in_range(hour, minute):
|
||||
if not in_range(hour, minute):
|
||||
return None
|
||||
return hour, minute, float(fetched_at)
|
||||
|
||||
|
||||
107
wake_alarm/tests/test_alarm_payload.py
Normal file
107
wake_alarm/tests/test_alarm_payload.py
Normal file
@ -0,0 +1,107 @@
|
||||
"""Tests for parsing and ranking the alarm payload devices publish.
|
||||
|
||||
A misparse here does not raise -- it resolves to the wrong wake-up time --
|
||||
so the malformed shapes are covered as carefully as the good ones.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from wake_alarm._alarm_payload import extract_hlc, newest_alarm, parse_log
|
||||
|
||||
|
||||
def _log(hour: object = 7, minute: object = 30, hlc: str = "hlc-1") -> str:
|
||||
"""Build a crdt_sync log payload shaped like the phone's."""
|
||||
return json.dumps(
|
||||
{
|
||||
"alarm": {
|
||||
"fields": {
|
||||
"alarm_hour": [hour, hlc],
|
||||
"alarm_minute": [minute, hlc],
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TestExtractHlc:
|
||||
def test_reads_the_stamp(self) -> None:
|
||||
"""The HLC is element 1 of the (value, HLC) pair."""
|
||||
assert extract_hlc(json.loads(_log(hlc="stamp-9")), "alarm_hour") == "stamp-9"
|
||||
|
||||
def test_a_pair_with_no_hlc_reads_as_empty(self) -> None:
|
||||
"""A one-element pair sorts below every real stamp rather than raising."""
|
||||
log = {"alarm": {"fields": {"alarm_hour": [7]}}}
|
||||
|
||||
assert extract_hlc(log, "alarm_hour") == ""
|
||||
|
||||
def test_a_non_string_hlc_reads_as_empty(self) -> None:
|
||||
"""A numeric stamp is not orderable against real ones; treat as absent."""
|
||||
log = {"alarm": {"fields": {"alarm_hour": [7, 12345]}}}
|
||||
|
||||
assert extract_hlc(log, "alarm_hour") == ""
|
||||
|
||||
def test_a_missing_record_reads_as_empty(self) -> None:
|
||||
"""No alarm record at all is not an error here."""
|
||||
assert extract_hlc({}, "alarm_hour") == ""
|
||||
|
||||
|
||||
class TestNewestAlarm:
|
||||
def test_no_devices_yields_none(self) -> None:
|
||||
"""Nothing published is a miss, not a crash."""
|
||||
assert newest_alarm([]) is None
|
||||
|
||||
def test_every_device_unusable_yields_none(self) -> None:
|
||||
"""If no device parses, there is no alarm to return."""
|
||||
assert newest_alarm([("a", "{not json"), ("b", "[]")]) is None
|
||||
|
||||
def test_newest_hlc_wins(self) -> None:
|
||||
"""The device that set the alarm last wins, not the first listed."""
|
||||
published = [
|
||||
("old", _log(9, 0, hlc="2026-07-01T10:00:00.000Z-0000-old")),
|
||||
("new", _log(5, 30, hlc="2026-08-09T10:00:00.000Z-0000-new")),
|
||||
]
|
||||
|
||||
assert newest_alarm(published) == (5, 30)
|
||||
|
||||
def test_ranking_is_independent_of_listing_order(self) -> None:
|
||||
"""Reversing the input must not change the winner."""
|
||||
published = [
|
||||
("new", _log(5, 30, hlc="2026-08-09T10:00:00.000Z-0000-new")),
|
||||
("old", _log(9, 0, hlc="2026-07-01T10:00:00.000Z-0000-old")),
|
||||
]
|
||||
|
||||
assert newest_alarm(published) == (5, 30)
|
||||
assert newest_alarm(list(reversed(published))) == (5, 30)
|
||||
|
||||
def test_an_exact_hlc_tie_breaks_on_device_id(self) -> None:
|
||||
"""Two devices stamped identically must not alternate between ticks."""
|
||||
published = [
|
||||
("aaa", _log(6, 0, hlc="same")),
|
||||
("zzz", _log(8, 0, hlc="same")),
|
||||
]
|
||||
|
||||
assert newest_alarm(published) == (8, 0)
|
||||
assert newest_alarm(list(reversed(published))) == (8, 0)
|
||||
|
||||
def test_an_unusable_device_is_skipped_not_fatal(self) -> None:
|
||||
"""A corrupt publisher must not shadow a healthy one."""
|
||||
published = [("broken", "{not json"), ("good", _log(7, 20))]
|
||||
|
||||
assert newest_alarm(published) == (7, 20)
|
||||
|
||||
def test_an_out_of_range_device_is_skipped(self) -> None:
|
||||
"""A nonsense hour never becomes a fire time, even if newest."""
|
||||
published = [
|
||||
("bad", _log(99, 0, hlc="2026-08-09T10:00:00.000Z-0000-bad")),
|
||||
("good", _log(7, 20, hlc="2026-07-01T10:00:00.000Z-0000-good")),
|
||||
]
|
||||
|
||||
assert newest_alarm(published) == (7, 20)
|
||||
|
||||
|
||||
class TestParseLog:
|
||||
def test_a_non_object_payload_yields_none(self) -> None:
|
||||
"""A JSON array is valid JSON but not a log."""
|
||||
assert parse_log("[]") is None
|
||||
@ -9,12 +9,11 @@ import urllib.error
|
||||
|
||||
import pytest
|
||||
|
||||
from wake_alarm._alarm_payload import extract_field, parse_log
|
||||
from wake_alarm._sync import (
|
||||
Alarm,
|
||||
_extract_field,
|
||||
_fetch_github_alarm,
|
||||
_parse_args,
|
||||
_parse_log,
|
||||
_read_cache,
|
||||
_read_token,
|
||||
_read_token_fallback,
|
||||
@ -156,7 +155,7 @@ class TestTokenFallback:
|
||||
|
||||
|
||||
class TestExtractField:
|
||||
"""Tests for _extract_field's tolerance of malformed logs."""
|
||||
"""Tests for extract_field's tolerance of malformed logs."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"log",
|
||||
@ -174,37 +173,37 @@ class TestExtractField:
|
||||
)
|
||||
def test_malformed_shapes_yield_none(self, log: dict[str, Any]) -> None:
|
||||
"""Every unexpected shape degrades to None rather than raising."""
|
||||
assert _extract_field(log, "alarm_hour") is None
|
||||
assert extract_field(log, "alarm_hour") is None
|
||||
|
||||
def test_extracts_value(self) -> None:
|
||||
"""The value is element 0 of the (value, HLC) pair."""
|
||||
log = {"alarm": {"fields": {"alarm_hour": [6, "hlc"]}}}
|
||||
assert _extract_field(log, "alarm_hour") == 6
|
||||
assert extract_field(log, "alarm_hour") == 6
|
||||
|
||||
|
||||
class TestParseLog:
|
||||
"""Tests for _parse_log."""
|
||||
"""Tests for parse_log."""
|
||||
|
||||
def test_valid(self) -> None:
|
||||
"""A well-formed log parses to an (hour, minute) pair."""
|
||||
assert _parse_log(_log(7, 30)) == (7, 30)
|
||||
assert parse_log(_log(7, 30)) == (7, 30)
|
||||
|
||||
def test_invalid_json(self) -> None:
|
||||
"""Unparsable JSON yields None."""
|
||||
assert _parse_log("{nope") is None
|
||||
assert parse_log("{nope") is None
|
||||
|
||||
def test_not_a_dict(self) -> None:
|
||||
"""A JSON array is not a log."""
|
||||
assert _parse_log("[]") is None
|
||||
assert parse_log("[]") is None
|
||||
|
||||
def test_missing_fields(self) -> None:
|
||||
"""A log without the alarm fields yields None."""
|
||||
assert _parse_log(json.dumps({"alarm": {"fields": {}}})) is None
|
||||
assert parse_log(json.dumps({"alarm": {"fields": {}}})) is None
|
||||
|
||||
@pytest.mark.parametrize(("hour", "minute"), [(24, 0), (-1, 0), (7, 60), (7, -1)])
|
||||
def test_out_of_range(self, hour: int, minute: int) -> None:
|
||||
"""Values outside 0-23 / 0-59 are rejected."""
|
||||
assert _parse_log(_log(hour, minute)) is None
|
||||
assert parse_log(_log(hour, minute)) is None
|
||||
|
||||
|
||||
class TestFetchRemoteAlarm:
|
||||
|
||||
@ -12,7 +12,11 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from wake_alarm._constants import SYNC_ALARM_PATH
|
||||
from wake_alarm._constants import (
|
||||
SYNC_ALARM_FILENAME,
|
||||
SYNC_ALARM_PATH,
|
||||
SYNC_DEVICES_PREFIX,
|
||||
)
|
||||
from wake_alarm._sync import _fetch_firebase_alarm, fetch_remote_alarm
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -31,14 +35,14 @@ class _FakeRemoteError(Exception):
|
||||
"""Stand-in for crdt_sync's RemoteSyncError."""
|
||||
|
||||
|
||||
def _log(hour: object = 7, minute: object = 30) -> str:
|
||||
def _log(hour: object = 7, minute: object = 30, hlc: str = "hlc-1") -> str:
|
||||
"""Build a crdt_sync log payload shaped like the phone's."""
|
||||
return json.dumps(
|
||||
{
|
||||
"alarm": {
|
||||
"fields": {
|
||||
"alarm_hour": [hour, "hlc-1"],
|
||||
"alarm_minute": [minute, "hlc-1"],
|
||||
"alarm_hour": [hour, hlc],
|
||||
"alarm_minute": [minute, hlc],
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -86,6 +90,7 @@ class TestFetchFirebaseAlarm:
|
||||
def test_success(self) -> None:
|
||||
"""A published alarm parses to its (hour, minute) pair."""
|
||||
client = MagicMock()
|
||||
client.list_directory.return_value = []
|
||||
client.get_file_text.return_value = _log(6, 15)
|
||||
with self._patched(client=client):
|
||||
assert _fetch_firebase_alarm() == (6, 15)
|
||||
@ -94,10 +99,63 @@ class TestFetchFirebaseAlarm:
|
||||
def test_absent_value_yields_none(self) -> None:
|
||||
"""Nothing published yet is a miss, not a crash."""
|
||||
client = MagicMock()
|
||||
client.list_directory.return_value = []
|
||||
client.get_file_text.return_value = None
|
||||
with self._patched(client=client):
|
||||
assert _fetch_firebase_alarm() is None
|
||||
|
||||
def test_reads_a_discovered_device_directory(self) -> None:
|
||||
"""A phone publishing under a uuid is found by listing, not by path."""
|
||||
client = MagicMock()
|
||||
client.list_directory.return_value = ["7f3c-uuid"]
|
||||
client.get_file_text.return_value = _log(6, 45)
|
||||
with self._patched(client=client):
|
||||
assert _fetch_firebase_alarm() == (6, 45)
|
||||
client.get_file_text.assert_called_once_with(
|
||||
f"{SYNC_DEVICES_PREFIX}/7f3c-uuid/{SYNC_ALARM_FILENAME}"
|
||||
)
|
||||
|
||||
def test_newest_device_wins_across_several(self) -> None:
|
||||
"""The alarm set most recently wins, whatever order devices list in.
|
||||
|
||||
This is the migration case: the phone's old "phone" directory still
|
||||
holds a stale alarm while its new uuid directory holds the current
|
||||
one. Ranking by HLC rather than listing order is what stops the PC
|
||||
waking at the abandoned time.
|
||||
"""
|
||||
client = MagicMock()
|
||||
client.list_directory.return_value = ["new-uuid", "phone"]
|
||||
client.get_file_text.side_effect = lambda path: {
|
||||
f"{SYNC_DEVICES_PREFIX}/new-uuid/{SYNC_ALARM_FILENAME}": _log(
|
||||
5, 30, hlc="2026-08-09T10:00:00.000Z-0000-new-uuid"
|
||||
),
|
||||
f"{SYNC_DEVICES_PREFIX}/phone/{SYNC_ALARM_FILENAME}": _log(
|
||||
9, 0, hlc="2026-07-01T10:00:00.000Z-0000-phone"
|
||||
),
|
||||
}[path]
|
||||
with self._patched(client=client):
|
||||
assert _fetch_firebase_alarm() == (5, 30)
|
||||
|
||||
def test_falls_back_to_the_legacy_path_when_nothing_lists(self) -> None:
|
||||
"""A backend that lists nothing still reads the un-migrated phone."""
|
||||
client = MagicMock()
|
||||
client.list_directory.return_value = ["phone"]
|
||||
client.get_file_text.side_effect = [None, _log(8, 5)]
|
||||
with self._patched(client=client):
|
||||
assert _fetch_firebase_alarm() == (8, 5)
|
||||
assert client.get_file_text.call_args_list[-1].args[0] == SYNC_ALARM_PATH
|
||||
|
||||
def test_a_device_publishing_garbage_does_not_win(self) -> None:
|
||||
"""One unparsable device must not shadow a healthy one."""
|
||||
client = MagicMock()
|
||||
client.list_directory.return_value = ["broken", "good"]
|
||||
client.get_file_text.side_effect = lambda path: {
|
||||
f"{SYNC_DEVICES_PREFIX}/broken/{SYNC_ALARM_FILENAME}": "{not json",
|
||||
f"{SYNC_DEVICES_PREFIX}/good/{SYNC_ALARM_FILENAME}": _log(7, 20),
|
||||
}[path]
|
||||
with self._patched(client=client):
|
||||
assert _fetch_firebase_alarm() == (7, 20)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exc",
|
||||
[
|
||||
|
||||
Loading…
Reference in New Issue
Block a user