Phase 4/3: close the ring screen on dismiss, and make NFC registerable

Two defects that each defeated the alarm in a different way:

1. Nothing finished AlarmActivity after a correct answer. Sound stopped and
   the alarm re-armed, but the dismiss screen stayed up over the lockscreen
   forever — back is disabled in both halves by design — and the live instance
   kept _inRoom=true with the solved answer still typed, so the next ring
   could reuse an already-passed gate. AlarmActivity now handles
   closeRingScreen; the shared AlarmChannel deliberately holds
   applicationContext and cannot finish an activity, hence the split handler.

2. Nothing could write roomProof.nfcTagId — there was no registration UI — so
   checkNfc always answered "no tag registered" and checkQr was never called.
   Functionally that left steps as the only satisfiable proof, which is
   exactly the state the decision rules out ("NFC or QR qualifies; steps alone
   does NOT") and removes the redundancy whose purpose is that one failing
   mechanism at 06:00 must never block dismissal. Added RoomProofScreen
   (reader mode from MainActivity, since registration needs an Activity) and
   linked it from Settings.

Also: StepCounter kept one listener instead of registering a fresh one per
ring and never unregistering, and the ring screen drops its NFC handler on
dispose.

New tests cover what had been dead code: a registered tag satisfies the proof
and reveals the challenge, a foreign tag does not, and a correct answer
invokes BOTH dismiss and closeRingScreen. The emulator cannot reach the
dismiss path at all (no step sensor, no NFC), which is why it is covered by
widget tests rather than claimed from the AVD run.

Documented ringNow/main_ring_probe.dart in AGENTS.md as a deliberate manual
test handle, the phone-side equivalent of wake-alarm.service.

flutter test: 69 pass. dart analyze lib/ test/: clean. pre-commit: passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
This commit is contained in:
Krzysztof Rudnicki 2026-08-27 21:39:01 +02:00
parent 9980b47cf4
commit dc2d54668b
13 changed files with 483 additions and 19 deletions

View File

@ -190,9 +190,29 @@ to it, so steps only accrue after pickup and can be faked by shaking it in
bed. Steps are accepted only together with elapsed time, as the fallback for
when both other mechanisms fail.
**`ringNow` + `lib/main_ring_probe.dart` are a deliberate manual test
handle**, the phone-side equivalent of `wake-alarm.service` on the PC: the
normal arm path needs a sync backend connected, so there is no other way to
fire the ring on an emulator. The probe runs the **real** app widget and
pushes `/ring` — an earlier version rendered its own UI and so proved nothing
about the route. `flutter build apk` uses `lib/main.dart`, so the probe is
never the shipped entrypoint; `ringNow` is reachable only over this app's own
channel and arms nothing. Do not "clean these up" without replacing the
capability.
Emulator: `wake_alarm_api36` (Pixel 6a profile, API 36). The SDK tools are at
`~/Android/Sdk` but **not on PATH** — export `ANDROID_HOME` and add
`cmdline-tools/latest/bin`, `emulator` and `platform-tools` yourself.
`cmdline-tools/latest/bin`, `emulator` and `platform-tools` yourself. The
phone (Pixel 6a, `23181JEGR08034`) holds a **release-signed** build, so
`phone_deploy.sh` needs `--release` or the install fails with
`INSTALL_FAILED_UPDATE_INCOMPATIBLE`.
**The ring screen must close itself on dismiss.** `AlarmActivity` handles
`closeRingScreen` (the shared `AlarmChannel` holds `applicationContext` and
cannot finish an activity). Without it the dismiss screen stays up over the
lockscreen forever — back is disabled by design in both halves — and the live
instance would carry a satisfied room proof and the solved answer into the
next ring.
## Module map

View File

@ -34,7 +34,21 @@ class AlarmActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
AlarmChannel.register(this, flutterEngine)
alarmChannel = channel(flutterEngine)
val channel = channel(flutterEngine)
alarmChannel = channel
// AlarmChannel deliberately holds applicationContext, so it cannot
// finish an activity. Without this the dismiss screen stays up over
// the lockscreen forever after a correct answer — back is disabled,
// and the live instance would carry _inRoom=true and the solved
// answer into the next ring.
channel.setMethodCallHandler { call, result ->
if (call.method == "closeRingScreen") {
finishAndRemoveTask()
result.success(null)
} else {
AlarmChannel.handleCall(applicationContext, call, result)
}
}
}
override fun onResume() {

View File

@ -18,10 +18,10 @@ object AlarmChannel {
fun register(context: Context, engine: FlutterEngine) {
val app = context.applicationContext
MethodChannel(engine.dartExecutor.binaryMessenger, AlarmActivity.CHANNEL)
.setMethodCallHandler { call, result -> handle(app, call, result) }
.setMethodCallHandler { call, result -> handleCall(app, call, result) }
}
private fun handle(context: Context, call: MethodCall, result: MethodChannel.Result) {
fun handleCall(context: Context, call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"schedule" -> schedule(context, call, result)
"cancel" -> {
@ -49,6 +49,9 @@ object AlarmChannel {
result.success(null)
}
"hasNfc" -> result.success(NfcReader.isAvailable(context))
// Handled by AlarmActivity, which owns the window; reaching here
// means the picker asked, where there is no ring screen to close.
"closeRingScreen" -> result.success(null)
"stepCount" -> result.success(StepCounter.stepsSinceRingStart(context))
"beginStepCount" -> {
StepCounter.markRingStart(context)

View File

@ -7,6 +7,7 @@ import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
/**
* The normal app entry point (the time picker).
@ -51,9 +52,43 @@ class MainActivity : FlutterActivity() {
)
}
/** Kept so NFC reader mode can push tag ids back to the Settings screen. */
private var alarmChannel: MethodChannel? = null
/** True while Settings is waiting for a tag to register. */
private var registeringTag = false
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
AlarmChannel.register(this, flutterEngine)
val channel = AlarmActivity.channel(flutterEngine)
alarmChannel = channel
// Registration needs an Activity for reader mode, which the shared
// channel handler cannot supply — it holds applicationContext.
channel.setMethodCallHandler { call, result ->
when (call.method) {
"startTagRegistration" -> {
registeringTag = NfcReader.start(this, channel)
result.success(registeringTag)
}
"stopTagRegistration" -> {
registeringTag = false
NfcReader.stop(this)
result.success(null)
}
else -> AlarmChannel.handleCall(applicationContext, call, result)
}
}
}
override fun onPause() {
if (registeringTag) NfcReader.stop(this)
super.onPause()
}
override fun onResume() {
super.onResume()
if (registeringTag) alarmChannel?.let { NfcReader.start(this, it) }
}
private companion object {

View File

@ -29,6 +29,9 @@ object StepCounter {
@Volatile
private var latest: Long? = null
/** The single registered listener; kept so rings do not accumulate them. */
private var listener: SensorEventListener? = null
/**
* Starts listening and records the first reading as this ring's baseline.
*
@ -70,22 +73,25 @@ object StepCounter {
* the ring screen polls repeatedly, and re-registering per read would cost
* a fresh sensor warm-up each time.
*/
@Synchronized
private fun listen(context: Context, onFirst: (Long) -> Unit) {
val manager =
context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager ?: return
val sensor = manager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) ?: return
manager.registerListener(
object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent) {
val total = event.values.firstOrNull()?.toLong() ?: return
latest = total
onFirst(total)
}
// One registration per process: markRingStart runs on every ring, and
// an unregistered listener per ring would accumulate for the lifetime
// of the app.
listener?.let { manager.unregisterListener(it) }
val fresh = object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent) {
val total = event.values.firstOrNull()?.toLong() ?: return
latest = total
onFirst(total)
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
},
sensor,
SensorManager.SENSOR_DELAY_NORMAL,
)
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
}
listener = fresh
manager.registerListener(fresh, sensor, SensorManager.SENSOR_DELAY_NORMAL)
}
}

View File

@ -90,6 +90,7 @@ class _RingScreenState extends State<RingScreen> {
@override
void dispose() {
_scheduler.clearNfcListener();
_flashTimer?.cancel();
_input.dispose();
_elapsed.stop();
@ -124,8 +125,16 @@ class _RingScreenState extends State<RingScreen> {
_startFlashTimerIfNeeded();
return;
}
unawaited(_scheduler.dismiss());
setState(() => _status = 'Dismissed');
unawaited(_finishDismiss());
}
/// Stops the ring, then closes this screen.
///
/// Ordered: the sound must stop even if closing the window fails.
Future<void> _finishDismiss() async {
await _scheduler.dismiss();
await _scheduler.closeRingScreen();
}
@override

View File

@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
import 'package:wake_alarm_sync/services/room_proof_settings.dart';
/// Registers the separate-room proofs: the NFC tag and the printed QR code.
///
/// Without a registered tag, NFC can never satisfy the room proof and the
/// steps fallback becomes the only way to dismiss — which is exactly the
/// single-mechanism dependency the redundancy exists to avoid.
class RoomProofScreen extends StatefulWidget {
/// Creates the screen; [scheduler] is injectable for tests.
const RoomProofScreen({super.key, this.scheduler});
/// Platform bridge used to drive NFC reader mode.
final AlarmScheduler? scheduler;
@override
State<RoomProofScreen> createState() => _RoomProofScreenState();
}
class _RoomProofScreenState extends State<RoomProofScreen> {
late final AlarmScheduler _scheduler = widget.scheduler ?? AlarmScheduler();
final TextEditingController _qr = TextEditingController();
String? _tagId;
bool _scanning = false;
String _status = '';
@override
void initState() {
super.initState();
unawaited(_load());
}
@override
void dispose() {
if (_scanning) unawaited(_scheduler.stopTagRegistration());
_scheduler.clearNfcListener();
_qr.dispose();
super.dispose();
}
Future<void> _load() async {
final settings = await RoomProofSettings.load();
if (!mounted) return;
setState(() {
_tagId = settings.nfcTagId;
_qr.text = settings.qrPayload ?? '';
});
}
Future<void> _startScan() async {
_scheduler.onNfcTag(_onTag);
final started = await _scheduler.startTagRegistration();
if (!mounted) return;
setState(() {
_scanning = started;
_status = started
? 'Tap the tag against the phone…'
: 'NFC is unavailable or switched off.';
});
}
Future<void> _onTag(String tagId) async {
await RoomProofSettings.saveNfcTag(tagId);
await _scheduler.stopTagRegistration();
if (!mounted) return;
setState(() {
_tagId = tagId;
_scanning = false;
_status = 'Tag registered.';
});
}
Future<void> _saveQr() async {
await RoomProofSettings.saveQrPayload(_qr.text.trim());
if (!mounted) return;
setState(() => _status = 'QR code saved.');
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Room proof')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text('NFC tag: ${_tagId ?? "none registered"}'),
const SizedBox(height: 12),
FilledButton(
onPressed: _scanning ? null : _startScan,
child: Text(_scanning ? 'Waiting for tag…' : 'Register NFC tag'),
),
const SizedBox(height: 32),
TextField(
controller: _qr,
decoration: const InputDecoration(
labelText: 'QR code text',
helperText: 'Text encoded in the code printed for that room',
),
),
const SizedBox(height: 12),
FilledButton(onPressed: _saveQr, child: const Text('Save QR code')),
const SizedBox(height: 24),
Text(_status),
],
),
),
);
}
}
/// Local helper so this file need not import dart:async for fire-and-forget.
void unawaited(Future<void> future) {}

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:sync_settings_ui/sync_settings_ui.dart';
import 'package:wake_alarm_sync/screens/github_mirror_screen.dart';
import 'package:wake_alarm_sync/screens/room_proof_screen.dart';
import 'package:wake_alarm_sync/services/firebase_backend.dart';
import 'package:wake_alarm_sync/services/google_sign_in_backend.dart';
@ -9,7 +10,9 @@ import 'package:wake_alarm_sync/services/google_sign_in_backend.dart';
///
/// "Sync settings" is the shared `sync_settings_ui` package (Firebase,
/// identical in shape to every other kuhy app's Sync settings screen).
/// "Advanced sync (GitHub)" stays app-local ([GitHubMirrorScreen]).
/// "Advanced sync (GitHub)" stays app-local ([GitHubMirrorScreen]), and
/// "Room proof" registers the NFC tag / QR code ([RoomProofScreen]) — without
/// a registered tag, NFC can never satisfy the dismiss room proof.
class SettingsScreen extends StatelessWidget {
/// Creates a [SettingsScreen].
const SettingsScreen({super.key});
@ -40,6 +43,15 @@ class SettingsScreen extends StatelessWidget {
),
),
),
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Room proof'),
subtitle: const Text('NFC tag and QR code for the other room'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => const RoomProofScreen()),
),
),
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Advanced sync (GitHub)'),

