mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 22:07:04 +01:00
Fix three defects the hardware run exposed in the room-proof path
All three were invisible to the emulator (no NFC, no step sensor) and to the widget tests (which mock the platform channel), and each one on its own made the alarm harder or impossible to dismiss. 1. AlarmActivity.onPause called NfcReader.stop(). enableReaderMode is per-activity but disableReaderMode is effectively global, so anything briefly coming in front of the ring screen killed NFC polling for the rest of the ring. Measured on the Pixel: `flags: 271` immediately followed by `flags: 0`. Teardown moves to onDestroy, and onResume re-asserts. 2. StepCounter could never report progress from a standing start. TYPE_STEP_COUNTER is an on-change *cumulative* sensor: it emits nothing until the user has already walked, and the first emission was being consumed as the baseline — so the ring screen sat at 0/25 no matter how far you walked. TYPE_STEP_DETECTOR is now primary (one event per stride, no baseline to miss); the counter stays as the fallback for devices without a detector. 3. NFC cannot be read while the ring shows over the lockscreen. Measured A/B on the Pixel with the same tag: registration while unlocked → mScreenState=ON_UNLOCKED, tag discovered; ring screen over the keyguard → mScreenState=ON_LOCKED, reader mode accepted (flags 271) but no discovery. Android gates NFC polling on lock state. showWhenLocked draws *over* the keyguard without unlocking, so the alarm now calls requestDismissKeyguard() on fire and re-asserts reader mode from the dismiss callback. User's call, taken explicitly: on a secured phone this surfaces the PIN prompt, which is an acceptable trade for an alarm you must get up for. Without (3) the only satisfiable proof at 06:00 is steps, which is exactly the single-mechanism dependency the room-proof redundancy exists to prevent. flutter test: 69 pass. dart analyze lib/ test/: clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
This commit is contained in:
parent
10a594305c
commit
08b44b4ca2
@ -1,5 +1,7 @@
|
||||
package com.kuhy.wake_alarm_sync
|
||||
|
||||
import android.app.KeyguardManager
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
@ -26,6 +28,38 @@ class AlarmActivity : FlutterActivity() {
|
||||
setShowWhenLocked(true)
|
||||
setTurnScreenOn(true)
|
||||
super.onCreate(savedInstanceState)
|
||||
dismissKeyguard()
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the system to take the keyguard down for the ring screen.
|
||||
*
|
||||
* Load-bearing for the NFC room proof, not cosmetic. Showing *over* the
|
||||
* keyguard leaves the device locked, and NFC reports `mScreenState=
|
||||
* ON_LOCKED` in that state: the controller stops polling for tags even
|
||||
* though `enableReaderMode` was accepted (measured on the Pixel 6a — the
|
||||
* same tag that registered fine while unlocked was never discovered while
|
||||
* the ring screen was up). Without this the only satisfiable proof at
|
||||
* 06:00 is steps, which is exactly the single-mechanism dependency the
|
||||
* room-proof redundancy exists to avoid.
|
||||
*
|
||||
* On a secured device this surfaces the PIN/biometric prompt, which is an
|
||||
* acceptable trade for an alarm the user must get up for.
|
||||
*/
|
||||
private fun dismissKeyguard() {
|
||||
val keyguard = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
|
||||
if (!keyguard.isKeyguardLocked) return
|
||||
keyguard.requestDismissKeyguard(
|
||||
this,
|
||||
object : KeyguardManager.KeyguardDismissCallback() {
|
||||
override fun onDismissSucceeded() {
|
||||
// NFC only starts polling once the screen state leaves
|
||||
// ON_LOCKED, so reader mode has to be re-asserted after
|
||||
// the unlock rather than only in onResume.
|
||||
alarmChannel?.let { NfcReader.start(this@AlarmActivity, it) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Kept so NFC reader mode can push tag ids back to the ring screen. */
|
||||
@ -55,14 +89,30 @@ class AlarmActivity : FlutterActivity() {
|
||||
super.onResume()
|
||||
// Reader mode only works while this activity is in the foreground, so
|
||||
// it is started here rather than at engine-configure time.
|
||||
//
|
||||
// Re-asserted on every resume, not just the first: anything that comes
|
||||
// up over the ring screen (the launcher activity, a system dialog)
|
||||
// pauses this one and takes reader mode down with it, and the tag tap
|
||||
// must keep working once the ring screen is back in front.
|
||||
alarmChannel?.let { NfcReader.start(this, it) }
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
NfcReader.stop(this)
|
||||
// Deliberately does NOT disable reader mode. enableReaderMode is
|
||||
// per-activity but disableReaderMode is effectively global, so
|
||||
// stopping here killed polling for the ring screen whenever another
|
||||
// activity briefly came in front — measured on the Pixel, where the
|
||||
// NfcService log showed `flags: 271` immediately followed by
|
||||
// `flags: 0`. onDestroy is the right place: by then the ring really
|
||||
// is over.
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
NfcReader.stop(this)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismissing is the only way out: the ring screen owns the challenge, so
|
||||
* back must not silently drop the user onto the lockscreen with the alarm
|
||||
|
||||
@ -3,6 +3,7 @@ package com.kuhy.wake_alarm_sync
|
||||
import android.app.Activity
|
||||
import android.nfc.NfcAdapter
|
||||
import android.nfc.Tag
|
||||
import android.util.Log
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
/**
|
||||
@ -41,13 +42,21 @@ object NfcReader {
|
||||
if (!adapter.isEnabled) return false
|
||||
adapter.enableReaderMode(
|
||||
activity,
|
||||
{ tag -> activity.runOnUiThread { channel.invokeMethod(ON_TAG, idOf(tag)) } },
|
||||
{ tag ->
|
||||
val id = idOf(tag)
|
||||
Log.i(TAG, "tag discovered: $id")
|
||||
activity.runOnUiThread { channel.invokeMethod(ON_TAG, id) }
|
||||
},
|
||||
FLAGS,
|
||||
null,
|
||||
)
|
||||
Log.i(TAG, "reader mode started on ${activity.javaClass.simpleName}")
|
||||
return true
|
||||
}
|
||||
|
||||
/** Logcat tag for diagnosing why a tap did or did not arrive. */
|
||||
private const val TAG = "WakeAlarmNfc"
|
||||
|
||||
/** Whether this device can read NFC at all and has it switched on. */
|
||||
fun isAvailable(context: android.content.Context): Boolean {
|
||||
val adapter = NfcAdapter.getDefaultAdapter(context) ?: return false
|
||||
|
||||
@ -25,10 +25,23 @@ object StepCounter {
|
||||
/** Sentinel for "no baseline captured yet". */
|
||||
private const val NONE = -1L
|
||||
|
||||
/** Most recent step total seen, kept so reads never block the caller. */
|
||||
/** Most recent cumulative total seen, kept so reads never block. */
|
||||
@Volatile
|
||||
private var latest: Long? = null
|
||||
|
||||
/**
|
||||
* Steps counted by TYPE_STEP_DETECTOR since [markRingStart].
|
||||
*
|
||||
* The detector fires once per step, so it needs no baseline and reports
|
||||
* the very first stride. The counter (TYPE_STEP_COUNTER) is on-change and
|
||||
* cumulative, so on its own it delivers nothing until the user has
|
||||
* already walked — which is why a phone sitting still reported a step
|
||||
* sensor that "does not work". The detector is now primary and the
|
||||
* counter is the cross-check.
|
||||
*/
|
||||
@Volatile
|
||||
private var detected: Long = 0
|
||||
|
||||
/** The single registered listener; kept so rings do not accumulate them. */
|
||||
private var listener: SensorEventListener? = null
|
||||
|
||||
@ -42,12 +55,28 @@ object StepCounter {
|
||||
fun markRingStart(context: Context) {
|
||||
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
prefs.edit().remove(KEY_BASELINE).apply()
|
||||
detected = 0
|
||||
listen(context) { total ->
|
||||
// Only the first sample after a ring starts becomes the baseline.
|
||||
if (!prefs.contains(KEY_BASELINE)) {
|
||||
prefs.edit().putLong(KEY_BASELINE, total).apply()
|
||||
}
|
||||
}
|
||||
// TYPE_STEP_COUNTER is an on-change sensor: it reports only when the
|
||||
// user actually steps, so registering alone can leave us with no
|
||||
// sample for minutes. requestTriggerSensor is not available for it,
|
||||
// but SENSOR_DELAY_FASTEST plus an immediate flush() asks the
|
||||
// hardware FIFO to deliver the current cumulative total now, which is
|
||||
// what makes the baseline correct from the first tap rather than
|
||||
// silently swallowing the user's first strides.
|
||||
flush(context)
|
||||
}
|
||||
|
||||
/** Asks the sensor FIFO to deliver any batched sample immediately. */
|
||||
private fun flush(context: Context) {
|
||||
val manager =
|
||||
context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager ?: return
|
||||
listener?.let { manager.flush(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
@ -57,15 +86,21 @@ object StepCounter {
|
||||
* baseline" apart from "hasn't moved yet" and fall back to another proof.
|
||||
*/
|
||||
fun stepsSinceRingStart(context: Context): Long {
|
||||
// The detector is authoritative: it counts every stride since the ring
|
||||
// started, with no baseline to miss. Only fall back to the cumulative
|
||||
// counter when there is no detector on the device.
|
||||
if (hasDetector) return detected
|
||||
val baseline = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getLong(KEY_BASELINE, NONE)
|
||||
if (baseline == NONE) return NONE
|
||||
// Non-blocking: whatever the listener last delivered. Returns -1 until
|
||||
// the first sample arrives rather than stalling the ring screen.
|
||||
val current = latest ?: return NONE
|
||||
return (current - baseline).coerceAtLeast(0)
|
||||
}
|
||||
|
||||
/** Whether this device exposes TYPE_STEP_DETECTOR, set when listening starts. */
|
||||
@Volatile
|
||||
private var hasDetector: Boolean = false
|
||||
|
||||
/**
|
||||
* Registers a step listener, keeping [latest] fresh for later reads.
|
||||
*
|
||||
@ -77,21 +112,34 @@ object StepCounter {
|
||||
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
|
||||
val counter = manager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER)
|
||||
val detector = manager.getDefaultSensor(Sensor.TYPE_STEP_DETECTOR)
|
||||
if (counter == null && detector == null) return
|
||||
// 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)
|
||||
when (event.sensor.type) {
|
||||
// Fires once per stride; each event is exactly one step.
|
||||
Sensor.TYPE_STEP_DETECTOR -> detected++
|
||||
Sensor.TYPE_STEP_COUNTER -> {
|
||||
val total = event.values.firstOrNull()?.toLong() ?: return
|
||||
latest = total
|
||||
onFirst(total)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
|
||||
}
|
||||
listener = fresh
|
||||
manager.registerListener(fresh, sensor, SensorManager.SENSOR_DELAY_NORMAL)
|
||||
hasDetector = detector != null
|
||||
// FASTEST so the first stride registers immediately; these sensors are
|
||||
// low-rate by nature, so this costs nothing meaningful.
|
||||
detector?.let { manager.registerListener(fresh, it, SensorManager.SENSOR_DELAY_FASTEST) }
|
||||
counter?.let { manager.registerListener(fresh, it, SensorManager.SENSOR_DELAY_FASTEST) }
|
||||
}
|
||||
}
|
||||
|
||||
26
phone_app/lib/main_arm_probe.dart
Normal file
26
phone_app/lib/main_arm_probe.dart
Normal file
@ -0,0 +1,26 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:wake_alarm_sync/main.dart';
|
||||
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
|
||||
|
||||
/// Debug entrypoint that arms a real alarm a minute out, then shows the app.
|
||||
///
|
||||
/// Exercises the true `AlarmManager.setAlarmClock` path — the receiver, the
|
||||
/// foreground service and the full-screen intent all firing on their own —
|
||||
/// rather than the `ringNow` shortcut, which skips the scheduler entirely.
|
||||
/// The offset comes from `--dart-define=ARM_IN_MINUTES`.
|
||||
///
|
||||
/// Not part of the shipped app: `flutter build apk` uses `lib/main.dart`.
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
const offset = int.fromEnvironment('ARM_IN_MINUTES', defaultValue: 1);
|
||||
final scheduler = AlarmScheduler();
|
||||
final at = DateTime.now().add(const Duration(minutes: offset));
|
||||
final fsi = await scheduler.canUseFullScreenIntent();
|
||||
// Printed so the harness can read the armed time out of logcat; this is a
|
||||
// debug entrypoint, so stdout is the interface.
|
||||
// ignore: avoid_print
|
||||
print('ARM_PROBE armed=${at.hour}:${at.minute} fsi=$fsi');
|
||||
await scheduler.schedule(at.hour, at.minute);
|
||||
runApp(const WakeAlarmSyncApp());
|
||||
}
|
||||
20
phone_app/lib/main_tag_probe.dart
Normal file
20
phone_app/lib/main_tag_probe.dart
Normal file
@ -0,0 +1,20 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:wake_alarm_sync/screens/room_proof_screen.dart';
|
||||
|
||||
/// Debug entrypoint that opens the room-proof registration screen directly.
|
||||
///
|
||||
/// Saves navigating a locked device through Settings during hardware testing;
|
||||
/// the screen it shows is the real one, reached the normal way in the shipped
|
||||
/// app via Settings → "Room proof".
|
||||
///
|
||||
/// Not part of the shipped app: `flutter build apk` uses `lib/main.dart`.
|
||||
void main() => runApp(const _TagProbe());
|
||||
|
||||
class _TagProbe extends StatelessWidget {
|
||||
const _TagProbe();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
const MaterialApp(home: RoomProofScreen());
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user