diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmContract.java b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmContract.java index 8cb07915..8d4803b1 100644 --- a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmContract.java +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmContract.java @@ -1,7 +1,10 @@ package com.timeflow.alarm; +import android.app.NotificationChannel; +import android.app.NotificationManager; import android.content.Context; import android.content.Intent; +import android.os.Build; final class AlarmContract { static final String ACTION_FIRE_ALARM = "com.timeflow.FIRE_ALARM"; @@ -11,10 +14,14 @@ final class AlarmContract { static final String EXTRA_TITLE = "alarm_title"; static final String EXTRA_SCHEDULE_ID = "schedule_id"; static final String EXTRA_SPEECH_TEXT = "speech_text"; - /** 响铃要不要震动/出声/弹全屏止铃界面;由 JS 侧按提醒强度换算后传入,缺省一律按 true 处理。 */ + /** 响铃要不要震动/弹全屏止铃界面、声音走哪个档位;由 JS 侧按提醒强度换算后传入。 */ static final String EXTRA_VIBRATE = "vibrate"; - static final String EXTRA_SOUND = "sound"; + static final String EXTRA_SOUND_TIER = "sound_tier"; static final String EXTRA_FULL_SCREEN = "full_screen"; + /** 声音档位取值:不出声 / 一次性短提示音 / 循环语音直到用户处理。 */ + static final String SOUND_TIER_NONE = "none"; + static final String SOUND_TIER_PING = "ping"; + static final String SOUND_TIER_FULL = "full"; static final String EXTRA_EVENT_TYPE = "event_type"; static final String EVENT_FIRED = "fired"; static final String EVENT_DISMISSED = "dismissed"; @@ -37,6 +44,32 @@ final class AlarmContract { private AlarmContract() { } + /** + * AlarmSoundService 和 AlarmReceiver(前台服务启动失败时的兜底通知)都要用同一个 + * 静音渠道——渠道级声音/震动创建后不可变,抽在这里避免两边各建一次、参数还可能 + * 对不上。 + */ + static void ensureChannel(Context context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return; + } + NotificationManager manager = + (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + if (manager == null || manager.getNotificationChannel(CHANNEL_ID) != null) { + return; + } + NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, "Timeflow", NotificationManager.IMPORTANCE_HIGH + ); + channel.setDescription("日程闹钟提醒"); + // 震动/声音都改成手动控制(AlarmSoundService 里的 startVibration/startPing/ + // startBundledSpeech),渠道级别的这两项创建后就改不了,留 true/有声就没法 + // 让某条闹钟单独静音。 + channel.enableVibration(false); + channel.setSound(null, null); + manager.createNotificationChannel(channel); + } + /** * AlarmSoundService 和 RingActivity 都要从触发 Intent 里读同一组字段、 * 补同样的 legacy id / scheduleId 反查 / 默认标题——抽在这里,避免两边分别写、 @@ -49,7 +82,7 @@ static final class ExtractedExtras { final String speechText; final int requestCode; final boolean vibrate; - final boolean sound; + final String soundTier; final boolean fullScreen; private ExtractedExtras( @@ -59,7 +92,7 @@ private ExtractedExtras( String speechText, int requestCode, boolean vibrate, - boolean sound, + String soundTier, boolean fullScreen ) { this.alarmId = alarmId; @@ -68,7 +101,7 @@ private ExtractedExtras( this.speechText = speechText; this.requestCode = requestCode; this.vibrate = vibrate; - this.sound = sound; + this.soundTier = soundTier; this.fullScreen = fullScreen; } @@ -78,9 +111,14 @@ static ExtractedExtras from(Context context, Intent intent) { String scheduleId = intent == null ? null : intent.getStringExtra(EXTRA_SCHEDULE_ID); String title = intent == null ? null : intent.getStringExtra(EXTRA_TITLE); String speechText = intent == null ? null : intent.getStringExtra(EXTRA_SPEECH_TEXT); - // 缺省按 true:兼容没有带这几个 extra 的旧闹钟/测试 Intent,保留改动前的全响铃行为。 + // 缺省按老行为处理:兼容没有带这几个 extra 的旧闹钟/测试 Intent, + // 保留改动前"全响铃"(震动 + 循环语音 + 全屏)的默认行为。 boolean vibrate = intent == null || intent.getBooleanExtra(EXTRA_VIBRATE, true); - boolean sound = intent == null || intent.getBooleanExtra(EXTRA_SOUND, true); + String soundTier = intent == null ? SOUND_TIER_FULL + : intent.getStringExtra(EXTRA_SOUND_TIER); + if (soundTier == null || soundTier.isEmpty()) { + soundTier = SOUND_TIER_FULL; + } boolean fullScreen = intent == null || intent.getBooleanExtra(EXTRA_FULL_SCREEN, true); if (alarmId == null || alarmId.isEmpty()) { alarmId = "legacy-" + requestCode; @@ -95,14 +133,7 @@ static ExtractedExtras from(Context context, Intent intent) { speechText = ReminderSpeechFormatter.format(title, System.currentTimeMillis()); } return new ExtractedExtras( - alarmId, - scheduleId, - title, - speechText, - requestCode, - vibrate, - sound, - fullScreen + alarmId, scheduleId, title, speechText, requestCode, vibrate, soundTier, fullScreen ); } } diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmModule.kt b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmModule.kt index ac53bf57..a0df4c70 100644 --- a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmModule.kt +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmModule.kt @@ -48,7 +48,7 @@ class AlarmModule(private val reactContext: ReactApplicationContext) : title: String?, scheduleId: String?, vibrate: Boolean, - sound: Boolean, + soundTier: String?, fullScreen: Boolean, speechText: String?, promise: Promise, @@ -57,7 +57,7 @@ class AlarmModule(private val reactContext: ReactApplicationContext) : Log.i( NAME, "schedule triggerAtMillis=$triggerAtMillis title=$title scheduleId=$scheduleId " + - "vibrate=$vibrate sound=$sound fullScreen=$fullScreen", + "vibrate=$vibrate soundTier=$soundTier fullScreen=$fullScreen", ) val alarmId = AlarmScheduler.schedule( reactContext, @@ -65,7 +65,7 @@ class AlarmModule(private val reactContext: ReactApplicationContext) : title ?: "日程提醒", scheduleId ?: "", vibrate, - sound, + soundTier ?: AlarmContract.SOUND_TIER_FULL, fullScreen, speechText ?: "", ) @@ -116,6 +116,57 @@ class AlarmModule(private val reactContext: ReactApplicationContext) : } } + /** + * 提醒守护后台任务(reminderGuardTask.ts)判断"这条时间型日程原生闹钟当初有没有 + * 挂上"用——JS 内存里的 registrations 在守护任务可能运行的独立/headless 上下文里 + * 拿不到,只有 AlarmScheduler 持久化的挂钟列表是跨上下文都能查的真相来源。 + */ + @ReactMethod + fun hasArmedAlarm(scheduleId: String?, promise: Promise) { + try { + if (scheduleId.isNullOrEmpty()) { + promise.resolve(false) + return + } + val armed = AlarmScheduler.loadAlarms(reactContext).any { it.scheduleId == scheduleId } + promise.resolve(armed) + } catch (error: Exception) { + promise.reject("HAS_ARMED_ALARM_FAILED", error.message, error) + } + } + + @ReactMethod + fun presentNow( + alarmId: String?, + scheduleId: String?, + title: String?, + vibrate: Boolean, + soundTier: String?, + fullScreen: Boolean, + promise: Promise, + ) { + try { + val resolvedAlarmId = alarmId?.takeIf { it.isNotEmpty() } ?: "geofence-${System.currentTimeMillis()}" + val resolvedScheduleId = scheduleId ?: "" + val resolvedTitle = title?.takeIf { it.isNotEmpty() } ?: "日程提醒" + AlarmSoundService.start( + reactContext, + resolvedAlarmId, + resolvedScheduleId, + AlarmScheduler.immediateRequestCode(resolvedAlarmId), + resolvedTitle, + vibrate, + soundTier ?: AlarmContract.SOUND_TIER_FULL, + fullScreen, + ) + Log.i(NAME, "presentNow requested alarmId=$resolvedAlarmId scheduleId=$resolvedScheduleId") + promise.resolve(true) + } catch (error: Exception) { + Log.w(NAME, "presentNow failed", error) + promise.reject("PRESENT_NOW_FAILED", error.message, error) + } + } + @ReactMethod fun peekNativeDispositions(promise: Promise) { try { diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmReceiver.java b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmReceiver.java index f5289454..06e91156 100644 --- a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmReceiver.java +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmReceiver.java @@ -1,5 +1,7 @@ package com.timeflow.alarm; +import android.app.Notification; +import android.app.NotificationManager; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; @@ -41,8 +43,8 @@ public void onReceive(Context context, Intent intent) { intent.getBooleanExtra(AlarmContract.EXTRA_VIBRATE, true) ) .putExtra( - AlarmContract.EXTRA_SOUND, - intent.getBooleanExtra(AlarmContract.EXTRA_SOUND, true) + AlarmContract.EXTRA_SOUND_TIER, + intent.getStringExtra(AlarmContract.EXTRA_SOUND_TIER) ) .putExtra( AlarmContract.EXTRA_FULL_SCREEN, @@ -61,6 +63,34 @@ public void onReceive(Context context, Intent intent) { // 原来这里完全没兜底,抛出去要么被系统吞掉、要么让这条广播直接崩溃退出, // 日志里连个痕迹都留不下。 Log.w(TAG, "startForegroundService failed for alarmId=" + alarmId, exception); + postFallbackNotification(context, title); + } + } + + /** + * 前台服务起不来时的最后一道兜底:发一条普通通知,不需要 startForegroundService + * 那一套权限,只要 POST_NOTIFICATIONS 就行——比"什么痕迹都不留"好,哪怕全屏止铃页 + * 和声音/震动都没能展示出来,用户至少能在通知栏看到这条提醒。 + */ + private void postFallbackNotification(Context context, String title) { + try { + AlarmContract.ensureChannel(context); + NotificationManager manager = + (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + if (manager == null) { + return; + } + Notification notification = new Notification.Builder(context, AlarmContract.CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_lock_idle_alarm) + .setContentTitle(title == null || title.isEmpty() ? "日程提醒" : title) + .setContentText("点击打开 Timeflow 处理") + .setCategory(Notification.CATEGORY_ALARM) + .setPriority(Notification.PRIORITY_MAX) + .setAutoCancel(true) + .build(); + manager.notify((int) (System.currentTimeMillis() & 0x7fffffff), notification); + } catch (RuntimeException ignored) { + // 尽力而为;这已经是最后一道兜底,失败就真的没办法了。 } } } diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmScheduler.java b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmScheduler.java index 26b63c44..5ec08f7e 100644 --- a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmScheduler.java +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmScheduler.java @@ -32,7 +32,7 @@ public static final class AlarmRecord { public final String speechText; public final boolean legacy; public final boolean vibrate; - public final boolean sound; + public final String soundTier; public final boolean fullScreen; AlarmRecord( @@ -44,7 +44,7 @@ public static final class AlarmRecord { String speechText, boolean legacy, boolean vibrate, - boolean sound, + String soundTier, boolean fullScreen ) { this.alarmId = alarmId; @@ -55,7 +55,8 @@ public static final class AlarmRecord { this.speechText = speechText == null ? "" : speechText; this.legacy = legacy; this.vibrate = vibrate; - this.sound = sound; + this.soundTier = soundTier == null || soundTier.isEmpty() + ? AlarmContract.SOUND_TIER_FULL : soundTier; this.fullScreen = fullScreen; } } @@ -66,7 +67,7 @@ public static String schedule( String title, String scheduleId, boolean vibrate, - boolean sound, + String soundTier, boolean fullScreen, String speechText ) { @@ -99,7 +100,7 @@ public static String schedule( speechText == null ? "" : speechText, false, vibrate, - sound, + soundTier, fullScreen ); @@ -119,22 +120,28 @@ public static String schedule( String speechText ) { return schedule( - context, triggerAtMillis, title, scheduleId, true, true, true, speechText + context, triggerAtMillis, title, scheduleId, true, + AlarmContract.SOUND_TIER_FULL, true, speechText ); } - /** @deprecated 请改用包含响铃通道和 speechText 的完整签名。 */ + /** @deprecated 请改用 {@link #schedule(Context, long, String, String, boolean, String, boolean, String)}。 */ @Deprecated public static String schedule( Context context, long triggerAtMillis, String title, String scheduleId ) { - return schedule(context, triggerAtMillis, title, scheduleId, true, true, true, ""); + return schedule( + context, triggerAtMillis, title, scheduleId, true, + AlarmContract.SOUND_TIER_FULL, true, "" + ); } - /** @deprecated 请改用包含响铃通道和 speechText 的完整签名。 */ + /** @deprecated 请改用 {@link #schedule(Context, long, String, String, boolean, String, boolean, String)}。 */ @Deprecated public static String schedule(Context context, long triggerAtMillis, String title) { - return schedule(context, triggerAtMillis, title, "", true, true, true, ""); + return schedule( + context, triggerAtMillis, title, "", true, AlarmContract.SOUND_TIER_FULL, true, "" + ); } /** @@ -187,7 +194,7 @@ private static void rearm(Context context, AlarmManager alarmManager, AlarmRecor .putExtra(AlarmContract.EXTRA_SPEECH_TEXT, record.speechText) .putExtra(AlarmContract.EXTRA_REQUEST_CODE, record.requestCode) .putExtra(AlarmContract.EXTRA_VIBRATE, record.vibrate) - .putExtra(AlarmContract.EXTRA_SOUND, record.sound) + .putExtra(AlarmContract.EXTRA_SOUND_TIER, record.soundTier) .putExtra(AlarmContract.EXTRA_FULL_SCREEN, record.fullScreen) .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); PendingIntent showPendingIntent = PendingIntent.getActivity( @@ -281,7 +288,7 @@ public static List loadAlarms(Context context) { object.optString("speech_text", ""), legacy, object.optBoolean("vibrate", true), - object.optBoolean("sound", true), + object.optString("sound_tier", AlarmContract.SOUND_TIER_FULL), object.optBoolean("full_screen", true) )); } @@ -334,6 +341,12 @@ static String scheduleIdForAlarm(Context context, String alarmId) { return ""; } + /** Stable positive notification/foreground-service id for an immediate alarm. */ + static int immediateRequestCode(String alarmId) { + int requestCode = alarmId == null ? 1 : alarmId.hashCode() & 0x7fffffff; + return requestCode == 0 ? 1 : requestCode; + } + private static void cancelPendingIntent(Context context, AlarmRecord record) { AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); @@ -376,7 +389,7 @@ private static JSONObject toJson(AlarmRecord alarm) throws JSONException { object.put("speech_text", alarm.speechText); object.put("legacy", alarm.legacy); object.put("vibrate", alarm.vibrate); - object.put("sound", alarm.sound); + object.put("sound_tier", alarm.soundTier); object.put("full_screen", alarm.fullScreen); return object; } @@ -394,7 +407,7 @@ private static PendingIntent buildAlarmBroadcastPendingIntent( .putExtra(AlarmContract.EXTRA_TITLE, record.title) .putExtra(AlarmContract.EXTRA_SPEECH_TEXT, record.speechText) .putExtra(AlarmContract.EXTRA_VIBRATE, record.vibrate) - .putExtra(AlarmContract.EXTRA_SOUND, record.sound) + .putExtra(AlarmContract.EXTRA_SOUND_TIER, record.soundTier) .putExtra(AlarmContract.EXTRA_FULL_SCREEN, record.fullScreen); if (!record.legacy) { intent.setData(alarmUri(record.alarmId)); diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmSoundService.java b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmSoundService.java index a10573c7..abec68c9 100644 --- a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmSoundService.java +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmSoundService.java @@ -1,7 +1,6 @@ package com.timeflow.alarm; import android.app.Notification; -import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.PendingIntent; import android.app.ActivityOptions; @@ -11,9 +10,11 @@ import android.content.pm.ServiceInfo; import android.graphics.PixelFormat; import android.media.AudioAttributes; +import android.media.MediaPlayer; +import android.media.Ringtone; +import android.media.RingtoneManager; import android.net.Uri; import android.os.Build; -import android.os.Bundle; import android.os.Handler; import android.os.IBinder; import android.os.Looper; @@ -21,34 +22,29 @@ import android.os.Vibrator; import android.os.VibratorManager; import android.provider.Settings; -import android.speech.tts.TextToSpeech; -import android.speech.tts.UtteranceProgressListener; import android.util.Log; import android.view.Gravity; import android.view.View; import android.view.WindowManager; +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; import java.util.ArrayDeque; import java.util.HashSet; -import java.util.Locale; import java.util.Set; -import java.util.UUID; public final class AlarmSoundService extends Service { private static final String TAG = "AlarmSoundService"; private static final long SPEECH_REPEAT_DELAY_MILLIS = 1_500L; - private static final float TTS_SPEECH_RATE = 0.9f; - private static final float TTS_PITCH = 1.0f; private final Handler playbackHandler = new Handler(Looper.getMainLooper()); - private final Runnable replaySpeech = this::scheduleTtsReplay; + private final Runnable replaySpeech = this::replaySpeech; - private TextToSpeech textToSpeech; - private boolean ttsReady; - private int ttsGeneration; + private MediaPlayer mediaPlayer; + private Ringtone pingRingtone; private boolean destroyed; - private String currentSpeechText; - private String currentUtteranceId; + private File bundledSpeechFile; private WindowManager overlayWindowManager; /** 包内可见(而非 private):AlarmSoundServiceTest 需要直接读取当前展示/排队状态。 */ View overlayView; @@ -56,7 +52,7 @@ public final class AlarmSoundService extends Service { String scheduleId; String alarmTitle; private boolean vibrateEnabled; - private boolean soundEnabled; + private String soundTier; private boolean fullScreenEnabled; private int requestCode; /** 已经通知过 JS "fired" 的 alarmId 集合;service 实例可能被多个不同闹钟复用。 */ @@ -77,7 +73,7 @@ public int onStartCommand(Intent intent, int flags, int startId) { // 走到这里——如果 AlarmReceiver 那边"startForegroundService requested"打出来了 // 但这里没打印,说明系统在投递 Intent 给这个 Service 之前就把它拦下来了。 Log.i(TAG, "onStartCommand alarmId=" + extras.alarmId + " scheduleId=" + extras.scheduleId - + " vibrate=" + extras.vibrate + " sound=" + extras.sound + + " vibrate=" + extras.vibrate + " soundTier=" + extras.soundTier + " fullScreen=" + extras.fullScreen); if (overlayView != null) { @@ -96,6 +92,7 @@ public int onStartCommand(Intent intent, int flags, int startId) { AlarmNativeBridge.notifyFired(this, extras.scheduleId, extras.alarmId, extras.title); } pendingQueue.add(extras); + postQueuedNotification(extras); return START_NOT_STICKY; } @@ -103,15 +100,41 @@ public int onStartCommand(Intent intent, int flags, int startId) { return START_NOT_STICKY; } + /** + * 前一条止铃页还没关掉、这条闹钟被排进队列时的兜底:发一条普通通知,不然这条 + * 闹钟在真正顶上来之前完全没有任何用户可见的痕迹——用户只会在"点开 App 才发现 + * 还有一条没处理"的时候才后知后觉,跟没提醒过没什么区别。 + */ + private void postQueuedNotification(AlarmContract.ExtractedExtras extras) { + try { + AlarmContract.ensureChannel(this); + NotificationManager manager = + (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + if (manager == null) { + return; + } + Notification notification = new Notification.Builder(this, AlarmContract.CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_lock_idle_alarm) + .setContentTitle(extras.title) + .setContentText("还有一条提醒排队中,点击打开 Timeflow 处理") + .setCategory(Notification.CATEGORY_ALARM) + .setPriority(Notification.PRIORITY_MAX) + .setAutoCancel(true) + .build(); + manager.notify(extras.requestCode, notification); + } catch (RuntimeException ignored) { + // 尽力而为,失败也不能挡住排队本身。 + } + } + /** 展示某一条闹钟:更新前台通知、摘除持久化记录、通知 JS fired、弹响铃界面。 */ private void presentAlarm(AlarmContract.ExtractedExtras extras) { requestCode = extras.requestCode; alarmId = extras.alarmId; scheduleId = extras.scheduleId; alarmTitle = extras.title; - currentSpeechText = extras.speechText; vibrateEnabled = extras.vibrate; - soundEnabled = extras.sound; + soundTier = extras.soundTier; fullScreenEnabled = extras.fullScreen; createNotificationChannel(); @@ -130,18 +153,20 @@ private void presentAlarm(AlarmContract.ExtractedExtras extras) { if (firedNotifiedAlarmIds.add(alarmId)) { AlarmNativeBridge.notifyFired(this, scheduleId, alarmId, alarmTitle); } - // 换成下一条闹钟前先清掉上一条的声音/震动状态,避免静音或不震动的 - // 新闹钟继续沿用前一条已经启动的系统资源。 - shutdownTts(); + // 换成下一条闹钟之前先清掉上一条的震动/声音状态,避免上一条闹钟启动的 + // 那次播放/震动残留到这条本该更安静的闹钟上。 stopVibration(); + stopPing(); if (vibrateEnabled) { startVibration(); } if (fullScreenEnabled) { showAlarmOverlay(alarmTitle); } - if (soundEnabled) { - initTtsAndSpeak(); + if (AlarmContract.SOUND_TIER_FULL.equals(soundTier) && mediaPlayer == null) { + startBundledSpeech(); + } else if (AlarmContract.SOUND_TIER_PING.equals(soundTier)) { + startPing(); } } catch (RuntimeException exception) { // 这里原来完全静默——startForeground() 在部分厂商 ROM/系统版本上会因为 @@ -173,8 +198,10 @@ public void onDestroy() { destroyed = true; playbackHandler.removeCallbacksAndMessages(null); removeAlarmOverlay(); - shutdownTts(); + releaseMediaPlayer(); stopVibration(); + stopPing(); + deleteCachedSpeechFile(); NotificationManager manager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); if (manager != null) { @@ -199,9 +226,8 @@ static void start( int requestCode, String title, boolean vibrate, - boolean sound, - boolean fullScreen, - String speechText + String soundTier, + boolean fullScreen ) { Intent intent = new Intent(context, AlarmSoundService.class) .putExtra(AlarmContract.EXTRA_ALARM_ID, alarmId) @@ -209,9 +235,8 @@ static void start( .putExtra(AlarmContract.EXTRA_REQUEST_CODE, requestCode) .putExtra(AlarmContract.EXTRA_TITLE, title) .putExtra(AlarmContract.EXTRA_VIBRATE, vibrate) - .putExtra(AlarmContract.EXTRA_SOUND, sound) - .putExtra(AlarmContract.EXTRA_FULL_SCREEN, fullScreen) - .putExtra(AlarmContract.EXTRA_SPEECH_TEXT, speechText); + .putExtra(AlarmContract.EXTRA_SOUND_TIER, soundTier) + .putExtra(AlarmContract.EXTRA_FULL_SCREEN, fullScreen); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { context.startForegroundService(intent); } else { @@ -226,9 +251,8 @@ private Notification buildNotification(String alarmId, String title, boolean ful .putExtra(AlarmContract.EXTRA_SCHEDULE_ID, scheduleId) .putExtra(AlarmContract.EXTRA_REQUEST_CODE, requestCode) .putExtra(AlarmContract.EXTRA_TITLE, title) - .putExtra(AlarmContract.EXTRA_SPEECH_TEXT, currentSpeechText) .putExtra(AlarmContract.EXTRA_VIBRATE, vibrateEnabled) - .putExtra(AlarmContract.EXTRA_SOUND, soundEnabled) + .putExtra(AlarmContract.EXTRA_SOUND_TIER, soundTier) .putExtra(AlarmContract.EXTRA_FULL_SCREEN, fullScreen) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_MULTIPLE_TASK @@ -280,25 +304,7 @@ private int backgroundActivityStartMode() { } private void createNotificationChannel() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - return; - } - NotificationManager manager = - (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); - if (manager == null || manager.getNotificationChannel(AlarmContract.CHANNEL_ID) != null) { - return; - } - NotificationChannel channel = new NotificationChannel( - AlarmContract.CHANNEL_ID, - "Timeflow", - NotificationManager.IMPORTANCE_HIGH - ); - channel.setDescription("日程闹钟提醒"); - // 震动改成 startVibration()/stopVibration() 手动控制,好按 vibrateEnabled 逐条开关; - // 渠道级震动创建后改不了,留 true 就没法让某条闹钟不震。 - channel.enableVibration(false); - channel.setSound(null, null); - manager.createNotificationChannel(channel); + AlarmContract.ensureChannel(this); } private void showAlarmOverlay(String title) { @@ -326,9 +332,8 @@ private void showAlarmOverlay(String title) { String targetAlarmId = alarmId; String targetScheduleId = scheduleId; String targetTitle = title; - String targetSpeechText = currentSpeechText; boolean targetVibrate = vibrateEnabled; - boolean targetSound = soundEnabled; + String targetSoundTier = soundTier; boolean targetFullScreen = fullScreenEnabled; View content = AlarmRingUi.build( @@ -344,9 +349,9 @@ private void showAlarmOverlay(String title) { targetTitle, targetScheduleId, targetVibrate, - targetSound, + targetSoundTier, targetFullScreen, - targetSpeechText + "" ); } catch (RuntimeException ignored) { // ignore @@ -421,109 +426,130 @@ private void removeAlarmOverlay() { overlayWindowManager = null; } - /** 初始化系统 TTS,优先使用简体中文,不可用时回退到系统默认语言。 */ - private void initTtsAndSpeak() { - if (destroyed || !soundEnabled || currentSpeechText == null) { + private void startBundledSpeech() { + if (destroyed || mediaPlayer != null) { return; } - - int generation = ++ttsGeneration; - textToSpeech = new TextToSpeech(this, status -> { - if (destroyed || generation != ttsGeneration || textToSpeech == null) { - return; - } - if (status != TextToSpeech.SUCCESS) { - Log.w(TAG, "TTS init failed with status: " + status); - shutdownTts(); - return; + try { + bundledSpeechFile = new File(getCacheDir(), "alarm_prompt_edge.mp3"); + try (InputStream input = getAssets().open("alarm_prompt.mp3"); + FileOutputStream output = new FileOutputStream(bundledSpeechFile, false)) { + byte[] buffer = new byte[8_192]; + int count; + while ((count = input.read(buffer)) != -1) { + output.write(buffer, 0, count); + } } + startAudioPlayback(bundledSpeechFile); + } catch (Exception exception) { + releaseMediaPlayer(); + } + } - int languageResult = textToSpeech.setLanguage(Locale.SIMPLIFIED_CHINESE); - if (languageResult == TextToSpeech.LANG_MISSING_DATA - || languageResult == TextToSpeech.LANG_NOT_SUPPORTED) { - textToSpeech.setLanguage(Locale.getDefault()); - } - textToSpeech.setAudioAttributes(new AudioAttributes.Builder() + private void startAudioPlayback(File audioFile) { + if (destroyed || mediaPlayer != null || audioFile == null || !audioFile.isFile()) { + return; + } + try { + MediaPlayer player = new MediaPlayer(); + player.setAudioAttributes(new AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_ALARM) .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) .build()); - textToSpeech.setSpeechRate(TTS_SPEECH_RATE); - textToSpeech.setPitch(TTS_PITCH); - textToSpeech.setOnUtteranceProgressListener(new UtteranceProgressListener() { - @Override - public void onStart(String utteranceId) { - } - - @Override - public void onDone(String utteranceId) { - if (!destroyed - && soundEnabled - && generation == ttsGeneration - && utteranceId.equals(currentUtteranceId)) { - playbackHandler.postDelayed(replaySpeech, SPEECH_REPEAT_DELAY_MILLIS); - } - } - - @Override - public void onError(String utteranceId) { - // 当前轮播报失败时停止循环,等待用户处理该提醒。 - } + player.setDataSource(audioFile.getAbsolutePath()); + player.setVolume(1.0f, 1.0f); + player.setOnCompletionListener(completed -> + playbackHandler.postDelayed(replaySpeech, SPEECH_REPEAT_DELAY_MILLIS)); + player.setOnErrorListener((failed, what, extra) -> { + releaseMediaPlayer(); + return true; }); - ttsReady = true; - speakCurrentText(); - }); + player.prepare(); + mediaPlayer = player; + player.start(); + } catch (Exception exception) { + releaseMediaPlayer(); + } } - private void speakCurrentText() { - if (!ttsReady - || textToSpeech == null - || destroyed - || !soundEnabled - || currentSpeechText == null) { + private void replaySpeech() { + if (destroyed || mediaPlayer == null) { return; } + try { + mediaPlayer.seekTo(0); + mediaPlayer.start(); + } catch (IllegalStateException ignored) { + releaseMediaPlayer(); + } + } - currentUtteranceId = "alarm_" + UUID.randomUUID(); - Bundle params = new Bundle(); - params.putInt(TextToSpeech.Engine.KEY_PARAM_STREAM, android.media.AudioManager.STREAM_ALARM); - int result = textToSpeech.speak( - currentSpeechText, - TextToSpeech.QUEUE_FLUSH, - params, - currentUtteranceId - ); - if (result == TextToSpeech.ERROR) { - Log.w(TAG, "TTS speak failed"); - shutdownTts(); + private void releaseMediaPlayer() { + playbackHandler.removeCallbacks(replaySpeech); + if (mediaPlayer == null) { + return; + } + mediaPlayer.setOnCompletionListener(null); + mediaPlayer.setOnErrorListener(null); + try { + mediaPlayer.stop(); + } catch (IllegalStateException ignored) { + // 播放器可能已结束或失败。 } + mediaPlayer.release(); + mediaPlayer = null; } - private void scheduleTtsReplay() { - if (!destroyed && soundEnabled && currentSpeechText != null) { - speakCurrentText(); + private void deleteCachedSpeechFile() { + if (bundledSpeechFile != null) { + bundledSpeechFile.delete(); } } - private void shutdownTts() { - ttsGeneration += 1; - playbackHandler.removeCallbacks(replaySpeech); - TextToSpeech tts = textToSpeech; - textToSpeech = null; - ttsReady = false; - currentUtteranceId = null; - if (tts == null) { + private void removeFromSavedAlarms() { + AlarmScheduler.removeAlarmRecord(this, alarmId, requestCode); + } + + /** + * 低/中强度用的一次性短提示音:直接用系统默认通知铃声播一遍就停,不循环、 + * 不需要额外打包音频素材——跟高强度那段循环语音(startBundledSpeech())是 + * 两套独立的播放器实例,互不干扰,也不共用同一个 MediaPlayer。 + */ + private void startPing() { + if (destroyed) { return; } try { - tts.stop(); - tts.shutdown(); + Uri soundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION); + if (soundUri == null) { + return; + } + Ringtone ringtone = RingtoneManager.getRingtone(this, soundUri); + if (ringtone == null) { + return; + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + ringtone.setAudioAttributes(new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_ALARM) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build()); + } + pingRingtone = ringtone; + ringtone.play(); } catch (RuntimeException ignored) { - // 系统 TTS 进程可能已退出;停铃仍继续释放其余资源。 + // 尽力播放一声提示音;拿不到系统默认铃声就静默跳过,不影响震动/全屏。 } } - private void removeFromSavedAlarms() { - AlarmScheduler.removeAlarmRecord(this, alarmId, requestCode); + private void stopPing() { + if (pingRingtone != null) { + try { + pingRingtone.stop(); + } catch (RuntimeException ignored) { + // 忽略停止失败。 + } + pingRingtone = null; + } } /** 震动 / 间隔(毫秒),跟 JS 侧 ReactNativeVibration 的 REPEAT_PATTERN 保持一致。 */ diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/RingActivity.java b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/RingActivity.java index e4adcf3c..c9c76dc2 100644 --- a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/RingActivity.java +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/RingActivity.java @@ -22,10 +22,9 @@ public final class RingActivity extends Activity { private String alarmId; private String scheduleId; private String alarmTitle; - private String speechText; private int requestCode; private boolean vibrate; - private boolean sound; + private String soundTier; private boolean fullScreen; private boolean dismissNotified; @@ -38,9 +37,8 @@ protected void onCreate(Bundle savedInstanceState) { alarmId = extras.alarmId; scheduleId = extras.scheduleId; alarmTitle = extras.title; - speechText = extras.speechText; vibrate = extras.vibrate; - sound = extras.sound; + soundTier = extras.soundTier; fullScreen = extras.fullScreen; makeVisibleOverLockScreen(); matchSystemBarsToReminder(); @@ -53,9 +51,8 @@ protected void onCreate(Bundle savedInstanceState) { requestCode, alarmTitle, vibrate, - sound, - fullScreen, - speechText + soundTier, + fullScreen ); } @@ -141,14 +138,7 @@ private void snoozeAndClose() { + AlarmContract.SNOOZE_MINUTES * 60_000L; try { AlarmScheduler.schedule( - this, - triggerAt, - alarmTitle, - scheduleId, - vibrate, - sound, - fullScreen, - speechText + this, triggerAt, alarmTitle, scheduleId, vibrate, soundTier, fullScreen, "" ); } catch (RuntimeException ignored) { // 尽力重新挂闹钟;即使失败也通知 JS 落 snooze 状态。 diff --git a/frontend/modules/timeflow-alarm/android/src/test/java/com/timeflow/alarm/AlarmIntentForwardingTest.java b/frontend/modules/timeflow-alarm/android/src/test/java/com/timeflow/alarm/AlarmIntentForwardingTest.java index e8c9b285..ca691cc0 100644 --- a/frontend/modules/timeflow-alarm/android/src/test/java/com/timeflow/alarm/AlarmIntentForwardingTest.java +++ b/frontend/modules/timeflow-alarm/android/src/test/java/com/timeflow/alarm/AlarmIntentForwardingTest.java @@ -3,7 +3,6 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; import android.content.Context; import android.content.Intent; @@ -18,7 +17,7 @@ import org.robolectric.annotation.Config; import org.robolectric.shadows.ShadowApplication; -/** 闹钟触发的各个 Android Intent handoff 都必须保留 JS 生成的播报文案。 */ +/** 闹钟触发的各个 Android Intent handoff 都必须保留响铃通道设置。 */ @RunWith(RobolectricTestRunner.class) @Config(sdk = Build.VERSION_CODES.UPSIDE_DOWN_CAKE) public class AlarmIntentForwardingTest { @@ -31,16 +30,14 @@ public void setUp() { } @Test - public void receiverForwardsSpeechTextToSoundService() { - String speechText = "晨会,时间到了。现在已经09点了。"; + public void receiverForwardsRingChannelsToSoundService() { Intent incoming = new Intent(AlarmContract.ACTION_FIRE_ALARM) .putExtra(AlarmContract.EXTRA_ALARM_ID, "alarm-1") .putExtra(AlarmContract.EXTRA_SCHEDULE_ID, "schedule-1") .putExtra(AlarmContract.EXTRA_REQUEST_CODE, 101) .putExtra(AlarmContract.EXTRA_TITLE, "晨会") - .putExtra(AlarmContract.EXTRA_SPEECH_TEXT, speechText) .putExtra(AlarmContract.EXTRA_VIBRATE, false) - .putExtra(AlarmContract.EXTRA_SOUND, true) + .putExtra(AlarmContract.EXTRA_SOUND_TIER, AlarmContract.SOUND_TIER_FULL) .putExtra(AlarmContract.EXTRA_FULL_SCREEN, false); new AlarmReceiver().onReceive(context, incoming); @@ -48,16 +45,16 @@ public void receiverForwardsSpeechTextToSoundService() { Intent started = ShadowApplication.getInstance().getNextStartedService(); assertNotNull(started); assertEquals(AlarmSoundService.class.getName(), started.getComponent().getClassName()); - assertEquals(speechText, started.getStringExtra(AlarmContract.EXTRA_SPEECH_TEXT)); assertFalse(started.getBooleanExtra(AlarmContract.EXTRA_VIBRATE, true)); - assertTrue(started.getBooleanExtra(AlarmContract.EXTRA_SOUND, false)); + assertEquals( + AlarmContract.SOUND_TIER_FULL, + started.getStringExtra(AlarmContract.EXTRA_SOUND_TIER) + ); assertFalse(started.getBooleanExtra(AlarmContract.EXTRA_FULL_SCREEN, true)); } @Test - public void activityServiceStartForwardsSpeechText() { - String speechText = "提交报告,时间到了。"; - + public void activityServiceStartForwardsRingChannels() { AlarmSoundService.start( context, "alarm-2", @@ -65,16 +62,15 @@ public void activityServiceStartForwardsSpeechText() { 202, "提交报告", true, - false, - true, - speechText + AlarmContract.SOUND_TIER_NONE, + true ); Intent started = ShadowApplication.getInstance().getNextStartedService(); assertNotNull(started); - assertEquals(speechText, started.getStringExtra(AlarmContract.EXTRA_SPEECH_TEXT)); - assertTrue(started.getBooleanExtra(AlarmContract.EXTRA_VIBRATE, false)); - assertFalse(started.getBooleanExtra(AlarmContract.EXTRA_SOUND, true)); - assertTrue(started.getBooleanExtra(AlarmContract.EXTRA_FULL_SCREEN, false)); + assertEquals( + AlarmContract.SOUND_TIER_NONE, + started.getStringExtra(AlarmContract.EXTRA_SOUND_TIER) + ); } } diff --git a/frontend/modules/timeflow-alarm/android/src/test/java/com/timeflow/alarm/AlarmSoundServiceTest.java b/frontend/modules/timeflow-alarm/android/src/test/java/com/timeflow/alarm/AlarmSoundServiceTest.java index 2af33bdd..0e431fbc 100644 --- a/frontend/modules/timeflow-alarm/android/src/test/java/com/timeflow/alarm/AlarmSoundServiceTest.java +++ b/frontend/modules/timeflow-alarm/android/src/test/java/com/timeflow/alarm/AlarmSoundServiceTest.java @@ -24,7 +24,7 @@ /** * 覆盖 Wintercom 在 PR #265 review 里要求的场景:两条闹钟重叠到达时都应该被展示、 * 各自的 disposition 不能串。不覆盖悬浮窗按钮点击 -> snooze/dismiss 这条链路(需要真的 - * 构建 AlarmRingUi 的 View),也不覆盖系统 TTS 播放(跟系统资源强绑定,presentAlarm() + * 构建 AlarmRingUi 的 View),也不覆盖 MediaPlayer 播放(跟系统资源强绑定,presentAlarm() * 本身已经有 try/catch 兜底)。 * * pin 住 API 34:这个模块 compileSdk 是 35,但 API 36 常量已经在生产代码里被移除 diff --git a/frontend/src/app/composition/createAppServices.ts b/frontend/src/app/composition/createAppServices.ts index a4df6d8d..04743f1e 100644 --- a/frontend/src/app/composition/createAppServices.ts +++ b/frontend/src/app/composition/createAppServices.ts @@ -5,7 +5,10 @@ import type { ReminderApplicationDependencies, ReminderApplicationPort, } from '../../features/reminder/application/interfaces'; -import { LocalReminderApplication } from '../../features/reminder/application'; +import { + LocalReminderApplication, + ReminderGuardCoordinator, +} from '../../features/reminder/application'; import { LocalReminderDelivery, LocalReminderRecovery, @@ -81,12 +84,20 @@ export function createAppServices(options: CreateAppServicesOptions = {}): AppSe }; const reminder = new LocalReminderApplication(reminderPorts); + const reminderGuard = new ReminderGuardCoordinator({ + schedules, + handleLocation: (sample) => reminder.handleLocation(sample), + }); const scheduleView = new ScheduleViewStore(); const runtime = new AppRuntime([ { start: () => reminder.start(), stop: () => reminder.stop(), }, + { + start: () => reminderGuard.start(), + stop: () => reminderGuard.stop(), + }, ]); auth.accountStateCleaners.register('schedule-view', () => scheduleView.clear()); diff --git a/frontend/src/features/reminder/application/LocalReminderApplication.ts b/frontend/src/features/reminder/application/LocalReminderApplication.ts index cf815d55..65062e4d 100644 --- a/frontend/src/features/reminder/application/LocalReminderApplication.ts +++ b/frontend/src/features/reminder/application/LocalReminderApplication.ts @@ -25,6 +25,7 @@ import type { } from '../domain'; import { DEFAULT_SNOOZE_MINUTES, buildReminderSpeechText } from '../domain'; import { evaluateGeofence, resolveGeofenceCenter, resolveWatchMode } from '../domain/geofence'; +import type { AlarmSoundTier } from '../domain/strengthDelivery'; import { resolveStrengthDeliveryPlan } from '../domain/strengthDelivery'; import { isSnoozeActive, @@ -315,6 +316,18 @@ export class LocalReminderApplication implements ReminderApplicationPort { if (!this.isLive(generation)) return []; + // location.rebuild() 会在系统围栏注册完成后立即派发一次当前位置样本。先放入 + // 占位 registration,确保这次初始样本不会因批量重建尚未返回 handle 而被忽略。 + for (const schedule of active) { + this.registrations.set(schedule.id, { + schedule_id: schedule.id, + time_listener_id: this.timeListenerId, + location_listener_id: null, + alarm_id: null, + schedule, + }); + } + const locationTargets = active .filter((schedule) => schedule.schedule_type === 'location') .map((schedule) => { @@ -330,9 +343,12 @@ export class LocalReminderApplication implements ReminderApplicationPort { }; }) .filter((target): target is NonNullable => target != null); - const locationHandles = await this.dependencies.location.rebuild(locationTargets, (event) => { - void this.handleLocationMonitorEvent(event); - }); + const locationHandles = await this.dependencies.location.rebuild( + locationTargets, + async (event) => { + await this.handleLocationMonitorEvent(event); + }, + ); const locationBySchedule = new Map( locationHandles.map((handle) => [handle.schedule_id, handle]), ); @@ -567,6 +583,17 @@ export class LocalReminderApplication implements ReminderApplicationPort { for (const raw of schedules) { if (!this.isLive(generation)) return; const schedule = await this.withStoredRuntime(raw); + // 时间型日程只要原生闹钟还接管着(无论是首次触发还是延后重挂),JS 这边 + // 完全不判定、不投递——之前两条通道各自独立判定、靠 activeDeliveries 互斥的 + // 设计里,谁先到点完全看运气:JS 这次 tick 一旦抢先,会在 runDeliver() 里 + // 主动撤销掉本来更可靠的原生闹钟(见 cancelScheduledAlarm),退化成通知/震动 + // 这种更容易被用户忽略的投递方式。原生已经接管的日程直接跳过,消除这个竞态。 + if ( + schedule.schedule_type === 'time' && + this.registrations.get(schedule.id)?.alarm_id != null + ) { + continue; + } if (!(await this.canDeliver(schedule, tick.observed_at, generation))) continue; if (isSnoozeExpired(schedule, tick.observed_at)) { @@ -655,53 +682,70 @@ export class LocalReminderApplication implements ReminderApplicationPort { let usedFallbackAudio = false; let deliveryId = `delivery-${schedule.id}`; - // 时间型日程只要排上了原生精确闹钟,响铃 UI 和声音就全权交给原生 - // RingActivity/AlarmSoundService;JS 这边再弹 Alert/放音会跟原生的全屏页 - // 抢事件,谁都关不掉谁。地点型日程没有原生等价物,走完整 JS 通道。 - const nativeAlarmOwnsRingUi = - schedule.schedule_type === 'time' && - this.registrations.get(schedule.id)?.alarm_id != null; - - // low=系统通知;medium=弹窗+短震动;high=弹窗+短震动+TTS(失败则本地音)。 - if (plan.useSystemNotification) { - const receipt = await this.dependencies.delivery.deliver(request); - deliveryId = receipt.delivery_id; - await this.dependencies.systemNotification.show({ - notification_id: `reminder-${schedule.id}`, + // 原生全屏响铃页统一优先:时间型日程走到这里,说明原生闹钟本来就没能 + // 挂上(挂上了的日程 runHandleTime 已经整条跳过,不会再进 runDeliver); + // 地点型没有原生闹钟等价物,一律靠这里的 presentNow。两种类型不再区分 + // 对待,全部强度都要全屏,不再有"低强度只发一条普通通知"这条路——只有 + // presentNow 本身不可用(iOS、原生模块拿不到)时才回退到下面的 JS 通道。 + let presentedNatively = false; + if (this.dependencies.alarms.presentNow != null) { + const nativeReceipt = await this.dependencies.alarms.presentNow({ + alarm_id: `present-${schedule.id}-${Date.now()}`, + schedule_id: schedule.id, title: schedule.title, - body: schedule.location_name ?? schedule.title, + vibrate: plan.useVibration, + sound_tier: plan.alarmSoundTier, + full_screen: true, }); - channels.push('system_notification'); - } - - if (plan.usePopup && !nativeAlarmOwnsRingUi) { - await this.dependencies.presenter.show(request); - channels.push('popup'); - } - - if (plan.useVibration) { - await this.dependencies.vibration.vibrate(); - channels.push('vibration'); + if (nativeReceipt.presented) { + presentedNatively = true; + deliveryId = `native-${nativeReceipt.alarm_id}`; + channels.push('native_full_screen'); + } } let audioPlayed = false; - if (plan.useAudio && !nativeAlarmOwnsRingUi) { - let audioReceipt = await this.dependencies.audio.playTts({ schedule_id: schedule.id }); - if (!audioReceipt.played) { - audioReceipt = await this.dependencies.audio.playLocalFallback({ - schedule_id: schedule.id, + if (!presentedNatively) { + // low=系统通知;medium=弹窗+短震动;high=弹窗+短震动+TTS(失败则本地音)。 + if (plan.useSystemNotification) { + const receipt = await this.dependencies.delivery.deliver(request); + deliveryId = receipt.delivery_id; + await this.dependencies.systemNotification.show({ + notification_id: `reminder-${schedule.id}`, + title: schedule.title, + body: schedule.location_name ?? schedule.title, }); + channels.push('system_notification'); } - audioPlayed = audioReceipt.played; - if (audioPlayed) { - channels.push(audioReceipt.used_local_fallback ? 'local_sound' : 'tts'); + + if (plan.usePopup) { + await this.dependencies.presenter.show(request); + channels.push('popup'); + } + + if (plan.useVibration) { + await this.dependencies.vibration.vibrate(); + channels.push('vibration'); } - usedFallbackAudio = audioReceipt.used_local_fallback; - } - await this.cancelScheduledAlarm(schedule.id); - if (!plan.useAudio || audioPlayed) { - await this.dependencies.alarms.stopRinging?.(); + if (plan.useAudio) { + let audioReceipt = await this.dependencies.audio.playTts({ schedule_id: schedule.id }); + if (!audioReceipt.played) { + audioReceipt = await this.dependencies.audio.playLocalFallback({ + schedule_id: schedule.id, + }); + } + audioPlayed = audioReceipt.played; + if (audioPlayed) { + channels.push(audioReceipt.used_local_fallback ? 'local_sound' : 'tts'); + } + usedFallbackAudio = audioReceipt.used_local_fallback; + } + + await this.cancelScheduledAlarm(schedule.id); + if (!plan.useAudio || audioPlayed) { + await this.dependencies.alarms.stopRinging?.(); + } } if (!this.isLive(generation)) { @@ -1000,8 +1044,8 @@ export class LocalReminderApplication implements ReminderApplicationPort { mode, background: true, }, - (event) => { - void this.handleLocationMonitorEvent(event); + async (event) => { + await this.handleLocationMonitorEvent(event); }, ); void this.reportPermissionGaps(schedule.id, ['location_foreground', 'location_background']); @@ -1027,6 +1071,7 @@ export class LocalReminderApplication implements ReminderApplicationPort { 'overlay', 'full_screen', 'battery_optimization', + 'notifications', ]); return receipt; } @@ -1098,17 +1143,17 @@ function toDeliveryRequest( } /** - * 原生闹钟响铃时要不要震动/出声/弹全屏止铃界面。全屏三档都要弹(时间型提醒 - * 没有别的可见形式,静音也得让用户看到),vibrate/sound 复用 JS 侧的强度 - * 换算表:低=都不要、中=只震动、高=震动+出声。 + * 原生闹钟响铃时要不要震动/弹全屏止铃界面、声音走哪个档位。全屏恒为真(时间型 + * 提醒没有别的可见形式,静音也得让用户看到),vibrate/soundTier 复用 JS 侧的 + * 强度换算表:低=一声提示音不震动、中=一声提示音+震动、高=循环语音+震动。 */ function alarmRingChannels(schedule: LocalReminderSchedule): { vibrate: boolean; - sound: boolean; + sound_tier: AlarmSoundTier; full_screen: boolean; } { const plan = resolveStrengthDeliveryPlan(schedule.reminder?.reminder_strength ?? 'medium'); - return { vibrate: plan.useVibration, sound: plan.useAudio, full_screen: true }; + return { vibrate: plan.useVibration, sound_tier: plan.alarmSoundTier, full_screen: true }; } function toAlarmSpeechText(schedule: LocalReminderSchedule, scheduledAt: string): string { diff --git a/frontend/src/features/reminder/application/ReminderGuardCoordinator.ts b/frontend/src/features/reminder/application/ReminderGuardCoordinator.ts new file mode 100644 index 00000000..c0359fd8 --- /dev/null +++ b/frontend/src/features/reminder/application/ReminderGuardCoordinator.ts @@ -0,0 +1,190 @@ +import * as Location from 'expo-location'; + +import type { LocalScheduleReader } from './interfaces'; +import type { GeoPoint, LocalReminderSchedule, LocationSample } from '../domain'; +import { resolveGeofenceCenter, resolveWatchMode } from '../domain/geofence'; +import { resolveEffectiveTriggerAt } from '../domain/timeWindow'; +import { + GUARD_TASK_NAME, + resolveNextPollIntervalMs, + subscribeGuardTaskEvents, + type GuardTaskSample, +} from '../../../infrastructure/location/reminderGuardTask'; + +const NOTIFICATION_TITLE = 'Timeflow 提醒守护'; +const RESTART_INTERVAL_RATIO_THRESHOLD = 0.2; + +export type ReminderGuardDependencies = { + schedules: LocalScheduleReader; + /** 收到位置心跳后,喂给 LocalReminderApplication 走完整的地点提醒判定链路。 */ + handleLocation: (sample: LocationSample) => Promise; +}; + +/** + * 常驻前台服务的 JS 侧协调器:不是自己起一个原生 Service,而是借用 + * expo-location 已经配置好的 startLocationUpdatesAsync 前台服务能力——只要还有 + * 未完成的提醒(时间型或地点型)就保持它运行,用它把进程钉在 Doze 豁免状态; + * 停止判定 + 通知文案 + 轮询间隔 全部是纯 JS 逻辑,可以脱离原生单独测试。 + * + * 时间型日程原生闹钟挂没挂上,这里不判定——那是 reminderGuardTask.ts 每次唤醒 + * 时直接查 AlarmScheduler 持久化状态做的事,这里只回答"要不要让这个前台服务 + * 继续活着"这个更粗粒度的问题。 + */ +export class ReminderGuardCoordinator { + private unsubscribeSchedules: (() => void) | null = null; + private unsubscribeGuardTask: (() => void) | null = null; + private started = false; + private running = false; + private currentIntervalMs: number | null = null; + private lastSample: GeoPoint | null = null; + private reconcileChain: Promise = Promise.resolve(); + + constructor(private readonly dependencies: ReminderGuardDependencies) {} + + async start(): Promise { + if (this.started) return; + this.started = true; + this.unsubscribeGuardTask = subscribeGuardTaskEvents((sample) => { + void this.handleSample(sample); + }); + this.unsubscribeSchedules = this.dependencies.schedules.subscribe(() => { + void this.reconcile(); + }); + await this.reconcile(); + } + + async stop(): Promise { + this.started = false; + this.unsubscribeGuardTask?.(); + this.unsubscribeGuardTask = null; + this.unsubscribeSchedules?.(); + this.unsubscribeSchedules = null; + await this.stopLocationUpdates(); + } + + private async handleSample(sample: GuardTaskSample): Promise { + this.lastSample = { latitude: sample.latitude, longitude: sample.longitude }; + await this.dependencies.handleLocation(sample); + await this.reconcile(); + } + + /** 串行化:日程变化和位置心跳可能挤在一起触发,不让两次 reconcile 并发跑。 */ + private reconcile(): Promise { + const run = this.reconcileChain.then(() => this.reconcileInternal()); + this.reconcileChain = run.catch(() => undefined); + return run; + } + + private async reconcileInternal(): Promise { + if (!this.started) return; + const schedules = await this.dependencies.schedules.listReminderSchedules(); + const active = schedules.filter( + (schedule) => + schedule.status === 'active' && schedule.runtime.reminder_disposition_state !== 'confirmed', + ); + + if (active.length === 0) { + await this.stopLocationUpdates(); + return; + } + + const locationTargets = active + .filter((schedule) => schedule.schedule_type === 'location') + .map((schedule) => resolveGeofenceCenter(schedule, resolveWatchMode(schedule))) + .filter((center): center is GeoPoint => center != null); + + const intervalMs = resolveNextPollIntervalMs(this.lastSample, locationTargets); + await this.ensureLocationUpdates(intervalMs, active); + } + + private async ensureLocationUpdates( + intervalMs: number, + active: readonly LocalReminderSchedule[], + ): Promise { + // 只查前台权限:startLocationUpdatesAsync() 传了 foregroundService 配置,走的是 + // "用户可见前台服务"这条路径,expo-location 原生侧本身就不要求 + // ACCESS_BACKGROUND_LOCATION(只有不带 foregroundService、纯后台位置服务那条路径 + // 才需要)——上一版这里连后台权限一起卡,导致用户只给了"仅使用时允许"(没给 + // "始终允许",这是安卓上很常见的选择)时,连时间型提醒的兜底轮询都启动不了, + // 跟这条日程要不要用到位置完全没关系。 + const { status: foreground } = await Location.getForegroundPermissionsAsync(); + if (foreground !== 'granted') { + console.warn('[guard] ensureLocationUpdates skipped: foreground permission not granted'); + return; + } + + const intervalChangedEnough = + this.currentIntervalMs == null || + Math.abs(intervalMs - this.currentIntervalMs) / this.currentIntervalMs > + RESTART_INTERVAL_RATIO_THRESHOLD; + + if (this.running && !intervalChangedEnough) { + return; + } + + try { + await Location.startLocationUpdatesAsync(GUARD_TASK_NAME, { + accuracy: Location.Accuracy.Balanced, + timeInterval: intervalMs, + distanceInterval: 0, + foregroundService: { + notificationTitle: NOTIFICATION_TITLE, + notificationBody: describeGuardNotification(active), + }, + }); + this.running = true; + this.currentIntervalMs = intervalMs; + } catch (error) { + console.warn('[guard] startLocationUpdatesAsync failed', error); + } + } + + private async stopLocationUpdates(): Promise { + if (!this.running) return; + try { + const hasStarted = await Location.hasStartedLocationUpdatesAsync(GUARD_TASK_NAME); + if (hasStarted) { + await Location.stopLocationUpdatesAsync(GUARD_TASK_NAME); + } + } catch (error) { + console.warn('[guard] stopLocationUpdatesAsync failed', error); + } finally { + this.running = false; + this.currentIntervalMs = null; + } + } +} + +/** + * 常驻通知文案:有时间型日程时显示最近一条的倒计时(信息量更大,优先展示); + * 只有地点型日程时改成"正在监听 N 个"——地点型没有确定的下一个触发时刻,凑不出 + * 倒计时。 + */ +function describeGuardNotification(active: readonly LocalReminderSchedule[]): string { + const timeSchedules = active.filter((schedule) => schedule.schedule_type === 'time'); + const locationCount = active.length - timeSchedules.length; + + const next = timeSchedules + .map((schedule) => ({ schedule, triggerAt: resolveNextTriggerAt(schedule) })) + .filter( + (entry): entry is { schedule: LocalReminderSchedule; triggerAt: number } => + entry.triggerAt != null, + ) + .sort((a, b) => a.triggerAt - b.triggerAt)[0]; + + if (next == null) { + return locationCount > 0 ? `正在监听 ${locationCount} 个地点提醒` : '正在后台监控提醒'; + } + + const minutesLeft = Math.max(0, Math.round((next.triggerAt - Date.now()) / 60_000)); + const countdown = minutesLeft <= 0 ? '即将到时' : `还有约 ${minutesLeft} 分钟`; + const suffix = locationCount > 0 ? `,另有 ${locationCount} 个地点提醒监听中` : ''; + return `下一条:${next.schedule.title},${countdown}${suffix}`; +} + +function resolveNextTriggerAt(schedule: LocalReminderSchedule): number | null { + const triggerAt = resolveEffectiveTriggerAt(schedule); + if (triggerAt == null) return null; + const parsed = Date.parse(triggerAt); + return Number.isNaN(parsed) ? null : parsed; +} diff --git a/frontend/src/features/reminder/application/index.ts b/frontend/src/features/reminder/application/index.ts index 3e7eaac4..ddceeed8 100644 --- a/frontend/src/features/reminder/application/index.ts +++ b/frontend/src/features/reminder/application/index.ts @@ -1,7 +1,11 @@ export { LocalReminderApplication } from './LocalReminderApplication'; +export { ReminderGuardCoordinator } from './ReminderGuardCoordinator'; +export type { ReminderGuardDependencies } from './ReminderGuardCoordinator'; export type { AlarmNativeDisposition, AlarmNativeEvent, + AlarmPresentationReceipt, + AlarmPresentationRequest, AlarmScheduleReceipt, AlarmScheduleRequest, AlarmSchedulerPort, diff --git a/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts b/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts index 48391875..c7bee0bd 100644 --- a/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts +++ b/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts @@ -1,3 +1,5 @@ +import type { AlarmSoundTier } from '../../domain/strengthDelivery'; + export type AlarmScheduleRequest = { schedule_id: string; trigger_at: string; @@ -5,7 +7,7 @@ export type AlarmScheduleRequest = { exact: boolean; /** 原生响铃时是否震动/出声/弹全屏止铃界面;由提醒强度换算而来。 */ vibrate: boolean; - sound: boolean; + sound_tier: AlarmSoundTier; full_screen: boolean; /** JS 生成的日程播报文案;原生侧仅负责按闹钟声道朗读。 */ speech_text?: string; @@ -33,6 +35,21 @@ export type AlarmNativeDisposition = { updated_at: string; }; +export type AlarmPresentationRequest = { + alarm_id: string; + schedule_id: string; + title: string; + vibrate: boolean; + sound_tier: AlarmSoundTier; + full_screen: boolean; +}; + +export type AlarmPresentationReceipt = { + alarm_id: string; + schedule_id: string; + presented: boolean; +}; + /** 原生闹钟映射边界;触发时间的选择留在应用层或领域层。 */ export interface AlarmSchedulerPort { schedule(request: AlarmScheduleRequest): Promise; @@ -46,4 +63,6 @@ export interface AlarmSchedulerPort { peekNativeDispositions?(): Promise; /** 确认对应 schedule_id 已经在 JS 侧落盘成功,原生缓冲区才真正删除这批记录。 */ ackNativeDispositions?(scheduleIds: readonly string[]): Promise; + /** 立即交给原生全局响铃页;不可用时返回 presented=false,由上层回退。 */ + presentNow?(request: AlarmPresentationRequest): Promise; } diff --git a/frontend/src/features/reminder/application/interfaces/index.ts b/frontend/src/features/reminder/application/interfaces/index.ts index 1ed95d03..d5c9e860 100644 --- a/frontend/src/features/reminder/application/interfaces/index.ts +++ b/frontend/src/features/reminder/application/interfaces/index.ts @@ -1,6 +1,8 @@ export type { AlarmNativeDisposition, AlarmNativeEvent, + AlarmPresentationReceipt, + AlarmPresentationRequest, AlarmScheduleReceipt, AlarmScheduleRequest, AlarmSchedulerPort, diff --git a/frontend/src/features/reminder/domain/geofence.ts b/frontend/src/features/reminder/domain/geofence.ts index ad75edb5..94bd9e2d 100644 --- a/frontend/src/features/reminder/domain/geofence.ts +++ b/frontend/src/features/reminder/domain/geofence.ts @@ -65,3 +65,35 @@ export function resolveGeofenceCenter( function toRadians(degrees: number): number { return (degrees * Math.PI) / 180; } + +/** 离最近目标 ≤ 此距离(米)时,按最密的轮询间隔查。 */ +const GUARD_POLL_NEAR_METERS = 50; +/** 离最近目标 ≥ 此距离(米)时,按最疏的轮询间隔查。 */ +const GUARD_POLL_FAR_METERS = 2_000; +/** 最密轮询间隔:贴近围栏边界时,尽量不错过穿越的瞬间。 */ +const GUARD_POLL_MIN_INTERVAL_MS = 15_000; +/** 最疏轮询间隔:离目标很远时没必要频繁定位,省电。 */ +const GUARD_POLL_MAX_INTERVAL_MS = 300_000; + +/** + * 常驻前台服务里持续定位的轮询间隔:离最近的地点提醒目标越近,查得越勤; + * 远离目标时退到最疏间隔省电。50m 内外和 2000m 外的两个端点截断,中间线性插值。 + * distanceMeters 为 Infinity(没有任何地点提醒在监听)时返回最疏间隔。 + */ +export function resolveGuardPollIntervalMs(distanceToNearestTargetMeters: number): number { + if ( + !Number.isFinite(distanceToNearestTargetMeters) || + distanceToNearestTargetMeters >= GUARD_POLL_FAR_METERS + ) { + return GUARD_POLL_MAX_INTERVAL_MS; + } + if (distanceToNearestTargetMeters <= GUARD_POLL_NEAR_METERS) { + return GUARD_POLL_MIN_INTERVAL_MS; + } + const ratio = + (distanceToNearestTargetMeters - GUARD_POLL_NEAR_METERS) / + (GUARD_POLL_FAR_METERS - GUARD_POLL_NEAR_METERS); + return Math.round( + GUARD_POLL_MIN_INTERVAL_MS + ratio * (GUARD_POLL_MAX_INTERVAL_MS - GUARD_POLL_MIN_INTERVAL_MS), + ); +} diff --git a/frontend/src/features/reminder/domain/reminder.ts b/frontend/src/features/reminder/domain/reminder.ts index 58b4c215..6a0f8b21 100644 --- a/frontend/src/features/reminder/domain/reminder.ts +++ b/frontend/src/features/reminder/domain/reminder.ts @@ -82,7 +82,8 @@ export type ReminderTrigger = { triggered_at: string; }; -export type DeliveryChannel = 'system_notification' | 'popup' | 'vibration' | 'tts' | 'local_sound'; +export type DeliveryChannel = + 'system_notification' | 'native_full_screen' | 'popup' | 'vibration' | 'tts' | 'local_sound'; export type ReminderDeliveryRequest = { reminder_id: string; diff --git a/frontend/src/features/reminder/domain/strengthDelivery.ts b/frontend/src/features/reminder/domain/strengthDelivery.ts index 37a56ff1..97c9c79d 100644 --- a/frontend/src/features/reminder/domain/strengthDelivery.ts +++ b/frontend/src/features/reminder/domain/strengthDelivery.ts @@ -1,11 +1,18 @@ import type { ReminderStrength } from './reminder'; +/** 原生全屏响铃页的声音档位:none=不出声,ping=一次性短提示音,full=循环语音直到处理。 */ +export type AlarmSoundTier = 'none' | 'ping' | 'full'; + /** 提醒强度对应的客户端送达通道组合。 */ export type StrengthDeliveryPlan = { + /** 只在原生全屏页不可用时的 JS 兜底路径里使用(iOS、或安卓侧原生模块拿不到)。 */ useSystemNotification: boolean; usePopup: boolean; useVibration: boolean; + /** JS 兜底路径专用的 TTS/本地音;跟 alarmSoundTier 是两回事,互不影响。 */ useAudio: boolean; + /** 原生全屏响铃页(presentNow / AlarmSoundService)用的声音档位。 */ + alarmSoundTier: AlarmSoundTier; }; export function resolveStrengthDeliveryPlan(strength: ReminderStrength): StrengthDeliveryPlan { @@ -16,6 +23,7 @@ export function resolveStrengthDeliveryPlan(strength: ReminderStrength): Strengt usePopup: false, useVibration: false, useAudio: false, + alarmSoundTier: 'ping', }; case 'medium': return { @@ -23,6 +31,7 @@ export function resolveStrengthDeliveryPlan(strength: ReminderStrength): Strengt usePopup: true, useVibration: true, useAudio: false, + alarmSoundTier: 'ping', }; case 'high': return { @@ -30,6 +39,7 @@ export function resolveStrengthDeliveryPlan(strength: ReminderStrength): Strengt usePopup: true, useVibration: true, useAudio: true, + alarmSoundTier: 'full', }; } } diff --git a/frontend/src/features/reminder/index.ts b/frontend/src/features/reminder/index.ts index c6b8b8d2..703661c3 100644 --- a/frontend/src/features/reminder/index.ts +++ b/frontend/src/features/reminder/index.ts @@ -39,6 +39,8 @@ export { export type { AlarmNativeDisposition, AlarmNativeEvent, + AlarmPresentationReceipt, + AlarmPresentationRequest, AlarmScheduleReceipt, AlarmScheduleRequest, AlarmSchedulerPort, diff --git a/frontend/src/infrastructure/location/geofenceTask.ts b/frontend/src/infrastructure/location/geofenceTask.ts index 211a25c9..9c7e1f6c 100644 --- a/frontend/src/infrastructure/location/geofenceTask.ts +++ b/frontend/src/infrastructure/location/geofenceTask.ts @@ -13,7 +13,7 @@ export type GeofenceTaskPayload = { observed_at: string; }; -type GeofenceTaskListener = (payload: GeofenceTaskPayload) => void; +type GeofenceTaskListener = (payload: GeofenceTaskPayload) => unknown; const listeners = new Set(); @@ -29,6 +29,7 @@ type HeadlessScheduleRow = { location_name: string | null; schedule_type: string; reminder_type: string | null; + reminder_strength: 'low' | 'medium' | 'high' | null; reminder_disposition_state: string | null; snoozed_until: string | null; geofence_armed: number; @@ -43,6 +44,45 @@ export function subscribeGeofenceTaskEvents(listener: GeofenceTaskListener): () }; } +/** + * Development-only hook for exercising the same event path as the native task. + * It deliberately resolves the center from SQLite so the UI does not need to + * expose location coordinates just to run a local test. + */ +export async function simulateGeofenceEventForTesting( + scheduleId: string, + event: GeofenceTaskPayload['event'], +): Promise { + const database = await openHeadlessDatabase(); + if (database == null) { + throw new Error('无法打开本地日程数据库'); + } + + const schedule = await database.getFirstAsync<{ + latitude: number | null; + longitude: number | null; + }>( + `SELECT latitude, longitude + FROM local_schedules + WHERE id = ? + AND schedule_type = 'location' + AND status = 'active'`, + scheduleId, + ); + if (schedule?.latitude == null || schedule.longitude == null) { + throw new Error('这条地点提醒没有可用的坐标'); + } + + await emit({ + schedule_id: scheduleId, + event, + latitude: schedule.latitude, + longitude: schedule.longitude, + radius: 200, + observed_at: new Date().toISOString(), + }); +} + /** 懒加载:expo-sqlite/kv-store 的默认导出是模块加载时就构造的单例,顶层 import * 会在测试环境(没有真实原生模块)里直接抛错——这个仓库里原生相关的按需依赖 * 一律走动态 import,参照 ExpoAudioPlayback.ts 的 loadExpoAudio()。 */ @@ -99,22 +139,28 @@ async function persistPendingEvent(payload: GeofenceTaskPayload): Promise async function emit(payload: GeofenceTaskPayload): Promise { if (listeners.size === 0) { - let delivered = false; + let result: HeadlessDeliveryResult | false = false; try { - delivered = await deliverHeadlessGeofenceEvent(payload); + result = await deliverHeadlessGeofenceEvent(payload); } catch { - delivered = false; + result = false; } - if (!delivered) { + if (result === false) { await persistPendingEvent(payload); } return; } for (const listener of listeners) { - listener(payload); + try { + await listener(payload); + } catch { + // 交给下一个订阅者继续处理;单个监听器失败不影响其余订阅者。 + } } } +type HeadlessDeliveryResult = 'native' | 'notification' | 'state_updated'; + /** * Deliver a notification while Expo has only started this headless task. * @@ -123,7 +169,9 @@ async function emit(payload: GeofenceTaskPayload): Promise { * Returning false leaves the event in the existing pending queue for replay when the * normal application session starts. */ -async function deliverHeadlessGeofenceEvent(payload: GeofenceTaskPayload): Promise { +async function deliverHeadlessGeofenceEvent( + payload: GeofenceTaskPayload, +): Promise { const database = await openHeadlessDatabase(); if (database == null) return false; @@ -141,11 +189,11 @@ async function deliverHeadlessGeofenceEvent(payload: GeofenceTaskPayload): Promi AND geofence_armed = 0`, payload.schedule_id, ); - return true; + return 'state_updated'; } const schedule = await database.getFirstAsync( - `SELECT id, title, location_name, schedule_type, reminder_type, + `SELECT id, title, location_name, schedule_type, reminder_type, reminder_strength, reminder_disposition_state, snoozed_until, geofence_armed, status FROM local_schedules WHERE id = ?`, @@ -162,35 +210,45 @@ async function deliverHeadlessGeofenceEvent(payload: GeofenceTaskPayload): Promi schedule.snoozed_until != null && Date.parse(schedule.snoozed_until) > Date.parse(payload.observed_at)) ) { - return true; + return 'state_updated'; } - const notifications = await loadNotifications(); - if (notifications == null) return false; - await ensureAndroidChannel(notifications); - notifications.setNotificationHandler({ - handleNotification: async () => ({ - shouldShowBanner: true, - shouldShowList: true, - shouldPlaySound: true, - shouldSetBadge: false, - }), - }); + const strength = schedule.reminder_strength ?? 'medium'; + const nativePresented = await presentHeadlessNativeAlarm( + schedule.id, + schedule.title || '日程提醒', + strength, + ); + if (!nativePresented) { + const notifications = await loadNotifications(); + if (notifications == null) return false; + await ensureAndroidChannel(notifications); + notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowBanner: true, + shouldShowList: true, + shouldPlaySound: true, + shouldSetBadge: false, + }), + }); - const notificationId = `reminder-${schedule.id}`; - await notifications.scheduleNotificationAsync({ - identifier: notificationId, - content: { - title: schedule.title || '日程提醒', - body: - schedule.reminder_type === 'return_to_recorded_location' - ? `您已回到${schedule.location_name ?? '记录地点'}附近,请及时处理。` - : `您已进入${schedule.location_name ?? '目标地点'}附近,请及时处理。`, - sound: 'default', - data: { schedule_id: schedule.id, reason: schedule.reminder_type ?? 'arrive_location' }, - }, - trigger: null, - }); + const notificationId = `reminder-${schedule.id}`; + await notifications.scheduleNotificationAsync({ + identifier: notificationId, + content: { + title: schedule.title || '日程提醒', + body: + schedule.reminder_type === 'return_to_recorded_location' + ? `您已回到${schedule.location_name ?? '记录地点'}附近,请及时处理。` + : `您已进入${schedule.location_name ?? '目标地点'}附近,请及时处理。`, + sound: 'default', + data: { schedule_id: schedule.id, reason: schedule.reminder_type ?? 'arrive_location' }, + }, + trigger: { channelId: 'timeflow-reminders' }, + }); + } + + const deliveryResult: HeadlessDeliveryResult = nativePresented ? 'native' : 'notification'; await database.runAsync( `UPDATE local_schedules @@ -207,7 +265,30 @@ async function deliverHeadlessGeofenceEvent(payload: GeofenceTaskPayload): Promi payload.observed_at, schedule.id, ); - return true; + return deliveryResult; + } +} + +async function presentHeadlessNativeAlarm( + scheduleId: string, + title: string, + strength: 'low' | 'medium' | 'high', +): Promise { + try { + const bridge = await import('../notifications/native/TimeflowAlarmBridge'); + const { resolveStrengthDeliveryPlan } = + await import('../../features/reminder/domain/strengthDelivery'); + const plan = resolveStrengthDeliveryPlan(strength); + return await bridge.nativePresentAlarmNow( + `geofence-${scheduleId}-${Date.now()}`, + scheduleId, + title, + plan.useVibration, + plan.alarmSoundTier, + true, + ); + } catch { + return false; } } @@ -237,11 +318,16 @@ async function loadNotifications(): Promise { + // 独立渠道 ID,不跟 ExpoSystemNotification.ts 的 'timeflow-reminders-quiet' 共用—— + // 这条路径故意要出声(地点提醒 headless 兜底),渠道声音创建后不可变,两边共用 + // 一个渠道 ID 谁先创建谁的设置就永久生效,另一边的诉求会被吃掉。显式给 sound + // 赋值而不是留给默认值,避免依赖未文档化的隐式行为。 await notifications.setNotificationChannelAsync('timeflow-reminders', { name: '日程提醒', importance: notifications.AndroidImportance.DEFAULT, vibrationPattern: [0, 180], lightColor: '#D7F36A', + sound: 'default', }); } diff --git a/frontend/src/infrastructure/location/reminderGuardTask.ts b/frontend/src/infrastructure/location/reminderGuardTask.ts new file mode 100644 index 00000000..506cb8c8 --- /dev/null +++ b/frontend/src/infrastructure/location/reminderGuardTask.ts @@ -0,0 +1,447 @@ +import * as Location from 'expo-location'; +import type { SQLiteDatabase } from 'expo-sqlite'; +import * as TaskManager from 'expo-task-manager'; + +import { + evaluateGeofence, + resolveGeofenceCenter, + resolveGuardPollIntervalMs, + resolveWatchMode, +} from '../../features/reminder/domain/geofence'; +import { resolveStrengthDeliveryPlan } from '../../features/reminder/domain/strengthDelivery'; +import { resolveEffectiveTriggerAt } from '../../features/reminder/domain/timeWindow'; +import type { + GeoPoint, + LocalReminderSchedule, + ReminderStrength, +} from '../../features/reminder/domain'; + +export const GUARD_TASK_NAME = 'timeflow-reminder-guard'; +const TIMEFLOW_DATABASE_NAME = 'timeflow.db'; +/** 卡在 pending 超过这么久还没被确认/延后,当成"响了但没送达"重新弹一次。 */ +const STUCK_PENDING_THRESHOLD_MS = 2 * 60_000; + +export type GuardTaskSample = { + latitude: number; + longitude: number; + accuracy_meters: number; + observed_at: string; +}; + +type GuardTaskListener = (sample: GuardTaskSample) => unknown; +const listeners = new Set(); + +/** 订阅常驻前台服务的位置心跳;须在应用入口尽早 import 本模块以完成 defineTask。 */ +export function subscribeGuardTaskEvents(listener: GuardTaskListener): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** 懒加载,参照 geofenceTask.ts 同名函数:避免顶层 import 在测试环境直接抛错。 */ +async function loadStorageModules(): Promise<{ + openDatabaseAsync: typeof import('expo-sqlite').openDatabaseAsync; +} | null> { + try { + const sqlite = await import('expo-sqlite'); + // istanbul ignore next -- unreachable in this Jest env: the import above always throws + // first (no --experimental-vm-modules), so this line never runs. + return { openDatabaseAsync: sqlite.openDatabaseAsync }; + } catch { + return null; + } +} + +async function openDatabase(): Promise { + const modules = await loadStorageModules(); + if (modules == null) return null; + try { + // istanbul ignore next -- same as above, unreachable without a real expo-sqlite. + return await modules.openDatabaseAsync(TIMEFLOW_DATABASE_NAME); + } catch { + return null; + } +} + +if (!TaskManager.isTaskDefined(GUARD_TASK_NAME)) { + TaskManager.defineTask(GUARD_TASK_NAME, async ({ data, error }) => { + if (error) { + console.warn('[guard] task reported error', error); + return; + } + + const payload = data as { locations?: Location.LocationObject[] } | undefined; + const location = payload?.locations?.[payload.locations.length - 1]; + const sample: GuardTaskSample | null = + location == null + ? null + : { + latitude: location.coords.latitude, + longitude: location.coords.longitude, + accuracy_meters: location.coords.accuracy ?? 0, + observed_at: new Date(location.timestamp).toISOString(), + }; + + if (sample == null) { + console.warn('[guard] task woken with no location payload', payload); + } else if (listeners.size === 0) { + console.warn('[guard] no live listeners, taking headless location pass', sample); + await runHeadlessLocationPass(sample); + } else { + console.warn('[guard] dispatching sample to', listeners.size, 'live listener(s)'); + for (const listener of listeners) { + await listener(sample); + } + } + + // 时间型兜底 + 卡住扫描:跟"喂位置样本"是两件独立的事,不管这次唤醒有没有 + // 拿到有效定位、不管会话是否存活,每次唤醒都要做——这两件事全靠直接查 + // SQLite + 原生桥接,不依赖 LocalReminderApplication 的内存状态,headless + // 上下文里也能跑。 + await runTimeFallbackPass(); + await runStuckPendingPass(); + }); +} + +/** 跟 SqliteLocalScheduleReader.ts 的 DEFAULT_GEOFENCE_RADIUS_METERS 保持一致—— + * 半径目前是应用级默认值,不是逐条日程可配的列,local_schedules 表里没有这一列。 */ +const DEFAULT_GEOFENCE_RADIUS_METERS = 200; + +type HeadlessLocationRow = { + id: string; + title: string; + latitude: number | null; + longitude: number | null; + reminder_type: string | null; + reminder_strength: 'low' | 'medium' | 'high' | null; + location_name: string | null; + reminder_disposition_state: string | null; + snoozed_until: string | null; + geofence_armed: number; +}; + +/** + * headless(没有存活会话)时的地点提醒判定:拿这次位置样本跟所有正在监听的 + * 地点提醒逐一比对,复用跟前台一致的 evaluateGeofence() 状态机,不是重新发明 + * 一套简化版判断逻辑。 + */ +async function runHeadlessLocationPass(sample: GuardTaskSample): Promise { + const database = await openDatabase(); + if (database == null) return; + // istanbul ignore next -- database only non-null with a real expo-sqlite, unreachable here. + { + const rows = await database.getAllAsync( + `SELECT id, title, latitude, longitude, reminder_type, + reminder_strength, location_name, reminder_disposition_state, snoozed_until, + geofence_armed + FROM local_schedules + WHERE schedule_type = 'location' + AND status = 'active' + AND (reminder_disposition_state IS NULL OR reminder_disposition_state = 'snoozed')`, + ); + + for (const row of rows) { + if ( + row.reminder_disposition_state === 'snoozed' && + row.snoozed_until != null && + Date.parse(row.snoozed_until) > Date.parse(sample.observed_at) + ) { + continue; + } + + const schedule = toPartialLocationSchedule(row); + const mode = resolveWatchMode(schedule); + const center = resolveGeofenceCenter(schedule, mode); + if (center == null) continue; + + const transition = evaluateGeofence(schedule, sample, mode); + if (transition === 'armed') { + await database.runAsync( + `UPDATE local_schedules SET geofence_armed = 1 WHERE id = ? AND geofence_armed = 0`, + row.id, + ); + continue; + } + if (transition !== 'triggered') continue; + + // 先消耗边沿再送达,失败也不恢复 armed——跟 geofenceTask.ts 的既有约定一致。 + await database.runAsync(`UPDATE local_schedules SET geofence_armed = 0 WHERE id = ?`, row.id); + await presentOrNotify( + row.id, + row.title || '日程提醒', + row.reminder_strength ?? 'medium', + row.reminder_type === 'return_to_recorded_location' + ? `您已回到${row.location_name ?? '记录地点'}附近,请及时处理。` + : `您已进入${row.location_name ?? '目标地点'}附近,请及时处理。`, + ); + await database.runAsync( + `UPDATE local_schedules + SET reminder_disposition_state = 'pending', + next_trigger_at = NULL, + disposition_updated_at = ?, + sync_status = 'pending' + WHERE id = ? + AND (reminder_disposition_state IS NULL OR reminder_disposition_state = 'snoozed')`, + sample.observed_at, + row.id, + ); + } + } +} + +function toPartialLocationSchedule(row: HeadlessLocationRow): LocalReminderSchedule { + return { + id: row.id, + account_id: '', + title: row.title, + schedule_type: 'location', + schedule_kind: 'once', + is_all_day: false, + start_time: null, + end_time: null, + timezone: 'UTC', + recurrence_rule: null, + location_name: row.location_name, + latitude: row.latitude, + longitude: row.longitude, + geofence_radius_meters: DEFAULT_GEOFENCE_RADIUS_METERS, + reminder: { + reminder_type: (row.reminder_type ?? 'arrive_location') as never, + reminder_trigger_at: null, + reminder_offset_minutes: null, + reminder_strength: row.reminder_strength ?? 'medium', + }, + runtime: { + reminder_disposition_state: null, + next_trigger_at: null, + snoozed_until: row.snoozed_until, + geofence_armed: row.geofence_armed === 1, + disposition_updated_at: null, + sync_status: 'pending', + // 跟 SqliteLocalScheduleReader.ts/SqliteReminderStateStore.ts 现状一致: + // recorded_location 目前没有持久化到 local_schedules,读出来恒为 null—— + // return_to_recorded_location 模式在这条 headless 直查路径下暂时判不了, + // resolveGeofenceCenter() 会因为拿不到中心点直接跳过,不是这次改动引入的新缺口。 + recorded_location: null, + }, + status: 'active', + revision: 0, + cloud_revision: 0, + updated_at: '', + }; +} + +type HeadlessTimeRow = { + id: string; + title: string; + schedule_kind: string; + reminder_type: string | null; + reminder_trigger_at: string | null; + reminder_offset_minutes: number | null; + reminder_strength: 'low' | 'medium' | 'high' | null; + reminder_disposition_state: string | null; + snoozed_until: string | null; + next_trigger_at: string | null; + start_time: string | null; +}; + +/** + * 时间型兜底:原生闹钟当初没能挂上(比如精确闹钟权限缺失)的日程,靠这个 + * 常驻任务顶上——每次醒来看一遍有没有该展示但原生没接管的,直接 presentNow()。 + * "原生有没有接管"查的是 AlarmScheduler 持久化的挂钟列表(hasArmedAlarm), + * 不是 JS 内存里的 registrations——这个任务可能跑在独立/headless 上下文, + * 拿不到那份内存状态。 + */ +async function runTimeFallbackPass(): Promise { + const database = await openDatabase(); + if (database == null) return; + // istanbul ignore next -- unreachable without a real expo-sqlite. + { + const rows = await database.getAllAsync( + `SELECT id, title, schedule_kind, reminder_type, reminder_trigger_at, + reminder_offset_minutes, reminder_strength, reminder_disposition_state, + snoozed_until, next_trigger_at, start_time + FROM local_schedules + WHERE schedule_type = 'time' + AND status = 'active' + AND (reminder_disposition_state IS NULL OR reminder_disposition_state = 'snoozed')`, + ); + if (rows.length === 0) return; + + const bridge = await import('../notifications/native/TimeflowAlarmBridge'); + const nowIso = new Date().toISOString(); + for (const row of rows) { + const triggerAt = resolveEffectiveTriggerAt(toPartialTimeSchedule(row)); + if (triggerAt == null || Date.parse(nowIso) < Date.parse(triggerAt)) continue; + + const armed = await bridge.nativeHasArmedAlarm(row.id); + if (armed) continue; + + await presentOrNotify( + row.id, + row.title || '日程提醒', + row.reminder_strength ?? 'medium', + null, + ); + await database.runAsync( + `UPDATE local_schedules + SET reminder_disposition_state = 'pending', + next_trigger_at = NULL, + disposition_updated_at = ?, + sync_status = 'pending' + WHERE id = ? + AND (reminder_disposition_state IS NULL OR reminder_disposition_state = 'snoozed')`, + nowIso, + row.id, + ); + } + } +} + +function toPartialTimeSchedule(row: HeadlessTimeRow): LocalReminderSchedule { + return { + id: row.id, + account_id: '', + title: row.title, + schedule_type: 'time', + schedule_kind: row.schedule_kind === 'recurring' ? 'recurring' : 'once', + is_all_day: false, + start_time: row.start_time, + end_time: null, + timezone: 'UTC', + recurrence_rule: null, + location_name: null, + latitude: null, + longitude: null, + geofence_radius_meters: 0, + reminder: { + reminder_type: (row.reminder_type ?? 'at_time') as never, + reminder_trigger_at: row.reminder_trigger_at, + reminder_offset_minutes: row.reminder_offset_minutes, + reminder_strength: row.reminder_strength ?? 'medium', + }, + runtime: { + reminder_disposition_state: row.reminder_disposition_state === 'snoozed' ? 'snoozed' : null, + next_trigger_at: row.next_trigger_at, + snoozed_until: row.snoozed_until, + geofence_armed: false, + disposition_updated_at: null, + sync_status: 'pending', + recorded_location: null, + }, + status: 'active', + revision: 0, + cloud_revision: 0, + updated_at: '', + }; +} + +type StuckPendingRow = { + id: string; + title: string; + reminder_strength: 'low' | 'medium' | 'high' | null; + disposition_updated_at: string | null; +}; + +/** + * ③④⑤ 那类"原生响了、notifyFired 也到了,但用户一直没确认"的安全网:disposition + * 一直卡在 pending 超过阈值,大概率是响铃页被 OEM 拦了或者被前一条挤进队列后 + * 没人记得回来处理——重新弹一次 presentNow(),给它一次补救机会。 + */ +async function runStuckPendingPass(): Promise { + const database = await openDatabase(); + if (database == null) return; + // istanbul ignore next -- unreachable without a real expo-sqlite. + { + const rows = await database.getAllAsync( + `SELECT id, title, reminder_strength, disposition_updated_at + FROM local_schedules + WHERE status = 'active' + AND reminder_disposition_state = 'pending'`, + ); + const nowMs = Date.now(); + for (const row of rows) { + const updatedMs = + row.disposition_updated_at == null ? null : Date.parse(row.disposition_updated_at); + if (updatedMs == null || Number.isNaN(updatedMs)) continue; + if (nowMs - updatedMs < STUCK_PENDING_THRESHOLD_MS) continue; + await presentOrNotify( + row.id, + row.title || '日程提醒', + row.reminder_strength ?? 'medium', + null, + ); + } + } +} + +/** 优先走原生全屏响铃页;presentNow 不可用/失败时退回普通系统通知。 */ +async function presentOrNotify( + scheduleId: string, + title: string, + strength: ReminderStrength, + fallbackBody: string | null, +): Promise { + const plan = resolveStrengthDeliveryPlan(strength); + try { + const bridge = await import('../notifications/native/TimeflowAlarmBridge'); + const presented = await bridge.nativePresentAlarmNow( + `guard-${scheduleId}-${Date.now()}`, + scheduleId, + title, + plan.useVibration, + plan.alarmSoundTier, + true, + ); + if (presented) return; + } catch { + // 走下面的系统通知兜底。 + } + + try { + const notifications = await import('expo-notifications'); + await notifications.setNotificationChannelAsync('timeflow-reminders', { + name: '日程提醒', + importance: notifications.AndroidImportance.DEFAULT, + vibrationPattern: [0, 180], + lightColor: '#D7F36A', + sound: 'default', + }); + await notifications.scheduleNotificationAsync({ + identifier: `reminder-${scheduleId}`, + content: { + title, + body: fallbackBody ?? '已到提醒时间,请及时处理。', + sound: 'default', + data: { schedule_id: scheduleId }, + }, + trigger: { channelId: 'timeflow-reminders' }, + }); + } catch (error) { + console.warn('[guard] presentOrNotify fallback notification failed', error); + } +} + +/** + * 根据当前正在监听的地点提醒目标,算出下一次该用多密的轮询间隔重新注册。 + * 没有任何地点提醒时传 Infinity,落到最疏间隔——纯粹当时间型兜底的心跳用。 + */ +export function resolveNextPollIntervalMs( + currentSample: GeoPoint | null, + targets: readonly GeoPoint[], +): number { + if (currentSample == null || targets.length === 0) { + return resolveGuardPollIntervalMs(Number.POSITIVE_INFINITY); + } + let nearest = Number.POSITIVE_INFINITY; + for (const target of targets) { + const dLat = target.latitude - currentSample.latitude; + const dLng = target.longitude - currentSample.longitude; + // 粗略估算就够用(只用来决定轮询密度,不用来判定进出圈),省得为了选轮询 + // 间隔又跑一遍 Haversine——真正的进出圈判定始终走 evaluateGeofence()。 + const approxMeters = Math.sqrt(dLat * dLat + dLng * dLng) * 111_000; + if (approxMeters < nearest) nearest = approxMeters; + } + return resolveGuardPollIntervalMs(nearest); +} diff --git a/frontend/src/infrastructure/notifications/ExpoSystemNotification.ts b/frontend/src/infrastructure/notifications/ExpoSystemNotification.ts index c72d1265..77b9f2a0 100644 --- a/frontend/src/infrastructure/notifications/ExpoSystemNotification.ts +++ b/frontend/src/infrastructure/notifications/ExpoSystemNotification.ts @@ -22,11 +22,16 @@ async function ensureAndroidChannel(Notifications: NotificationsModule): Promise return; } channelReady = (async () => { - await Notifications.setNotificationChannelAsync('timeflow-reminders', { - name: '日程提醒', + // 独立渠道 ID,不跟 geofenceTask.ts 的 'timeflow-reminders'(故意要出声)共用—— + // Android 8+ 声音是渠道级别、创建后不可变,两边共用一个渠道 ID 会导致谁先创建 + // 谁的声音设置就永久生效,另一边的 sound 参数全部失效。这个渠道要保持静音, + // 所以 sound 在这里就必须显式传 null,不能留给默认值。 + await Notifications.setNotificationChannelAsync('timeflow-reminders-quiet', { + name: '日程提醒(静音)', importance: Notifications.AndroidImportance.DEFAULT, vibrationPattern: [0, 180], lightColor: '#D7F36A', + sound: null, }); })().catch((error) => { // 失败别永久缓存住:清掉 channelReady,让下一条提醒重试,而不是这次失败 @@ -67,7 +72,7 @@ export class ExpoSystemNotification implements SystemNotificationPort { body: request.body, sound: false, }, - trigger: null, + trigger: { channelId: 'timeflow-reminders-quiet' }, }); return { notification_id: request.notification_id, shown: true }; diff --git a/frontend/src/infrastructure/notifications/NativeAlarmScheduler.ts b/frontend/src/infrastructure/notifications/NativeAlarmScheduler.ts index 1faff116..7169246c 100644 --- a/frontend/src/infrastructure/notifications/NativeAlarmScheduler.ts +++ b/frontend/src/infrastructure/notifications/NativeAlarmScheduler.ts @@ -1,6 +1,8 @@ import type { AlarmNativeDisposition, AlarmNativeEvent, + AlarmPresentationReceipt, + AlarmPresentationRequest, AlarmScheduleReceipt, AlarmScheduleRequest, AlarmSchedulerPort, @@ -12,6 +14,7 @@ import { nativeCancelAlarm, nativeCancelAllAlarms, nativePeekAlarmDispositions, + nativePresentAlarmNow, nativeScheduleAlarm, nativeStopAlarmRinging, subscribeNativeAlarmEvents, @@ -19,6 +22,23 @@ import { /** Android TimeflowAlarm 适配器;无法挂上时返回 scheduled=false。 */ export class NativeAlarmScheduler implements AlarmSchedulerPort { + async presentNow(request: AlarmPresentationRequest): Promise { + const alarmId = request.alarm_id || `geofence-${request.schedule_id}-${Date.now()}`; + const presented = await nativePresentAlarmNow( + alarmId, + request.schedule_id, + request.title, + request.vibrate, + request.sound_tier, + request.full_screen, + ); + return { + alarm_id: alarmId, + schedule_id: request.schedule_id, + presented, + }; + } + async schedule(request: AlarmScheduleRequest): Promise { if (!isTimeflowAlarmAvailable()) { return unscheduled(request.schedule_id); @@ -39,7 +59,7 @@ export class NativeAlarmScheduler implements AlarmSchedulerPort { request.title, request.schedule_id, request.vibrate, - request.sound, + request.sound_tier, request.full_screen, request.speech_text, ); diff --git a/frontend/src/infrastructure/notifications/index.ts b/frontend/src/infrastructure/notifications/index.ts index ec8a77d6..3064c3fa 100644 --- a/frontend/src/infrastructure/notifications/index.ts +++ b/frontend/src/infrastructure/notifications/index.ts @@ -13,8 +13,10 @@ export { nativeCancelAlarm, nativeCancelAllAlarms, nativeGetAlarmPermissionStatus, + nativeHasArmedAlarm, nativeOpenAlarmPermissionSettings, nativePeekAlarmDispositions, + nativePresentAlarmNow, nativeRequestNotificationPermission, nativeScheduleAlarm, nativeStopAlarmRinging, diff --git a/frontend/src/infrastructure/notifications/native/TimeflowAlarmBridge.ts b/frontend/src/infrastructure/notifications/native/TimeflowAlarmBridge.ts index d84a5b56..6722a91a 100644 --- a/frontend/src/infrastructure/notifications/native/TimeflowAlarmBridge.ts +++ b/frontend/src/infrastructure/notifications/native/TimeflowAlarmBridge.ts @@ -1,5 +1,7 @@ import { NativeEventEmitter, NativeModules, Platform } from 'react-native'; +import type { AlarmSoundTier } from '../../../features/reminder/domain/strengthDelivery'; + export type NativeAlarmPermissionStatus = { exactAlarm: boolean; overlay: boolean; @@ -35,13 +37,22 @@ type TimeflowAlarmNative = { title?: string | null, scheduleId?: string | null, vibrate?: boolean, - sound?: boolean, + soundTier?: AlarmSoundTier, fullScreen?: boolean, speechText?: string | null, ) => Promise<{ alarmId: string; scheduleId?: string }>; cancel: (alarmId: string) => Promise; cancelAll: () => Promise; stopRinging: () => Promise; + presentNow: ( + alarmId: string, + scheduleId: string, + title: string, + vibrate: boolean, + soundTier: AlarmSoundTier, + fullScreen: boolean, + ) => Promise; + hasArmedAlarm: (scheduleId: string) => Promise; peekNativeDispositions: () => Promise; ackNativeDispositions: (scheduleIds: string[]) => Promise; getPermissionStatus: () => Promise; @@ -69,7 +80,7 @@ export async function nativeScheduleAlarm( title: string, scheduleId?: string, vibrate?: boolean, - sound?: boolean, + soundTier?: AlarmSoundTier, fullScreen?: boolean, speechText?: string, ): Promise { @@ -81,7 +92,7 @@ export async function nativeScheduleAlarm( title, scheduleId ?? '', vibrate ?? true, - sound ?? true, + soundTier ?? 'full', fullScreen ?? true, speechText ?? '', ); @@ -124,6 +135,34 @@ export async function nativeStopAlarmRinging(): Promise { } } +export async function nativePresentAlarmNow( + alarmId: string, + scheduleId: string, + title: string, + vibrate: boolean, + soundTier: AlarmSoundTier, + fullScreen: boolean, +): Promise { + const native = getNativeAlarm(); + if (!isTimeflowAlarmAvailable() || native == null) return false; + try { + return await native.presentNow(alarmId, scheduleId, title, vibrate, soundTier, fullScreen); + } catch (error) { + console.warn('[TimeflowAlarm] nativePresentAlarmNow failed', error); + return false; + } +} + +export async function nativeHasArmedAlarm(scheduleId: string): Promise { + const native = getNativeAlarm(); + if (!isTimeflowAlarmAvailable() || native == null || !scheduleId) return false; + try { + return await native.hasArmedAlarm(scheduleId); + } catch { + return false; + } +} + export async function nativePeekAlarmDispositions(): Promise { const native = getNativeAlarm(); if (!isTimeflowAlarmAvailable() || native == null) return []; diff --git a/frontend/tests/unit/app/createAppServices.test.ts b/frontend/tests/unit/app/createAppServices.test.ts index 00e351b1..8610a938 100644 --- a/frontend/tests/unit/app/createAppServices.test.ts +++ b/frontend/tests/unit/app/createAppServices.test.ts @@ -6,8 +6,20 @@ import { SqliteLocalScheduleReader, SqliteReminderStateStore, } from '../../../src/features/reminder'; +import { subscribeGuardTaskEvents } from '../../../src/infrastructure/location/reminderGuardTask'; import { FakeAuthSessionStore } from '../../fakes/FakeAuthSessionStore'; +jest.mock('../../../src/infrastructure/location/reminderGuardTask', () => { + const actual = jest.requireActual< + typeof import('../../../src/infrastructure/location/reminderGuardTask') + >('../../../src/infrastructure/location/reminderGuardTask'); + return { + GUARD_TASK_NAME: actual.GUARD_TASK_NAME, + subscribeGuardTaskEvents: jest.fn(() => () => {}), + resolveNextPollIntervalMs: actual.resolveNextPollIntervalMs, + }; +}); + describe('createAppServices', () => { it('exposes authenticated transports and registers production account cleaners', async () => { const services = createAppServices({ @@ -45,6 +57,51 @@ describe('createAppServices', () => { // 不是一个 start()/handleTime() 全是空操作的桩。 expect(startTimeListener).toHaveBeenCalledTimes(1); }); + + it('routes guard samples through the coordinator into reminder.handleLocation', async () => { + type Sample = { + latitude: number; + longitude: number; + accuracy_meters: number; + observed_at: string; + }; + let sampleListener: ((sample: Sample) => void) | undefined; + const subscribe = subscribeGuardTaskEvents as jest.MockedFunction< + typeof subscribeGuardTaskEvents + >; + subscribe.mockImplementation((listener) => { + sampleListener = listener as typeof sampleListener; + return () => {}; + }); + + const services = createAppServices({ + auth: { + fetch: createSuccessfulAuthFetch() as typeof global.fetch, + store: new FakeAuthSessionStore(), + }, + }); + const handleLocation = jest + .spyOn(services.reminder, 'handleLocation') + .mockResolvedValue(undefined); + await services.runtime.start(); + + const sample: Sample = { + latitude: 31.2304, + longitude: 121.4737, + accuracy_meters: 10, + observed_at: '2026-08-18T10:00:00.000Z', + }; + await sampleListener?.(sample); + // handleSample 是 fire-and-forget(void handleSample),松开微任务让它跑到 + // handleLocation 那次调用。 + for (let i = 0; i < 8; i += 1) { + await Promise.resolve(); + } + + expect(handleLocation).toHaveBeenCalledWith(sample); + handleLocation.mockRestore(); + await services.runtime.stop(); + }); }); function createSuccessfulAuthFetch() { diff --git a/frontend/tests/unit/features/reminder/application/LocalReminderApplication.test.ts b/frontend/tests/unit/features/reminder/application/LocalReminderApplication.test.ts index d615675d..1c9eb6fb 100644 --- a/frontend/tests/unit/features/reminder/application/LocalReminderApplication.test.ts +++ b/frontend/tests/unit/features/reminder/application/LocalReminderApplication.test.ts @@ -383,8 +383,8 @@ describe('LocalReminderApplication', () => { const deps = createDeps({ schedules: new FakeScheduleReader([schedule]) }); const app = new LocalReminderApplication(deps); // start() 自己的 rebuildInternal() 会把 reader 里这条 time 类型日程注册 - // 上原生闹钟,之后 popup/audio 就会因为"原生已经托管响铃 UI"被跳过—— - // 用不受这条影响的 vibration 通道来判断到底响了几次更直接。 + // 上原生闹钟。deliver() 走的是公开 API,不经过 runHandleTime() 的跳过判定, + // 所以这里仍然用它来验证 deliverLocks/activeDeliveries 这层互斥锁本身没坏。 await app.start(); // deliver() 内部 runDeliver() 在第一个 await 之前就同步把 schedule_id 加进 @@ -404,6 +404,60 @@ describe('LocalReminderApplication', () => { expect(deps.vibration.vibrate).toHaveBeenCalledTimes(1); }); + it('runHandleTime() skips a time-type schedule whose native alarm is already armed', async () => { + // 核心的去竞态修复:原生闹钟已经接管的日程,JS 30s 轮询完全不判定、不 + // 投递——哪怕 isTimeWindowReached() 本身会判 true,也不应该走到这一步。 + // 这堵住了"JS tick 抢先送达、顺手把更可靠的原生闹钟撤销掉"这条路。 + const schedule = fixtureSchedule({ + id: 's1', + reminder: { + reminder_type: 'at_time', + reminder_trigger_at: '2026-08-18T10:00:00.000Z', + reminder_offset_minutes: null, + reminder_strength: 'high', + }, + }); + const deps = createDeps({ schedules: new FakeScheduleReader([schedule]) }); + const app = new LocalReminderApplication(deps); + await app.start(); + + await app.handleTime({ observed_at: '2026-08-18T10:30:00.000Z' }); + + expect(deps.vibration.vibrate).not.toHaveBeenCalled(); + expect(deps.presenter.show).not.toHaveBeenCalled(); + expect(deps.systemNotification.show).not.toHaveBeenCalled(); + // 没被投递过,运行时状态压根没落盘。 + await expect(deps.state.read('s1')).resolves.toBeNull(); + }); + + it('runHandleTime() still delivers a time-type schedule whose native alarm never armed', async () => { + // 反过来验证:原生闹钟没挂上(比如权限缺失)的日程,JS 轮询依然是唯一 + // 兜底,不能被上面那条跳过判定误伤。 + const schedule = fixtureSchedule({ + id: 's1', + reminder: { + reminder_type: 'at_time', + reminder_trigger_at: '2026-08-18T10:00:00.000Z', + reminder_offset_minutes: null, + reminder_strength: 'high', + }, + }); + const { alarms } = createFakeAlarms({ + schedule: jest.fn(async (request: AlarmScheduleRequest) => ({ + alarm_id: '', + schedule_id: request.schedule_id, + scheduled: false, + })), + }); + const deps = createDeps({ alarms, schedules: new FakeScheduleReader([schedule]) }); + const app = new LocalReminderApplication(deps); + await app.start(); + + await app.handleTime({ observed_at: '2026-08-18T10:30:00.000Z' }); + + expect(deps.vibration.vibrate).toHaveBeenCalledTimes(1); + }); + it('skips a schedule already marked pending/confirmed when a duplicate native fire arrives', async () => { const schedule = fixtureSchedule({ id: 's1', @@ -581,6 +635,95 @@ describe('LocalReminderApplication', () => { reminder_disposition_state: null, }); }); + + it('start() bails out early when stop() races in right after recovery.registerForRestart()', async () => { + // startInternal() 在每个 await 之后都重新检查 isLive(generation)——覆盖 + // registerForRestart() 之后这第一处检查:模拟调用方在这一步就并发调了 stop()。 + const schedule = fixtureSchedule({ id: 's1' }); + const deps = createDeps({ + schedules: new FakeScheduleReader([schedule]), + recovery: { + registerForRestart: jest.fn(async () => { + void app.stop(); + return { registered: true, recovery_id: 'r1' }; + }), + restoreAfterRestart: jest.fn(async () => ({ registered: true, recovery_id: 'r1' })), + }, + }); + const app = new LocalReminderApplication(deps); + + await app.start(); + + expect(deps.time.start).not.toHaveBeenCalled(); + }); + + it('rebuildInternal() discards freshly-scheduled alarms/registrations when stop() races in during alarms.rebuild()', async () => { + // rebuildInternal() 在 alarms.rebuild() 返回之后也要重新检查 isLive(generation): + // 模拟 stop() 恰好在这次批量排闹钟期间被调用,验证它会撤销掉刚拿到的原生 + // 闹钟、清空 registrations,而不是把一批"属于已失效世代"的注册留下来。 + const schedule = fixtureSchedule({ id: 's1' }); + const { alarms, cancelCalls } = createFakeAlarms({ + rebuild: jest.fn(async (requests: readonly AlarmScheduleRequest[]) => { + void app.stop(); + return requests.map((request, index) => ({ + alarm_id: `alarm-${index + 1}`, + schedule_id: request.schedule_id, + scheduled: true, + })); + }), + }); + const deps = createDeps({ alarms, schedules: new FakeScheduleReader([schedule]) }); + const app = new LocalReminderApplication(deps); + + await app.start(); + + expect(cancelCalls).toContain('alarm-1'); + }); + + it('presenter/time callbacks registered at start route into confirm/snooze and handleTime', async () => { + // onAction 和 time.start 的回调都是 fire-and-forget(void ...),要真的调用 + // 一次才会跑到 handlePresentationAction/handleTime 内部那几行。 + const schedule = fixtureSchedule({ id: 's1' }); + const presenterActionRef: { + current: ((event: { schedule_id: string; action: 'confirm' | 'snooze' }) => void) | null; + } = { current: null }; + const timeTickRef: { current: ((tick: { observed_at: string }) => void) | null } = { + current: null, + }; + const deps = createDeps({ + schedules: new FakeScheduleReader([schedule]), + presenter: { + hide: jest.fn(async () => {}), + onAction: jest.fn( + (listener: (event: { schedule_id: string; action: 'confirm' | 'snooze' }) => void) => { + presenterActionRef.current = listener; + return () => { + presenterActionRef.current = null; + }; + }, + ), + show: jest.fn(async () => ({ presentation_id: 'p1', visible: true })), + }, + time: { + start: jest.fn(async (listener: (tick: { observed_at: string }) => void) => { + timeTickRef.current = listener; + return { listener_id: 'time-1' }; + }), + stop: jest.fn(async () => {}), + }, + }); + const app = new LocalReminderApplication(deps); + await app.start(); + + presenterActionRef.current?.({ schedule_id: 's1', action: 'confirm' }); + await flushAsync(); + await expect(deps.state.read('s1')).resolves.toMatchObject({ + reminder_disposition_state: 'confirmed', + }); + + timeTickRef.current?.({ observed_at: '2026-08-18T10:30:00.000Z' }); + await flushAsync(); + }); }); describe('recurring reminder advancement', () => { @@ -730,6 +873,161 @@ describe('LocalReminderApplication', () => { expect(deps.presenter.show).not.toHaveBeenCalled(); }); + + it('routes a watch() callback event through runLocationMonitorEvent to arm the geofence', async () => { + // watchLocationSchedule() 挂给 location.watch() 的回调是 + // handleLocationMonitorEvent → runLocationMonitorEvent,走的是跟 rebuild() + // 批量重建不同的单条注册路径——直接调用一次这个回调,覆盖它自己的守卫分支 + // (已注册、能拿到日程、类型是 location)。 + const schedule = fixtureLocationSchedule({ id: 's1' }); + const deps = createDeps({ schedules: new FakeScheduleReader([schedule]) }); + let watchListener: + | ((event: { + schedule_id: string; + phase: string; + sample: { + latitude: number; + longitude: number; + accuracy_meters: number; + observed_at: string; + }; + }) => Promise) + | undefined; + deps.location.watch = jest.fn(async (request: LocationWatchRequest, listener) => { + watchListener = listener as typeof watchListener; + return { listener_id: `loc-${request.schedule_id}`, schedule_id: request.schedule_id }; + }); + const app = new LocalReminderApplication(deps); + await app.start(); + await app.register(schedule); + + await watchListener?.({ + schedule_id: 's1', + phase: 'inside', + sample: { + latitude: 40, + longitude: 121.4737, + accuracy_meters: 10, + observed_at: '2026-08-18T10:00:00.000Z', + }, + }); + + await expect(deps.state.read('s1')).resolves.toMatchObject({ geofence_armed: true }); + }); + + it('ignores a watch() callback for a schedule that has already been dropped from registrations', async () => { + const schedule = fixtureLocationSchedule({ id: 's1' }); + const deps = createDeps({ schedules: new FakeScheduleReader([schedule]) }); + let watchListener: + | ((event: { + schedule_id: string; + phase: string; + sample: { + latitude: number; + longitude: number; + accuracy_meters: number; + observed_at: string; + }; + }) => Promise) + | undefined; + deps.location.watch = jest.fn(async (request: LocationWatchRequest, listener) => { + watchListener = listener as typeof watchListener; + return { listener_id: `loc-${request.schedule_id}`, schedule_id: request.schedule_id }; + }); + const app = new LocalReminderApplication(deps); + await app.start(); + await app.register(schedule); + await app.confirm('s1', '2026-08-18T10:00:00.000Z'); + + await expect( + watchListener?.({ + schedule_id: 's1', + phase: 'inside', + sample: { + latitude: 40, + longitude: 121.4737, + accuracy_meters: 10, + observed_at: '2026-08-18T10:00:00.000Z', + }, + }), + ).resolves.toBeUndefined(); + }); + + it('drops a registration whose location.unwatch() call fails, without throwing', async () => { + const schedule = fixtureLocationSchedule({ id: 's1' }); + const deps = createDeps({ schedules: new FakeScheduleReader([schedule]) }); + deps.location.unwatch = jest.fn(async () => { + throw new Error('unwatch failed'); + }); + const app = new LocalReminderApplication(deps); + await app.start(); + const registration = await app.register(schedule); + expect(registration.location_listener_id).not.toBeNull(); + + await expect(app.confirm('s1', '2026-08-18T10:00:00.000Z')).resolves.toMatchObject({ + accepted: true, + }); + }); + }); + + describe('runDeliver edge receipts', () => { + it('returns an inflight receipt without re-delivering when the same schedule is already locked', async () => { + const schedule = fixtureSchedule({ id: 's1' }); + const deps = createDeps({ schedules: new FakeScheduleReader([schedule]) }); + const app = new LocalReminderApplication(deps); + await app.start(); + + const trigger = { + reminder_id: 'r1', + schedule_id: 's1', + reason: 'at_time' as const, + triggered_at: '2026-08-18T10:00:00.000Z', + }; + const first = app.deliver(trigger); + const second = await app.deliver(trigger); + + expect(second.delivery_id).toBe('inflight-s1'); + expect(second.channels).toEqual([]); + await first; + }); + + it('returns a missing receipt when the schedule cannot be found by id', async () => { + const deps = createDeps({ schedules: new FakeScheduleReader([]) }); + const app = new LocalReminderApplication(deps); + await app.start(); + + const receipt = await app.deliver({ + reminder_id: 'r1', + schedule_id: 'ghost', + reason: 'at_time', + triggered_at: '2026-08-18T10:00:00.000Z', + }); + + expect(receipt).toMatchObject({ delivery_id: 'missing-ghost', channels: [] }); + }); + + it('rolls back runtime and tears down delivery when a channel dependency throws', async () => { + const schedule = fixtureSchedule({ id: 's1' }); + const deps = createDeps({ schedules: new FakeScheduleReader([schedule]) }); + deps.vibration.vibrate = jest.fn(async () => { + throw new Error('vibrate failed'); + }); + const app = new LocalReminderApplication(deps); + await app.start(); + + await expect( + app.deliver({ + reminder_id: 'r1', + schedule_id: 's1', + reason: 'at_time', + triggered_at: '2026-08-18T10:00:00.000Z', + }), + ).rejects.toThrow('vibrate failed'); + + await expect(deps.state.read('s1')).resolves.toMatchObject({ + reminder_disposition_state: null, + }); + }); }); describe('delivery channels by strength', () => { @@ -763,9 +1061,9 @@ describe('LocalReminderApplication', () => { }); it('high strength: popup + vibration + tts, falls back to local audio when tts fails', async () => { - // 用 location 类型:time 类型经过 start() 内部 rebuild 会自动挂上原生闹钟, - // 一旦 nativeAlarmOwnsRingUi 为真,popup/audio 都会被跳过——这里就是要 - // 验证这两个通道本身,用不会触发这条豁免的日程类型。 + // 默认的 fake alarms 不实现 presentNow,所以不管日程类型,runDeliver() + // 都会跳过"原生全屏页优先"这一步、直接走下面这套 JS 强度通道——用 location + // 类型只是顺手避开 time 类型经 start() 自动挂原生闹钟这件事本身没有影响。 const schedule = fixtureSchedule({ id: 's1', schedule_type: 'location', @@ -803,15 +1101,134 @@ describe('LocalReminderApplication', () => { }); }); + describe('native full-screen presentNow takes priority over JS channels', () => { + it('time-type: uses presentNow when the native alarm never got scheduled', async () => { + const schedule = fixtureSchedule({ + id: 's1', + reminder: { + reminder_type: 'at_time', + reminder_trigger_at: '2026-08-18T10:00:00.000Z', + reminder_offset_minutes: null, + reminder_strength: 'high', + }, + }); + const presentNow = jest.fn(async () => ({ + alarm_id: 'native-1', + schedule_id: 's1', + presented: true, + })); + const { alarms } = createFakeAlarms({ + schedule: jest.fn(async (request: AlarmScheduleRequest) => ({ + alarm_id: '', + schedule_id: request.schedule_id, + scheduled: false, + })), + presentNow, + }); + const deps = createDeps({ alarms, schedules: new FakeScheduleReader([schedule]) }); + const app = new LocalReminderApplication(deps); + await app.start(); + + const receipt = await app.deliver({ + reminder_id: 'r1', + schedule_id: 's1', + reason: 'at_time', + triggered_at: '2026-08-18T10:00:00.000Z', + }); + + expect(receipt.channels).toEqual(['native_full_screen']); + expect(presentNow).toHaveBeenCalledWith( + expect.objectContaining({ vibrate: true, sound_tier: 'full', full_screen: true }), + ); + expect(deps.presenter.show).not.toHaveBeenCalled(); + expect(deps.systemNotification.show).not.toHaveBeenCalled(); + expect(deps.vibration.vibrate).not.toHaveBeenCalled(); + }); + + it('location-type: uses presentNow with the low-strength ping tier', async () => { + const schedule = fixtureSchedule({ + id: 's1', + schedule_type: 'location', + latitude: 31.2304, + longitude: 121.4737, + geofence_radius_meters: 100, + reminder: { + reminder_type: 'arrive_location', + reminder_trigger_at: null, + reminder_offset_minutes: null, + reminder_strength: 'low', + }, + }); + const presentNow = jest.fn(async () => ({ + alarm_id: 'native-1', + schedule_id: 's1', + presented: true, + })); + const { alarms } = createFakeAlarms({ presentNow }); + const deps = createDeps({ alarms, schedules: new FakeScheduleReader([schedule]) }); + const app = new LocalReminderApplication(deps); + await app.start(); + + const receipt = await app.deliver({ + reminder_id: 'r1', + schedule_id: 's1', + reason: 'arrive_location', + triggered_at: '2026-08-18T10:00:00.000Z', + }); + + expect(receipt.channels).toEqual(['native_full_screen']); + expect(presentNow).toHaveBeenCalledWith( + expect.objectContaining({ vibrate: false, sound_tier: 'ping', full_screen: true }), + ); + }); + + it('falls back to JS channels when presentNow is available but declines', async () => { + const schedule = fixtureSchedule({ + id: 's1', + schedule_type: 'location', + latitude: 31.2304, + longitude: 121.4737, + geofence_radius_meters: 100, + reminder: { + reminder_type: 'arrive_location', + reminder_trigger_at: null, + reminder_offset_minutes: null, + reminder_strength: 'medium', + }, + }); + const presentNow = jest.fn(async () => ({ + alarm_id: '', + schedule_id: 's1', + presented: false, + })); + const { alarms } = createFakeAlarms({ presentNow }); + const deps = createDeps({ alarms, schedules: new FakeScheduleReader([schedule]) }); + const app = new LocalReminderApplication(deps); + await app.start(); + + const receipt = await app.deliver({ + reminder_id: 'r1', + schedule_id: 's1', + reason: 'arrive_location', + triggered_at: '2026-08-18T10:00:00.000Z', + }); + + expect(presentNow).toHaveBeenCalledTimes(1); + expect(receipt.channels).toEqual(['popup', 'vibration']); + }); + }); + describe('native alarm ring channels by strength', () => { - // 全屏三档都要弹(静音也要让用户看得见),vibrate/sound 才按强度递进: - // 低=都不要、中=只震动、高=震动+出声。 - const cases: [ReminderStrength, { vibrate: boolean; sound: boolean; full_screen: boolean }][] = - [ - ['low', { vibrate: false, sound: false, full_screen: true }], - ['medium', { vibrate: true, sound: false, full_screen: true }], - ['high', { vibrate: true, sound: true, full_screen: true }], - ]; + // 全屏三档都要弹(静音也要让用户看得见),vibrate/sound_tier 才按强度递进: + // 低=一声提示音不震动、中=一声提示音+震动、高=循环语音+震动。 + const cases: [ + ReminderStrength, + { vibrate: boolean; sound_tier: 'none' | 'ping' | 'full'; full_screen: boolean }, + ][] = [ + ['low', { vibrate: false, sound_tier: 'ping', full_screen: true }], + ['medium', { vibrate: true, sound_tier: 'ping', full_screen: true }], + ['high', { vibrate: true, sound_tier: 'full', full_screen: true }], + ]; it.each(cases)('%s strength schedules the native alarm with %j', async (strength, expected) => { const schedule = fixtureSchedule({ id: 's1', @@ -827,7 +1244,7 @@ describe('LocalReminderApplication', () => { const app = new LocalReminderApplication(deps); await app.start(); // start() 的 rebuild 已经把这条日程排过一次;重置掉,只看接下来这次 - // register() 调用实际传给原生的 vibrate/sound/full_screen。 + // register() 调用实际传给原生的 vibrate/sound_tier/full_screen。 scheduleCalls.length = 0; const registration = await app.register(schedule); diff --git a/frontend/tests/unit/features/reminder/application/ReminderGuardCoordinator.test.ts b/frontend/tests/unit/features/reminder/application/ReminderGuardCoordinator.test.ts new file mode 100644 index 00000000..a78ba6d8 --- /dev/null +++ b/frontend/tests/unit/features/reminder/application/ReminderGuardCoordinator.test.ts @@ -0,0 +1,347 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import * as Location from 'expo-location'; + +import type { LocalReminderSchedule } from '../../../../../src/features/reminder/domain'; +import { ReminderGuardCoordinator } from '../../../../../src/features/reminder/application/ReminderGuardCoordinator'; +import { + GUARD_TASK_NAME, + subscribeGuardTaskEvents, +} from '../../../../../src/infrastructure/location/reminderGuardTask'; + +jest.mock('expo-location', () => ({ + Accuracy: { Balanced: 3 }, + getForegroundPermissionsAsync: jest.fn(), + startLocationUpdatesAsync: jest.fn(), + stopLocationUpdatesAsync: jest.fn(), + hasStartedLocationUpdatesAsync: jest.fn(), +})); + +jest.mock('../../../../../src/infrastructure/location/reminderGuardTask', () => { + const actual = jest.requireActual< + typeof import('../../../../../src/infrastructure/location/reminderGuardTask') + >('../../../../../src/infrastructure/location/reminderGuardTask'); + return { + GUARD_TASK_NAME: 'timeflow-reminder-guard', + subscribeGuardTaskEvents: jest.fn(() => () => {}), + resolveNextPollIntervalMs: actual.resolveNextPollIntervalMs, + }; +}); + +const getForeground = Location.getForegroundPermissionsAsync as jest.MockedFunction< + typeof Location.getForegroundPermissionsAsync +>; +const startUpdates = Location.startLocationUpdatesAsync as jest.MockedFunction< + typeof Location.startLocationUpdatesAsync +>; +const stopUpdates = Location.stopLocationUpdatesAsync as jest.MockedFunction< + typeof Location.stopLocationUpdatesAsync +>; +const hasStarted = Location.hasStartedLocationUpdatesAsync as jest.MockedFunction< + typeof Location.hasStartedLocationUpdatesAsync +>; +const subscribeTaskEvents = subscribeGuardTaskEvents as jest.MockedFunction< + typeof subscribeGuardTaskEvents +>; + +function granted(): Location.PermissionResponse { + return { + status: 'granted' as Location.PermissionStatus, + granted: true, + canAskAgain: true, + expires: 'never', + }; +} + +function timeSchedule(overrides: Partial = {}): LocalReminderSchedule { + return { + id: 't1', + account_id: 'a1', + title: '晨会', + schedule_type: 'time', + schedule_kind: 'once', + is_all_day: false, + start_time: '2026-08-18T10:00:00.000Z', + end_time: null, + timezone: 'UTC', + recurrence_rule: null, + location_name: null, + latitude: null, + longitude: null, + geofence_radius_meters: 0, + reminder: { + reminder_type: 'at_time', + reminder_trigger_at: '2026-08-18T10:00:00.000Z', + reminder_offset_minutes: null, + reminder_strength: 'medium', + }, + runtime: { + reminder_disposition_state: null, + next_trigger_at: null, + snoozed_until: null, + geofence_armed: false, + disposition_updated_at: null, + sync_status: 'synced', + recorded_location: null, + }, + status: 'active', + revision: 1, + cloud_revision: 1, + updated_at: '2026-08-18T09:00:00.000Z', + ...overrides, + }; +} + +function locationSchedule(overrides: Partial = {}): LocalReminderSchedule { + return { + ...timeSchedule(), + id: 'l1', + schedule_type: 'location', + start_time: null, + latitude: 31.2304, + longitude: 121.4737, + geofence_radius_meters: 200, + reminder: { + reminder_type: 'arrive_location', + reminder_trigger_at: null, + reminder_offset_minutes: null, + reminder_strength: 'medium', + }, + ...overrides, + }; +} + +function createReader(schedules: LocalReminderSchedule[]) { + const listeners = new Set<(schedules: readonly LocalReminderSchedule[]) => void>(); + return { + listReminderSchedules: jest.fn(async () => schedules), + getReminderSchedule: jest.fn(async (id: string) => schedules.find((s) => s.id === id) ?? null), + subscribe: jest.fn((listener: (schedules: readonly LocalReminderSchedule[]) => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }), + emit: () => { + for (const listener of listeners) listener(schedules); + }, + }; +} + +async function flushMicrotasks(): Promise { + for (let i = 0; i < 8; i += 1) { + await Promise.resolve(); + } +} + +describe('ReminderGuardCoordinator', () => { + beforeEach(() => { + jest.clearAllMocks(); + getForeground.mockResolvedValue(granted()); + startUpdates.mockResolvedValue(undefined); + stopUpdates.mockResolvedValue(undefined); + hasStarted.mockResolvedValue(true); + subscribeTaskEvents.mockReturnValue(() => {}); + }); + + it('does not start location updates when there is nothing active to watch', async () => { + const reader = createReader([]); + const coordinator = new ReminderGuardCoordinator({ + schedules: reader, + handleLocation: jest.fn(async () => {}), + }); + + await coordinator.start(); + + expect(startUpdates).not.toHaveBeenCalled(); + }); + + it('starts location updates at the sparsest interval for a time-only backlog', async () => { + const reader = createReader([timeSchedule()]); + const coordinator = new ReminderGuardCoordinator({ + schedules: reader, + handleLocation: jest.fn(async () => {}), + }); + + await coordinator.start(); + + expect(startUpdates).toHaveBeenCalledTimes(1); + const [taskName, options] = startUpdates.mock.calls[0]; + expect(taskName).toBe(GUARD_TASK_NAME); + expect(options?.timeInterval).toBe(300_000); + expect(options?.foregroundService?.notificationBody).toContain('晨会'); + }); + + it('mentions the location backlog count alongside the next time-type countdown', async () => { + const reader = createReader([timeSchedule(), locationSchedule()]); + const coordinator = new ReminderGuardCoordinator({ + schedules: reader, + handleLocation: jest.fn(async () => {}), + }); + + await coordinator.start(); + + const [, options] = startUpdates.mock.calls[0]; + expect(options?.foregroundService?.notificationBody).toContain('晨会'); + expect(options?.foregroundService?.notificationBody).toContain('1 个地点提醒'); + }); + + it('orders multiple time schedules by their next trigger for the countdown', async () => { + // describeGuardNotification 里 .sort() 的比较函数只有在 ≥2 条时间型日程时才被 + // 真正调用;用一前一后两个触发时刻验证它按最早触发的排在最前。 + const reader = createReader([ + timeSchedule({ id: 't1', title: '晨会' }), + timeSchedule({ + id: 't2', + title: '午会', + start_time: '2026-08-18T14:00:00.000Z', + reminder: { + reminder_type: 'at_time', + reminder_trigger_at: '2026-08-18T14:00:00.000Z', + reminder_offset_minutes: null, + reminder_strength: 'medium', + }, + }), + ]); + const coordinator = new ReminderGuardCoordinator({ + schedules: reader, + handleLocation: jest.fn(async () => {}), + }); + + await coordinator.start(); + + const [, options] = startUpdates.mock.calls[0]; + expect(options?.foregroundService?.notificationBody).toContain('晨会'); + expect(options?.foregroundService?.notificationBody).not.toContain('午会'); + }); + + it('does not start when foreground location permission is missing', async () => { + getForeground.mockResolvedValue({ + status: 'denied' as Location.PermissionStatus, + granted: false, + canAskAgain: true, + expires: 'never', + }); + const reader = createReader([timeSchedule()]); + const coordinator = new ReminderGuardCoordinator({ + schedules: reader, + handleLocation: jest.fn(async () => {}), + }); + + await coordinator.start(); + + expect(startUpdates).not.toHaveBeenCalled(); + }); + + it('starts the time-only fallback with just foreground permission (no background permission needed)', async () => { + // startLocationUpdatesAsync() 走的是 foregroundService 配置这条路径,原生侧本身 + // 就不要求 ACCESS_BACKGROUND_LOCATION——用户只给了"仅使用时允许"也应该照样启动, + // 不然纯时间型日程的兜底轮询会被一个跟它无关的权限卡死。 + const reader = createReader([timeSchedule()]); + const coordinator = new ReminderGuardCoordinator({ + schedules: reader, + handleLocation: jest.fn(async () => {}), + }); + + await coordinator.start(); + + expect(startUpdates).toHaveBeenCalledTimes(1); + }); + + it('stops location updates once every schedule is confirmed or removed', async () => { + const schedule = timeSchedule(); + const reader = createReader([schedule]); + const coordinator = new ReminderGuardCoordinator({ + schedules: reader, + handleLocation: jest.fn(async () => {}), + }); + await coordinator.start(); + expect(startUpdates).toHaveBeenCalledTimes(1); + + schedule.runtime = { ...schedule.runtime, reminder_disposition_state: 'confirmed' }; + reader.emit(); + await flushMicrotasks(); + + expect(stopUpdates).toHaveBeenCalledWith(GUARD_TASK_NAME); + }); + + it('stop() tears down the subscription and stops location updates', async () => { + const reader = createReader([timeSchedule()]); + const coordinator = new ReminderGuardCoordinator({ + schedules: reader, + handleLocation: jest.fn(async () => {}), + }); + await coordinator.start(); + + await coordinator.stop(); + + expect(stopUpdates).toHaveBeenCalledWith(GUARD_TASK_NAME); + }); + + it('routes an incoming guard sample to handleLocation and re-reconciles', async () => { + let sampleListener: + | ((sample: { + latitude: number; + longitude: number; + accuracy_meters: number; + observed_at: string; + }) => void) + | undefined; + subscribeTaskEvents.mockImplementation((listener) => { + sampleListener = listener as typeof sampleListener; + return () => {}; + }); + const handleLocation = jest.fn(async () => {}); + const reader = createReader([timeSchedule()]); + const coordinator = new ReminderGuardCoordinator({ schedules: reader, handleLocation }); + await coordinator.start(); + startUpdates.mockClear(); + + await sampleListener?.({ + latitude: 31.2304, + longitude: 121.4737, + accuracy_meters: 10, + observed_at: '2026-08-18T10:00:00.000Z', + }); + + expect(handleLocation).toHaveBeenCalledTimes(1); + }); + + it('describes a location-only backlog without a time countdown', async () => { + // describeGuardNotification 的 next == null 分支:没有时间型日程时拼不出倒计时, + // 退化成"正在监听 N 个地点提醒"。 + const reader = createReader([locationSchedule()]); + const coordinator = new ReminderGuardCoordinator({ + schedules: reader, + handleLocation: jest.fn(async () => {}), + }); + + await coordinator.start(); + + const [, options] = startUpdates.mock.calls[0]; + expect(options?.foregroundService?.notificationBody).toBe('正在监听 1 个地点提醒'); + }); + + it('swallows a startLocationUpdatesAsync failure without rethrowing', async () => { + startUpdates.mockRejectedValue(new Error('start failed')); + const reader = createReader([timeSchedule()]); + const coordinator = new ReminderGuardCoordinator({ + schedules: reader, + handleLocation: jest.fn(async () => {}), + }); + + await coordinator.start(); + + expect(startUpdates).toHaveBeenCalledTimes(1); + }); + + it('stop() survives a stopLocationUpdatesAsync failure while tearing down', async () => { + const reader = createReader([timeSchedule()]); + const coordinator = new ReminderGuardCoordinator({ + schedules: reader, + handleLocation: jest.fn(async () => {}), + }); + await coordinator.start(); + expect(startUpdates).toHaveBeenCalledTimes(1); + + stopUpdates.mockRejectedValue(new Error('stop failed')); + await coordinator.stop(); + }); +}); diff --git a/frontend/tests/unit/features/reminder/domain/geofence.test.ts b/frontend/tests/unit/features/reminder/domain/geofence.test.ts new file mode 100644 index 00000000..9856ae54 --- /dev/null +++ b/frontend/tests/unit/features/reminder/domain/geofence.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from '@jest/globals'; + +import { resolveGuardPollIntervalMs } from '../../../../../src/features/reminder/domain/geofence'; + +describe('resolveGuardPollIntervalMs', () => { + it('returns the densest interval at or inside the near threshold', () => { + expect(resolveGuardPollIntervalMs(0)).toBe(15_000); + expect(resolveGuardPollIntervalMs(50)).toBe(15_000); + }); + + it('returns the sparsest interval at or beyond the far threshold', () => { + expect(resolveGuardPollIntervalMs(2_000)).toBe(300_000); + expect(resolveGuardPollIntervalMs(10_000)).toBe(300_000); + }); + + it('interpolates linearly between the two thresholds', () => { + // 中点 (50+2000)/2 = 1025m -> 中间值 + expect(resolveGuardPollIntervalMs(1_025)).toBe(157_500); + }); + + it('falls back to the sparsest interval for a non-finite distance (nothing being watched)', () => { + expect(resolveGuardPollIntervalMs(Number.POSITIVE_INFINITY)).toBe(300_000); + expect(resolveGuardPollIntervalMs(NaN)).toBe(300_000); + }); +}); diff --git a/frontend/tests/unit/features/reminder/domain/strengthDelivery.test.ts b/frontend/tests/unit/features/reminder/domain/strengthDelivery.test.ts index 65007036..bda06179 100644 --- a/frontend/tests/unit/features/reminder/domain/strengthDelivery.test.ts +++ b/frontend/tests/unit/features/reminder/domain/strengthDelivery.test.ts @@ -3,30 +3,33 @@ import { describe, expect, it } from '@jest/globals'; import { resolveStrengthDeliveryPlan } from '../../../../../src/features/reminder/domain/strengthDelivery'; describe('resolveStrengthDeliveryPlan', () => { - it('low: system notification only', () => { + it('low: system notification only, native ring page gets a one-shot ping', () => { expect(resolveStrengthDeliveryPlan('low')).toEqual({ useSystemNotification: true, usePopup: false, useVibration: false, useAudio: false, + alarmSoundTier: 'ping', }); }); - it('medium: popup + vibration, no audio', () => { + it('medium: popup + vibration, no JS audio, native ring page gets a one-shot ping', () => { expect(resolveStrengthDeliveryPlan('medium')).toEqual({ useSystemNotification: false, usePopup: true, useVibration: true, useAudio: false, + alarmSoundTier: 'ping', }); }); - it('high: popup + vibration + audio', () => { + it('high: popup + vibration + audio, native ring page gets the full looping sound', () => { expect(resolveStrengthDeliveryPlan('high')).toEqual({ useSystemNotification: false, usePopup: true, useVibration: true, useAudio: true, + alarmSoundTier: 'full', }); }); }); diff --git a/frontend/tests/unit/infrastructure/location/geofenceTask.test.ts b/frontend/tests/unit/infrastructure/location/geofenceTask.test.ts index f26f2e7b..64ff8e6a 100644 --- a/frontend/tests/unit/infrastructure/location/geofenceTask.test.ts +++ b/frontend/tests/unit/infrastructure/location/geofenceTask.test.ts @@ -191,4 +191,32 @@ describe('geofenceTask', () => { await expect(module.drainPendingGeofenceEvents()).resolves.toEqual([]); }); }); + + describe('simulateGeofenceEventForTesting()', () => { + it('rejects when the headless database cannot be opened', async () => { + const { module } = loadModule(); + // openHeadlessDatabase() 拿到 null 即抛,这是 dev-only hook 在无原生模块环境的预期行为。 + await expect(module.simulateGeofenceEventForTesting('schedule-1', 'enter')).rejects.toThrow( + '无法打开本地日程数据库', + ); + }); + }); + + describe('headless delivery routing', () => { + it('routes a subscriber-less enter event through headless delivery and queues it', async () => { + // listeners.size === 0 → deliverHeadlessGeofenceEvent → openHeadlessDatabase 拿到 + // null 返回 false → persistPendingEvent(loadStorage 拿到 null 即返回)。整条链 + // 都是"原生不可用"分支,验证它不会抛错、而是走完队列化兜底。 + const { executor } = loadModule(); + await expect( + executor({ + data: { + eventType: 1, + region: { identifier: 'schedule-1', latitude: 1, longitude: 2, radius: 100 }, + }, + error: null, + }), + ).resolves.toBeUndefined(); + }); + }); }); diff --git a/frontend/tests/unit/infrastructure/location/reminderGuardTask.test.ts b/frontend/tests/unit/infrastructure/location/reminderGuardTask.test.ts new file mode 100644 index 00000000..47fc4bb6 --- /dev/null +++ b/frontend/tests/unit/infrastructure/location/reminderGuardTask.test.ts @@ -0,0 +1,175 @@ +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; + +/** + * 跟 geofenceTask.test.ts 同一条约束:真正碰数据库的那段(openDatabase() → loadStorageModules() + * 动态 import 'expo-sqlite')在这个 Jest 环境里必然失败返回 null(没有 + * --experimental-vm-modules,裸 await import() 会直接抛错),所以 runHeadlessLocationPass / + * runTimeFallbackPass / runStuckPendingPass 拿到连接之后的逻辑无法从单测触达。这里只覆盖 + * 连接边界之前的同步路由:错误分支、无位置样本分支、有订阅者时的分发、无订阅者时的 + * headless 入口,以及每次唤醒无条件执行的兜底 pass 的入口(openDatabase 拿 null 即返回)。 + */ + +type TaskExecutor = (args: { data: unknown; error: unknown }) => Promise; + +const mockIsTaskDefined = jest.fn<(name: string) => boolean>(); +const mockDefineTask = jest.fn<(name: string, executor: TaskExecutor) => void>(); + +jest.mock('expo-task-manager', () => ({ + isTaskDefined: (name: string) => mockIsTaskDefined(name), + defineTask: (name: string, executor: TaskExecutor) => mockDefineTask(name, executor), +})); + +jest.mock('expo-location', () => ({})); + +type Sample = { + latitude: number; + longitude: number; + accuracy_meters: number; + observed_at: string; +}; + +function locationPayload( + overrides: Partial<{ lat: number; lon: number; accuracy: number | undefined; ts: number }> = {}, +) { + const timestamp = overrides.ts ?? 1_752_000_000_000; + return { + locations: [ + { + coords: { + latitude: overrides.lat ?? 31.2304, + longitude: overrides.lon ?? 121.4737, + accuracy: overrides.accuracy, + }, + timestamp, + }, + ], + }; +} + +/** 每个测试都重新 require 模块,拿到一份干净的 listeners 集合和被捕获的 task executor。 */ +function loadModule(): { + module: typeof import('../../../../src/infrastructure/location/reminderGuardTask'); + executor: TaskExecutor; +} { + mockIsTaskDefined.mockReturnValue(false); + jest.resetModules(); + // eslint-disable-next-line @typescript-eslint/no-require-imports -- fresh module registry per test needs require(), not static import + const required = require('../../../../src/infrastructure/location/reminderGuardTask'); + const module: typeof import('../../../../src/infrastructure/location/reminderGuardTask') = + required; + const executor = mockDefineTask.mock.calls.at(-1)?.[1] as TaskExecutor; + return { module, executor }; +} + +describe('resolveNextPollIntervalMs', () => { + it('falls back to the sparsest interval when there is no sample or no targets', () => { + const { module } = loadModule(); + expect(module.resolveNextPollIntervalMs(null, [{ latitude: 31.23, longitude: 121.47 }])).toBe( + 300_000, + ); + expect(module.resolveNextPollIntervalMs({ latitude: 31.23, longitude: 121.47 }, [])).toBe( + 300_000, + ); + }); + + it('picks the nearest target among several to decide the interval', () => { + const { module } = loadModule(); + const sample = { latitude: 31.2304, longitude: 121.4737 }; + const near = { latitude: 31.2304, longitude: 121.4737 }; // ~0m away + const far = { latitude: 31.32, longitude: 121.4737 }; // far away + expect(module.resolveNextPollIntervalMs(sample, [far, near])).toBe(15_000); + }); +}); + +describe('guard task definition and subscription', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.resetModules(); + }); + + it('defines the task only once, keyed by GUARD_TASK_NAME', () => { + const { module } = loadModule(); + expect(module.GUARD_TASK_NAME).toBe('timeflow-reminder-guard'); + expect(mockDefineTask).toHaveBeenCalledWith('timeflow-reminder-guard', expect.any(Function)); + }); + + it('does not redefine the task if it is already defined', () => { + mockIsTaskDefined.mockReturnValue(true); + jest.resetModules(); + // eslint-disable-next-line @typescript-eslint/no-require-imports + require('../../../../src/infrastructure/location/reminderGuardTask'); + expect(mockDefineTask).not.toHaveBeenCalled(); + }); + + it('subscribeGuardTaskEvents stops delivering once unsubscribed', () => { + const { module } = loadModule(); + const listener = jest.fn(); + const unsubscribe = module.subscribeGuardTaskEvents(listener); + unsubscribe(); + expect(listener).not.toHaveBeenCalled(); + }); +}); + +describe('guard task executor routing', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.resetModules(); + }); + + it('ignores the callback when TaskManager reports an error', async () => { + const { executor } = loadModule(); + await expect(executor({ data: undefined, error: new Error('boom') })).resolves.toBeUndefined(); + }); + + it('runs the fallback passes when woken with no location payload', async () => { + const { module, executor } = loadModule(); + const listener = jest.fn(); + module.subscribeGuardTaskEvents(listener); + + // 没有位置样本时不喂任何 listener;但时间型兜底 + 卡住扫描这两个 pass 无条件执行(拿 null 即返回)。 + await expect(executor({ data: undefined, error: null })).resolves.toBeUndefined(); + expect(listener).not.toHaveBeenCalled(); + }); + + it('dispatches the last location sample to every live listener', async () => { + const { module, executor } = loadModule(); + const first = jest.fn(); + const second = jest.fn(); + module.subscribeGuardTaskEvents(first); + module.subscribeGuardTaskEvents(second); + + await executor({ data: locationPayload({ ts: 1_752_000_000_000, accuracy: 10 }), error: null }); + + const expected: Sample = { + latitude: 31.2304, + longitude: 121.4737, + accuracy_meters: 10, + observed_at: new Date(1_752_000_000_000).toISOString(), + }; + expect(first).toHaveBeenCalledWith(expected); + expect(second).toHaveBeenCalledWith(expected); + }); + + it('falls back to accuracy 0 when the location sample omits accuracy', async () => { + const { module, executor } = loadModule(); + const listener = jest.fn(); + module.subscribeGuardTaskEvents(listener); + + await executor({ data: locationPayload({ accuracy: undefined }), error: null }); + + expect(listener).toHaveBeenCalledWith(expect.objectContaining({ accuracy_meters: 0 })); + }); + + it('completes a headless pass without live listeners (openDatabase returns null)', async () => { + const { executor } = loadModule(); + // 没有任何订阅者 → listeners.size === 0 → 走 headless 分支,openDatabase 拿到 null 即返回, + // 接着无条件跑 runTimeFallbackPass/runStuckPendingPass(同样拿 null 即返回)。 + await expect(executor({ data: locationPayload(), error: null })).resolves.toBeUndefined(); + }); +}); diff --git a/frontend/tests/unit/infrastructure/notifications/expoSystemNotification.test.ts b/frontend/tests/unit/infrastructure/notifications/expoSystemNotification.test.ts index 26cbf00a..cef47246 100644 --- a/frontend/tests/unit/infrastructure/notifications/expoSystemNotification.test.ts +++ b/frontend/tests/unit/infrastructure/notifications/expoSystemNotification.test.ts @@ -70,13 +70,13 @@ describe('ExpoSystemNotification (fake native notifications module injected)', ( expect(receipt).toEqual({ notification_id: 'n1', shown: true }); expect(setNotificationChannelAsync).toHaveBeenCalledTimes(1); expect(setNotificationChannelAsync).toHaveBeenCalledWith( - 'timeflow-reminders', - expect.objectContaining({ name: '日程提醒' }), + 'timeflow-reminders-quiet', + expect.objectContaining({ name: '日程提醒(静音)', sound: null }), ); expect(scheduleNotificationAsync).toHaveBeenCalledWith({ identifier: 'n1', content: { title: '标题', body: '内容', sound: false }, - trigger: null, + trigger: { channelId: 'timeflow-reminders-quiet' }, }); }); diff --git a/frontend/tests/unit/infrastructure/notifications/nativeAlarmScheduler.test.ts b/frontend/tests/unit/infrastructure/notifications/nativeAlarmScheduler.test.ts index dcc37a2e..af2f6056 100644 --- a/frontend/tests/unit/infrastructure/notifications/nativeAlarmScheduler.test.ts +++ b/frontend/tests/unit/infrastructure/notifications/nativeAlarmScheduler.test.ts @@ -10,8 +10,10 @@ import { nativeCancelAlarm, nativeCancelAllAlarms, nativeGetAlarmPermissionStatus, + nativeHasArmedAlarm, nativeOpenAlarmPermissionSettings, nativePeekAlarmDispositions, + nativePresentAlarmNow, nativeRequestNotificationPermission, nativeScheduleAlarm, nativeStopAlarmRinging, @@ -27,6 +29,8 @@ jest.mock('react-native', () => { cancel: jest.fn(), cancelAll: jest.fn(), stopRinging: jest.fn(), + presentNow: jest.fn(), + hasArmedAlarm: jest.fn(), peekNativeDispositions: jest.fn(), ackNativeDispositions: jest.fn(), getPermissionStatus: jest.fn(), @@ -63,7 +67,7 @@ type NativeAlarmMock = { title?: string | null, scheduleId?: string | null, vibrate?: boolean, - sound?: boolean, + soundTier?: string, fullScreen?: boolean, speechText?: string | null, ) => Promise<{ alarmId: string }> @@ -71,6 +75,17 @@ type NativeAlarmMock = { cancel: jest.MockedFunction<(alarmId: string) => Promise>; cancelAll: jest.MockedFunction<() => Promise>; stopRinging: jest.MockedFunction<() => Promise>; + presentNow: jest.MockedFunction< + ( + alarmId: string, + scheduleId: string, + title: string, + vibrate: boolean, + soundTier: string, + fullScreen: boolean, + ) => Promise + >; + hasArmedAlarm: jest.MockedFunction<(scheduleId: string) => Promise>; peekNativeDispositions: jest.MockedFunction< () => Promise<{ scheduleId: string; alarmId: string; state: string; updatedAtMillis: number }[]> >; @@ -101,7 +116,7 @@ function request(overrides: Partial = {}): AlarmScheduleRe title: '晨会', exact: true, vibrate: true, - sound: true, + sound_tier: 'full', full_screen: true, ...overrides, }; @@ -136,6 +151,8 @@ describe('TimeflowAlarmBridge and NativeAlarmScheduler', () => { native.cancel.mockReset(); native.cancelAll.mockReset(); native.stopRinging.mockReset(); + native.presentNow.mockReset(); + native.hasArmedAlarm.mockReset(); native.peekNativeDispositions.mockReset(); native.ackNativeDispositions.mockReset(); native.getPermissionStatus.mockReset(); @@ -146,6 +163,8 @@ describe('TimeflowAlarmBridge and NativeAlarmScheduler', () => { native.cancel.mockResolvedValue(true); native.cancelAll.mockResolvedValue(0); native.stopRinging.mockResolvedValue(true); + native.presentNow.mockResolvedValue(true); + native.hasArmedAlarm.mockResolvedValue(false); native.peekNativeDispositions.mockResolvedValue([]); native.ackNativeDispositions.mockResolvedValue(true); native.openPermissionSettings.mockResolvedValue(true); @@ -244,30 +263,23 @@ describe('TimeflowAlarmBridge and NativeAlarmScheduler', () => { '晨会', 'schedule-1', true, - true, + 'full', true, '', ); }); - it('forwards ring channels and speech text through to the native bridge', async () => { + it('forwards vibrate/sound_tier/full_screen through to the native bridge', async () => { const scheduler = new NativeAlarmScheduler(); - await scheduler.schedule( - request({ - vibrate: false, - sound: false, - full_screen: false, - speech_text: '晨会,时间到了。', - }), - ); + await scheduler.schedule(request({ vibrate: false, sound_tier: 'none', full_screen: false })); expect(native.schedule).toHaveBeenCalledWith( Date.parse(FUTURE), '晨会', 'schedule-1', false, + 'none', false, - false, - '晨会,时间到了。', + '', ); }); @@ -359,7 +371,7 @@ describe('TimeflowAlarmBridge and NativeAlarmScheduler', () => { '晨会', 'ok', true, - true, + 'full', true, '', ); @@ -369,7 +381,7 @@ describe('TimeflowAlarmBridge and NativeAlarmScheduler', () => { '午会', 'later', true, - true, + 'full', true, '', ); @@ -389,6 +401,92 @@ describe('TimeflowAlarmBridge and NativeAlarmScheduler', () => { await expect(nativeStopAlarmRinging()).resolves.toBeUndefined(); }); + it('presentNow forwards to the native bridge and returns the receipt', async () => { + const scheduler = new NativeAlarmScheduler(); + await expect( + scheduler.presentNow({ + alarm_id: 'alarm-9', + schedule_id: 'schedule-1', + title: '晨会', + vibrate: true, + sound_tier: 'full', + full_screen: true, + }), + ).resolves.toEqual({ alarm_id: 'alarm-9', schedule_id: 'schedule-1', presented: true }); + expect(native.presentNow).toHaveBeenCalledWith( + 'alarm-9', + 'schedule-1', + '晨会', + true, + 'full', + true, + ); + }); + + it('presentNow generates a guard-ids alarm id when none is provided', async () => { + const scheduler = new NativeAlarmScheduler(); + const receipt = await scheduler.presentNow({ + alarm_id: '', + schedule_id: 'schedule-1', + title: '晨会', + vibrate: false, + sound_tier: 'ping', + full_screen: true, + }); + expect(receipt.alarm_id).toMatch(/^geofence-schedule-1-\d+$/); + expect(native.presentNow).toHaveBeenCalledWith( + receipt.alarm_id, + 'schedule-1', + '晨会', + false, + 'ping', + true, + ); + }); + + it('nativePresentAlarmNow returns true on the happy path and false when declined', async () => { + await expect( + nativePresentAlarmNow('alarm-1', 'schedule-1', '晨会', true, 'full', true), + ).resolves.toBe(true); + + native.presentNow.mockResolvedValue(false); + await expect( + nativePresentAlarmNow('alarm-1', 'schedule-1', '晨会', true, 'full', true), + ).resolves.toBe(false); + }); + + it('nativePresentAlarmNow fails closed off Android and on native rejection', async () => { + Platform.OS = 'ios'; + await expect( + nativePresentAlarmNow('alarm-1', 'schedule-1', '晨会', true, 'full', true), + ).resolves.toBe(false); + Platform.OS = 'android'; + + native.presentNow.mockRejectedValue(new Error('present failed')); + await expect( + nativePresentAlarmNow('alarm-1', 'schedule-1', '晨会', true, 'full', true), + ).resolves.toBe(false); + }); + + it('nativeHasArmedAlarm forwards the native query and falls back to false', async () => { + native.hasArmedAlarm.mockResolvedValue(true); + await expect(nativeHasArmedAlarm('schedule-1')).resolves.toBe(true); + expect(native.hasArmedAlarm).toHaveBeenCalledWith('schedule-1'); + + native.hasArmedAlarm.mockResolvedValue(false); + await expect(nativeHasArmedAlarm('schedule-1')).resolves.toBe(false); + }); + + it('nativeHasArmedAlarm fails closed off Android, with an empty id, and on rejection', async () => { + Platform.OS = 'ios'; + await expect(nativeHasArmedAlarm('schedule-1')).resolves.toBe(false); + Platform.OS = 'android'; + await expect(nativeHasArmedAlarm('')).resolves.toBe(false); + + native.hasArmedAlarm.mockRejectedValue(new Error('query failed')); + await expect(nativeHasArmedAlarm('schedule-1')).resolves.toBe(false); + }); + it('peeks native dispositions and maps known states, dropping unknown ones', async () => { native.peekNativeDispositions.mockResolvedValue([ { scheduleId: 'a', alarmId: 'alarm-a', state: 'confirmed', updatedAtMillis: 1000 },