View File

@ -65,6 +65,28 @@ class AlarmScheduler {
});
}
/// Starts reader mode so the next tag tapped can be registered.
///
/// Returns false when the device has no NFC or it is switched off, so the
/// UI can say so rather than waiting on a tap that cannot come.
Future<bool> startTagRegistration() async =>
await _channel.invokeMethod<bool>('startTagRegistration') ?? false;
/// Stops reader mode after registration.
Future<void> stopTagRegistration() =>
_channel.invokeMethod<void>('stopTagRegistration');
/// Stops listening for NFC taps.
void clearNfcListener() => _channel.setMethodCallHandler(null);
/// Closes the ring screen after a successful dismiss.
///
/// Without this the dismiss screen stays up over the lockscreen forever —
/// back is disabled by design — and the live instance would carry a
/// satisfied room proof and the solved answer into the next ring.
Future<void> closeRingScreen() =>
_channel.invokeMethod<void>('closeRingScreen');
/// Starts the ring immediately, without waiting for the alarm instant.
///
/// Exists so the foreground-service and full-screen-intent path can be

View File

@ -1,3 +1,5 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
@ -5,6 +7,8 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:wake_alarm_sync/main.dart';
import 'package:wake_alarm_sync/screens/ring_screen.dart';
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
import 'package:wake_alarm_sync/services/challenge.dart';
import 'package:wake_alarm_sync/services/room_proof_settings.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@ -44,6 +48,116 @@ void main() {
expect(find.text('Dismiss'), findsNothing);
});
testWidgets('a registered NFC tag tap satisfies the proof and reveals '
'the challenge', (tester) async {
SharedPreferences.setMockInitialValues(<String, Object>{
RoomProofSettings.kNfcTagKey: '04a2b3c4',
});
const channel = MethodChannel(AlarmScheduler.channelName);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'hasNfc') return true;
if (call.method == 'stepCount') return 0;
return null;
});
await tester.pumpWidget(const MaterialApp(home: RingScreen()));
await tester.pumpAndSettle();
expect(find.textContaining('tap the NFC tag'), findsOneWidget);
// Simulate the tag arriving over the channel, as NfcReader pushes it.
await TestDefaultBinaryMessengerBinding
.instance
.defaultBinaryMessenger
.handlePlatformMessage(
AlarmScheduler.channelName,
const StandardMethodCodec().encodeMethodCall(
const MethodCall('onNfcTag', '04a2b3c4'),
),
(_) {},
);
await tester.pumpAndSettle();
expect(find.text('Dismiss'), findsOneWidget);
});
testWidgets('a foreign NFC tag does not satisfy the proof', (tester) async {
SharedPreferences.setMockInitialValues(<String, Object>{
RoomProofSettings.kNfcTagKey: '04a2b3c4',
});
const channel = MethodChannel(AlarmScheduler.channelName);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'hasNfc') return true;
if (call.method == 'stepCount') return 0;
return null;
});
await tester.pumpWidget(const MaterialApp(home: RingScreen()));
await tester.pumpAndSettle();
await TestDefaultBinaryMessengerBinding
.instance
.defaultBinaryMessenger
.handlePlatformMessage(
AlarmScheduler.channelName,
const StandardMethodCodec().encodeMethodCall(
const MethodCall('onNfcTag', 'deadbeef'),
),
(_) {},
);
await tester.pumpAndSettle();
expect(find.text('Dismiss'), findsNothing);
expect(find.text('Wrong NFC tag'), findsOneWidget);
});
testWidgets('a correct answer stops the ring AND closes the screen', (
tester,
) async {
// Closing is load-bearing: back is disabled in both halves, so a screen
// left up after a correct answer is a dead end over the lockscreen, and
// the live instance would carry the satisfied proof into the next ring.
SharedPreferences.setMockInitialValues(<String, Object>{
RoomProofSettings.kNfcTagKey: '04a2b3c4',
});
final calls = <String>[];
const channel = MethodChannel(AlarmScheduler.channelName);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
calls.add(call.method);
if (call.method == 'hasNfc') return true;
if (call.method == 'stepCount') return 0;
return null;
});
// A fixed generator so the expected answer is known.
final generator = ChallengeGenerator(random: Random(1));
final challenge = ChallengeGenerator(random: Random(1)).next();
await tester.pumpWidget(
MaterialApp(home: RingScreen(generator: generator)),
);
await tester.pumpAndSettle();
await TestDefaultBinaryMessengerBinding
.instance
.defaultBinaryMessenger
.handlePlatformMessage(
AlarmScheduler.channelName,
const StandardMethodCodec().encodeMethodCall(
const MethodCall('onNfcTag', '04a2b3c4'),
),
(_) {},
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), challenge.answer);
await tester.tap(find.text('Dismiss'));
await tester.pumpAndSettle();
expect(calls, contains('dismiss'));
expect(calls, contains('closeRingScreen'));
});
testWidgets('satisfying the room proof reveals the challenge', (
tester,
) async {

View File

@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:wake_alarm_sync/screens/room_proof_screen.dart';
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
import 'package:wake_alarm_sync/services/room_proof_settings.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late List<String> calls;
late bool nfcStarts;
late MethodChannel channel;
setUp(() {
SharedPreferences.setMockInitialValues(<String, Object>{});
calls = <String>[];
nfcStarts = true;
channel = const MethodChannel(AlarmScheduler.channelName);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
calls.add(call.method);
if (call.method == 'startTagRegistration') return nfcStarts;
return null;
});
});
testWidgets('shows that no tag is registered on a fresh install', (
tester,
) async {
await tester.pumpWidget(const MaterialApp(home: RoomProofScreen()));
await tester.pumpAndSettle();
expect(find.textContaining('none registered'), findsOneWidget);
});
testWidgets('starting a scan puts the reader into waiting state', (
tester,
) async {
await tester.pumpWidget(const MaterialApp(home: RoomProofScreen()));
await tester.pumpAndSettle();
await tester.tap(find.text('Register NFC tag'));
await tester.pumpAndSettle();
expect(calls, contains('startTagRegistration'));
expect(find.textContaining('Tap the tag'), findsOneWidget);
});
testWidgets('says so when the device cannot scan, rather than waiting', (
tester,
) async {
nfcStarts = false;
await tester.pumpWidget(const MaterialApp(home: RoomProofScreen()));
await tester.pumpAndSettle();
await tester.tap(find.text('Register NFC tag'));
await tester.pumpAndSettle();
expect(find.textContaining('unavailable'), findsOneWidget);
});
testWidgets('saving a QR payload persists it', (tester) async {
await tester.pumpWidget(const MaterialApp(home: RoomProofScreen()));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'kitchen');
await tester.tap(find.text('Save QR code'));
await tester.pumpAndSettle();
expect((await RoomProofSettings.load()).qrPayload, 'kitchen');
expect(find.text('QR code saved.'), findsOneWidget);
});
testWidgets('a registered tag is shown when reopening the screen', (
tester,
) async {
await RoomProofSettings.saveNfcTag('04a2b3c4');
await tester.pumpWidget(const MaterialApp(home: RoomProofScreen()));
await tester.pumpAndSettle();
expect(find.textContaining('04a2b3c4'), findsOneWidget);
});
}

View File

@ -0,0 +1,33 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:wake_alarm_sync/services/room_proof_settings.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() => SharedPreferences.setMockInitialValues(<String, Object>{}));
test('an unconfigured install registers neither proof', () async {
final settings = await RoomProofSettings.load();
expect(settings.nfcTagId, isNull);
expect(settings.qrPayload, isNull);
});
test('a saved NFC tag round-trips', () async {
await RoomProofSettings.saveNfcTag('04a2b3c4');
expect((await RoomProofSettings.load()).nfcTagId, '04a2b3c4');
});
test('a saved QR payload round-trips', () async {
await RoomProofSettings.saveQrPayload('kitchen');
expect((await RoomProofSettings.load()).qrPayload, 'kitchen');
});
test('the two proofs are stored independently', () async {
await RoomProofSettings.saveNfcTag('04a2b3c4');
await RoomProofSettings.saveQrPayload('kitchen');
final settings = await RoomProofSettings.load();
expect(settings.nfcTagId, '04a2b3c4');
expect(settings.qrPayload, 'kitchen');
});
}

View File

@ -7,7 +7,7 @@ void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late AlarmScheduler scheduler;
int steps = 0;
var steps = 0;
setUp(() {
const channel = MethodChannel(AlarmScheduler.channelName);