mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 19:27:02 +01:00
Migrate phone_app alarm sync from GitHub Gist to crdt_sync
phone_app: the alarm now syncs through the shared crdt_sync transport
(v0.2.0) into the fixed private repo kuhyx/syncs, replacing the bespoke
Gist. AlarmSyncService is the sole writer: it holds the alarm locally in a
crdt_sync LogStore (one HLC-stamped Record) and pushes it to
wake-alarm-sync/devices/phone/alarm.json. gist_service.dart is deleted;
SyncSettings drops the Gist id and keeps only a token; the device-flow scope
moves from `gist` to `repo` (the Contents API needs private-repo access).
shutdown-wrapper.sh: the PC reader now fetches that alarm from the Contents
API (Accept: application/vnd.github.raw, so no base64) and parses the
crdt_sync Record Log with jq (.alarm.fields.alarm_hour[0]). Single-file read
of the sole "phone" device; unchanged fallback to WAKE_AFTER_HOURS on any
miss so a parse failure can never brick the wake. Config moves from
gist_sync.json {token, gist_id} to sync.json {token}.
Verified: flutter analyze clean + tests pass; shellcheck clean and the full
get_synced_alarm_epoch verified against the real Log format; on-device the
app builds, installs with adb install -r (no data loss), launches and
renders. Live round-trip needs a one-time repo-scoped re-auth on the phone
and ~/.config/wake_alarm/sync.json on the PC.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019KwUCPESvE5CjCtmUFz8xQ
This commit is contained in:
parent
d2f75f7f21
commit
512fdd5c95
@ -4,10 +4,11 @@ import 'package:android_intent_plus/android_intent.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:wake_alarm_sync/screens/settings_screen.dart';
|
||||
import 'package:wake_alarm_sync/services/gist_service.dart';
|
||||
import 'package:wake_alarm_sync/services/alarm_sync_service.dart';
|
||||
import 'package:wake_alarm_sync/services/sync_settings.dart';
|
||||
|
||||
/// Main screen: displays the current alarm time and syncs it to the Gist.
|
||||
/// Main screen: displays the current alarm time and syncs it to the PC via
|
||||
/// the shared crdt_sync transport.
|
||||
class HomeScreen extends StatefulWidget {
|
||||
/// Creates a [HomeScreen].
|
||||
const HomeScreen({super.key});
|
||||
@ -22,6 +23,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
bool _busy = false;
|
||||
String _status = '';
|
||||
bool _configured = false;
|
||||
AlarmSyncService? _service;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -30,25 +32,27 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
}
|
||||
|
||||
Future<void> _loadSavedTime() async {
|
||||
final settings = await SyncSettings.load();
|
||||
if (!mounted) return;
|
||||
setState(() => _configured = settings.isConfigured);
|
||||
if (!settings.isConfigured) return;
|
||||
|
||||
final SyncSettings settings;
|
||||
final AlarmSyncService service;
|
||||
try {
|
||||
final time = await readGistAlarm(
|
||||
token: settings.token,
|
||||
gistId: settings.gistId,
|
||||
);
|
||||
if (time != null && mounted) {
|
||||
setState(() {
|
||||
_hour = time.hour;
|
||||
_minute = time.minute;
|
||||
_status = 'Loaded from Gist';
|
||||
});
|
||||
}
|
||||
settings = await SyncSettings.load();
|
||||
service = await AlarmSyncService.open();
|
||||
} on Exception catch (_) {
|
||||
// Non-fatal: local defaults used.
|
||||
// Storage/plugins unavailable (e.g. under a widget test, or a keystore
|
||||
// hiccup): keep the on-screen defaults rather than crashing.
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
_service = service;
|
||||
setState(() => _configured = settings.isConfigured);
|
||||
|
||||
final alarm = service.currentAlarm;
|
||||
if (alarm != null && mounted) {
|
||||
setState(() {
|
||||
_hour = alarm.hour;
|
||||
_minute = alarm.minute;
|
||||
_status = 'Loaded saved alarm';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -88,7 +92,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
|
||||
Future<void> _syncAndSet() async {
|
||||
final settings = await SyncSettings.load();
|
||||
if (!settings.isConfigured) {
|
||||
final service = _service;
|
||||
if (!settings.isConfigured || service == null) {
|
||||
setState(
|
||||
() => _status = 'Configure GitHub in Settings first.',
|
||||
);
|
||||
@ -97,17 +102,13 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_status = 'Syncing to Gist…';
|
||||
_status = 'Syncing to PC…';
|
||||
});
|
||||
try {
|
||||
await writeGistAlarm(
|
||||
token: settings.token,
|
||||
gistId: settings.gistId,
|
||||
hour: _hour,
|
||||
minute: _minute,
|
||||
);
|
||||
await service.setLocal(_hour, _minute);
|
||||
await service.push(settings.token);
|
||||
setState(
|
||||
() => _status = 'Gist updated ✓ — opening Clock app…',
|
||||
() => _status = 'Synced ✓ — opening Clock app…',
|
||||
);
|
||||
await _setPhoneAlarm();
|
||||
setState(
|
||||
|
||||
@ -4,11 +4,10 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import 'package:wake_alarm_sync/services/gist_service.dart';
|
||||
import 'package:wake_alarm_sync/services/github_device_auth.dart';
|
||||
import 'package:wake_alarm_sync/services/sync_settings.dart';
|
||||
|
||||
/// Settings screen for GitHub connection and Gist configuration.
|
||||
/// Settings screen for the GitHub connection used to sync the alarm.
|
||||
///
|
||||
/// The "Connect GitHub" button runs the OAuth device flow — the user
|
||||
/// enters a short code on github.com; no token pasting required.
|
||||
@ -21,7 +20,7 @@ class SettingsScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
SyncSettings _settings = const SyncSettings(token: '', gistId: '');
|
||||
SyncSettings _settings = const SyncSettings(token: '');
|
||||
bool _busy = false;
|
||||
String _status = '';
|
||||
|
||||
@ -52,16 +51,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_status = 'Connected — creating Gist…';
|
||||
_status = 'Connecting…';
|
||||
});
|
||||
final gistId = await createGist(token: token);
|
||||
final updated = SyncSettings(token: token, gistId: gistId);
|
||||
final updated = SyncSettings(token: token);
|
||||
await updated.save();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_settings = updated;
|
||||
_busy = false;
|
||||
_status = 'Connected! Gist ID: $gistId';
|
||||
_status = 'Connected! Alarm will sync to kuhyx/syncs.';
|
||||
});
|
||||
}
|
||||
} on Exception catch (e) {
|
||||
@ -80,7 +78,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
await SyncSettings.clear();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_settings = const SyncSettings(token: '', gistId: '');
|
||||
_settings = const SyncSettings(token: '');
|
||||
_status = 'Disconnected.';
|
||||
});
|
||||
}
|
||||
@ -96,7 +94,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_StatusCard(connected: isConnected, gistId: _settings.gistId),
|
||||
_StatusCard(connected: isConnected),
|
||||
const SizedBox(height: 16),
|
||||
if (!isConnected)
|
||||
ElevatedButton.icon(
|
||||
@ -133,8 +131,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
const Divider(height: 32),
|
||||
const Text(
|
||||
'PC config — create this file:\n'
|
||||
'~/.config/wake_alarm/gist_sync.json\n\n'
|
||||
'{ "token": "<same token>", "gist_id": "<shown above>" }',
|
||||
'~/.config/wake_alarm/sync.json\n\n'
|
||||
'{ "token": "<same token>" }\n\n'
|
||||
'The token needs "repo" scope so the PC can read '
|
||||
'kuhyx/syncs at shutdown.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
@ -145,10 +145,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
|
||||
class _StatusCard extends StatelessWidget {
|
||||
const _StatusCard({required this.connected, required this.gistId});
|
||||
const _StatusCard({required this.connected});
|
||||
|
||||
final bool connected;
|
||||
final String gistId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -164,7 +163,7 @@ class _StatusCard extends StatelessWidget {
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
connected ? 'Connected — Gist: $gistId' : 'Not connected',
|
||||
connected ? 'Connected to GitHub' : 'Not connected',
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
100
phone_app/lib/services/alarm_sync_service.dart
Normal file
100
phone_app/lib/services/alarm_sync_service.dart
Normal file
@ -0,0 +1,100 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
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';
|
||||
|
||||
/// The alarm time this device holds, as read from or written to the store.
|
||||
typedef Alarm = ({int hour, int minute});
|
||||
|
||||
/// Cross-device alarm sync for wake_alarm_sync, on the shared `crdt_sync`
|
||||
/// transport instead of a bespoke GitHub Gist.
|
||||
///
|
||||
/// 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` in the private `kuhyx/syncs`
|
||||
/// repo — the same "GitHub as dumb file storage under `devices/<id>/`" layout
|
||||
/// diet_guard's app and the workout app already use. The PC side
|
||||
/// (`shutdown-wrapper.sh`) is read-only: it fetches that file at shutdown to
|
||||
/// set `rtcwake`. There is no pull/merge: 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);
|
||||
|
||||
final LogStore _store;
|
||||
|
||||
/// Fixed sync target: the shared private repo used by every companion app.
|
||||
static const owner = 'kuhyx';
|
||||
|
||||
/// 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';
|
||||
|
||||
static const _remotePath = 'wake-alarm-sync/devices/$deviceId/alarm.json';
|
||||
static const _recordId = 'alarm';
|
||||
static const _hourField = 'alarm_hour';
|
||||
static const _minuteField = 'alarm_minute';
|
||||
|
||||
/// 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 {
|
||||
final backend = persistence ?? await _defaultPersistence();
|
||||
final store = LogStore(persistence: backend, nodeId: deviceId);
|
||||
await store.load();
|
||||
return AlarmSyncService(store);
|
||||
}
|
||||
|
||||
static Future<LogPersistence> _defaultPersistence() async {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
return FileLogPersistence(File(p.join(dir.path, 'alarm_log.json')));
|
||||
}
|
||||
|
||||
/// The alarm currently stored locally, or `null` if none is set yet.
|
||||
Alarm? get currentAlarm {
|
||||
final record = _store.get(_recordId);
|
||||
if (record == null || record.deleted) return null;
|
||||
final hour = record.fields[_hourField]?.$1;
|
||||
final minute = record.fields[_minuteField]?.$1;
|
||||
if (hour is int && minute is int) return (hour: hour, minute: minute);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Stores [hour]:[minute] locally, stamped with a fresh monotonic HLC.
|
||||
///
|
||||
/// Both fields share one clock tick — they are always set together, so a
|
||||
/// concurrent per-field merge would never split them.
|
||||
Future<void> setLocal(int hour, int minute) async {
|
||||
final hlc = _store.nextHlc();
|
||||
await _store.upsert(
|
||||
Record(
|
||||
id: _recordId,
|
||||
fields: {_hourField: (hour, hlc), _minuteField: (minute, hlc)},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Pushes the local alarm log to `kuhyx/syncs` using [client].
|
||||
Future<void> pushWith(GitHubClient client) => client.putFileText(
|
||||
_remotePath,
|
||||
logToJson(_store.snapshot()),
|
||||
message: 'wake_alarm_sync: update alarm',
|
||||
);
|
||||
|
||||
/// Pushes using a [token]-authenticated client for the fixed repo.
|
||||
Future<void> push(String token, {http.Client? httpClient}) => pushWith(
|
||||
GitHubClient(
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
token: token,
|
||||
httpClient: httpClient,
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -1,87 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
const _gistFileName = 'wake_alarm_sync.json';
|
||||
|
||||
/// Reads the current alarm time from the Gist.
|
||||
///
|
||||
/// Returns null if the Gist is not yet configured or the request fails.
|
||||
Future<({int hour, int minute})?> readGistAlarm({
|
||||
required String token,
|
||||
required String gistId,
|
||||
}) async {
|
||||
final uri = Uri.parse('https://api.github.com/gists/$gistId');
|
||||
final response = await http.get(
|
||||
uri,
|
||||
headers: {'Authorization': 'Bearer $token'},
|
||||
);
|
||||
if (response.statusCode != 200) return null;
|
||||
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final files = body['files'] as Map<String, dynamic>?;
|
||||
final fileEntry = files?[_gistFileName] as Map<String, dynamic>?;
|
||||
final fileContent = fileEntry?['content'] as String?;
|
||||
if (fileContent == null) return null;
|
||||
|
||||
final data = jsonDecode(fileContent) as Map<String, dynamic>;
|
||||
final hour = data['alarm_hour'] as int?;
|
||||
final minute = data['alarm_minute'] as int?;
|
||||
if (hour == null || minute == null) return null;
|
||||
return (hour: hour, minute: minute);
|
||||
}
|
||||
|
||||
/// Writes the chosen alarm time to the Gist, creating the file if absent.
|
||||
Future<void> writeGistAlarm({
|
||||
required String token,
|
||||
required String gistId,
|
||||
required int hour,
|
||||
required int minute,
|
||||
}) async {
|
||||
final content = jsonEncode({'alarm_hour': hour, 'alarm_minute': minute});
|
||||
final uri = Uri.parse('https://api.github.com/gists/$gistId');
|
||||
final response = await http.patch(
|
||||
uri,
|
||||
headers: {
|
||||
'Authorization': 'Bearer $token',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: jsonEncode({
|
||||
'files': {
|
||||
_gistFileName: {'content': content},
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception(
|
||||
'Gist write failed: HTTP ${response.statusCode}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new private Gist and returns its ID.
|
||||
Future<String> createGist({required String token}) async {
|
||||
final content = jsonEncode({'alarm_hour': 7, 'alarm_minute': 0});
|
||||
final uri = Uri.parse('https://api.github.com/gists');
|
||||
final response = await http.post(
|
||||
uri,
|
||||
headers: {
|
||||
'Authorization': 'Bearer $token',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: jsonEncode({
|
||||
'description': 'wake_alarm PC sync',
|
||||
'public': false,
|
||||
'files': {
|
||||
_gistFileName: {'content': content},
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (response.statusCode != 201) {
|
||||
throw Exception(
|
||||
'Gist create failed: HTTP ${response.statusCode}',
|
||||
);
|
||||
}
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return body['id'] as String;
|
||||
}
|
||||
@ -62,12 +62,12 @@ class DeviceAuthException implements Exception {
|
||||
///
|
||||
/// Device flow needs only a public `client_id` (no client secret), which
|
||||
/// makes it safe for a distributed app. The resulting access token is then
|
||||
/// used exactly like a PAT by the Gist service.
|
||||
/// used exactly like a PAT by the crdt_sync GitHub-Contents transport.
|
||||
class GitHubDeviceAuth {
|
||||
/// Creates a [GitHubDeviceAuth] instance.
|
||||
GitHubDeviceAuth({
|
||||
required this.clientId,
|
||||
this.scope = 'gist',
|
||||
this.scope = 'repo',
|
||||
http.Client? httpClient,
|
||||
Future<void> Function(Duration)? delay,
|
||||
}) : _http = httpClient ?? http.Client(),
|
||||
@ -76,7 +76,8 @@ class GitHubDeviceAuth {
|
||||
/// OAuth App client ID (public identifier, not a secret).
|
||||
final String clientId;
|
||||
|
||||
/// OAuth scope requested. `gist` is required to read/write Gists.
|
||||
/// OAuth scope requested. `repo` is required for the Contents API to
|
||||
/// read/write the private `kuhyx/syncs` repo (Gists are no longer used).
|
||||
final String scope;
|
||||
|
||||
final http.Client _http;
|
||||
|
||||
@ -3,31 +3,31 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Locally-stored GitHub sync configuration for wake_alarm_sync.
|
||||
///
|
||||
/// The GitHub token is kept in the OS keystore (Android Keystore) via
|
||||
/// [FlutterSecureStorage]; the Gist ID (not a secret) lives in
|
||||
/// `SharedPreferences`. Legacy builds stored the token in plaintext
|
||||
/// prefs; [load]/[save] migrate it transparently and never drop the
|
||||
/// plaintext copy until a secure write is confirmed.
|
||||
/// Only a GitHub token is stored now: the alarm syncs through the shared
|
||||
/// `crdt_sync` transport into the fixed private repo `kuhyx/syncs` (see
|
||||
/// `AlarmSyncService`), so there is no per-install Gist id to keep. The token
|
||||
/// is kept in the OS keystore (Android Keystore) via [FlutterSecureStorage].
|
||||
/// Legacy builds stored the token in plaintext prefs (and a `gist_id`);
|
||||
/// [load]/[save] migrate the token transparently and drop the stale keys.
|
||||
class SyncSettings {
|
||||
/// Creates a [SyncSettings].
|
||||
const SyncSettings({required this.token, required this.gistId});
|
||||
const SyncSettings({required this.token});
|
||||
|
||||
/// GitHub access token (PAT or device-flow token).
|
||||
/// GitHub access token (device-flow token or PAT) with `repo` scope so the
|
||||
/// Contents API can read/write the private `kuhyx/syncs` repo.
|
||||
final String token;
|
||||
|
||||
/// ID of the private Gist used to sync the alarm time.
|
||||
final String gistId;
|
||||
|
||||
/// Baked-in OAuth App client ID for the device-flow "Connect" button.
|
||||
/// Device-flow client IDs are public identifiers, not secrets.
|
||||
static const defaultClientId = 'Ov23liGUPrPe1FFRSel6';
|
||||
|
||||
/// True when both token and gist ID are present and sync can proceed.
|
||||
bool get isConfigured => token.isNotEmpty && gistId.isNotEmpty;
|
||||
/// True when a token is present and sync can proceed.
|
||||
bool get isConfigured => token.isNotEmpty;
|
||||
|
||||
// Legacy plaintext key — read-only; removed once migrated to keystore.
|
||||
static const _kLegacyToken = 'github_token';
|
||||
static const _kGistId = 'gist_id';
|
||||
// Legacy Gist id key — no longer used; cleared on load/save/clear.
|
||||
static const _kLegacyGistId = 'gist_id';
|
||||
static const _secureTokenKey = 'github_token';
|
||||
|
||||
static const _secure = FlutterSecureStorage();
|
||||
@ -35,15 +35,12 @@ class SyncSettings {
|
||||
/// Loads settings from persistent storage.
|
||||
static Future<SyncSettings> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return SyncSettings(
|
||||
token: await _loadToken(prefs),
|
||||
gistId: prefs.getString(_kGistId) ?? '',
|
||||
);
|
||||
return SyncSettings(token: await _loadToken(prefs));
|
||||
}
|
||||
|
||||
/// Reads the token from the keystore, falling back to the legacy
|
||||
/// plaintext value. A legacy value is migrated into the keystore on
|
||||
/// read, but only deleted from prefs once that write succeeds.
|
||||
/// Reads the token from the keystore, falling back to the legacy plaintext
|
||||
/// value. A legacy value is migrated into the keystore on read, but only
|
||||
/// deleted from prefs once that write succeeds.
|
||||
static Future<String> _loadToken(SharedPreferences prefs) async {
|
||||
String? secure;
|
||||
try {
|
||||
@ -60,10 +57,10 @@ class SyncSettings {
|
||||
return legacy;
|
||||
}
|
||||
|
||||
/// Persists current settings.
|
||||
/// Persists current settings and drops the stale Gist id.
|
||||
Future<void> save() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_kGistId, gistId);
|
||||
await prefs.remove(_kLegacyGistId);
|
||||
if (await _writeSecureToken(token)) {
|
||||
await prefs.remove(_kLegacyToken);
|
||||
} else {
|
||||
@ -74,13 +71,13 @@ class SyncSettings {
|
||||
/// Removes all stored credentials.
|
||||
static Future<void> clear() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_kGistId);
|
||||
await prefs.remove(_kLegacyGistId);
|
||||
await prefs.remove(_kLegacyToken);
|
||||
await _writeSecureToken('');
|
||||
}
|
||||
|
||||
/// Writes [token] to the keystore. Returns false if the platform
|
||||
/// secret service is unavailable.
|
||||
/// Writes [token] to the keystore. Returns false if the platform secret
|
||||
/// service is unavailable.
|
||||
static Future<bool> _writeSecureToken(String token) async {
|
||||
try {
|
||||
if (token.isEmpty) {
|
||||
@ -95,10 +92,7 @@ class SyncSettings {
|
||||
}
|
||||
|
||||
/// Returns a copy with optional field overrides.
|
||||
SyncSettings copyWith({String? token, String? gistId}) {
|
||||
return SyncSettings(
|
||||
token: token ?? this.token,
|
||||
gistId: gistId ?? this.gistId,
|
||||
);
|
||||
SyncSettings copyWith({String? token}) {
|
||||
return SyncSettings(token: token ?? this.token);
|
||||
}
|
||||
}
|
||||
|
||||
@ -65,6 +65,15 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
crdt_sync:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: crdt_sync_dart
|
||||
ref: "crdt_sync_dart-v0.2.0"
|
||||
resolved-ref: "5be99ab11d1657ec04edd65508b8c4ecae4da906"
|
||||
url: "https://github.com/kuhyx/utils"
|
||||
source: git
|
||||
version: "0.2.0"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -289,7 +298,7 @@ packages:
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
@ -297,7 +306,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
name: wake_alarm_sync
|
||||
description: "Wake alarm phone-side sync app — sets phone alarm and writes time to GitHub Gist."
|
||||
description: "Wake alarm phone-side sync app — sets the phone alarm and syncs the time to the PC via the shared crdt_sync transport."
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.0.0+1
|
||||
@ -9,11 +9,18 @@ environment:
|
||||
|
||||
dependencies:
|
||||
android_intent_plus: ^5.0.0
|
||||
crdt_sync:
|
||||
git:
|
||||
url: https://github.com/kuhyx/utils
|
||||
ref: crdt_sync_dart-v0.2.0
|
||||
path: crdt_sync_dart
|
||||
cupertino_icons: ^1.0.8
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_secure_storage: ^9.0.0
|
||||
http: ^1.2.0
|
||||
path: ^1.9.0
|
||||
path_provider: ^2.1.0
|
||||
shared_preferences: ^2.3.0
|
||||
url_launcher: ^6.3.0
|
||||
|
||||
|
||||
104
phone_app/test/services/alarm_sync_service_test.dart
Normal file
104
phone_app/test/services/alarm_sync_service_test.dart
Normal file
@ -0,0 +1,104 @@
|
||||
import 'dart:convert';
|
||||
|
||||
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:wake_alarm_sync/services/alarm_sync_service.dart';
|
||||
|
||||
const _alarmPath = 'wake-alarm-sync/devices/phone/alarm.json';
|
||||
|
||||
http.Response _response(int statusCode, [Object? jsonBody]) =>
|
||||
http.Response(jsonEncode(jsonBody ?? {}), statusCode);
|
||||
|
||||
/// In-memory persistence so the store never touches the real documents dir.
|
||||
class _MemPersistence implements LogPersistence {
|
||||
String? text;
|
||||
|
||||
@override
|
||||
Future<String?> read() async => text;
|
||||
|
||||
@override
|
||||
Future<void> write(String value) async => text = value;
|
||||
}
|
||||
|
||||
class _PutCall {
|
||||
_PutCall(this.path, this.body);
|
||||
final String path;
|
||||
final Map<String, dynamic> body;
|
||||
}
|
||||
|
||||
/// Mock GitHub Contents API: GET `.../contents/<key>` returns
|
||||
/// [contentResponses]`[key]` (404 if absent), the bare repo-existence GET
|
||||
/// always succeeds, and every PUT is recorded. Mirrors the workout_app test.
|
||||
({http.Client httpClient, List<_PutCall> putCalls}) _mockGitHub({
|
||||
Map<String, http.Response> contentResponses = const {},
|
||||
}) {
|
||||
final putCalls = <_PutCall>[];
|
||||
final client = http_testing.MockClient((request) async {
|
||||
final path = request.url.path;
|
||||
if (!path.contains('/contents/')) return _response(200);
|
||||
final key = path.split('/contents/').last;
|
||||
if (request.method == 'PUT') {
|
||||
putCalls.add(
|
||||
_PutCall(key, jsonDecode(request.body) as Map<String, dynamic>),
|
||||
);
|
||||
return _response(200);
|
||||
}
|
||||
return contentResponses[key] ?? _response(404);
|
||||
});
|
||||
return (httpClient: client, putCalls: putCalls);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _decodePush(_PutCall call) =>
|
||||
jsonDecode(utf8.decode(base64.decode(call.body['content'] as String)))
|
||||
as Map<String, dynamic>;
|
||||
|
||||
void main() {
|
||||
test('currentAlarm is null before anything is set', () async {
|
||||
final service = await AlarmSyncService.open(persistence: _MemPersistence());
|
||||
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);
|
||||
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);
|
||||
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());
|
||||
await service.setLocal(7, 15);
|
||||
final (:httpClient, :putCalls) = _mockGitHub();
|
||||
|
||||
await service.push('tok', httpClient: httpClient);
|
||||
|
||||
expect(putCalls, hasLength(1));
|
||||
expect(putCalls.single.path, _alarmPath);
|
||||
final pushed = _decodePush(putCalls.single);
|
||||
final fields = (pushed['alarm'] as Map<String, dynamic>)['fields']
|
||||
as Map<String, dynamic>;
|
||||
expect((fields['alarm_hour'] as List).first, 7);
|
||||
expect((fields['alarm_minute'] as List).first, 15);
|
||||
});
|
||||
|
||||
test('push is a network round-trip that swallows a server error', () async {
|
||||
final service = await AlarmSyncService.open(persistence: _MemPersistence());
|
||||
await service.setLocal(9, 0);
|
||||
final client = http_testing.MockClient(
|
||||
(request) async => http.Response('', 500),
|
||||
);
|
||||
|
||||
// A GitHubSyncError from the transport propagates as an Exception the UI
|
||||
// can catch; it must be a normal Exception, not an Error.
|
||||
await expectLater(
|
||||
service.push('tok', httpClient: client),
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -7,10 +7,11 @@
|
||||
# Install to /usr/local/bin/shutdown (takes priority over /usr/bin/shutdown
|
||||
# because /usr/local/bin appears first in PATH).
|
||||
#
|
||||
# Wake time is taken from the private GitHub Gist written by the phone app
|
||||
# (see phone_app/). Falls back to WAKE_AFTER_HOURS if the Gist is not
|
||||
# configured or unreachable. Config: ~/.config/wake_alarm/gist_sync.json
|
||||
# with keys "token" (GitHub PAT with gist scope) and "gist_id".
|
||||
# Wake time is taken from the alarm the phone app pushes to the private
|
||||
# kuhyx/syncs repo via the shared crdt_sync transport (see phone_app/). Falls
|
||||
# back to WAKE_AFTER_HOURS if sync is not configured or unreachable. Config:
|
||||
# ~/.config/wake_alarm/sync.json with key "token" (GitHub PAT/token with
|
||||
# "repo" scope, so the Contents API can read the private repo).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@ -29,27 +30,36 @@ for arg in "$@"; do
|
||||
esac
|
||||
done
|
||||
|
||||
# Read alarm time from the private GitHub Gist written by the phone app.
|
||||
# Read the alarm time the phone pushed to kuhyx/syncs via crdt_sync.
|
||||
# Returns the Unix epoch (seconds) of the next occurrence of the stored H:M.
|
||||
# Exits non-zero if the Gist is not configured, unreachable, or malformed.
|
||||
get_gist_alarm_epoch() {
|
||||
local cfg="$HOME/.config/wake_alarm/gist_sync.json"
|
||||
# Exits non-zero if sync is not configured, unreachable, or malformed.
|
||||
#
|
||||
# The phone is the sole writer, under the fixed device id "phone", so a single
|
||||
# Contents-API read of its alarm.json is enough (no device discovery / HLC
|
||||
# ordering needed here — that would only matter with multiple writers). The
|
||||
# stored payload is a crdt_sync Log: {"alarm": {"fields": {"alarm_hour":
|
||||
# [H, "<hlc>"], "alarm_minute": [M, "<hlc>"]}, ...}}. The raw media type makes
|
||||
# the Contents API return that JSON directly, so no base64 decode is needed.
|
||||
get_synced_alarm_epoch() {
|
||||
local cfg="$HOME/.config/wake_alarm/sync.json"
|
||||
[[ -f "$cfg" ]] || return 1
|
||||
|
||||
local token gist_id raw_content hour minute target_epoch now_epoch
|
||||
local token log_json hour minute target_epoch now_epoch
|
||||
token=$(jq -r .token "$cfg" 2>/dev/null) || return 1
|
||||
gist_id=$(jq -r .gist_id "$cfg" 2>/dev/null) || return 1
|
||||
[[ -n "$token" && "$token" != "null" ]] || return 1
|
||||
[[ -n "$gist_id" && "$gist_id" != "null" ]] || return 1
|
||||
|
||||
raw_content=$(curl -sf --max-time 10 \
|
||||
local url="https://api.github.com/repos/kuhyx/syncs/contents"
|
||||
url="$url/wake-alarm-sync/devices/phone/alarm.json"
|
||||
log_json=$(curl -sf --max-time 10 \
|
||||
-H "Authorization: token $token" \
|
||||
"https://api.github.com/gists/$gist_id" \
|
||||
| jq -r '.files["wake_alarm_sync.json"].content') || return 1
|
||||
[[ -n "$raw_content" && "$raw_content" != "null" ]] || return 1
|
||||
-H "Accept: application/vnd.github.raw" \
|
||||
"$url") || return 1
|
||||
[[ -n "$log_json" ]] || return 1
|
||||
|
||||
hour=$(printf '%s' "$raw_content" | jq -r .alarm_hour 2>/dev/null) || return 1
|
||||
minute=$(printf '%s' "$raw_content" | jq -r .alarm_minute 2>/dev/null) || return 1
|
||||
hour=$(printf '%s' "$log_json" \
|
||||
| jq -r '.alarm.fields.alarm_hour[0]' 2>/dev/null) || return 1
|
||||
minute=$(printf '%s' "$log_json" \
|
||||
| jq -r '.alarm.fields.alarm_minute[0]' 2>/dev/null) || return 1
|
||||
[[ "$hour" =~ ^[0-9]+$ && "$minute" =~ ^[0-9]+$ ]] || return 1
|
||||
|
||||
printf -v now_epoch '%(%s)T' -1
|
||||
@ -64,13 +74,13 @@ get_gist_alarm_epoch() {
|
||||
tomorrow_dow=$(date -d "tomorrow" +%u)
|
||||
case "$tomorrow_dow" in
|
||||
1|2|3|4|5|6|7)
|
||||
if wake_epoch=$(get_gist_alarm_epoch 2>/dev/null); then
|
||||
if wake_epoch=$(get_synced_alarm_epoch 2>/dev/null); then
|
||||
logger -t shutdown-wrapper \
|
||||
"Gist sync: waking at $(date -d "@$wake_epoch") (epoch $wake_epoch)"
|
||||
"crdt_sync: waking at $(date -d "@$wake_epoch") (epoch $wake_epoch)"
|
||||
else
|
||||
wake_epoch=$(( $(printf '%(%s)T' -1) + WAKE_AFTER_HOURS * 3600 ))
|
||||
logger -t shutdown-wrapper \
|
||||
"Gist unavailable — fallback +${WAKE_AFTER_HOURS}h (epoch $wake_epoch)"
|
||||
"sync unavailable — fallback +${WAKE_AFTER_HOURS}h (epoch $wake_epoch)"
|
||||
fi
|
||||
sudo "$RTCWAKE" -m no -t "$wake_epoch"
|
||||
exec /usr/bin/systemctl hibernate
|
||||
|
||||
Loading…
Reference in New Issue
Block a user