alarms = loadAlarms(context);
+ String alarmId = UUID.randomUUID().toString();
+ AlarmRecord record = new AlarmRecord(
+ alarmId,
+ scheduleId == null ? "" : scheduleId,
+ triggerAtMillis,
+ nextRequestCode(alarms),
+ title == null ? "" : title,
+ false
+ );
+
+ rearm(context, alarmManager, record);
+
+ alarms.add(record);
+ saveAlarms(context, alarms);
+ return alarmId;
+ }
+
+ /** @deprecated 请改用 {@link #schedule(Context, long, String, String)}。 */
+ @Deprecated
+ public static String schedule(Context context, long triggerAtMillis, String title) {
+ return schedule(context, triggerAtMillis, title, "");
+ }
+
+ /**
+ * 系统重启 / 应用更新后重新挂上所有还没过期的持久化闹钟。
+ * AlarmManager 的注册在这两种情况下都会被系统清空,但 SharedPreferences 里的记录还在;
+ * 不重新挂,用户在下次自己打开 App 之前不会再收到任何提醒。
+ *
+ * 已经过期的记录直接从持久化列表里丢弃,不在这里补响——JS 侧
+ * {@code LocalReminderApplication} 自己会在下次 rebuild 时把错过的提醒当到点处理,
+ * 这里再触发一次会导致同一条提醒响两次。
+ */
+ public static void rescheduleAfterBoot(Context context) {
+ AlarmManager alarmManager =
+ (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
+ if (alarmManager == null) {
+ return;
+ }
+ boolean canScheduleExact = android.os.Build.VERSION.SDK_INT
+ < android.os.Build.VERSION_CODES.S
+ || alarmManager.canScheduleExactAlarms();
+
+ long now = System.currentTimeMillis();
+ List survivors = new ArrayList<>();
+ for (AlarmRecord record : loadAlarms(context)) {
+ if (record.triggerAtMillis <= now || !canScheduleExact) {
+ continue;
+ }
+ rearm(context, alarmManager, record);
+ survivors.add(record);
+ }
+ saveAlarms(context, survivors);
+ }
+
+ private static void rearm(Context context, AlarmManager alarmManager, AlarmRecord record) {
+ PendingIntent operation = buildAlarmBroadcastPendingIntent(
+ context,
+ record,
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
+ );
+ Intent showIntent = context.getPackageManager()
+ .getLaunchIntentForPackage(context.getPackageName());
+ if (showIntent == null) {
+ showIntent = new Intent(Intent.ACTION_MAIN)
+ .setPackage(context.getPackageName());
+ }
+ showIntent.setData(alarmUri(record.alarmId))
+ .putExtra(AlarmContract.EXTRA_ALARM_ID, record.alarmId)
+ .putExtra(AlarmContract.EXTRA_SCHEDULE_ID, record.scheduleId)
+ .putExtra(AlarmContract.EXTRA_TITLE, record.title)
+ .putExtra(AlarmContract.EXTRA_REQUEST_CODE, record.requestCode)
+ .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);
+ PendingIntent showPendingIntent = PendingIntent.getActivity(
+ context,
+ record.requestCode,
+ showIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
+ );
+ alarmManager.setAlarmClock(
+ new AlarmManager.AlarmClockInfo(record.triggerAtMillis, showPendingIntent),
+ operation
+ );
+ }
+
+ public static boolean cancel(Context context, String alarmId) {
+ if (alarmId == null || alarmId.isEmpty()) {
+ return false;
+ }
+ List alarms = loadAlarms(context);
+ int index = indexById(alarms, alarmId);
+ if (index < 0) {
+ return false;
+ }
+
+ AlarmRecord record = alarms.get(index);
+ cancelPendingIntent(context, record);
+ alarms.remove(index);
+ saveAlarms(context, alarms);
+ return true;
+ }
+
+ public static int cancelByScheduleId(Context context, String scheduleId) {
+ if (scheduleId == null || scheduleId.isEmpty()) {
+ return 0;
+ }
+ List alarms = loadAlarms(context);
+ List remaining = new ArrayList<>();
+ int cancelled = 0;
+ for (AlarmRecord record : alarms) {
+ if (scheduleId.equals(record.scheduleId)) {
+ cancelPendingIntent(context, record);
+ cancelled += 1;
+ } else {
+ remaining.add(record);
+ }
+ }
+ if (cancelled > 0) {
+ saveAlarms(context, remaining);
+ }
+ return cancelled;
+ }
+
+ public static int cancelAll(Context context) {
+ List alarms = loadAlarms(context);
+ for (AlarmRecord record : alarms) {
+ cancelPendingIntent(context, record);
+ }
+ saveAlarms(context, new ArrayList());
+ return alarms.size();
+ }
+
+ public static List loadAlarms(Context context) {
+ SharedPreferences preferences =
+ context.getSharedPreferences(AlarmContract.PREFS_NAME, Context.MODE_PRIVATE);
+ String serialized = preferences.getString(AlarmContract.ALARMS_KEY, "[]");
+ List alarms = new ArrayList<>();
+ try {
+ JSONArray array = new JSONArray(serialized);
+ for (int index = 0; index < array.length(); index++) {
+ Object value = array.get(index);
+ if (!(value instanceof JSONObject)) {
+ continue;
+ }
+ JSONObject object = (JSONObject) value;
+ long triggerAt = object.optLong("trigger_at", -1L);
+ int requestCode = object.optInt("request_code", -1);
+ if (triggerAt <= 0 || requestCode < 0) {
+ continue;
+ }
+ String alarmId = object.optString("alarm_id", "");
+ boolean legacy = object.optBoolean("legacy", alarmId.isEmpty());
+ if (alarmId.isEmpty()) {
+ alarmId = "legacy-" + requestCode;
+ }
+ alarms.add(new AlarmRecord(
+ alarmId,
+ object.optString("schedule_id", ""),
+ triggerAt,
+ requestCode,
+ object.optString("title", ""),
+ legacy
+ ));
+ }
+ } catch (JSONException ignored) {
+ alarms.clear();
+ }
+ return alarms;
+ }
+
+ static void removeAlarmRecord(Context context, String alarmId, int requestCode) {
+ List alarms = loadAlarms(context);
+ JSONArray remaining = new JSONArray();
+ for (AlarmRecord alarm : alarms) {
+ boolean match;
+ if (!alarm.alarmId.isEmpty()) {
+ match = alarm.alarmId.equals(alarmId);
+ } else {
+ match = alarm.requestCode == requestCode;
+ }
+ if (match) {
+ continue;
+ }
+ try {
+ remaining.put(toJson(alarm));
+ } catch (JSONException ignored) {
+ // 忽略单条序列化失败
+ }
+ }
+ context.getSharedPreferences(AlarmContract.PREFS_NAME, Context.MODE_PRIVATE)
+ .edit()
+ .putString(AlarmContract.ALARMS_KEY, remaining.toString())
+ .apply();
+ }
+
+ static Uri alarmUri(String alarmId) {
+ return Uri.parse(
+ AlarmContract.ALARM_URI_SCHEME + "://alarm/" + Uri.encode(alarmId)
+ );
+ }
+
+ static String scheduleIdForAlarm(Context context, String alarmId) {
+ if (alarmId == null || alarmId.isEmpty()) {
+ return "";
+ }
+ for (AlarmRecord alarm : loadAlarms(context)) {
+ if (alarmId.equals(alarm.alarmId)) {
+ return alarm.scheduleId;
+ }
+ }
+ return "";
+ }
+
+ private static void cancelPendingIntent(Context context, AlarmRecord record) {
+ AlarmManager alarmManager =
+ (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
+ if (alarmManager == null) {
+ return;
+ }
+ PendingIntent operation = buildAlarmBroadcastPendingIntent(
+ context,
+ record,
+ PendingIntent.FLAG_NO_CREATE | PendingIntent.FLAG_IMMUTABLE
+ );
+ if (operation != null) {
+ alarmManager.cancel(operation);
+ operation.cancel();
+ }
+ }
+
+ private static void saveAlarms(Context context, List alarms) {
+ JSONArray array = new JSONArray();
+ for (AlarmRecord alarm : alarms) {
+ try {
+ array.put(toJson(alarm));
+ } catch (JSONException ignored) {
+ // 忽略单条序列化失败
+ }
+ }
+ context.getSharedPreferences(AlarmContract.PREFS_NAME, Context.MODE_PRIVATE)
+ .edit()
+ .putString(AlarmContract.ALARMS_KEY, array.toString())
+ .apply();
+ }
+
+ private static JSONObject toJson(AlarmRecord alarm) throws JSONException {
+ JSONObject object = new JSONObject();
+ object.put("alarm_id", alarm.alarmId);
+ object.put("schedule_id", alarm.scheduleId);
+ object.put("trigger_at", alarm.triggerAtMillis);
+ object.put("request_code", alarm.requestCode);
+ object.put("title", alarm.title);
+ object.put("legacy", alarm.legacy);
+ return object;
+ }
+
+ private static PendingIntent buildAlarmBroadcastPendingIntent(
+ Context context,
+ AlarmRecord record,
+ int flags
+ ) {
+ Intent intent = new Intent(context, AlarmReceiver.class)
+ .setAction(AlarmContract.ACTION_FIRE_ALARM)
+ .putExtra(AlarmContract.EXTRA_ALARM_ID, record.alarmId)
+ .putExtra(AlarmContract.EXTRA_SCHEDULE_ID, record.scheduleId)
+ .putExtra(AlarmContract.EXTRA_REQUEST_CODE, record.requestCode)
+ .putExtra(AlarmContract.EXTRA_TITLE, record.title);
+ if (!record.legacy) {
+ intent.setData(alarmUri(record.alarmId));
+ }
+ return PendingIntent.getBroadcast(context, record.requestCode, intent, flags);
+ }
+
+ private static int nextRequestCode(List alarms) {
+ int requestCode = (int) (System.currentTimeMillis() & 0x7fffffff);
+ if (requestCode == 0) {
+ requestCode = 1;
+ }
+ while (containsRequestCode(alarms, requestCode)) {
+ requestCode = requestCode == Integer.MAX_VALUE ? 1 : requestCode + 1;
+ }
+ return requestCode;
+ }
+
+ private static boolean containsRequestCode(List alarms, int requestCode) {
+ for (AlarmRecord alarm : alarms) {
+ if (alarm.requestCode == requestCode) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static int indexById(List alarms, String alarmId) {
+ for (int index = 0; index < alarms.size(); index++) {
+ if (alarms.get(index).alarmId.equals(alarmId)) {
+ return index;
+ }
+ }
+ return -1;
+ }
+}
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
new file mode 100644
index 00000000..af58a492
--- /dev/null
+++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmSoundService.java
@@ -0,0 +1,382 @@
+package com.timeflow.alarm;
+
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.app.ActivityOptions;
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ServiceInfo;
+import android.graphics.PixelFormat;
+import android.media.AudioAttributes;
+import android.media.MediaPlayer;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Looper;
+import android.provider.Settings;
+import android.view.Gravity;
+import android.view.View;
+import android.view.WindowManager;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.InputStream;
+
+public final class AlarmSoundService extends Service {
+ private static final long SPEECH_REPEAT_DELAY_MILLIS = 1_500L;
+
+ private final Handler playbackHandler = new Handler(Looper.getMainLooper());
+ private final Runnable replaySpeech = this::replaySpeech;
+
+ private MediaPlayer mediaPlayer;
+ private boolean destroyed;
+ private File bundledSpeechFile;
+ private WindowManager overlayWindowManager;
+ private View overlayView;
+ private String alarmId;
+ private String scheduleId;
+ private String alarmTitle;
+ private int requestCode;
+ private boolean firedNotified;
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ requestCode = intent == null
+ ? 0
+ : intent.getIntExtra(AlarmContract.EXTRA_REQUEST_CODE, 0);
+ alarmId = intent == null
+ ? null
+ : intent.getStringExtra(AlarmContract.EXTRA_ALARM_ID);
+ scheduleId = intent == null
+ ? null
+ : intent.getStringExtra(AlarmContract.EXTRA_SCHEDULE_ID);
+ alarmTitle = intent == null
+ ? null
+ : intent.getStringExtra(AlarmContract.EXTRA_TITLE);
+ if (alarmId == null || alarmId.isEmpty()) {
+ alarmId = "legacy-" + requestCode;
+ }
+ if (scheduleId == null || scheduleId.isEmpty()) {
+ scheduleId = AlarmScheduler.scheduleIdForAlarm(this, alarmId);
+ }
+ if (alarmTitle == null || alarmTitle.isEmpty()) {
+ alarmTitle = "日程提醒";
+ }
+
+ createNotificationChannel();
+ Notification notification = buildNotification(alarmId, alarmTitle);
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ startForeground(
+ requestCode,
+ notification,
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
+ );
+ } else {
+ startForeground(requestCode, notification);
+ }
+ removeFromSavedAlarms();
+ if (!firedNotified) {
+ firedNotified = true;
+ AlarmNativeBridge.notifyFired(this, scheduleId, alarmId, alarmTitle);
+ }
+ showAlarmOverlay(alarmTitle);
+ if (mediaPlayer == null) {
+ startBundledSpeech();
+ }
+ } catch (RuntimeException exception) {
+ stopSelf();
+ }
+ return START_NOT_STICKY;
+ }
+
+ @Override
+ public void onDestroy() {
+ destroyed = true;
+ playbackHandler.removeCallbacksAndMessages(null);
+ removeAlarmOverlay();
+ releaseMediaPlayer();
+ deleteCachedSpeechFile();
+ NotificationManager manager =
+ (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
+ if (manager != null) {
+ manager.cancel(requestCode);
+ }
+ super.onDestroy();
+ }
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ return null;
+ }
+
+ static void stop(Context context) {
+ context.stopService(new Intent(context, AlarmSoundService.class));
+ }
+
+ static void start(
+ Context context,
+ String alarmId,
+ String scheduleId,
+ int requestCode,
+ String title
+ ) {
+ Intent intent = new Intent(context, AlarmSoundService.class)
+ .putExtra(AlarmContract.EXTRA_ALARM_ID, alarmId)
+ .putExtra(AlarmContract.EXTRA_SCHEDULE_ID, scheduleId)
+ .putExtra(AlarmContract.EXTRA_REQUEST_CODE, requestCode)
+ .putExtra(AlarmContract.EXTRA_TITLE, title);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(intent);
+ } else {
+ context.startService(intent);
+ }
+ }
+
+ private Notification buildNotification(String alarmId, String title) {
+ Intent ringIntent = new Intent(this, RingActivity.class)
+ .setData(alarmUri(alarmId))
+ .putExtra(AlarmContract.EXTRA_ALARM_ID, alarmId)
+ .putExtra(AlarmContract.EXTRA_SCHEDULE_ID, scheduleId)
+ .putExtra(AlarmContract.EXTRA_REQUEST_CODE, requestCode)
+ .putExtra(AlarmContract.EXTRA_TITLE, title)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK
+ | Intent.FLAG_ACTIVITY_MULTIPLE_TASK
+ | Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS);
+ PendingIntent fullScreenIntent = PendingIntent.getActivity(
+ this,
+ requestCode,
+ ringIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE,
+ pendingIntentOptions()
+ );
+ return new Notification.Builder(this, AlarmContract.CHANNEL_ID)
+ .setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
+ .setContentTitle(title)
+ .setContentText("点击停止提醒")
+ .setCategory(Notification.CATEGORY_ALARM)
+ .setVisibility(Notification.VISIBILITY_PUBLIC)
+ .setPriority(Notification.PRIORITY_MAX)
+ .setOngoing(true)
+ .setAutoCancel(false)
+ .setFullScreenIntent(fullScreenIntent, true)
+ .build();
+ }
+
+ private Uri alarmUri(String value) {
+ return AlarmScheduler.alarmUri(value);
+ }
+
+ private android.os.Bundle pendingIntentOptions() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ return null;
+ }
+ ActivityOptions options = ActivityOptions.makeBasic();
+ options.setPendingIntentCreatorBackgroundActivityStartMode(
+ backgroundActivityStartMode()
+ );
+ return options.toBundle();
+ }
+
+ private int backgroundActivityStartMode() {
+ if (Build.VERSION.SDK_INT >= 36) {
+ return ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOW_ALWAYS;
+ }
+ return ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED;
+ }
+
+ 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("日程闹钟提醒");
+ channel.enableVibration(true);
+ channel.setSound(null, null);
+ manager.createNotificationChannel(channel);
+ }
+
+ private void showAlarmOverlay(String title) {
+ if (overlayView != null
+ || Build.VERSION.SDK_INT < Build.VERSION_CODES.M
+ || !Settings.canDrawOverlays(this)) {
+ return;
+ }
+
+ overlayWindowManager = (WindowManager) getSystemService(Context.WINDOW_SERVICE);
+ if (overlayWindowManager == null) {
+ return;
+ }
+
+ View content = AlarmRingUi.build(
+ this,
+ title,
+ view -> {
+ long triggerAt = System.currentTimeMillis()
+ + AlarmContract.SNOOZE_MINUTES * 60_000L;
+ try {
+ AlarmScheduler.schedule(this, triggerAt, alarmTitle, scheduleId);
+ } catch (RuntimeException ignored) {
+ // ignore
+ }
+ AlarmNativeBridge.notifySnoozed(this, scheduleId, alarmId, alarmTitle);
+ removeAlarmOverlay();
+ RingActivity.finishIfOpen();
+ stopSelf();
+ },
+ view -> {
+ AlarmNativeBridge.notifyDismissed(this, scheduleId, alarmId, alarmTitle);
+ removeAlarmOverlay();
+ RingActivity.finishIfOpen();
+ stopSelf();
+ }
+ );
+ int windowType = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
+ ? WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
+ : WindowManager.LayoutParams.TYPE_SYSTEM_ALERT;
+ int windowFlags = WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN
+ | WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
+ | WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
+ | WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
+ | WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD
+ | WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
+ | WindowManager.LayoutParams.FLAG_FULLSCREEN;
+ WindowManager.LayoutParams params = new WindowManager.LayoutParams(
+ WindowManager.LayoutParams.MATCH_PARENT,
+ WindowManager.LayoutParams.MATCH_PARENT,
+ windowType,
+ windowFlags,
+ PixelFormat.OPAQUE
+ );
+ params.gravity = Gravity.TOP | Gravity.START;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ params.layoutInDisplayCutoutMode =
+ WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES;
+ }
+ content.setSystemUiVisibility(
+ View.SYSTEM_UI_FLAG_LAYOUT_STABLE
+ | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
+ | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
+ | View.SYSTEM_UI_FLAG_FULLSCREEN
+ | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
+ | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
+ );
+
+ try {
+ overlayWindowManager.addView(content, params);
+ overlayView = content;
+ } catch (RuntimeException exception) {
+ overlayWindowManager = null;
+ }
+ }
+
+ private void removeAlarmOverlay() {
+ if (overlayWindowManager != null && overlayView != null) {
+ try {
+ overlayWindowManager.removeViewImmediate(overlayView);
+ } catch (RuntimeException ignored) {
+ // 系统可能已移除悬浮窗。
+ }
+ }
+ overlayView = null;
+ overlayWindowManager = null;
+ }
+
+ private void startBundledSpeech() {
+ if (destroyed || mediaPlayer != null) {
+ 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();
+ }
+ }
+
+ 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());
+ 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;
+ });
+ player.prepare();
+ mediaPlayer = player;
+ player.start();
+ } catch (Exception exception) {
+ releaseMediaPlayer();
+ }
+ }
+
+ private void replaySpeech() {
+ if (destroyed || mediaPlayer == null) {
+ return;
+ }
+ try {
+ mediaPlayer.seekTo(0);
+ mediaPlayer.start();
+ } catch (IllegalStateException ignored) {
+ releaseMediaPlayer();
+ }
+ }
+
+ 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 deleteCachedSpeechFile() {
+ if (bundledSpeechFile != null) {
+ bundledSpeechFile.delete();
+ }
+ }
+
+ private void removeFromSavedAlarms() {
+ AlarmScheduler.removeAlarmRecord(this, alarmId, requestCode);
+ }
+
+}
diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/BootReceiver.java b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/BootReceiver.java
new file mode 100644
index 00000000..6af9dbf7
--- /dev/null
+++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/BootReceiver.java
@@ -0,0 +1,21 @@
+package com.timeflow.alarm;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+
+/**
+ * 重新挂上系统重启 / 应用更新后被清空的 AlarmManager 注册。
+ * MY_PACKAGE_REPLACED 覆盖应用更新场景——同样会清空 AlarmManager,行为跟重启一致。
+ */
+public final class BootReceiver extends BroadcastReceiver {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = intent == null ? null : intent.getAction();
+ if (!Intent.ACTION_BOOT_COMPLETED.equals(action)
+ && !Intent.ACTION_MY_PACKAGE_REPLACED.equals(action)) {
+ return;
+ }
+ AlarmScheduler.rescheduleAfterBoot(context);
+ }
+}
diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/DayRulerView.java b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/DayRulerView.java
new file mode 100644
index 00000000..fcdab9c9
--- /dev/null
+++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/DayRulerView.java
@@ -0,0 +1,157 @@
+package com.timeflow.alarm;
+
+import android.animation.ValueAnimator;
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.provider.Settings;
+import android.view.View;
+import android.view.animation.AccelerateDecelerateInterpolator;
+
+import java.util.Calendar;
+
+/**
+ * 以「今天」为刻度尺:每小时一刻度,每六小时加长刻度,
+ * 青柠指针落在响铃那一分钟。已过去的刻度变淡,未到的刻度更实。
+ *
+ * 这是提醒屏上唯一的结构装置,也是唯一标出「这次打断落在一天何处」的元素。
+ */
+final class DayRulerView extends View {
+ private static final int HOURS_PER_DAY = 24;
+ private static final int HOURS_PER_QUARTER = 6;
+ private static final long BREATH_MILLIS = 1_700L;
+ private static final int ALPHA_AHEAD = 56;
+ private static final int ALPHA_SPENT = 23;
+ private static final float HALO_ALPHA_TIGHT = 0.30f;
+ private static final float HALO_ALPHA_WIDE = 0.08f;
+ private static final float BREATH_AT_REST = 0.4f;
+
+ private final Paint tickPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint needlePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint haloPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+
+ private final float hourTickLength;
+ private final float quarterTickLength;
+ private final float tickThickness;
+ private final float needleThickness;
+ private final float haloTightHeight;
+ private final float haloWideHeight;
+ private final float haloRadius;
+ private final boolean breathing;
+
+ private ValueAnimator breathAnimator;
+ private float breath;
+ private float dayFraction;
+
+ DayRulerView(Context context, int tickColor, int needleColor) {
+ super(context);
+ float density = context.getResources().getDisplayMetrics().density;
+ hourTickLength = 9 * density;
+ quarterTickLength = 17 * density;
+ tickThickness = 1.5f * density;
+ needleThickness = 3 * density;
+ haloTightHeight = 8 * density;
+ haloWideHeight = 16 * density;
+ haloRadius = 8 * density;
+
+ tickPaint.setColor(tickColor);
+ needlePaint.setColor(needleColor);
+ haloPaint.setColor(needleColor);
+
+ breathing = animatorsEnabled(context);
+ breath = breathing ? 0f : BREATH_AT_REST;
+ syncToClock();
+ }
+
+ /** 重新读取墙上时钟,使长响过程中指针仍与当前时刻同步。 */
+ void syncToClock() {
+ Calendar now = Calendar.getInstance();
+ int minutesIntoDay = now.get(Calendar.HOUR_OF_DAY) * 60 + now.get(Calendar.MINUTE);
+ dayFraction = minutesIntoDay / (float) (HOURS_PER_DAY * 60);
+ invalidate();
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ super.onDraw(canvas);
+ float height = getHeight();
+ float width = getWidth();
+ if (height <= 0 || width <= 0) {
+ return;
+ }
+
+ float needleY = clampToTrack(height * dayFraction, height, needleThickness);
+ for (int hour = 0; hour <= HOURS_PER_DAY; hour++) {
+ float y = clampToTrack(height * hour / HOURS_PER_DAY, height, tickThickness);
+ float length = hour % HOURS_PER_QUARTER == 0 ? quarterTickLength : hourTickLength;
+ tickPaint.setAlpha(y < needleY ? ALPHA_SPENT : ALPHA_AHEAD);
+ drawBar(canvas, y, length, tickThickness, tickThickness, tickPaint);
+ }
+
+ float haloHeight = haloTightHeight + (haloWideHeight - haloTightHeight) * breath;
+ float haloAlpha = HALO_ALPHA_TIGHT + (HALO_ALPHA_WIDE - HALO_ALPHA_TIGHT) * breath;
+ haloPaint.setAlpha(Math.round(haloAlpha * 255f));
+ drawBar(canvas, needleY, width, haloHeight, haloRadius, haloPaint);
+ drawBar(canvas, needleY, width, needleThickness, needleThickness, needlePaint);
+ }
+
+ @Override
+ protected void onAttachedToWindow() {
+ super.onAttachedToWindow();
+ if (!breathing || breathAnimator != null) {
+ return;
+ }
+ breathAnimator = ValueAnimator.ofFloat(0f, 1f);
+ breathAnimator.setDuration(BREATH_MILLIS);
+ breathAnimator.setRepeatMode(ValueAnimator.REVERSE);
+ breathAnimator.setRepeatCount(ValueAnimator.INFINITE);
+ breathAnimator.setInterpolator(new AccelerateDecelerateInterpolator());
+ breathAnimator.addUpdateListener(animator -> {
+ breath = (float) animator.getAnimatedValue();
+ invalidate();
+ });
+ breathAnimator.start();
+ }
+
+ @Override
+ protected void onDetachedFromWindow() {
+ if (breathAnimator != null) {
+ breathAnimator.cancel();
+ breathAnimator = null;
+ }
+ super.onDetachedFromWindow();
+ }
+
+ private static void drawBar(
+ Canvas canvas,
+ float centerY,
+ float length,
+ float thickness,
+ float radius,
+ Paint paint
+ ) {
+ canvas.drawRoundRect(
+ 0f,
+ centerY - thickness / 2f,
+ length,
+ centerY + thickness / 2f,
+ radius,
+ radius,
+ paint
+ );
+ }
+
+ /** 保证一天的首末刻度仍完整落在列内。 */
+ private static float clampToTrack(float y, float height, float thickness) {
+ float inset = thickness / 2f;
+ return Math.min(Math.max(y, inset), height - inset);
+ }
+
+ private static boolean animatorsEnabled(Context context) {
+ return Settings.Global.getFloat(
+ context.getContentResolver(),
+ Settings.Global.ANIMATOR_DURATION_SCALE,
+ 1f
+ ) > 0f;
+ }
+}
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
new file mode 100644
index 00000000..fdaace80
--- /dev/null
+++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/RingActivity.java
@@ -0,0 +1,212 @@
+package com.timeflow.alarm;
+
+import android.app.AlarmManager;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.view.View;
+import android.view.Window;
+import android.view.WindowInsetsController;
+import android.view.WindowManager;
+
+import java.lang.ref.WeakReference;
+
+public final class RingActivity extends Activity {
+ private static WeakReference currentActivity = new WeakReference<>(null);
+
+ private String alarmId;
+ private String scheduleId;
+ private String alarmTitle;
+ private int requestCode;
+ private boolean dismissNotified;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ currentActivity = new WeakReference<>(this);
+ requestCode = getIntent().getIntExtra(AlarmContract.EXTRA_REQUEST_CODE, 0);
+ alarmId = getIntent().getStringExtra(AlarmContract.EXTRA_ALARM_ID);
+ scheduleId = getIntent().getStringExtra(AlarmContract.EXTRA_SCHEDULE_ID);
+ alarmTitle = getIntent().getStringExtra(AlarmContract.EXTRA_TITLE);
+ if (alarmId == null || alarmId.isEmpty()) {
+ alarmId = "legacy-" + requestCode;
+ }
+ if (scheduleId == null || scheduleId.isEmpty()) {
+ scheduleId = AlarmScheduler.scheduleIdForAlarm(this, alarmId);
+ }
+ if (alarmTitle == null || alarmTitle.isEmpty()) {
+ alarmTitle = "日程提醒";
+ }
+ makeVisibleOverLockScreen();
+ matchSystemBarsToReminder();
+ setContentView(buildContentView());
+ removeFromSavedAlarms();
+ AlarmSoundService.start(
+ this,
+ alarmId,
+ scheduleId,
+ requestCode,
+ alarmTitle
+ );
+ }
+
+ @Override
+ protected void onDestroy() {
+ if (currentActivity.get() == this) {
+ currentActivity.clear();
+ }
+ super.onDestroy();
+ }
+
+ static void finishIfOpen() {
+ RingActivity activity = currentActivity.get();
+ if (activity != null && !activity.isFinishing()) {
+ activity.finishAndRemoveTask();
+ }
+ }
+
+ private View buildContentView() {
+ return AlarmRingUi.build(
+ this,
+ alarmTitle,
+ view -> snoozeAndClose(),
+ view -> confirmAndClose()
+ );
+ }
+
+ private void makeVisibleOverLockScreen() {
+ Window window = getWindow();
+ window.addFlags(
+ WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
+ | WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD
+ | WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
+ | WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
+ );
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
+ setShowWhenLocked(true);
+ setTurnScreenOn(true);
+ }
+ }
+
+ /**
+ * 提醒界面偏浅色,系统栏需使用深色图标;
+ * 否则深色模式下浅底上会出现白色图标。
+ */
+ private void matchSystemBarsToReminder() {
+ Window window = getWindow();
+ window.setStatusBarColor(AlarmRingUi.topEdgeColor());
+ window.setNavigationBarColor(AlarmRingUi.bottomEdgeColor());
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ WindowInsetsController controller = window.getInsetsController();
+ if (controller != null) {
+ controller.setSystemBarsAppearance(
+ WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS
+ | WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS,
+ WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS
+ | WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS
+ );
+ }
+ return;
+ }
+ window.getDecorView().setSystemUiVisibility(
+ View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
+ | View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR
+ );
+ }
+
+ private void confirmAndClose() {
+ if (!dismissNotified) {
+ dismissNotified = true;
+ AlarmNativeBridge.notifyDismissed(this, scheduleId, alarmId, alarmTitle);
+ }
+ AlarmSoundService.stop(this);
+ cancelNotification();
+ cancelAlarmPendingIntent();
+ finishAndRemoveTask();
+ }
+
+ private void snoozeAndClose() {
+ if (!dismissNotified) {
+ dismissNotified = true;
+ long triggerAt = System.currentTimeMillis()
+ + AlarmContract.SNOOZE_MINUTES * 60_000L;
+ try {
+ AlarmScheduler.schedule(this, triggerAt, alarmTitle, scheduleId);
+ } catch (RuntimeException ignored) {
+ // 尽力重新挂闹钟;即使失败也通知 JS 落 snooze 状态。
+ }
+ AlarmNativeBridge.notifySnoozed(this, scheduleId, alarmId, alarmTitle);
+ }
+ AlarmSoundService.stop(this);
+ cancelNotification();
+ cancelAlarmPendingIntent();
+ finishAndRemoveTask();
+ }
+
+ private void cancelNotification() {
+ NotificationManager manager =
+ (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
+ if (manager != null) {
+ manager.cancel(requestCode);
+ }
+ }
+
+ private void cancelAlarmPendingIntent() {
+ AlarmManager alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE);
+ if (alarmManager == null) {
+ return;
+ }
+
+ Intent activityIntent = new Intent(this, RingActivity.class)
+ .setData(alarmUri(alarmId));
+ PendingIntent activityPendingIntent = PendingIntent.getActivity(
+ this,
+ requestCode,
+ activityIntent,
+ PendingIntent.FLAG_NO_CREATE | PendingIntent.FLAG_IMMUTABLE
+ );
+ Intent broadcastIntent = new Intent(this, AlarmReceiver.class)
+ .setAction(AlarmContract.ACTION_FIRE_ALARM);
+ if (!isLegacyAlarm()) {
+ broadcastIntent.setData(alarmUri(alarmId));
+ }
+ PendingIntent broadcastPendingIntent = PendingIntent.getBroadcast(
+ this,
+ requestCode,
+ broadcastIntent,
+ PendingIntent.FLAG_NO_CREATE | PendingIntent.FLAG_IMMUTABLE
+ );
+ if (activityPendingIntent != null) {
+ alarmManager.cancel(activityPendingIntent);
+ activityPendingIntent.cancel();
+ }
+ if (broadcastPendingIntent != null) {
+ alarmManager.cancel(broadcastPendingIntent);
+ broadcastPendingIntent.cancel();
+ }
+ }
+
+ private Uri alarmUri(String value) {
+ return AlarmScheduler.alarmUri(value);
+ }
+
+ private boolean isLegacyAlarm() {
+ return alarmId != null && alarmId.startsWith("legacy-");
+ }
+
+ private void removeFromSavedAlarms() {
+ AlarmScheduler.removeAlarmRecord(this, alarmId, requestCode);
+ }
+
+
+ @Override
+ public void onBackPressed() {
+ confirmAndClose();
+ }
+
+}
diff --git a/frontend/modules/timeflow-alarm/index.js b/frontend/modules/timeflow-alarm/index.js
new file mode 100644
index 00000000..37e2f90e
--- /dev/null
+++ b/frontend/modules/timeflow-alarm/index.js
@@ -0,0 +1,3 @@
+// 原生模块经 React Native autolinking(AlarmPackage)接入。
+// JS 侧通过 NativeModules.TimeflowAlarm 调用。
+module.exports = {};
diff --git a/frontend/modules/timeflow-alarm/package.json b/frontend/modules/timeflow-alarm/package.json
new file mode 100644
index 00000000..92afe2da
--- /dev/null
+++ b/frontend/modules/timeflow-alarm/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "timeflow-alarm",
+ "version": "1.0.0",
+ "description": "Native Android exact-alarm bridge for Timeflow",
+ "main": "index.js",
+ "license": "UNLICENSED",
+ "private": true,
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*"
+ }
+}
diff --git a/frontend/modules/timeflow-alarm/react-native.config.js b/frontend/modules/timeflow-alarm/react-native.config.js
new file mode 100644
index 00000000..60b6f8ee
--- /dev/null
+++ b/frontend/modules/timeflow-alarm/react-native.config.js
@@ -0,0 +1,12 @@
+module.exports = {
+ dependency: {
+ platforms: {
+ android: {
+ sourceDir: './android',
+ packageImportPath: 'import com.timeflow.alarm.AlarmPackage;',
+ packageInstance: 'new AlarmPackage()',
+ },
+ ios: null,
+ },
+ },
+};
diff --git a/frontend/modules/timeflow-baidu-location/.gitignore b/frontend/modules/timeflow-baidu-location/.gitignore
new file mode 100644
index 00000000..a4a00d77
--- /dev/null
+++ b/frontend/modules/timeflow-baidu-location/.gitignore
@@ -0,0 +1,3 @@
+android/build/
+android/.gradle/
+*.iml
diff --git a/frontend/modules/timeflow-baidu-location/README.md b/frontend/modules/timeflow-baidu-location/README.md
new file mode 100644
index 00000000..7683c6d3
--- /dev/null
+++ b/frontend/modules/timeflow-baidu-location/README.md
@@ -0,0 +1,16 @@
+# timeflow-baidu-location
+
+Android 百度定位桥接(`LocationClient` 连续定位),**不使用 Google Geofencing**。
+
+- 原生模块名:`TimeflowBaiduLocation`
+- 事件:`TimeflowBaiduLocation`(latitude / longitude / accuracy / observedAt)
+- 坐标系:`gcj02`
+- AK 通过 Expo 插件写入 `com.baidu.lbsapi.API_KEY`
+
+## 控制台要求
+
+Android AK 必须与包名 **`com.timeflow`** + 签名证书 **SHA1** 绑定,否则定位会失败(常见 locType 鉴权错误)。
+
+## 应用侧
+
+`NativeLocationMonitor` 订阅连续定位,用 Haversine 判断进出圈。
diff --git a/frontend/modules/timeflow-baidu-location/android/build.gradle b/frontend/modules/timeflow-baidu-location/android/build.gradle
new file mode 100644
index 00000000..c330f520
--- /dev/null
+++ b/frontend/modules/timeflow-baidu-location/android/build.gradle
@@ -0,0 +1,39 @@
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+
+def getExtOrDefault(name, defaultValue) {
+ return rootProject.ext.has(name) ? rootProject.ext.get(name) : defaultValue
+}
+
+android {
+ namespace "com.timeflow.baidulocation"
+
+ compileSdkVersion getExtOrDefault('compileSdkVersion', 35)
+
+ defaultConfig {
+ minSdkVersion getExtOrDefault('minSdkVersion', 24)
+ targetSdkVersion getExtOrDefault('targetSdkVersion', 35)
+ }
+
+ sourceSets {
+ main {
+ java.srcDirs = ['src/main/java']
+ }
+ }
+
+ lintOptions {
+ abortOnError false
+ }
+}
+
+repositories {
+ mavenCentral()
+ google()
+}
+
+dependencies {
+ implementation 'com.facebook.react:react-android'
+ implementation 'androidx.core:core-ktx:1.13.1'
+ // 仅定位 SDK,不引入 Google Geofencing。
+ implementation 'com.baidu.lbsyun:BaiduMapSDK_Location:9.6.4'
+}
diff --git a/frontend/modules/timeflow-baidu-location/android/src/main/AndroidManifest.xml b/frontend/modules/timeflow-baidu-location/android/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..51e7a1b6
--- /dev/null
+++ b/frontend/modules/timeflow-baidu-location/android/src/main/AndroidManifest.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/modules/timeflow-baidu-location/android/src/main/java/com/timeflow/baidulocation/BaiduLocationModule.kt b/frontend/modules/timeflow-baidu-location/android/src/main/java/com/timeflow/baidulocation/BaiduLocationModule.kt
new file mode 100644
index 00000000..dd732dd7
--- /dev/null
+++ b/frontend/modules/timeflow-baidu-location/android/src/main/java/com/timeflow/baidulocation/BaiduLocationModule.kt
@@ -0,0 +1,191 @@
+package com.timeflow.baidulocation
+
+import android.util.Log
+import com.baidu.location.BDAbstractLocationListener
+import com.baidu.location.BDLocation
+import com.baidu.location.LocationClient
+import com.baidu.location.LocationClientOption
+import com.facebook.react.bridge.Arguments
+import com.facebook.react.bridge.Promise
+import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.bridge.ReactContextBaseJavaModule
+import com.facebook.react.bridge.ReactMethod
+import com.facebook.react.bridge.WritableMap
+import com.facebook.react.modules.core.DeviceEventManagerModule
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.TimeZone
+
+/**
+ * 百度连续定位桥:不使用 Google Geofencing。
+ * 坐标系默认 gcj02,便于与国内常见选点坐标对齐后做 Haversine。
+ */
+class BaiduLocationModule(
+ private val reactContext: ReactApplicationContext,
+) : ReactContextBaseJavaModule(reactContext) {
+
+ private var client: LocationClient? = null
+ private var updating = false
+ private var lastLocation: WritableMap? = null
+
+ private val isoFormat =
+ SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply {
+ timeZone = TimeZone.getTimeZone("UTC")
+ }
+
+ private val listener =
+ object : BDAbstractLocationListener() {
+ override fun onReceiveLocation(location: BDLocation?) {
+ if (location == null) return
+ if (!isUsableLocation(location)) {
+ Log.w(NAME, "ignore locType=${location.locType}")
+ return
+ }
+
+ val payload = Arguments.createMap()
+ payload.putDouble("latitude", location.latitude)
+ payload.putDouble("longitude", location.longitude)
+ payload.putDouble(
+ "accuracy",
+ if (location.radius > 0) location.radius.toDouble() else 0.0,
+ )
+ payload.putString("observedAt", isoFormat.format(Date()))
+ payload.putInt("locType", location.locType)
+ lastLocation = copyMap(payload)
+
+ if (reactContext.hasActiveReactInstance()) {
+ reactContext
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
+ .emit(EVENT_LOCATION, payload)
+ }
+ }
+ }
+
+ override fun getName(): String = NAME
+
+ @ReactMethod
+ fun setAgreePrivacy(agree: Boolean, promise: Promise) {
+ try {
+ LocationClient.setAgreePrivacy(agree)
+ promise.resolve(true)
+ } catch (error: Exception) {
+ promise.reject("PRIVACY_FAILED", error.message, error)
+ }
+ }
+
+ @ReactMethod
+ fun init(ak: String?, promise: Promise) {
+ try {
+ LocationClient.setAgreePrivacy(true)
+ if (client == null) {
+ client = LocationClient(reactContext.applicationContext)
+ client?.registerLocationListener(listener)
+ }
+ if (!ak.isNullOrBlank()) {
+ Log.i(NAME, "init akLength=${ak.length}")
+ }
+ promise.resolve(true)
+ } catch (error: Exception) {
+ promise.reject("INIT_FAILED", error.message, error)
+ }
+ }
+
+ @ReactMethod
+ fun startUpdating(intervalMs: Double, promise: Promise) {
+ try {
+ LocationClient.setAgreePrivacy(true)
+ val locationClient =
+ client ?: LocationClient(reactContext.applicationContext).also {
+ it.registerLocationListener(listener)
+ client = it
+ }
+
+ val span = intervalMs.toInt().coerceAtLeast(1000)
+ val option = LocationClientOption()
+ option.locationMode = LocationClientOption.LocationMode.Hight_Accuracy
+ option.setCoorType("gcj02")
+ option.setScanSpan(span)
+ option.isOpenGps = true
+ option.setIsNeedAddress(false)
+ option.setNeedNewVersionRgc(false)
+ locationClient.locOption = option
+
+ if (!updating) {
+ locationClient.start()
+ updating = true
+ } else {
+ locationClient.restart()
+ }
+ promise.resolve(true)
+ } catch (error: Exception) {
+ promise.reject("START_FAILED", error.message, error)
+ }
+ }
+
+ @ReactMethod
+ fun stopUpdating(promise: Promise) {
+ try {
+ client?.stop()
+ updating = false
+ promise.resolve(true)
+ } catch (error: Exception) {
+ promise.reject("STOP_FAILED", error.message, error)
+ }
+ }
+
+ @ReactMethod
+ fun getCurrentPosition(promise: Promise) {
+ val cached = lastLocation
+ if (cached != null) {
+ promise.resolve(copyMap(cached))
+ return
+ }
+ promise.resolve(null)
+ }
+
+ @ReactMethod
+ fun addListener(eventName: String?) {
+ // RN event emitter bookkeeping.
+ }
+
+ @ReactMethod
+ fun removeListeners(count: Double) {
+ // RN event emitter bookkeeping.
+ }
+
+ override fun invalidate() {
+ try {
+ client?.unRegisterLocationListener(listener)
+ client?.stop()
+ } catch (_: Exception) {
+ }
+ client = null
+ updating = false
+ super.invalidate()
+ }
+
+ companion object {
+ const val NAME = "TimeflowBaiduLocation"
+ const val EVENT_LOCATION = "TimeflowBaiduLocation"
+
+ private fun isUsableLocation(location: BDLocation): Boolean {
+ if (location.latitude == 0.0 && location.longitude == 0.0) return false
+ // 常见成功:61 GPS、161 网络、66 离线;其它带有效坐标的也接受。
+ return when (location.locType) {
+ BDLocation.TypeGpsLocation,
+ BDLocation.TypeNetWorkLocation,
+ BDLocation.TypeOffLineLocation,
+ BDLocation.TypeCacheLocation,
+ -> true
+ else -> location.radius > 0
+ }
+ }
+
+ private fun copyMap(source: WritableMap): WritableMap {
+ val copy = Arguments.createMap()
+ copy.merge(source)
+ return copy
+ }
+ }
+}
diff --git a/frontend/modules/timeflow-baidu-location/android/src/main/java/com/timeflow/baidulocation/BaiduLocationPackage.kt b/frontend/modules/timeflow-baidu-location/android/src/main/java/com/timeflow/baidulocation/BaiduLocationPackage.kt
new file mode 100644
index 00000000..ba27aca8
--- /dev/null
+++ b/frontend/modules/timeflow-baidu-location/android/src/main/java/com/timeflow/baidulocation/BaiduLocationPackage.kt
@@ -0,0 +1,18 @@
+package com.timeflow.baidulocation
+
+import com.facebook.react.ReactPackage
+import com.facebook.react.bridge.NativeModule
+import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.uimanager.ViewManager
+
+class BaiduLocationPackage : ReactPackage {
+ override fun createNativeModules(reactContext: ReactApplicationContext): List {
+ return listOf(BaiduLocationModule(reactContext))
+ }
+
+ override fun createViewManagers(
+ reactContext: ReactApplicationContext,
+ ): List> {
+ return emptyList()
+ }
+}
diff --git a/frontend/modules/timeflow-baidu-location/index.js b/frontend/modules/timeflow-baidu-location/index.js
new file mode 100644
index 00000000..1d760182
--- /dev/null
+++ b/frontend/modules/timeflow-baidu-location/index.js
@@ -0,0 +1,3 @@
+// Native module is linked via React Native autolinking (BaiduLocationPackage).
+// JS callers use NativeModules.TimeflowBaiduLocation from the app layer.
+module.exports = {};
diff --git a/frontend/modules/timeflow-baidu-location/package.json b/frontend/modules/timeflow-baidu-location/package.json
new file mode 100644
index 00000000..6ebff60e
--- /dev/null
+++ b/frontend/modules/timeflow-baidu-location/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "timeflow-baidu-location",
+ "version": "1.0.0",
+ "description": "Native Android Baidu LocationClient bridge for Timeflow (no Google geofencing)",
+ "main": "index.js",
+ "license": "UNLICENSED",
+ "private": true,
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*"
+ }
+}
diff --git a/frontend/modules/timeflow-baidu-location/react-native.config.js b/frontend/modules/timeflow-baidu-location/react-native.config.js
new file mode 100644
index 00000000..3be4ed84
--- /dev/null
+++ b/frontend/modules/timeflow-baidu-location/react-native.config.js
@@ -0,0 +1,12 @@
+module.exports = {
+ dependency: {
+ platforms: {
+ android: {
+ sourceDir: './android',
+ packageImportPath: 'import com.timeflow.baidulocation.BaiduLocationPackage;',
+ packageInstance: 'new BaiduLocationPackage()',
+ },
+ ios: null,
+ },
+ },
+};
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 052bc763..4d6b8c70 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -12,16 +12,21 @@
"@expo/metro-runtime": "~57.0.8",
"@irvingouj/expo-audio-stream": "3.1.0",
"expo": "~57.0.7",
+ "expo-audio": "~57.0.3",
"expo-location": "~57.0.9",
+ "expo-notifications": "~57.0.10",
"expo-secure-store": "~57.0.1",
"expo-sqlite": "~57.0.1",
"expo-status-bar": "~57.0.1",
+ "expo-task-manager": "~57.0.9",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.86.0",
"react-native-svg": "15.15.4",
"react-native-web": "^0.21.2",
- "rrule": "^2.8.1"
+ "rrule": "^2.8.1",
+ "timeflow-alarm": "file:modules/timeflow-alarm",
+ "timeflow-baidu-location": "file:modules/timeflow-baidu-location"
},
"devDependencies": {
"@testing-library/react-native": "13.3.3",
@@ -45,6 +50,22 @@
"npm": ">=10.8.2 <11"
}
},
+ "modules/timeflow-alarm": {
+ "version": "1.0.0",
+ "license": "UNLICENSED",
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*"
+ }
+ },
+ "modules/timeflow-baidu-location": {
+ "version": "1.0.0",
+ "license": "UNLICENSED",
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -2590,22 +2611,25 @@
}
},
"node_modules/@napi-rs/wasm-runtime": {
- "version": "1.1.6",
- "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
- "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.3.tgz",
+ "integrity": "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.3"
},
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=23.5.0"
+ },
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
- "@emnapi/core": "^1.7.1",
- "@emnapi/runtime": "^1.7.1"
+ "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4",
+ "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4"
}
},
"node_modules/@nolyfill/is-core-module": {
@@ -4707,6 +4731,12 @@
"@babel/core": "^7.0.0"
}
},
+ "node_modules/badgin": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz",
+ "integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==",
+ "license": "MIT"
+ },
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@@ -6888,6 +6918,55 @@
}
}
},
+ "node_modules/expo-application": {
+ "version": "57.0.2",
+ "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-57.0.2.tgz",
+ "integrity": "sha512-q31YwcXyymviAmdrtDfAg3Dld4VMxLCNAfgMHip7vZpPX4lzF/AfwsywqBJUAjQnJknzaNAkzqvMVjO5XmKYDA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "expo": "*"
+ }
+ },
+ "node_modules/expo-asset": {
+ "version": "57.0.10",
+ "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-57.0.10.tgz",
+ "integrity": "sha512-32QpNkWlb8ftxq3ClAriwFXcZlpzuP7Qx4z3Gc9vhbAm1QZzGsCN+PwYt3fN8W46HBw5qaI6DSXcAyBlYzeVqA==",
+ "license": "MIT",
+ "dependencies": {
+ "@expo/image-utils": "^0.11.4",
+ "expo-constants": "~57.0.10"
+ },
+ "peerDependencies": {
+ "expo": "*",
+ "react": "*",
+ "react-native": "*"
+ }
+ },
+ "node_modules/expo-audio": {
+ "version": "57.0.3",
+ "resolved": "https://registry.npmjs.org/expo-audio/-/expo-audio-57.0.3.tgz",
+ "integrity": "sha512-FzO0gnVmlrKmNoox7xc/795uNiuuqnYBovo2kgnNICDKJ0kDi1Y5UJjqX+NATxCelZcNv5BtWs3POkKJADhNCA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "expo": "*",
+ "expo-asset": "*",
+ "react": "*",
+ "react-native": "*"
+ }
+ },
+ "node_modules/expo-constants": {
+ "version": "57.0.10",
+ "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.10.tgz",
+ "integrity": "sha512-GCDXYEsloBfouMdT3BzoGhAkcLnYxEFNLQoSbNKIvIrD9FY5MmSeWuvRSveJdOiuydwQY2iH6hy020vTaQflCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@expo/env": "~2.4.2"
+ },
+ "peerDependencies": {
+ "expo": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/expo-location": {
"version": "57.0.9",
"resolved": "https://registry.npmjs.org/expo-location/-/expo-location-57.0.9.tgz",
@@ -6945,6 +7024,24 @@
"react-native": "*"
}
},
+ "node_modules/expo-notifications": {
+ "version": "57.0.10",
+ "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-57.0.10.tgz",
+ "integrity": "sha512-Zrwwd2eGzSuk3LyD01P5QzhiE/oXNNWaUq/NLQnPoeo63Ek2R6sWA00b0o0rl47uIqdmMtuQUfWbXgJJyc3Uag==",
+ "license": "MIT",
+ "dependencies": {
+ "@expo/image-utils": "^0.11.4",
+ "abort-controller": "^3.0.0",
+ "badgin": "^1.1.5",
+ "expo-application": "~57.0.2",
+ "expo-constants": "~57.0.10"
+ },
+ "peerDependencies": {
+ "expo": "*",
+ "react": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/expo-secure-store": {
"version": "57.0.1",
"resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-57.0.1.tgz",
@@ -6988,6 +7085,19 @@
"react-native": "*"
}
},
+ "node_modules/expo-task-manager": {
+ "version": "57.0.9",
+ "resolved": "https://registry.npmjs.org/expo-task-manager/-/expo-task-manager-57.0.9.tgz",
+ "integrity": "sha512-98L0EIexQkAxJ1GKPAev9rJcEJvZDfZOF8vmanvXisvfpC/Z0Rsel4vzV1bVldzXwQ4rMBu3V5C9evFJQMh1PA==",
+ "license": "MIT",
+ "dependencies": {
+ "unimodules-app-loader": "~57.0.1"
+ },
+ "peerDependencies": {
+ "expo": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/expo/node_modules/@expo/cli": {
"version": "57.0.9",
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.9.tgz",
@@ -7247,34 +7357,6 @@
"node": ">=8"
}
},
- "node_modules/expo/node_modules/expo-asset": {
- "version": "57.0.6",
- "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-57.0.6.tgz",
- "integrity": "sha512-n3Yb1VxcP+BMRTyC4R1x2It4+m5EDkNXiVCHGWbnIREQUUkMs2Yeul7D5qfFWAYtIn2Z3hbGMndwU6Az1FPSEg==",
- "license": "MIT",
- "dependencies": {
- "@expo/image-utils": "^0.11.3",
- "expo-constants": "~57.0.6"
- },
- "peerDependencies": {
- "expo": "*",
- "react": "*",
- "react-native": "*"
- }
- },
- "node_modules/expo/node_modules/expo-constants": {
- "version": "57.0.6",
- "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.6.tgz",
- "integrity": "sha512-OV+4XUshdO18TKNlo1cxUkXeJWgUOPgalvl8ofmc7kmPPHoyfz2hGJ94tyY/RND/GG5RREE+me9YHClNEzo+Ow==",
- "license": "MIT",
- "dependencies": {
- "@expo/env": "~2.4.2"
- },
- "peerDependencies": {
- "expo": "*",
- "react-native": "*"
- }
- },
"node_modules/expo/node_modules/expo-file-system": {
"version": "57.0.1",
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-57.0.1.tgz",
@@ -13842,6 +13924,14 @@
"integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==",
"license": "MIT"
},
+ "node_modules/timeflow-alarm": {
+ "resolved": "modules/timeflow-alarm",
+ "link": true
+ },
+ "node_modules/timeflow-baidu-location": {
+ "resolved": "modules/timeflow-baidu-location",
+ "link": true
+ },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -14246,6 +14336,12 @@
"node": ">=4"
}
},
+ "node_modules/unimodules-app-loader": {
+ "version": "57.0.1",
+ "resolved": "https://registry.npmjs.org/unimodules-app-loader/-/unimodules-app-loader-57.0.1.tgz",
+ "integrity": "sha512-wey5ChoJkCTq0j0JWdIMu2QB81vVrdhmrNAP14ZZ6WDslnZ7ff7Ezv8rMdEnVHaCKz3xK4mIVXbVU51xHgdyCA==",
+ "license": "MIT"
+ },
"node_modules/universalify": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index a8e99ea7..5bd00d59 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -11,16 +11,21 @@
"@expo/metro-runtime": "~57.0.8",
"@irvingouj/expo-audio-stream": "3.1.0",
"expo": "~57.0.7",
+ "expo-audio": "~57.0.3",
"expo-location": "~57.0.9",
+ "expo-notifications": "~57.0.10",
"expo-secure-store": "~57.0.1",
"expo-sqlite": "~57.0.1",
"expo-status-bar": "~57.0.1",
+ "expo-task-manager": "~57.0.9",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.86.0",
"react-native-svg": "15.15.4",
"react-native-web": "^0.21.2",
- "rrule": "^2.8.1"
+ "rrule": "^2.8.1",
+ "timeflow-alarm": "file:modules/timeflow-alarm",
+ "timeflow-baidu-location": "file:modules/timeflow-baidu-location"
},
"devDependencies": {
"@testing-library/react-native": "13.3.3",
diff --git a/frontend/patches/@irvingouj+expo-audio-stream+3.1.0.patch b/frontend/patches/@irvingouj+expo-audio-stream+3.1.0.patch
index 5c352fc2..6936bf65 100644
--- a/frontend/patches/@irvingouj+expo-audio-stream+3.1.0.patch
+++ b/frontend/patches/@irvingouj+expo-audio-stream+3.1.0.patch
@@ -1,3 +1,26 @@
+diff --git a/node_modules/@irvingouj/expo-audio-stream/android/build.gradle b/node_modules/@irvingouj/expo-audio-stream/android/build.gradle
+index c3038e4..dde8fe9 100644
+--- a/node_modules/@irvingouj/expo-audio-stream/android/build.gradle
++++ b/node_modules/@irvingouj/expo-audio-stream/android/build.gradle
+@@ -63,6 +63,18 @@ android {
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_11.majorVersion
+ }
++ } else {
++ // AGP>=8 时 javac 走 AGP 自己的默认值(17),但这个模块的 buildscript 单独
++ // pin 了一份 Kotlin Gradle Plugin,没有显式 jvmTarget 就会用跑 Gradle 的
++ // JDK(这台机器是 21)当目标,跟 javac 对不上,编译直接失败。
++ compileOptions {
++ sourceCompatibility JavaVersion.VERSION_17
++ targetCompatibility JavaVersion.VERSION_17
++ }
++
++ kotlinOptions {
++ jvmTarget = JavaVersion.VERSION_17.majorVersion
++ }
+ }
+
+ namespace "expo.modules.audiostream"
diff --git a/node_modules/@irvingouj/expo-audio-stream/android/src/main/java/expo/modules/audiostream/AudioRecorderManager.kt b/node_modules/@irvingouj/expo-audio-stream/android/src/main/java/expo/modules/audiostream/AudioRecorderManager.kt
index 9ee227e..0373656 100644
--- a/node_modules/@irvingouj/expo-audio-stream/android/src/main/java/expo/modules/audiostream/AudioRecorderManager.kt
diff --git a/frontend/plugins/withTimeflowAlarm.js b/frontend/plugins/withTimeflowAlarm.js
new file mode 100644
index 00000000..aebc28b1
--- /dev/null
+++ b/frontend/plugins/withTimeflowAlarm.js
@@ -0,0 +1,29 @@
+const { AndroidConfig, createRunOncePlugin, withAndroidManifest } = require('expo/config-plugins');
+
+const PACKAGE_NAME = 'timeflow-alarm';
+const PERMISSIONS = [
+ 'android.permission.POST_NOTIFICATIONS',
+ 'android.permission.SCHEDULE_EXACT_ALARM',
+ 'android.permission.SYSTEM_ALERT_WINDOW',
+ 'android.permission.USE_FULL_SCREEN_INTENT',
+ 'android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS',
+ 'android.permission.VIBRATE',
+ 'android.permission.FOREGROUND_SERVICE',
+ 'android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK',
+];
+
+/**
+ * 保证应用级闹钟权限在 prebuild 后仍然保留。
+ * 原生源码、AlarmPackage 自动链接与组件声明在 modules/timeflow-alarm
+ * (经 Android library manifest 合并)。
+ */
+function withTimeflowAlarm(config) {
+ config = AndroidConfig.Permissions.withPermissions(config, PERMISSIONS);
+ config = withAndroidManifest(config, (config) => {
+ AndroidConfig.Permissions.ensurePermissions(config.modResults, PERMISSIONS);
+ return config;
+ });
+ return config;
+}
+
+module.exports = createRunOncePlugin(withTimeflowAlarm, PACKAGE_NAME, '1.0.0');
diff --git a/frontend/plugins/withTimeflowBaiduLocation.js b/frontend/plugins/withTimeflowBaiduLocation.js
new file mode 100644
index 00000000..4a103523
--- /dev/null
+++ b/frontend/plugins/withTimeflowBaiduLocation.js
@@ -0,0 +1,59 @@
+const { AndroidConfig, createRunOncePlugin, withAndroidManifest } = require('expo/config-plugins');
+
+const PACKAGE_NAME = 'timeflow-baidu-location';
+const PERMISSIONS = [
+ 'android.permission.ACCESS_COARSE_LOCATION',
+ 'android.permission.ACCESS_FINE_LOCATION',
+ 'android.permission.ACCESS_BACKGROUND_LOCATION',
+ 'android.permission.ACCESS_WIFI_STATE',
+ 'android.permission.ACCESS_NETWORK_STATE',
+ 'android.permission.CHANGE_WIFI_STATE',
+ 'android.permission.INTERNET',
+ 'android.permission.FOREGROUND_SERVICE',
+ 'android.permission.FOREGROUND_SERVICE_LOCATION',
+];
+
+/**
+ * 注入百度定位 AK(com.baidu.lbsapi.API_KEY)与相关权限。
+ * 原生 LocationClient / Service 在 modules/timeflow-baidu-location。
+ *
+ * app.json:
+ * ["./plugins/withTimeflowBaiduLocation", { "apiKey": "YOUR_AK" }]
+ */
+function withTimeflowBaiduLocation(config, props = {}) {
+ const apiKey = typeof props.apiKey === 'string' ? props.apiKey.trim() : '';
+ if (!apiKey) {
+ throw new Error(
+ 'withTimeflowBaiduLocation: missing apiKey. Pass { apiKey } in app.json plugins.',
+ );
+ }
+
+ config = AndroidConfig.Permissions.withPermissions(config, PERMISSIONS);
+ config = withAndroidManifest(config, (config) => {
+ AndroidConfig.Permissions.ensurePermissions(config.modResults, PERMISSIONS);
+ const app = AndroidConfig.Manifest.getMainApplicationOrThrow(config.modResults);
+ ensureMetaData(app, 'com.baidu.lbsapi.API_KEY', apiKey);
+ return config;
+ });
+ return config;
+}
+
+function ensureMetaData(application, name, value) {
+ if (!application['meta-data']) {
+ application['meta-data'] = [];
+ }
+ const list = application['meta-data'];
+ const existing = list.find((item) => item?.$?.['android:name'] === name);
+ if (existing) {
+ existing.$['android:value'] = value;
+ return;
+ }
+ list.push({
+ $: {
+ 'android:name': name,
+ 'android:value': value,
+ },
+ });
+}
+
+module.exports = createRunOncePlugin(withTimeflowBaiduLocation, PACKAGE_NAME, '1.0.0');
diff --git a/frontend/react-native.config.js b/frontend/react-native.config.js
new file mode 100644
index 00000000..575aca2e
--- /dev/null
+++ b/frontend/react-native.config.js
@@ -0,0 +1,12 @@
+const path = require('path');
+
+module.exports = {
+ dependencies: {
+ 'timeflow-alarm': {
+ root: path.join(__dirname, 'modules/timeflow-alarm'),
+ },
+ 'timeflow-baidu-location': {
+ root: path.join(__dirname, 'modules/timeflow-baidu-location'),
+ },
+ },
+};
diff --git a/frontend/src/app/AppProviders.tsx b/frontend/src/app/AppProviders.tsx
index 9fb78e24..62ee61fc 100644
--- a/frontend/src/app/AppProviders.tsx
+++ b/frontend/src/app/AppProviders.tsx
@@ -1,7 +1,8 @@
-import { type PropsWithChildren, useEffect } from 'react';
+import { type PropsWithChildren, useCallback, useEffect } from 'react';
import type { AuthController, AuthInvalidationCoordinator } from '../features/auth/application';
import { AuthProvider, useAuth } from '../features/auth/presentation/AuthProvider';
+import { useReminderPermissionsOnLaunch } from '../features/reminder';
import { AppServicesProvider } from './composition/AppServicesProvider';
import type { AppServices } from './composition/createAppServices';
@@ -19,26 +20,37 @@ export function AppProviders({
return (
-
+
{children}
);
}
-function AuthenticatedRuntime({ runtime }: { readonly runtime: AppServices['runtime'] }) {
+function AuthenticatedRuntime({ services }: { readonly services: AppServices }) {
const { viewState } = useAuth();
+ const isAuthenticated = viewState.status === 'authenticated';
+
+ const onPermissionsUpdated = useCallback(() => {
+ void services.reminder.rebuild();
+ }, [services]);
+
+ useReminderPermissionsOnLaunch(
+ isAuthenticated ? services.reminderPorts.device : null,
+ isAuthenticated ? services.alertDialog : null,
+ onPermissionsUpdated,
+ );
useEffect(() => {
- if (viewState.status !== 'authenticated') {
+ if (!isAuthenticated) {
return;
}
- void runtime.start();
+ void services.runtime.start();
return () => {
- void runtime.stop();
+ void services.runtime.stop();
};
- }, [runtime, viewState.status]);
+ }, [isAuthenticated, services]);
return null;
}
diff --git a/frontend/src/app/AppRoot.tsx b/frontend/src/app/AppRoot.tsx
index dcde474a..6df0e7a5 100644
--- a/frontend/src/app/AppRoot.tsx
+++ b/frontend/src/app/AppRoot.tsx
@@ -10,6 +10,7 @@ import { AuthenticatedVoiceTransport } from '../features/assistant/data/websocke
import { LocalScheduleWriter } from '../features/assistant/data/local/LocalScheduleWriter';
import type { AuthController } from '../features/auth/application';
import { useAuth } from '../features/auth/presentation/AuthProvider';
+import type { SqliteLocalScheduleReader, SqliteReminderStateStore } from '../features/reminder';
import { SqliteScheduleClientService } from '../features/schedule/application';
import { ScheduleLocalRepository } from '../features/schedule/data';
import { RNAppStateProvider } from '../infrastructure/appState/RNAppStateProvider';
@@ -37,14 +38,22 @@ export function AppRoot({
invalidationCoordinator={authController ? undefined : services.auth.invalidationCoordinator}
services={services}
>
-
+
);
}
function AuthRoute({
+ reminderState,
+ scheduleReader,
webSocketClient,
}: {
+ readonly reminderState: SqliteReminderStateStore;
+ readonly scheduleReader: SqliteLocalScheduleReader;
readonly webSocketClient: AuthenticatedWebSocketClient;
}) {
const { retryInitialization, viewState } = useAuth();
@@ -77,6 +86,8 @@ function AuthRoute({
@@ -96,10 +107,14 @@ type ScheduleLoadState =
function AuthenticatedScheduleRoute({
accountId,
+ reminderState,
+ scheduleReader,
username,
webSocketClient,
}: {
readonly accountId: string;
+ readonly reminderState: SqliteReminderStateStore;
+ readonly scheduleReader: SqliteLocalScheduleReader;
readonly username: string;
readonly webSocketClient: AuthenticatedWebSocketClient;
}) {
@@ -150,6 +165,24 @@ function AuthenticatedScheduleRoute({
[currentLoadState],
);
+ // 提醒引擎(LocalReminderApplication)在认证态一变化就 start(),早于这里数据库
+ // 打开完成;SqliteLocalScheduleReader / SqliteReminderStateStore 都是整个 App
+ // 生命周期唯一一个实例,attach() 只是把它们接到这次登录/重试打开的仓储上。
+ // reminderState 要先于 refresh() 触发的 rebuild 接上,不然 rebuild 读到的运行时
+ // 状态还是空的,会把已经响过的提醒当成没响过又送一遍。
+ useEffect(() => {
+ if (currentLoadState?.status !== 'ready') {
+ return;
+ }
+ reminderState.attach(currentLoadState.repository, accountId);
+ scheduleReader.attach(currentLoadState.repository, accountId);
+ void scheduleReader.refresh();
+ return () => {
+ scheduleReader.detach();
+ reminderState.detach();
+ };
+ }, [accountId, currentLoadState, reminderState, scheduleReader]);
+
// 语音这条连接复用应用唯一的 AuthenticatedWebSocketClient——握手、鉴权失效、
// 断线通知都由它统一处理,这里不再单独持有 access_token/device_id/wsUrl。
// 按住说话和免提通话共用同一批 capture/playback/location/appState 端口
@@ -166,11 +199,11 @@ function AuthenticatedScheduleRoute({
return {
appState: new RNAppStateProvider(),
capture: new ExpoAudioCapture(),
- localScheduleWriter: new LocalScheduleWriter(currentLoadState.repository),
+ localScheduleWriter: new LocalScheduleWriter(currentLoadState.repository, scheduleReader),
location: new ExpoLocationProvider(),
playback: new ExpoAudioPlayback(),
};
- }, [currentLoadState]);
+ }, [currentLoadState, scheduleReader]);
const pushToTalkApplication = useMemo(() => {
if (assistantDependencies === null) {
diff --git a/frontend/src/app/composition/.gitkeep b/frontend/src/app/composition/.gitkeep
deleted file mode 100644
index 8b137891..00000000
--- a/frontend/src/app/composition/.gitkeep
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/frontend/src/app/composition/createAppServices.ts b/frontend/src/app/composition/createAppServices.ts
index 66823c6c..5b0f0377 100644
--- a/frontend/src/app/composition/createAppServices.ts
+++ b/frontend/src/app/composition/createAppServices.ts
@@ -1,30 +1,41 @@
import { AppRuntime } from '../orchestration/AppRuntime';
import { createAuthRuntime, type AuthRuntime, type CreateAuthRuntimeOptions } from '../authRuntime';
import type {
+ AlertDialogPort,
ReminderApplicationDependencies,
ReminderApplicationPort,
} from '../../features/reminder/application/interfaces';
+import { LocalReminderApplication } from '../../features/reminder/application';
import {
- MockLocalScheduleReader,
- MockReminderApplication,
- MockReminderDispositionSync,
- MockReminderStateStore,
+ LocalReminderDelivery,
+ LocalReminderDispositionSync,
+ LocalReminderRecovery,
+ NoopPopup,
+ SqliteLocalScheduleReader,
+ SqliteReminderStateStore,
} from '../../features/reminder/data/local';
-import { MockAudioPlayback } from '../../infrastructure/audio';
-import { MockLocationMonitor } from '../../infrastructure/location';
+import { AlertReminderPresenter } from '../../features/reminder/presentation';
+import { ExpoAudioPlayback } from '../../infrastructure/audio';
+// NativeLocationMonitor(百度定位 SDK)保留在仓库里没删,只是这次没接进来——现在用的
+// 是 ExpoLocationMonitor(系统原生地理围栏),不依赖百度控制台那个 AK 注册。百度那个
+// Key 配置好之后如果想切回去,把下面这行 import 和 reminderPorts.location 换回来就行。
+import { ExpoLocationMonitor } from '../../infrastructure/location';
import {
- MockPopup,
- MockReminderRecovery,
- MockReminderDelivery,
- MockSystemNotification,
- MockVibration,
+ ExpoSystemNotification,
NativeAlarmScheduler,
NativeDeviceCapability,
+ ReactNativeAlertDialog,
+ ReactNativeVibration,
} from '../../infrastructure/notifications';
-import { MockTimeListener } from '../../shared/time';
-import { MockReminderPresenter } from '../../features/reminder/presentation';
+import { IntervalTimeListener } from '../../infrastructure/time';
import { ScheduleViewStore } from '../../features/schedule/presentation';
+export interface CreateAppServicesOptions {
+ readonly auth?: CreateAuthRuntimeOptions;
+ readonly schedules?: SqliteLocalScheduleReader;
+ readonly overrides?: Partial;
+}
+
export type AppServices = {
auth: AuthRuntime;
protectedClient: AuthRuntime['protectedClient'];
@@ -33,32 +44,47 @@ export type AppServices = {
reminderPorts: ReminderApplicationDependencies;
scheduleView: ScheduleViewStore;
webSocketClient: AuthRuntime['webSocketClient'];
+ /** 需要 attach()/detach()/refresh() 时用这些具体类型;LocalReminderApplication 只经 reminderPorts 访问端口接口。 */
+ schedules: SqliteLocalScheduleReader;
+ reminderState: SqliteReminderStateStore;
+ alertDialog: AlertDialogPort;
};
-export interface CreateAppServicesOptions {
- readonly auth?: CreateAuthRuntimeOptions;
-}
-
/** 应用唯一组合根:认证传输、功能服务、生命周期和账号内存清理在此接线。 */
export function createAppServices(options: CreateAppServicesOptions = {}): AppServices {
const auth = createAuthRuntime(options.auth);
+ const alertDialog = new ReactNativeAlertDialog();
+ const schedules = options.schedules ?? new SqliteLocalScheduleReader();
+ const reminderState = new SqliteReminderStateStore();
+ const presenter =
+ (options.overrides?.presenter as AlertReminderPresenter | undefined) ??
+ new AlertReminderPresenter(alertDialog);
+
+ const {
+ schedules: _ignoredSchedules,
+ presenter: _ignoredPresenter,
+ ...restOverrides
+ } = options.overrides ?? {};
+
const reminderPorts: ReminderApplicationDependencies = {
- schedules: new MockLocalScheduleReader(),
- time: new MockTimeListener(),
- location: new MockLocationMonitor(),
+ time: new IntervalTimeListener(),
+ location: new ExpoLocationMonitor(),
alarms: new NativeAlarmScheduler(),
- delivery: new MockReminderDelivery(),
- audio: new MockAudioPlayback(),
+ delivery: new LocalReminderDelivery(),
+ audio: new ExpoAudioPlayback(),
device: new NativeDeviceCapability(),
- presenter: new MockReminderPresenter(),
- systemNotification: new MockSystemNotification(),
- popup: new MockPopup(),
- vibration: new MockVibration(),
- recovery: new MockReminderRecovery(),
- state: new MockReminderStateStore(),
- dispositionSync: new MockReminderDispositionSync(),
+ systemNotification: new ExpoSystemNotification(),
+ popup: new NoopPopup(),
+ vibration: new ReactNativeVibration(),
+ recovery: new LocalReminderRecovery(),
+ state: reminderState,
+ dispositionSync: new LocalReminderDispositionSync(),
+ ...restOverrides,
+ schedules,
+ presenter,
};
- const reminder = new MockReminderApplication(reminderPorts);
+
+ const reminder = new LocalReminderApplication(reminderPorts);
const scheduleView = new ScheduleViewStore();
const runtime = new AppRuntime([
{
@@ -78,5 +104,8 @@ export function createAppServices(options: CreateAppServicesOptions = {}): AppSe
reminderPorts,
scheduleView,
webSocketClient: auth.webSocketClient,
+ schedules,
+ reminderState,
+ alertDialog,
};
}
diff --git a/frontend/src/app/orchestration/.gitkeep b/frontend/src/app/orchestration/.gitkeep
deleted file mode 100644
index 8b137891..00000000
--- a/frontend/src/app/orchestration/.gitkeep
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts b/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts
index 18e84ab6..5dd367f0 100644
--- a/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts
+++ b/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts
@@ -52,6 +52,8 @@ export class AssistantContinuousConversationService implements AssistantApplicat
private streamId: string | null = null;
/** 非 null 表示当前正处于 voice.tts.start 和 voice.tts.end/canceled 之间。 */
private currentAudioId: string | null = null;
+ /** 最近被打断的音频 id;服务端会在 canceled 后补发同 id 的 tts.end。 */
+ private canceledAudioId: string | null = null;
private streamStartedWaiter: ((conversationId: string) => void) | null = null;
/** 跟 streamStartedWaiter 配对;传输层报错/连接掉线时用它让等待方结束,不然会永远卡住。 */
private streamStartRejecter: ((error: Error) => void) | null = null;
@@ -81,6 +83,8 @@ export class AssistantContinuousConversationService implements AssistantApplicat
* startStream() 内部有一个没人等的 await(配置原生播放器),如果紧跟着的
* 第一块音频不排在它后面,可能在原生侧还没配置完时就到达。 */
private playbackChain: Promise = Promise.resolve();
+ /** 取消时递增,让已经排队但尚未执行的旧流操作失效。 */
+ private playbackGeneration = 0;
private readonly unsubscribeAppState: () => void;
constructor(
@@ -237,7 +241,7 @@ export class AssistantContinuousConversationService implements AssistantApplicat
this.replyText = null;
this.currentAudioId = null;
this.notifyListeners();
- await this.deps.playback.stop().catch(() => {});
+ await this.stopPlaybackImmediately();
}
/** 用户点圆圈暂停/恢复。暂停期间空闲计时器照常跑——忘记恢复也会兜底挂断。 */
@@ -286,13 +290,22 @@ export class AssistantContinuousConversationService implements AssistantApplicat
// (尤其冷启动 GPS)可能耗时数秒,放在连接之后拿会把这段时间算进握手预算,
// 导致 hello 送达前就被服务端以 1008 断开。超时兜底,拿不到就不带,不阻塞握手。
let timeoutId: ReturnType | undefined;
+ let locationTimedOut = false;
const sample = await Promise.race([
this.deps.location.getCurrentSample().catch(() => null),
new Promise((resolve) => {
- timeoutId = setTimeout(() => resolve(null), LOCATION_TIMEOUT_MS);
+ timeoutId = setTimeout(() => {
+ locationTimedOut = true;
+ resolve(null);
+ }, LOCATION_TIMEOUT_MS);
}),
]);
clearTimeout(timeoutId);
+ if (sample === null) {
+ console.warn('[location-search] voice handshake has no location', {
+ reason: locationTimedOut ? 'timeout' : 'unavailable',
+ });
+ }
// session.hello → session.ready 的握手已经在 transport.connect() 内部完成
// (共享的 AuthenticatedWebSocketClient 负责,voice_mode 已经绑定在这个
@@ -362,6 +375,8 @@ export class AssistantContinuousConversationService implements AssistantApplicat
this.notifyListeners();
return;
case 'voice.tts.start':
+ this.playbackGeneration += 1;
+ this.canceledAudioId = null;
this.currentAudioId = message.audio_id;
this.setState({ conversationId: message.conversation_id, phase: 'speaking' });
this.chainPlayback(() =>
@@ -372,7 +387,16 @@ export class AssistantContinuousConversationService implements AssistantApplicat
);
return;
case 'voice.tts.end':
+ // canceled 后服务端仍会补发同一条 tts.end;它不能收尾新流,也不能把
+ // interrupted 状态提前改回 listening。
+ if (
+ (this.canceledAudioId !== null && message.audio_id === this.canceledAudioId) ||
+ (this.currentAudioId !== null && message.audio_id !== this.currentAudioId)
+ ) {
+ return;
+ }
this.currentAudioId = null;
+ this.canceledAudioId = null;
this.chainPlayback(() => this.deps.playback.endStream());
this.setState({ conversationId: message.conversation_id, phase: 'listening' });
// 播报完成后给一个全新的窗口,对应"播报完成后进入短暂等待"——不用单独
@@ -380,10 +404,17 @@ export class AssistantContinuousConversationService implements AssistantApplicat
this.armIdleTimer();
return;
case 'voice.tts.canceled':
- // 用户开口打断了正在播的回复:立刻丢掉播放端缓冲里还没放出来的音频,
- // 而不是等 voice.tts.end(后面仍会补发,但语义已经不是"正常说完")。
+ // 用户开口打断了正在播的回复:stop 必须绕过 playbackChain 立即执行,
+ // 否则已排队的 PCM 会先继续喂给原生播放器;旧队列随后由代次检查丢弃。
+ if (
+ this.currentAudioId !== null &&
+ (message.audio_id === '' || message.audio_id !== this.currentAudioId)
+ ) {
+ return;
+ }
+ this.canceledAudioId = message.audio_id || this.currentAudioId;
this.currentAudioId = null;
- this.chainPlayback(() => this.deps.playback.stop());
+ void this.stopPlaybackImmediately();
this.setState({ conversationId: message.conversation_id, phase: 'interrupted' });
return;
case 'voice.session.end':
@@ -422,9 +453,25 @@ export class AssistantContinuousConversationService implements AssistantApplicat
}
/** 把一次对原生播放模块的调用接到 playbackChain 末尾,保证上一次真正执行完
- * (不管成功与否)才轮到这一次。 */
+ * (不管成功与否)才轮到这一次;取消后旧代次的操作会被跳过。 */
private chainPlayback(run: () => Promise): void {
- this.playbackChain = this.playbackChain.then(run).catch(() => {});
+ const generation = this.playbackGeneration;
+ this.playbackChain = this.playbackChain
+ .then(async () => {
+ if (generation !== this.playbackGeneration) {
+ return;
+ }
+ await run();
+ })
+ .catch(() => {});
+ }
+
+ /** 立即清空原生播放器,并把后续新操作排在 stop 完成之后。 */
+ private async stopPlaybackImmediately(): Promise {
+ this.playbackGeneration += 1;
+ const stop = this.deps.playback.stop().catch(() => {});
+ this.playbackChain = stop;
+ await stop;
}
private handleClose(event: { code: number; reason: string }): void {
diff --git a/frontend/src/features/assistant/application/AssistantConversationService.ts b/frontend/src/features/assistant/application/AssistantConversationService.ts
index c58bfbe7..da30af94 100644
--- a/frontend/src/features/assistant/application/AssistantConversationService.ts
+++ b/frontend/src/features/assistant/application/AssistantConversationService.ts
@@ -180,13 +180,22 @@ export class AssistantConversationService implements AssistantApplicationPort {
// 清掉超时定时器,不然赢了比赛的那次调用还会留一个挂到 2s 之后才触发的
// 空转定时器(无功能影响,但测试环境里会被当成没清理干净的异步句柄)。
let timeoutId: ReturnType | undefined;
+ let locationTimedOut = false;
const sample = await Promise.race([
this.deps.location.getCurrentSample().catch(() => null),
new Promise((resolve) => {
- timeoutId = setTimeout(() => resolve(null), LOCATION_TIMEOUT_MS);
+ timeoutId = setTimeout(() => {
+ locationTimedOut = true;
+ resolve(null);
+ }, LOCATION_TIMEOUT_MS);
}),
]);
clearTimeout(timeoutId);
+ if (sample === null) {
+ console.warn('[location-search] voice handshake has no location', {
+ reason: locationTimedOut ? 'timeout' : 'unavailable',
+ });
+ }
// session.hello → session.ready 的握手已经在 transport.connect() 内部完成
// (共享的 AuthenticatedWebSocketClient 负责),这里拿到的就是已经 ready 的连接。
@@ -288,6 +297,10 @@ export class AssistantConversationService implements AssistantApplicationPort {
}
private handleClose(event: { code: number; reason: string }): void {
+ // 从按住说话切到连续对话时,共享 WS 会因 voiceMode 不同而主动断开重连。
+ // 旧连接若只把 unsubscribeConnection 置空却不执行,旧服务仍会订阅新连接的
+ // TTS/PCM,并与连续对话服务把同一句话重复送进播放器。
+ this.unsubscribeConnection?.();
this.connection = null;
this.unsubscribeConnection = null;
const message = event.reason || `连接已断开(${event.code})`;
diff --git a/frontend/src/features/assistant/data/local/LocalScheduleWriter.ts b/frontend/src/features/assistant/data/local/LocalScheduleWriter.ts
index b8b432ef..9133c088 100644
--- a/frontend/src/features/assistant/data/local/LocalScheduleWriter.ts
+++ b/frontend/src/features/assistant/data/local/LocalScheduleWriter.ts
@@ -1,3 +1,4 @@
+import type { SqliteLocalScheduleReader } from '../../../reminder';
import type { CloudScheduleRow, ScheduleLocalRepository } from '../../../schedule/data';
import type { LocalScheduleWriterPort } from '../../application/interfaces/LocalScheduleWriterPort';
import type { AppliedCommand } from '../../domain/ConversationTurn';
@@ -23,13 +24,19 @@ import type { AppliedCommand } from '../../domain/ConversationTurn';
* 同步,得等后端把 override 数据也传下来。
*/
export class LocalScheduleWriter implements LocalScheduleWriterPort {
- public constructor(private readonly repository: ScheduleLocalRepository) {}
+ public constructor(
+ private readonly repository: ScheduleLocalRepository,
+ private readonly scheduleReader?: SqliteLocalScheduleReader,
+ ) {}
public async applyCommandResult(accountId: string, command: AppliedCommand): Promise {
if (command.status !== 'applied' || !command.schedule) {
return;
}
await this.repository.applyCloudSchedule(toCloudScheduleRow(accountId, command.schedule));
+ // 提醒引擎读的是 SqliteLocalScheduleReader 的投影,不是这个仓储本身;写完
+ // 必须主动刷新一次,不然新建/改动的日程要等下次 rebuild 才会被提醒引擎看到。
+ await this.scheduleReader?.refresh();
}
}
diff --git a/frontend/src/features/reminder/application/LocalReminderApplication.ts b/frontend/src/features/reminder/application/LocalReminderApplication.ts
index e8904c8a..0de4509f 100644
--- a/frontend/src/features/reminder/application/LocalReminderApplication.ts
+++ b/frontend/src/features/reminder/application/LocalReminderApplication.ts
@@ -7,8 +7,10 @@ import type {
LocationMonitorEvent,
LocationWatchHandle,
AlarmScheduleReceipt,
+ AlarmNativeEvent,
} from './interfaces';
import type {
+ DeliveryChannel,
LocalReminderSchedule,
LocationSample,
ReminderDeliveryReceipt,
@@ -21,6 +23,7 @@ import type {
} from '../domain';
import { DEFAULT_SNOOZE_MINUTES } from '../domain';
import { evaluateGeofence, resolveGeofenceCenter, resolveWatchMode } from '../domain/geofence';
+import { resolveStrengthDeliveryPlan } from '../domain/strengthDelivery';
import {
isSnoozeActive,
isSnoozeExpired,
@@ -50,9 +53,12 @@ export class LocalReminderApplication implements ReminderApplicationPort {
private timeListenerId: string | null = null;
private unsubscribePresenter: (() => void) | null = null;
private unsubscribeSchedules: (() => void) | null = null;
+ private unsubscribeAlarms: (() => void) | null = null;
private readonly registrations = new Map();
private readonly activeDeliveries = new Set();
private readonly deliverLocks = new Set();
+ /** 原生已响铃、由原生 UI 承接展示的日程;JS 不再叠加弹窗/TTS。 */
+ private readonly nativePresented = new Set();
private readonly inFlight = new Set>();
private opChain: Promise = Promise.resolve();
/** 每次 stop / 失败回滚自增;停机前开始的工作持有旧世代,重启后仍视为已取消。 */
@@ -161,6 +167,10 @@ export class LocalReminderApplication implements ReminderApplicationPort {
this.unsubscribePresenter = this.dependencies.presenter.onAction((event) => {
void this.handlePresentationAction(event.schedule_id, event.action);
});
+ this.unsubscribeAlarms =
+ this.dependencies.alarms.subscribe?.((event) => {
+ void this.handleNativeAlarmEvent(event);
+ }) ?? null;
// IntervalTimeListener 不在 start 时同步打点;先挂上 listener id 再 rebuild。
const timeHandle = await this.dependencies.time.start(
@@ -175,6 +185,12 @@ export class LocalReminderApplication implements ReminderApplicationPort {
return;
}
+ await this.hydrateNativeDispositions();
+ if (!this.isLive(generation)) {
+ await this.stopInternal();
+ return;
+ }
+
this.unsubscribeSchedules = this.dependencies.schedules.subscribe(() => {
void this.enqueueRebuild();
});
@@ -209,6 +225,7 @@ export class LocalReminderApplication implements ReminderApplicationPort {
}
this.activeDeliveries.clear();
this.deliverLocks.clear();
+ this.nativePresented.clear();
this.started = false;
}
@@ -217,6 +234,8 @@ export class LocalReminderApplication implements ReminderApplicationPort {
this.unsubscribePresenter = null;
this.unsubscribeSchedules?.();
this.unsubscribeSchedules = null;
+ this.unsubscribeAlarms?.();
+ this.unsubscribeAlarms = null;
}
private async registerInternal(
@@ -283,8 +302,22 @@ export class LocalReminderApplication implements ReminderApplicationPort {
if (!this.isLive(generation)) return [];
- const locationSchedules = active.filter((schedule) => schedule.schedule_type === 'location');
- const locationHandles = await this.dependencies.location.rebuild(locationSchedules, (event) => {
+ const locationTargets = active
+ .filter((schedule) => schedule.schedule_type === 'location')
+ .map((schedule) => {
+ const mode = resolveWatchMode(schedule);
+ const center = resolveGeofenceCenter(schedule, mode);
+ if (center == null) return null;
+ return {
+ schedule_id: schedule.id,
+ center,
+ radius_meters: schedule.geofence_radius_meters,
+ mode,
+ background: true,
+ };
+ })
+ .filter((target): target is NonNullable => target != null);
+ const locationHandles = await this.dependencies.location.rebuild(locationTargets, (event) => {
void this.handleLocationMonitorEvent(event);
});
const locationBySchedule = new Map(
@@ -563,25 +596,58 @@ export class LocalReminderApplication implements ReminderApplicationPort {
});
const request = toDeliveryRequest(schedule, trigger);
- const receipt = await this.dependencies.delivery.deliver(request);
- await this.dependencies.presenter.show(request);
- await this.dependencies.systemNotification.show({
- notification_id: `reminder-${schedule.id}`,
- title: schedule.title,
- body: schedule.location_name ?? schedule.title,
- });
- await this.dependencies.popup.show({
- popup_id: `reminder-${schedule.id}`,
- title: schedule.title,
- body: schedule.location_name ?? schedule.title,
- });
- await this.dependencies.vibration.vibrate();
-
- let audioReceipt = await this.dependencies.audio.playTts({ schedule_id: schedule.id });
- if (!audioReceipt.played) {
- audioReceipt = await this.dependencies.audio.playLocalFallback({
- schedule_id: schedule.id,
+ const plan = resolveStrengthDeliveryPlan(request.strength);
+ const channels: DeliveryChannel[] = [];
+ 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}`,
+ title: schedule.title,
+ body: schedule.location_name ?? schedule.title,
});
+ 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');
+ }
+
+ 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,
+ });
+ }
+ 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)) {
@@ -593,14 +659,11 @@ export class LocalReminderApplication implements ReminderApplicationPort {
}
return {
- ...receipt,
- channels: [
- ...receipt.channels,
- 'popup',
- 'vibration',
- audioReceipt.used_local_fallback ? 'local_sound' : 'tts',
- ],
- used_fallback_audio: audioReceipt.used_local_fallback,
+ delivery_id: deliveryId,
+ schedule_id: schedule.id,
+ delivered_at: trigger.triggered_at,
+ channels,
+ used_fallback_audio: usedFallbackAudio,
};
} catch (error) {
if (!this.acceptingWork || this.isLive(generation)) {
@@ -641,12 +704,83 @@ export class LocalReminderApplication implements ReminderApplicationPort {
}
await this.enqueueOp(() =>
this.snoozeInternal(
- { schedule_id: scheduleId, snooze_minutes: DEFAULT_SNOOZE_MINUTES },
+ { schedule_id: scheduleId, snooze_until: null, snooze_minutes: DEFAULT_SNOOZE_MINUTES },
generation,
),
);
}
+ private async handleNativeAlarmEvent(event: AlarmNativeEvent): Promise {
+ if (!event.schedule_id) return;
+ if (event.type === 'fired') {
+ await this.acknowledgeNativeFire(event.schedule_id, event.at);
+ return;
+ }
+ if (event.type === 'snoozed') {
+ await this.snooze({ schedule_id: event.schedule_id, snooze_until: null });
+ return;
+ }
+ await this.confirm(event.schedule_id, event.at);
+ }
+
+ /**
+ * 原生已承接响铃 UI:只落 pending disposition,避免 JS 再弹 Alert/TTS。
+ * handleTime 会因 pending / activeDeliveries 跳过,从而消除双通道连响。
+ */
+ private async acknowledgeNativeFire(scheduleId: string, firedAt: string): Promise {
+ const runtime = (await this.readRuntime(scheduleId)) ?? emptyRuntime();
+ if (
+ runtime.reminder_disposition_state === 'confirmed' ||
+ runtime.reminder_disposition_state === 'pending'
+ ) {
+ this.nativePresented.add(scheduleId);
+ this.activeDeliveries.add(scheduleId);
+ return;
+ }
+
+ this.nativePresented.add(scheduleId);
+ this.activeDeliveries.add(scheduleId);
+ await this.cancelScheduledAlarm(scheduleId);
+ await this.patchRuntime(scheduleId, {
+ ...runtime,
+ reminder_disposition_state: 'pending',
+ next_trigger_at: null,
+ disposition_updated_at: firedAt,
+ sync_status: 'pending',
+ });
+ await this.dependencies.state.setDisposition(scheduleId, {
+ schedule_id: scheduleId,
+ state: 'pending',
+ updated_at: firedAt,
+ snoozed_until: null,
+ sync_status: 'pending',
+ });
+ }
+
+ private async hydrateNativeDispositions(): Promise {
+ const rows = await this.dependencies.alarms.consumeNativeDispositions?.();
+ if (rows == null || rows.length === 0) return;
+
+ for (const row of rows) {
+ if (row.state === 'confirmed') {
+ await this.confirm(row.schedule_id, row.updated_at);
+ continue;
+ }
+ if (row.state === 'snoozed') {
+ await this.snooze({ schedule_id: row.schedule_id, snooze_until: null });
+ continue;
+ }
+ await this.acknowledgeNativeFire(row.schedule_id, row.updated_at);
+ }
+ }
+
+ private async cancelScheduledAlarm(scheduleId: string): Promise {
+ const registration = this.registrations.get(scheduleId);
+ if (registration?.alarm_id == null) return;
+ await this.dependencies.alarms.cancel(registration.alarm_id);
+ registration.alarm_id = null;
+ }
+
private async handleLocationMonitorEvent(event: LocationMonitorEvent): Promise {
const generation = this.generation;
await this.track(this.runLocationMonitorEvent(event, generation));
diff --git a/frontend/src/features/reminder/application/index.ts b/frontend/src/features/reminder/application/index.ts
index 6e87c456..63351bc7 100644
--- a/frontend/src/features/reminder/application/index.ts
+++ b/frontend/src/features/reminder/application/index.ts
@@ -1,5 +1,7 @@
export { LocalReminderApplication } from './LocalReminderApplication';
export type {
+ AlarmNativeDisposition,
+ AlarmNativeEvent,
AlarmScheduleReceipt,
AlarmScheduleRequest,
AlarmSchedulerPort,
@@ -13,9 +15,13 @@ export type {
LocalTimeTick,
LocationMonitorEvent,
LocationMonitorPort,
+ LocationRebuildTarget,
LocationWatchHandle,
LocationWatchMode,
LocationWatchRequest,
+ AlertDialogButton,
+ AlertDialogPort,
+ AlertDialogRequest,
PopupPort,
PopupReceipt,
PopupRequest,
@@ -24,6 +30,8 @@ export type {
ReminderApplicationResult,
ReminderConfirmedDisposition,
ReminderDeliveryPort,
+ ReminderDeliveryReceipt,
+ ReminderDeliveryRequest,
ReminderDispositionSyncPort,
ReminderDispositionSyncReceipt,
ReminderPresentationAction,
diff --git a/frontend/src/features/reminder/application/interfaces/.gitkeep b/frontend/src/features/reminder/application/interfaces/.gitkeep
deleted file mode 100644
index 8b137891..00000000
--- a/frontend/src/features/reminder/application/interfaces/.gitkeep
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts b/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts
index 17949c02..b7bb86fc 100644
--- a/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts
+++ b/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts
@@ -12,9 +12,30 @@ export type AlarmScheduleReceipt = {
scheduled: boolean;
};
+export type AlarmNativeEvent = {
+ type: 'fired' | 'dismissed' | 'snoozed';
+ schedule_id: string;
+ alarm_id: string;
+ title: string;
+ at: string;
+};
+
+export type AlarmNativeDisposition = {
+ schedule_id: string;
+ alarm_id: string;
+ state: 'pending' | 'confirmed' | 'snoozed';
+ updated_at: string;
+};
+
/** 原生闹钟映射边界;触发时间的选择留在应用层或领域层。 */
export interface AlarmSchedulerPort {
schedule(request: AlarmScheduleRequest): Promise;
cancel(alarmId: string | null): Promise<{ cancelled: boolean }>;
rebuild(requests: readonly AlarmScheduleRequest[]): Promise;
+ /** 停止正在响铃的原生界面/音频(尽力而为)。 */
+ stopRinging?(): Promise;
+ /** 订阅原生响铃/停铃事件;无原生桥时可不实现。 */
+ subscribe?(listener: (event: AlarmNativeEvent) => void): () => void;
+ /** 拉取并清空进程外写入的处置状态(冷启动补水)。 */
+ consumeNativeDispositions?(): Promise;
}
diff --git a/frontend/src/features/reminder/application/interfaces/DeviceCapabilityPort.ts b/frontend/src/features/reminder/application/interfaces/DeviceCapabilityPort.ts
index 9ba6ae5c..1eb2b688 100644
--- a/frontend/src/features/reminder/application/interfaces/DeviceCapabilityPort.ts
+++ b/frontend/src/features/reminder/application/interfaces/DeviceCapabilityPort.ts
@@ -19,4 +19,6 @@ export interface DeviceCapabilityPort {
getStatus(): Promise;
requestPermission(permission: DevicePermission): Promise;
openSettings(permission: DevicePermission): Promise;
+ /** 订阅应用回到前台;返回取消订阅函数。 */
+ onAppActive(listener: () => void): () => void;
}
diff --git a/frontend/src/features/reminder/application/interfaces/LocationMonitorPort.ts b/frontend/src/features/reminder/application/interfaces/LocationMonitorPort.ts
index 05711ee8..aa2dfa58 100644
--- a/frontend/src/features/reminder/application/interfaces/LocationMonitorPort.ts
+++ b/frontend/src/features/reminder/application/interfaces/LocationMonitorPort.ts
@@ -1,7 +1,19 @@
-import type { GeoPoint, LocalReminderSchedule, LocationSample } from '../../domain';
+import type { GeoPoint, LocationSample } from '../../domain';
export type LocationWatchMode = 'arrive' | 'return';
+/**
+ * 围栏重建目标:携带 watch 所需几何参数,避免 infrastructure 依赖完整领域日程,
+ * 同时保证冷启动 rebuild 能重新挂上系统围栏。
+ */
+export type LocationRebuildTarget = {
+ schedule_id: string;
+ center: GeoPoint;
+ radius_meters: number;
+ mode: LocationWatchMode;
+ background: boolean;
+};
+
export type LocationWatchRequest = {
schedule_id: string;
center: GeoPoint;
@@ -29,7 +41,7 @@ export interface LocationMonitorPort {
): Promise;
unwatch(listenerId: string): Promise;
rebuild(
- schedules: readonly LocalReminderSchedule[],
+ targets: readonly LocationRebuildTarget[],
listener: (event: LocationMonitorEvent) => void,
): Promise;
getLastSample(): Promise;
diff --git a/frontend/src/features/reminder/application/interfaces/NotificationChannels.ts b/frontend/src/features/reminder/application/interfaces/NotificationChannels.ts
index 3c62b6e9..c9706ee5 100644
--- a/frontend/src/features/reminder/application/interfaces/NotificationChannels.ts
+++ b/frontend/src/features/reminder/application/interfaces/NotificationChannels.ts
@@ -34,3 +34,23 @@ export interface VibrationPort {
vibrate(): Promise;
stop(): Promise;
}
+
+export type AlertDialogButton = {
+ text: string;
+ style?: 'default' | 'cancel' | 'destructive';
+ onPress?: () => void;
+};
+
+export type AlertDialogRequest = {
+ title: string;
+ message: string;
+ buttons: readonly AlertDialogButton[];
+ /** Android:返回键关闭时回调;未点按钮也要能结束等待。 */
+ onDismiss?: () => void;
+ cancelable?: boolean;
+};
+
+/** 系统确认/选择对话框;由 infrastructure 适配,presentation 不直接依赖 RN Alert。 */
+export interface AlertDialogPort {
+ show(request: AlertDialogRequest): Promise;
+}
diff --git a/frontend/src/features/reminder/application/interfaces/ReminderApplicationPort.ts b/frontend/src/features/reminder/application/interfaces/ReminderApplicationPort.ts
index 903b1dbd..5f0b4fe8 100644
--- a/frontend/src/features/reminder/application/interfaces/ReminderApplicationPort.ts
+++ b/frontend/src/features/reminder/application/interfaces/ReminderApplicationPort.ts
@@ -24,21 +24,11 @@ export type ReminderApplicationDependencies = {
dispositionSync: import('./ReminderDispositionSyncPort').ReminderDispositionSyncPort;
};
-/**
- * 贪睡必须且只能提供一种目标时刻表达:绝对时间或相对分钟。
- * 两种字段互斥,避免实现层自行决定优先级,也禁止无目标时刻的请求。
- */
-export type ReminderSnoozeRequest =
- | {
- schedule_id: string;
- snooze_until: string;
- snooze_minutes?: never;
- }
- | {
- schedule_id: string;
- snooze_minutes: number;
- snooze_until?: never;
- };
+export type ReminderSnoozeRequest = {
+ schedule_id: string;
+ snooze_until: string | null;
+ snooze_minutes?: number | null;
+};
export type ReminderApplicationResult = {
accepted: boolean;
diff --git a/frontend/src/features/reminder/application/interfaces/ReminderDeliveryPort.ts b/frontend/src/features/reminder/application/interfaces/ReminderDeliveryPort.ts
index f13ba733..611712a6 100644
--- a/frontend/src/features/reminder/application/interfaces/ReminderDeliveryPort.ts
+++ b/frontend/src/features/reminder/application/interfaces/ReminderDeliveryPort.ts
@@ -1,5 +1,7 @@
import type { ReminderDeliveryReceipt, ReminderDeliveryRequest } from '../../domain';
+export type { ReminderDeliveryReceipt, ReminderDeliveryRequest };
+
/** 通过与平台无关的展示边界送达提醒。 */
export interface ReminderDeliveryPort {
deliver(request: ReminderDeliveryRequest): Promise;
diff --git a/frontend/src/features/reminder/application/interfaces/index.ts b/frontend/src/features/reminder/application/interfaces/index.ts
index ca99f198..788b9f5d 100644
--- a/frontend/src/features/reminder/application/interfaces/index.ts
+++ b/frontend/src/features/reminder/application/interfaces/index.ts
@@ -1,4 +1,6 @@
export type {
+ AlarmNativeDisposition,
+ AlarmNativeEvent,
AlarmScheduleReceipt,
AlarmScheduleRequest,
AlarmSchedulerPort,
@@ -17,11 +19,15 @@ export type { LocalScheduleReader } from './LocalScheduleReader';
export type {
LocationMonitorEvent,
LocationMonitorPort,
+ LocationRebuildTarget,
LocationWatchHandle,
LocationWatchMode,
LocationWatchRequest,
} from './LocationMonitorPort';
export type {
+ AlertDialogButton,
+ AlertDialogPort,
+ AlertDialogRequest,
PopupPort,
PopupReceipt,
PopupRequest,
@@ -36,7 +42,11 @@ export type {
ReminderApplicationResult,
ReminderSnoozeRequest,
} from './ReminderApplicationPort';
-export type { ReminderDeliveryPort } from './ReminderDeliveryPort';
+export type {
+ ReminderDeliveryPort,
+ ReminderDeliveryReceipt,
+ ReminderDeliveryRequest,
+} from './ReminderDeliveryPort';
export type {
ReminderConfirmedDisposition,
ReminderDispositionSyncPort,
diff --git a/frontend/src/features/reminder/data/local/.gitkeep b/frontend/src/features/reminder/data/local/.gitkeep
deleted file mode 100644
index 8b137891..00000000
--- a/frontend/src/features/reminder/data/local/.gitkeep
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/frontend/src/features/reminder/data/local/InMemoryLocalScheduleReader.ts b/frontend/src/features/reminder/data/local/InMemoryLocalScheduleReader.ts
new file mode 100644
index 00000000..fd175135
--- /dev/null
+++ b/frontend/src/features/reminder/data/local/InMemoryLocalScheduleReader.ts
@@ -0,0 +1,61 @@
+import type { LocalScheduleReader } from '../../application/interfaces';
+import type { LocalReminderSchedule } from '../../domain';
+
+/**
+ * 进程内日程投影,供调用方写入后再由 LocalReminderApplication rebuild/start 接管。
+ * 正式本地 DB 接入前作为默认 LocalScheduleReader。
+ */
+export class InMemoryLocalScheduleReader implements LocalScheduleReader {
+ private readonly byId = new Map();
+ private readonly listeners = new Set<(schedules: readonly LocalReminderSchedule[]) => void>();
+
+ list(): readonly LocalReminderSchedule[] {
+ return [...this.byId.values()];
+ }
+
+ async listReminderSchedules(): Promise {
+ return this.list();
+ }
+
+ async getReminderSchedule(scheduleId: string): Promise {
+ return this.byId.get(scheduleId) ?? null;
+ }
+
+ subscribe(listener: (schedules: readonly LocalReminderSchedule[]) => void): () => void {
+ this.listeners.add(listener);
+ return () => {
+ this.listeners.delete(listener);
+ };
+ }
+
+ replaceAll(schedules: readonly LocalReminderSchedule[]): void {
+ this.byId.clear();
+ for (const schedule of schedules) {
+ this.byId.set(schedule.id, schedule);
+ }
+ this.notify();
+ }
+
+ upsert(schedule: LocalReminderSchedule): void {
+ this.byId.set(schedule.id, schedule);
+ this.notify();
+ }
+
+ remove(scheduleId: string): void {
+ if (!this.byId.delete(scheduleId)) return;
+ this.notify();
+ }
+
+ clear(): void {
+ if (this.byId.size === 0) return;
+ this.byId.clear();
+ this.notify();
+ }
+
+ private notify(): void {
+ const snapshot = this.list();
+ for (const listener of this.listeners) {
+ listener(snapshot);
+ }
+ }
+}
diff --git a/frontend/src/features/reminder/data/local/LocalReminderAdapters.ts b/frontend/src/features/reminder/data/local/LocalReminderAdapters.ts
new file mode 100644
index 00000000..604ca59a
--- /dev/null
+++ b/frontend/src/features/reminder/data/local/LocalReminderAdapters.ts
@@ -0,0 +1,78 @@
+import type {
+ PopupPort,
+ PopupReceipt,
+ PopupRequest,
+ ReminderDeliveryPort,
+ ReminderDeliveryReceipt,
+ ReminderDeliveryRequest,
+ ReminderDispositionSyncPort,
+ ReminderDispositionSyncReceipt,
+ ReminderRecoveryPort,
+ ReminderRecoveryReceipt,
+ SystemNotificationPort,
+ SystemNotificationReceipt,
+ SystemNotificationRequest,
+ ReminderConfirmedDisposition,
+} from '../../application/interfaces';
+
+/** 送达记账:展示由 systemNotification / presenter 完成,此处只返回回执。 */
+export class LocalReminderDelivery implements ReminderDeliveryPort {
+ async deliver(request: ReminderDeliveryRequest): Promise {
+ return {
+ delivery_id: `delivery-${request.schedule_id}-${Date.now()}`,
+ schedule_id: request.schedule_id,
+ delivered_at: new Date().toISOString(),
+ channels: [],
+ used_fallback_audio: false,
+ };
+ }
+
+ async dismiss(_scheduleId: string): Promise {
+ return Promise.resolve();
+ }
+}
+
+/** 弹窗通道占位:提醒页由 ReminderPresenter 承接。 */
+export class NoopPopup implements PopupPort {
+ async show(request: PopupRequest): Promise {
+ return { popup_id: request.popup_id, visible: false };
+ }
+
+ async dismiss(_popupId: string): Promise {
+ return Promise.resolve();
+ }
+}
+
+/** 系统通知占位:接入 expo-notifications 前保持可替换端口。 */
+export class LocalSystemNotification implements SystemNotificationPort {
+ async show(request: SystemNotificationRequest): Promise {
+ return { notification_id: request.notification_id, shown: true };
+ }
+
+ async cancel(_notificationId: string): Promise {
+ return Promise.resolve();
+ }
+}
+
+/** 重启恢复占位:后续可接开机广播 / 精确闹钟重挂。 */
+export class LocalReminderRecovery implements ReminderRecoveryPort {
+ async registerForRestart(): Promise {
+ return { registered: true, recovery_id: `recovery-${Date.now()}` };
+ }
+
+ async restoreAfterRestart(): Promise {
+ return { registered: true, recovery_id: `recovery-${Date.now()}` };
+ }
+}
+
+/** 确认态本地受理:无网络时也返回 accepted,供后续 sync 替换。 */
+export class LocalReminderDispositionSync implements ReminderDispositionSyncPort {
+ async submitConfirmed(
+ disposition: ReminderConfirmedDisposition,
+ ): Promise {
+ return {
+ schedule_id: disposition.schedule_id,
+ accepted: true,
+ };
+ }
+}
diff --git a/frontend/src/features/reminder/data/local/MockLocalScheduleReader.ts b/frontend/src/features/reminder/data/local/MockLocalScheduleReader.ts
deleted file mode 100644
index 9b5942a4..00000000
--- a/frontend/src/features/reminder/data/local/MockLocalScheduleReader.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import type { LocalScheduleReader } from '../../application/interfaces';
-import type { LocalReminderSchedule } from '../../domain';
-
-import { MOCK_REMINDER_SCHEDULES } from './mockReminderSchedules';
-
-/** 代替本地数据库端口的固定只读适配器。 */
-export class MockLocalScheduleReader implements LocalScheduleReader {
- readonly schedules = MOCK_REMINDER_SCHEDULES;
- private readonly listeners = new Set<(schedules: readonly LocalReminderSchedule[]) => void>();
-
- async listReminderSchedules(): Promise {
- return this.schedules;
- }
-
- async getReminderSchedule(scheduleId: string): Promise {
- return this.schedules.find((schedule) => schedule.id === scheduleId) ?? null;
- }
-
- subscribe(listener: (schedules: readonly LocalReminderSchedule[]) => void): () => void {
- this.listeners.add(listener);
- return () => {
- this.listeners.delete(listener);
- };
- }
-
- /** 测试或本地写入后通知订阅方;subscribe 本身不重放,避免与 start/rebuild 重入。 */
- notify(): void {
- for (const listener of this.listeners) {
- listener(this.schedules);
- }
- }
-}
diff --git a/frontend/src/features/reminder/data/local/MockReminderApplication.ts b/frontend/src/features/reminder/data/local/MockReminderApplication.ts
deleted file mode 100644
index bc717c72..00000000
--- a/frontend/src/features/reminder/data/local/MockReminderApplication.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-import type {
- ReminderApplicationDependencies,
- ReminderApplicationPort,
- ReminderApplicationResult,
- ReminderSnoozeRequest,
-} from '../../application/interfaces';
-import type {
- LocalReminderSchedule,
- LocationSample,
- ReminderDeliveryReceipt,
- ReminderRegistration,
- ReminderTrigger,
-} from '../../domain';
-
-const MOCK_DELIVERY: ReminderDeliveryReceipt = {
- delivery_id: 'mock-delivery-001',
- schedule_id: 'mock-schedule-time-001',
- delivered_at: '2026-08-07T01:00:00.000Z',
- channels: ['system_notification', 'popup', 'vibration'],
- used_fallback_audio: false,
-};
-
-function registrationFor(schedule: LocalReminderSchedule): ReminderRegistration {
- if (schedule.schedule_type === 'location') {
- return {
- schedule_id: schedule.id,
- time_listener_id: null,
- location_listener_id: `mock-location-listener-${schedule.id}`,
- alarm_id: null,
- };
- }
-
- return {
- schedule_id: schedule.id,
- time_listener_id: `mock-time-listener-${schedule.id}`,
- location_listener_id: null,
- alarm_id: `mock-alarm-${schedule.id}`,
- };
-}
-
-/** 真实协调器完成前供应用外壳使用的应用门面。 */
-export class MockReminderApplication implements ReminderApplicationPort {
- constructor(readonly dependencies: ReminderApplicationDependencies) {}
-
- async start(): Promise {
- return Promise.resolve();
- }
-
- async stop(): Promise {
- return Promise.resolve();
- }
-
- async register(schedule: LocalReminderSchedule): Promise {
- return registrationFor(schedule);
- }
-
- async rebuild(): Promise {
- const schedules = await this.dependencies.schedules.listReminderSchedules();
- return schedules.map(registrationFor);
- }
-
- async handleTime(_tick: { observed_at: string }): Promise {
- return Promise.resolve();
- }
-
- async handleLocation(_sample: LocationSample): Promise {
- return Promise.resolve();
- }
-
- async deliver(trigger: ReminderTrigger): Promise {
- return { ...MOCK_DELIVERY, schedule_id: trigger.schedule_id };
- }
-
- async confirm(scheduleId: string, confirmedAt: string): Promise {
- return {
- accepted: true,
- schedule_id: scheduleId,
- disposition: {
- schedule_id: scheduleId,
- state: 'confirmed',
- updated_at: confirmedAt,
- snoozed_until: null,
- sync_status: 'pending',
- },
- };
- }
-
- async snooze(request: ReminderSnoozeRequest): Promise {
- // 确定性 mock:绝对时间原样回传;相对分钟映射到固定 fixture 时刻。
- const snoozed_until =
- 'snooze_minutes' in request ? '2026-08-07T01:10:00.000Z' : request.snooze_until;
-
- return {
- accepted: true,
- schedule_id: request.schedule_id,
- disposition: {
- schedule_id: request.schedule_id,
- state: 'snoozed',
- updated_at: '2026-08-07T01:00:00.000Z',
- snoozed_until,
- sync_status: 'pending',
- },
- };
- }
-}
diff --git a/frontend/src/features/reminder/data/local/MockReminderDispositionSync.ts b/frontend/src/features/reminder/data/local/MockReminderDispositionSync.ts
deleted file mode 100644
index 5a7ef1c5..00000000
--- a/frontend/src/features/reminder/data/local/MockReminderDispositionSync.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import type {
- ReminderConfirmedDisposition,
- ReminderDispositionSyncPort,
- ReminderDispositionSyncReceipt,
-} from '../../application/interfaces';
-
-/** 最终确认状态网络同步回调的模拟实现。 */
-export class MockReminderDispositionSync implements ReminderDispositionSyncPort {
- async submitConfirmed(
- disposition: ReminderConfirmedDisposition,
- ): Promise {
- return {
- schedule_id: disposition.schedule_id,
- accepted: true,
- };
- }
-}
diff --git a/frontend/src/features/reminder/data/local/MockReminderStateStore.ts b/frontend/src/features/reminder/data/local/MockReminderStateStore.ts
deleted file mode 100644
index 187d26c4..00000000
--- a/frontend/src/features/reminder/data/local/MockReminderStateStore.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import type { ReminderStateStore } from '../../application/interfaces';
-import type { ReminderDisposition, ReminderRuntimeState } from '../../domain';
-
-const MOCK_STATE: ReminderRuntimeState = {
- reminder_disposition_state: null,
- next_trigger_at: '2026-08-07T01:00:00.000Z',
- snoozed_until: null,
- geofence_armed: false,
- disposition_updated_at: null,
- sync_status: 'synced',
- recorded_location: null,
-};
-
-/** 读取结果固定的本地状态空操作适配器,不打开本地数据库。 */
-export class MockReminderStateStore implements ReminderStateStore {
- async read(_scheduleId: string): Promise {
- return { ...MOCK_STATE };
- }
-
- async write(_scheduleId: string, _state: ReminderRuntimeState): Promise {
- return Promise.resolve();
- }
-
- async setDisposition(_scheduleId: string, _disposition: ReminderDisposition): Promise {
- return Promise.resolve();
- }
-}
diff --git a/frontend/src/features/reminder/data/local/SqliteLocalScheduleReader.ts b/frontend/src/features/reminder/data/local/SqliteLocalScheduleReader.ts
new file mode 100644
index 00000000..b66ca3a6
--- /dev/null
+++ b/frontend/src/features/reminder/data/local/SqliteLocalScheduleReader.ts
@@ -0,0 +1,102 @@
+import type { ScheduleLocalRepository, LocalScheduleRow } from '../../../schedule/data';
+import type { LocalScheduleReader } from '../../application/interfaces';
+import type { LocalReminderSchedule, ReminderStrength } from '../../domain';
+
+/** local_schedules 表没有这一列;地点提醒暂时统一按 200 米围栏处理。 */
+const DEFAULT_GEOFENCE_RADIUS_METERS = 200;
+
+type Target = { repository: ScheduleLocalRepository; accountId: string };
+
+/**
+ * 真实本地日程数据源:读 SQLite `local_schedules`,供 LocalReminderApplication 使用。
+ *
+ * createAppServices() 在认证/数据库就绪之前就要把这个类接进 reminderPorts,所以构造时
+ * 不直接拿 repository/accountId——由调用方在数据库打开、账号确定后调用 attach(),
+ * 账号切换或登出时调用 detach()。整个应用生命周期内只有这一个实例,
+ * LocalReminderApplication.start() 对它的 subscribe() 只挂一次,重新绑定 target 不会
+ * 让那次订阅失效。
+ */
+export class SqliteLocalScheduleReader implements LocalScheduleReader {
+ private readonly listeners = new Set<(schedules: readonly LocalReminderSchedule[]) => void>();
+ private target: Target | null = null;
+
+ public attach(repository: ScheduleLocalRepository, accountId: string): void {
+ this.target = { repository, accountId };
+ }
+
+ public detach(): void {
+ this.target = null;
+ }
+
+ public async listReminderSchedules(): Promise {
+ if (this.target === null) return [];
+ const rows = await this.target.repository.listSchedules(this.target.accountId);
+ return rows.map(toLocalReminderSchedule);
+ }
+
+ public async getReminderSchedule(scheduleId: string): Promise {
+ if (this.target === null) return null;
+ const row = await this.target.repository.getSchedule(this.target.accountId, scheduleId);
+ return row === null ? null : toLocalReminderSchedule(row);
+ }
+
+ public subscribe(listener: (schedules: readonly LocalReminderSchedule[]) => void): () => void {
+ this.listeners.add(listener);
+ return () => {
+ this.listeners.delete(listener);
+ };
+ }
+
+ /** attach() 之后或本地写入提交后调用:重新读取 SQLite 并通知订阅方触发 rebuild。 */
+ public async refresh(): Promise {
+ const schedules = await this.listReminderSchedules();
+ for (const listener of this.listeners) {
+ listener(schedules);
+ }
+ }
+}
+
+function toLocalReminderSchedule(row: LocalScheduleRow): LocalReminderSchedule {
+ return {
+ id: row.id,
+ account_id: row.account_id,
+ title: row.title,
+ schedule_type: row.schedule_type,
+ schedule_kind: row.schedule_kind,
+ is_all_day: row.is_all_day === 1,
+ start_time: row.start_time,
+ end_time: row.end_time,
+ timezone: row.timezone,
+ recurrence_rule: row.recurrence_rule,
+ location_name: row.location_name,
+ latitude: row.latitude,
+ longitude: row.longitude,
+ geofence_radius_meters: DEFAULT_GEOFENCE_RADIUS_METERS,
+ reminder:
+ row.reminder_type === null
+ ? null
+ : {
+ reminder_type: row.reminder_type,
+ reminder_trigger_at: row.reminder_trigger_at,
+ reminder_offset_minutes: row.reminder_offset_minutes,
+ // reminder_type 非空时 reminder_strength 一定非空,由建表 CHECK 约束保证。
+ reminder_strength: row.reminder_strength as ReminderStrength,
+ },
+ runtime: {
+ reminder_disposition_state: row.reminder_disposition_state,
+ next_trigger_at: row.next_trigger_at,
+ snoozed_until: row.snoozed_until,
+ geofence_armed: row.geofence_armed === 1,
+ disposition_updated_at: row.disposition_updated_at,
+ sync_status: row.sync_status,
+ // local_schedules 没有单独记录到达点位的列,围栏到达位置暂不持久化。
+ recorded_location: null,
+ },
+ status: row.status,
+ // 表里没有独立的设备端 revision 列,用 cloud_revision 兼当;这个字段在
+ // reminder 领域逻辑里当前也没被读取。
+ revision: row.cloud_revision,
+ cloud_revision: row.cloud_revision,
+ updated_at: row.updated_at,
+ };
+}
diff --git a/frontend/src/features/reminder/data/local/SqliteReminderStateStore.ts b/frontend/src/features/reminder/data/local/SqliteReminderStateStore.ts
new file mode 100644
index 00000000..5f678c9a
--- /dev/null
+++ b/frontend/src/features/reminder/data/local/SqliteReminderStateStore.ts
@@ -0,0 +1,136 @@
+import type {
+ LocalReminderRuntimeUpdate,
+ LocalScheduleRow,
+ ScheduleLocalRepository,
+} from '../../../schedule/data';
+import {
+ floatingDateToLocalParts,
+ instantToZonedParts,
+ isValidIanaTimezone,
+ localPartsToFloatingDate,
+ zonedPartsToInstant,
+} from '../../../schedule/domain/scheduleDateTime';
+import {
+ normalizeUtcUntilForFloatingRrule,
+ parseScheduleRrule,
+} from '../../../schedule/domain/scheduleRecurrence';
+import type { ReminderStateStore } from '../../application/interfaces';
+import type { ReminderDisposition, ReminderRuntimeState } from '../../domain';
+
+type Target = { repository: ScheduleLocalRepository; accountId: string };
+
+/**
+ * 提醒运行时状态(响没响、确认没确认)的真实持久化:写 SQLite `local_schedules` 的
+ * 运行时字段。跟 SqliteLocalScheduleReader 一样用 attach()/detach() 延迟绑定——
+ * createAppServices() 构造时账号和仓储都还没有,整个 App 生命周期只有这一个实例。
+ *
+ * 没有这个类之前用的是 MemoryReminderStateStore(纯内存),App 进程一死状态就丢,
+ * 已经响过的到点提醒下次启动会被当成全新的重新弹一遍。
+ */
+export class SqliteReminderStateStore implements ReminderStateStore {
+ private target: Target | null = null;
+
+ public attach(repository: ScheduleLocalRepository, accountId: string): void {
+ this.target = { repository, accountId };
+ }
+
+ public detach(): void {
+ this.target = null;
+ }
+
+ public async read(scheduleId: string): Promise {
+ if (this.target === null) return null;
+ const row = await this.target.repository.getSchedule(this.target.accountId, scheduleId);
+ if (row === null) return null;
+
+ // 重复日程的 occurrence 光标为空:要么是云端刚同步下来的新记录(从没落过库),
+ // 要么是上一次 occurrence 被 confirmInternal 消费后显式清空的(它就是靠置空
+ // next_trigger_at 触发"这条该往前挪一格了")。resolveTimeTriggerAt() 对重复
+ // 日程光标为空时明确返回 null,不会回退到系列 start_time,所以这里必须补上
+ // 下一次发生时间,不然这条提醒永远不会再触发第二次。同时把上一轮的
+ // disposition 状态一起重置,否则 canDeliver() 会因为它还是 'confirmed' 继续
+ // 拦住这条全新的 occurrence。
+ if (row.schedule_kind === 'recurring' && row.next_trigger_at === null) {
+ const nextOccurrence = nextRecurringOccurrenceAtOrAfter(row, new Date());
+ if (nextOccurrence !== null) {
+ return {
+ reminder_disposition_state: null,
+ next_trigger_at: nextOccurrence,
+ snoozed_until: null,
+ geofence_armed: row.geofence_armed === 1,
+ disposition_updated_at: null,
+ sync_status: row.sync_status,
+ recorded_location: null,
+ };
+ }
+ // 系列已经结束(RRULE 的 UNTIL/COUNT 用完了)或规则本身有问题:没有下一次
+ // 发生时间可算,透传原始(空)状态,让上层照常按"这条排不上"处理。
+ }
+
+ return {
+ reminder_disposition_state: row.reminder_disposition_state,
+ next_trigger_at: row.next_trigger_at,
+ snoozed_until: row.snoozed_until,
+ geofence_armed: row.geofence_armed === 1,
+ disposition_updated_at: row.disposition_updated_at,
+ sync_status: row.sync_status,
+ // local_schedules 没有单独记录到达点位的列,跟 SqliteLocalScheduleReader 一致处理。
+ recorded_location: null,
+ };
+ }
+
+ public async write(scheduleId: string, state: ReminderRuntimeState): Promise {
+ if (this.target === null) return;
+ await this.target.repository.updateReminderRuntime(
+ this.target.accountId,
+ scheduleId,
+ toRuntimeUpdate(state),
+ );
+ }
+
+ public async setDisposition(scheduleId: string, disposition: ReminderDisposition): Promise {
+ if (this.target === null) return;
+ const current = await this.read(scheduleId);
+ await this.target.repository.updateReminderRuntime(this.target.accountId, scheduleId, {
+ reminder_disposition_state: disposition.state,
+ next_trigger_at: current?.next_trigger_at ?? null,
+ snoozed_until: disposition.snoozed_until,
+ geofence_armed: (current?.geofence_armed ?? false) ? 1 : 0,
+ disposition_updated_at: disposition.updated_at,
+ sync_status: disposition.sync_status,
+ });
+ }
+}
+
+/** 重复日程从 now 起(含 now 本身)的下一次发生时间;规则用完/无效时返回 null。 */
+function nextRecurringOccurrenceAtOrAfter(row: LocalScheduleRow, now: Date): string | null {
+ if (row.start_time === null || row.recurrence_rule === null) return null;
+ if (!isValidIanaTimezone(row.timezone)) return null;
+
+ try {
+ const floatingStart = localPartsToFloatingDate(
+ instantToZonedParts(new Date(row.start_time), row.timezone),
+ );
+ const rule = parseScheduleRrule(
+ normalizeUtcUntilForFloatingRrule(row.recurrence_rule, row.timezone),
+ floatingStart,
+ );
+ const floatingNow = localPartsToFloatingDate(instantToZonedParts(now, row.timezone));
+ const nextFloating = rule.after(floatingNow, true);
+ if (nextFloating === null) return null;
+ return zonedPartsToInstant(floatingDateToLocalParts(nextFloating), row.timezone).toISOString();
+ } catch {
+ return null;
+ }
+}
+
+function toRuntimeUpdate(state: ReminderRuntimeState): LocalReminderRuntimeUpdate {
+ return {
+ reminder_disposition_state: state.reminder_disposition_state,
+ next_trigger_at: state.next_trigger_at,
+ snoozed_until: state.snoozed_until,
+ geofence_armed: state.geofence_armed ? 1 : 0,
+ disposition_updated_at: state.disposition_updated_at,
+ sync_status: state.sync_status,
+ };
+}
diff --git a/frontend/src/features/reminder/data/local/index.ts b/frontend/src/features/reminder/data/local/index.ts
index 4f8b255e..191e728b 100644
--- a/frontend/src/features/reminder/data/local/index.ts
+++ b/frontend/src/features/reminder/data/local/index.ts
@@ -1,6 +1,11 @@
-export { MockLocalScheduleReader } from './MockLocalScheduleReader';
-export { MockReminderApplication } from './MockReminderApplication';
-export { MockReminderDispositionSync } from './MockReminderDispositionSync';
-export { MockReminderStateStore } from './MockReminderStateStore';
export { MemoryReminderStateStore } from './MemoryReminderStateStore';
-export { MOCK_REMINDER_SCHEDULES } from './mockReminderSchedules';
+export { InMemoryLocalScheduleReader } from './InMemoryLocalScheduleReader';
+export { SqliteLocalScheduleReader } from './SqliteLocalScheduleReader';
+export { SqliteReminderStateStore } from './SqliteReminderStateStore';
+export {
+ LocalReminderDelivery,
+ LocalReminderDispositionSync,
+ LocalReminderRecovery,
+ LocalSystemNotification,
+ NoopPopup,
+} from './LocalReminderAdapters';
diff --git a/frontend/src/features/reminder/data/local/mockReminderSchedules.ts b/frontend/src/features/reminder/data/local/mockReminderSchedules.ts
deleted file mode 100644
index cc5de05e..00000000
--- a/frontend/src/features/reminder/data/local/mockReminderSchedules.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-import type { LocalReminderSchedule } from '../../domain';
-
-const MOCK_RUNTIME = {
- reminder_disposition_state: null,
- next_trigger_at: '2026-08-07T01:00:00.000Z',
- snoozed_until: null,
- geofence_armed: false,
- disposition_updated_at: null,
- sync_status: 'synced' as const,
- recorded_location: null,
-};
-
-/** 接入本地数据库读取器前使用的固定快照。 */
-export const MOCK_REMINDER_SCHEDULES: readonly LocalReminderSchedule[] = [
- {
- id: 'mock-schedule-time-001',
- account_id: 'mock-account-001',
- title: '项目例会',
- schedule_type: 'time',
- schedule_kind: 'once',
- is_all_day: false,
- start_time: '2026-08-07T01:15:00.000Z',
- end_time: null,
- timezone: 'Asia/Shanghai',
- recurrence_rule: null,
- location_name: '203 会议室',
- latitude: null,
- longitude: null,
- geofence_radius_meters: 100,
- reminder: {
- reminder_type: 'before_start',
- reminder_trigger_at: null,
- reminder_offset_minutes: 15,
- reminder_strength: 'medium',
- },
- runtime: { ...MOCK_RUNTIME },
- status: 'active',
- revision: 1,
- cloud_revision: 1,
- updated_at: '2026-08-06T09:00:00.000Z',
- },
- {
- id: 'mock-schedule-location-001',
- account_id: 'mock-account-001',
- title: '取车提醒',
- schedule_type: 'location',
- schedule_kind: 'once',
- is_all_day: false,
- start_time: null,
- end_time: null,
- timezone: 'Asia/Shanghai',
- recurrence_rule: null,
- location_name: '停车场',
- latitude: 31.2304,
- longitude: 121.4737,
- geofence_radius_meters: 100,
- reminder: {
- reminder_type: 'return_to_recorded_location',
- reminder_trigger_at: null,
- reminder_offset_minutes: null,
- reminder_strength: 'high',
- },
- runtime: {
- ...MOCK_RUNTIME,
- next_trigger_at: null,
- recorded_location: { latitude: 31.2304, longitude: 121.4737 },
- },
- status: 'active',
- revision: 1,
- cloud_revision: 1,
- updated_at: '2026-08-06T09:00:00.000Z',
- },
-];
diff --git a/frontend/src/features/reminder/domain/.gitkeep b/frontend/src/features/reminder/domain/.gitkeep
deleted file mode 100644
index 8b137891..00000000
--- a/frontend/src/features/reminder/domain/.gitkeep
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/frontend/src/features/reminder/domain/index.ts b/frontend/src/features/reminder/domain/index.ts
index 86f87408..328cc015 100644
--- a/frontend/src/features/reminder/domain/index.ts
+++ b/frontend/src/features/reminder/domain/index.ts
@@ -35,3 +35,5 @@ export {
resolveSnoozeUntil,
resolveTimeTriggerAt,
} from './timeWindow';
+export type { StrengthDeliveryPlan } from './strengthDelivery';
+export { resolveStrengthDeliveryPlan } from './strengthDelivery';
diff --git a/frontend/src/features/reminder/domain/reminder.ts b/frontend/src/features/reminder/domain/reminder.ts
index c20b859d..58b4c215 100644
--- a/frontend/src/features/reminder/domain/reminder.ts
+++ b/frontend/src/features/reminder/domain/reminder.ts
@@ -73,12 +73,7 @@ export type LocalReminderSchedule = {
};
export type ReminderTriggerReason =
- | 'at_time'
- | 'before_start'
- | 'arrive_location'
- | 'return_to_recorded_location'
- | 'snooze_expired'
- | 'mock';
+ 'at_time' | 'before_start' | 'arrive_location' | 'return_to_recorded_location' | 'snooze_expired';
export type ReminderTrigger = {
reminder_id: string;
diff --git a/frontend/src/features/reminder/domain/strengthDelivery.ts b/frontend/src/features/reminder/domain/strengthDelivery.ts
new file mode 100644
index 00000000..37a56ff1
--- /dev/null
+++ b/frontend/src/features/reminder/domain/strengthDelivery.ts
@@ -0,0 +1,35 @@
+import type { ReminderStrength } from './reminder';
+
+/** 提醒强度对应的客户端送达通道组合。 */
+export type StrengthDeliveryPlan = {
+ useSystemNotification: boolean;
+ usePopup: boolean;
+ useVibration: boolean;
+ useAudio: boolean;
+};
+
+export function resolveStrengthDeliveryPlan(strength: ReminderStrength): StrengthDeliveryPlan {
+ switch (strength) {
+ case 'low':
+ return {
+ useSystemNotification: true,
+ usePopup: false,
+ useVibration: false,
+ useAudio: false,
+ };
+ case 'medium':
+ return {
+ useSystemNotification: false,
+ usePopup: true,
+ useVibration: true,
+ useAudio: false,
+ };
+ case 'high':
+ return {
+ useSystemNotification: false,
+ usePopup: true,
+ useVibration: true,
+ useAudio: true,
+ };
+ }
+}
diff --git a/frontend/src/features/reminder/index.ts b/frontend/src/features/reminder/index.ts
index 5548f3b5..3b376418 100644
--- a/frontend/src/features/reminder/index.ts
+++ b/frontend/src/features/reminder/index.ts
@@ -18,9 +18,27 @@ export type {
ReminderTrigger,
ReminderTriggerReason,
ReminderType,
+ GeofenceTransition,
+ GeofenceWatchMode,
+ StrengthDeliveryPlan,
+} from './domain';
+export {
+ DEFAULT_SNOOZE_MINUTES,
+ distanceMeters,
+ evaluateGeofence,
+ isSnoozeActive,
+ isSnoozeExpired,
+ isTimeWindowReached,
+ resolveEffectiveTriggerAt,
+ resolveGeofenceCenter,
+ resolveSnoozeUntil,
+ resolveStrengthDeliveryPlan,
+ resolveTimeTriggerAt,
+ resolveWatchMode,
} from './domain';
-export { DEFAULT_SNOOZE_MINUTES } from './domain';
export type {
+ AlarmNativeDisposition,
+ AlarmNativeEvent,
AlarmScheduleReceipt,
AlarmScheduleRequest,
AlarmSchedulerPort,
@@ -34,9 +52,13 @@ export type {
LocalTimeTick,
LocationMonitorEvent,
LocationMonitorPort,
+ LocationRebuildTarget,
LocationWatchHandle,
LocationWatchMode,
LocationWatchRequest,
+ AlertDialogButton,
+ AlertDialogPort,
+ AlertDialogRequest,
PopupPort,
PopupReceipt,
PopupRequest,
@@ -64,11 +86,15 @@ export type {
} from './application';
export { LocalReminderApplication } from './application';
export {
- MockLocalScheduleReader,
- MockReminderApplication,
- MockReminderDispositionSync,
- MockReminderStateStore,
- MOCK_REMINDER_SCHEDULES,
MemoryReminderStateStore,
+ InMemoryLocalScheduleReader,
+ SqliteLocalScheduleReader,
+ SqliteReminderStateStore,
+ LocalReminderDelivery,
+ LocalReminderDispositionSync,
+ LocalReminderRecovery,
+ LocalSystemNotification,
+ NoopPopup,
} from './data/local';
-export { MockReminderPresenter, useReminderPermissionsOnLaunch } from './presentation';
+export { AlertReminderPresenter, useReminderPermissionsOnLaunch } from './presentation';
+export type { ReminderActionHandler, ReminderViewModel } from './presentation';
diff --git a/frontend/src/features/reminder/presentation/.gitkeep b/frontend/src/features/reminder/presentation/.gitkeep
deleted file mode 100644
index 8b137891..00000000
--- a/frontend/src/features/reminder/presentation/.gitkeep
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/frontend/src/features/reminder/presentation/AlertReminderPresenter.ts b/frontend/src/features/reminder/presentation/AlertReminderPresenter.ts
new file mode 100644
index 00000000..a35306e9
--- /dev/null
+++ b/frontend/src/features/reminder/presentation/AlertReminderPresenter.ts
@@ -0,0 +1,76 @@
+import type {
+ ReminderPresentationAction,
+ ReminderPresentationReceipt,
+ ReminderPresenterPort,
+ AlertDialogPort,
+} from '../application/interfaces';
+import type { ReminderDeliveryRequest } from '../domain';
+
+const MESSAGE_BY_REASON: Record = {
+ arrive_location: '您已进入目标地点附近,请及时处理。',
+ return_to_recorded_location: '您已回到记录地点附近,请及时处理。',
+ at_time: '已到提醒时间,请及时处理。',
+ before_start: '日程即将开始,请及时处理。',
+ snooze_expired: '延后提醒时间已到,请及时处理。',
+};
+
+/** 提醒展示编排;平台 Alert 由注入的 AlertDialogPort 完成。 */
+export class AlertReminderPresenter implements ReminderPresenterPort {
+ private readonly actionListeners = new Set<
+ (event: { schedule_id: string; action: ReminderPresentationAction }) => void
+ >();
+ private visibleScheduleId: string | null = null;
+ private readonly suppressed = new Set();
+
+ constructor(private readonly dialog: AlertDialogPort) {}
+
+ async show(request: ReminderDeliveryRequest): Promise {
+ this.suppressed.delete(request.schedule_id);
+ this.visibleScheduleId = request.schedule_id;
+ const message = MESSAGE_BY_REASON[request.trigger.reason] ?? '日程提醒已触发,请及时处理。';
+
+ await this.dialog.show({
+ title: request.title || '日程提醒',
+ message,
+ buttons: [
+ {
+ text: '延后',
+ style: 'cancel',
+ onPress: () => this.emit(request.schedule_id, 'snooze'),
+ },
+ {
+ text: '确认',
+ onPress: () => this.emit(request.schedule_id, 'confirm'),
+ },
+ ],
+ });
+
+ return {
+ presentation_id: `alert-${request.schedule_id}`,
+ visible: true,
+ };
+ }
+
+ async hide(scheduleId: string): Promise {
+ this.suppressed.add(scheduleId);
+ if (this.visibleScheduleId === scheduleId) {
+ this.visibleScheduleId = null;
+ }
+ }
+
+ onAction(
+ listener: (event: { schedule_id: string; action: ReminderPresentationAction }) => void,
+ ): () => void {
+ this.actionListeners.add(listener);
+ return () => {
+ this.actionListeners.delete(listener);
+ };
+ }
+
+ private emit(scheduleId: string, action: ReminderPresentationAction): void {
+ if (this.suppressed.has(scheduleId)) return;
+ for (const listener of this.actionListeners) {
+ listener({ schedule_id: scheduleId, action });
+ }
+ }
+}
diff --git a/frontend/src/features/reminder/presentation/MockReminderPresenter.ts b/frontend/src/features/reminder/presentation/MockReminderPresenter.ts
deleted file mode 100644
index 34bc0fc5..00000000
--- a/frontend/src/features/reminder/presentation/MockReminderPresenter.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import type {
- ReminderPresenterPort,
- ReminderPresentationAction,
- ReminderPresentationReceipt,
-} from '../application/interfaces';
-import type { ReminderDeliveryRequest } from '../domain';
-
-/** 应用级弹窗/悬浮层完成前使用的固定展示器。 */
-export class MockReminderPresenter implements ReminderPresenterPort {
- async show(_request: ReminderDeliveryRequest): Promise {
- return {
- presentation_id: 'mock-presentation-001',
- visible: true,
- };
- }
-
- async hide(_scheduleId: string): Promise {
- return Promise.resolve();
- }
-
- onAction(
- _listener: (event: { schedule_id: string; action: ReminderPresentationAction }) => void,
- ): () => void {
- return () => undefined;
- }
-}
diff --git a/frontend/src/features/reminder/presentation/index.ts b/frontend/src/features/reminder/presentation/index.ts
index 90ea79ea..bd6e4fdc 100644
--- a/frontend/src/features/reminder/presentation/index.ts
+++ b/frontend/src/features/reminder/presentation/index.ts
@@ -1,3 +1,3 @@
-export { MockReminderPresenter } from './MockReminderPresenter';
+export { AlertReminderPresenter } from './AlertReminderPresenter';
export { useReminderPermissionsOnLaunch } from './useReminderPermissionsOnLaunch';
export type { ReminderActionHandler, ReminderViewModel } from './ReminderViewModel';
diff --git a/frontend/src/features/reminder/presentation/useReminderPermissionsOnLaunch.ts b/frontend/src/features/reminder/presentation/useReminderPermissionsOnLaunch.ts
index a62a036d..ca37781d 100644
--- a/frontend/src/features/reminder/presentation/useReminderPermissionsOnLaunch.ts
+++ b/frontend/src/features/reminder/presentation/useReminderPermissionsOnLaunch.ts
@@ -1,9 +1,12 @@
import { useEffect, useRef } from 'react';
-import { Alert, AppState, type AppStateStatus, Platform } from 'react-native';
-import type { DeviceCapabilityPort, DevicePermission } from '../application/interfaces';
+import type {
+ AlertDialogPort,
+ DeviceCapabilityPort,
+ DevicePermission,
+} from '../application/interfaces';
-const PERMISSION_ORDER: DevicePermission[] = [
+const ANDROID_ALARM_ORDER: DevicePermission[] = [
'notifications',
'exact_alarm',
'overlay',
@@ -11,6 +14,8 @@ const PERMISSION_ORDER: DevicePermission[] = [
'battery_optimization',
];
+const LOCATION_ORDER: DevicePermission[] = ['location_foreground', 'location_background'];
+
const PERMISSION_PROMPTS: Partial> = {
notifications: {
title: '需要通知权限',
@@ -32,31 +37,49 @@ const PERMISSION_PROMPTS: Partial = new Set(['notifications']);
+
+/** 精确闹钟没有系统授权框,只能跳设置页;先弹说明框会多一步点击,直接跳过去。 */
+const DIRECT_SETTINGS: ReadonlySet = new Set(['exact_alarm']);
+
+const LOCATION_PERMISSIONS: ReadonlySet = new Set([
+ 'location_foreground',
+ 'location_background',
+]);
+
/**
- * 启动时逐项申请提醒相关权限;通过 DeviceCapabilityPort 访问平台,
- * 不在 UI 层直接依赖 NativeModules。
+ * 启动时逐项申请提醒相关权限;通过 DeviceCapabilityPort / AlertDialogPort 访问平台,
+ * 不在 UI 层直接依赖 react-native。
+ *
+ * - Android(alarm 能力可用):闹钟相关权限 + 定位权限
+ * - 其它平台 / alarm 不可用:仍申请定位权限,保证地点提醒链路可授权
+ *
+ * @param onPermissionsUpdated 某项权限刚授权成功时回调(用于重建围栏/闹钟)。
*/
-export function useReminderPermissionsOnLaunch(device: DeviceCapabilityPort | null): void {
+export function useReminderPermissionsOnLaunch(
+ device: DeviceCapabilityPort | null,
+ dialog: AlertDialogPort | null,
+ onPermissionsUpdated?: () => void,
+): void {
const busyRef = useRef(false);
const awaitingReturnRef = useRef(false);
const skippedRef = useRef(new Set());
useEffect(() => {
- if (Platform.OS !== 'android' || device == null) return;
+ if (device == null || dialog == null) return;
let cancelled = false;
- const timeouts = new Set>();
-
- const schedule = (fn: () => void, ms: number) => {
- const id = setTimeout(() => {
- timeouts.delete(id);
- if (cancelled) return;
- fn();
- }, ms);
- timeouts.add(id);
- };
const runPrompt = () => {
if (cancelled) return;
@@ -66,90 +89,127 @@ export function useReminderPermissionsOnLaunch(device: DeviceCapabilityPort | nu
};
const promptNext = async () => {
- if (cancelled || busyRef.current) return;
+ if (busyRef.current || cancelled || awaitingReturnRef.current) return;
busyRef.current = true;
+ let queueNext = false;
try {
const status = await device.getStatus();
- if (cancelled) return;
- if (!status.supported) return;
+ if (status.platform === 'web' || status.platform === 'unknown') return;
- const missing = PERMISSION_ORDER.find((permission) => {
- if (skippedRef.current.has(permission)) return false;
- return !status.permissions[permission];
- });
+ const missing = nextMissingPermission(status);
if (missing == null) return;
const prompt = PERMISSION_PROMPTS[missing];
if (prompt == null) {
skippedRef.current.add(missing);
+ queueNext = true;
return;
}
- if (missing === 'notifications') {
- let granted = false;
- try {
- granted = await device.requestPermission('notifications');
- } catch {
- granted = false;
+ // 通知:直接系统授权框。
+ if (DIRECT_REQUEST.has(missing)) {
+ const granted = await device.requestPermission(missing);
+ if (!granted) {
+ skippedRef.current.add(missing);
+ } else {
+ onPermissionsUpdated?.();
+ }
+ queueNext = true;
+ return;
+ }
+
+ // 精确闹钟没有系统授权框,只能跳设置页,先弹说明框只会多一次点击,直接跳过去。
+ if (DIRECT_SETTINGS.has(missing)) {
+ awaitingReturnRef.current = true;
+ const opened = await device.openSettings(missing);
+ if (!opened) {
+ awaitingReturnRef.current = false;
+ skippedRef.current.add(missing);
+ queueNext = true;
}
- if (cancelled) return;
- if (!granted) skippedRef.current.add('notifications');
- busyRef.current = false;
- schedule(runPrompt, 350);
return;
}
- const shouldAuthorize = await confirmAsync(prompt.title, prompt.message);
- if (cancelled) return;
+ const shouldAuthorize = await confirmAsync(dialog, prompt.title, prompt.message);
if (!shouldAuthorize) {
skippedRef.current.add(missing);
- } else {
- let opened = false;
- try {
- opened = await device.openSettings(missing);
- } catch {
- opened = false;
+ queueNext = true;
+ return;
+ }
+
+ // 定位:先等应用内说明框关掉,再申请系统权限;失败则跳转设置,避免“点了没反应还连弹”。
+ if (LOCATION_PERMISSIONS.has(missing)) {
+ await delay(350);
+ const granted = await device.requestPermission(missing);
+ if (granted) {
+ onPermissionsUpdated?.();
+ queueNext = true;
+ return;
}
- if (cancelled) return;
- if (opened) {
- awaitingReturnRef.current = true;
- } else {
+ awaitingReturnRef.current = true;
+ const opened = await device.openSettings(missing);
+ if (!opened) {
+ awaitingReturnRef.current = false;
skippedRef.current.add(missing);
+ queueNext = true;
}
+ return;
+ }
+
+ // 精确闹钟 / 悬浮窗等:跳转系统设置,回来后再继续。
+ awaitingReturnRef.current = true;
+ const opened = await device.openSettings(missing);
+ if (!opened) {
+ awaitingReturnRef.current = false;
+ skippedRef.current.add(missing);
+ queueNext = true;
}
} finally {
busyRef.current = false;
}
- if (!cancelled && !awaitingReturnRef.current) {
- schedule(runPrompt, 200);
+ if (queueNext && !awaitingReturnRef.current) {
+ setTimeout(runPrompt, 250);
}
};
- const onAppStateChange = (state: AppStateStatus) => {
- if (cancelled) return;
- if (state !== 'active') return;
+ const nextMissingPermission = (
+ status: Awaited>,
+ ): DevicePermission | null => {
+ if (status.platform === 'android' && status.supported) {
+ const alarmMissing = ANDROID_ALARM_ORDER.find((permission) => {
+ if (skippedRef.current.has(permission)) return false;
+ return !status.permissions[permission];
+ });
+ if (alarmMissing != null) return alarmMissing;
+ }
+
+ return (
+ LOCATION_ORDER.find((permission) => {
+ if (skippedRef.current.has(permission)) return false;
+ return !status.permissions[permission];
+ }) ?? null
+ );
+ };
+
+ const unsubscribe = device.onAppActive(() => {
if (!awaitingReturnRef.current) return;
awaitingReturnRef.current = false;
- schedule(runPrompt, 300);
- };
+ onPermissionsUpdated?.();
+ setTimeout(runPrompt, 300);
+ });
- schedule(runPrompt, 600);
- const subscription = AppState.addEventListener('change', onAppStateChange);
+ const timer = setTimeout(runPrompt, 600);
return () => {
cancelled = true;
- awaitingReturnRef.current = false;
- for (const id of timeouts) {
- clearTimeout(id);
- }
- timeouts.clear();
- subscription.remove();
+ clearTimeout(timer);
+ unsubscribe();
};
- }, [device]);
+ }, [device, dialog, onPermissionsUpdated]);
}
-function confirmAsync(title: string, message: string): Promise {
+function confirmAsync(dialog: AlertDialogPort, title: string, message: string): Promise {
return new Promise((resolve) => {
let settled = false;
const finish = (value: boolean) => {
@@ -157,14 +217,19 @@ function confirmAsync(title: string, message: string): Promise {
settled = true;
resolve(value);
};
- Alert.alert(
+ void dialog.show({
title,
message,
- [
+ cancelable: true,
+ onDismiss: () => finish(false),
+ buttons: [
{ text: '暂不', style: 'cancel', onPress: () => finish(false) },
{ text: '去授权', onPress: () => finish(true) },
],
- { cancelable: true, onDismiss: () => finish(false) },
- );
+ });
});
}
+
+function delay(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
diff --git a/frontend/src/infrastructure/audio/.gitkeep b/frontend/src/infrastructure/audio/.gitkeep
deleted file mode 100644
index 8b137891..00000000
--- a/frontend/src/infrastructure/audio/.gitkeep
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/frontend/src/infrastructure/audio/ExpoAudioPlayback.ts b/frontend/src/infrastructure/audio/ExpoAudioPlayback.ts
new file mode 100644
index 00000000..548922e2
--- /dev/null
+++ b/frontend/src/infrastructure/audio/ExpoAudioPlayback.ts
@@ -0,0 +1,147 @@
+import type {
+ AudioPlaybackPort,
+ AudioPlaybackReceipt,
+ AudioPlaybackRequest,
+} from '../../features/reminder/application/interfaces';
+import { buildAudioDataUri } from './audioDataUri';
+
+// Metro 资源 id;高强度本地兜底铃声(与原生 AlarmSoundService 同源)。
+const LOCAL_ALARM_SOUND = require('../../../assets/sounds/alarm_prompt.mp3') as number;
+
+type AudioPlayerLike = {
+ pause: () => void;
+ replace: (source: string | number) => void;
+ play: () => void;
+ volume: number;
+ loop: boolean;
+};
+
+type ExpoAudioModule = {
+ createAudioPlayer: (source?: string | number | null) => AudioPlayerLike;
+ setAudioModeAsync: (mode: Record) => Promise;
+};
+
+/**
+ * 音频播放适配器:
+ * - TTS 有字节时播放并循环,直到 stop
+ * - 否则播放打包的 alarm_prompt.mp3(高强度本地兜底)
+ */
+export class ExpoAudioPlayback implements AudioPlaybackPort {
+ private player: AudioPlayerLike | null = null;
+ private activeScheduleId: string | null = null;
+ private modeReady: Promise | null = null;
+
+ async isTtsAvailable(): Promise {
+ // TTS 字节管线尚未接入;有 data 时由 playTts 直接播放。
+ return false;
+ }
+
+ async playTts(request: AudioPlaybackRequest): Promise {
+ if (request.data == null || request.data.byteLength === 0) {
+ return {
+ playback_id: `tts-empty-${request.schedule_id}`,
+ played: false,
+ used_local_fallback: false,
+ };
+ }
+ const played = await this.playBytes(request.schedule_id, request.data, request.format ?? 'wav');
+ return {
+ playback_id: `tts-${request.schedule_id}`,
+ played,
+ used_local_fallback: false,
+ };
+ }
+
+ async playLocalFallback(request: AudioPlaybackRequest): Promise {
+ if (request.data != null && request.data.byteLength > 0) {
+ const played = await this.playBytes(
+ request.schedule_id,
+ request.data,
+ request.format ?? 'wav',
+ );
+ return {
+ playback_id: `local-${request.schedule_id}`,
+ played,
+ used_local_fallback: true,
+ };
+ }
+
+ const played = await this.playBundledAlarm(request.schedule_id);
+ return {
+ playback_id: `local-bundled-${request.schedule_id}`,
+ played,
+ used_local_fallback: true,
+ };
+ }
+
+ async stop(scheduleId: string): Promise {
+ if (this.activeScheduleId !== scheduleId) return;
+ if (this.player != null) {
+ this.player.loop = false;
+ this.player.pause();
+ }
+ this.activeScheduleId = null;
+ }
+
+ private async playBytes(scheduleId: string, data: Uint8Array, format: string): Promise {
+ const expoAudio = await loadExpoAudio();
+ if (expoAudio == null) return false;
+
+ await this.ensureAudioMode(expoAudio);
+ const player = this.ensurePlayer(expoAudio);
+ player.pause();
+ player.replace(buildAudioDataUri(data, format));
+ player.loop = true;
+ player.volume = 1;
+ this.activeScheduleId = scheduleId;
+ player.play();
+ return true;
+ }
+
+ private async playBundledAlarm(scheduleId: string): Promise {
+ const expoAudio = await loadExpoAudio();
+ if (expoAudio == null) return false;
+
+ await this.ensureAudioMode(expoAudio);
+ const player = this.ensurePlayer(expoAudio);
+ player.pause();
+ player.replace(LOCAL_ALARM_SOUND);
+ player.loop = true;
+ player.volume = 1;
+ this.activeScheduleId = scheduleId;
+ player.play();
+ return true;
+ }
+
+ private ensurePlayer(expoAudio: ExpoAudioModule): AudioPlayerLike {
+ if (this.player == null) {
+ this.player = expoAudio.createAudioPlayer(null);
+ }
+ return this.player;
+ }
+
+ private async ensureAudioMode(expoAudio: ExpoAudioModule): Promise {
+ if (this.modeReady == null) {
+ this.modeReady = expoAudio
+ .setAudioModeAsync({
+ allowsRecording: false,
+ interruptionMode: 'doNotMix',
+ playsInSilentMode: true,
+ shouldPlayInBackground: true,
+ shouldRouteThroughEarpiece: false,
+ })
+ .catch(() => undefined);
+ }
+ await this.modeReady;
+ }
+}
+
+async function loadExpoAudio(): Promise {
+ try {
+ const mod = await import('expo-audio');
+ if (typeof mod.createAudioPlayer !== 'function') return null;
+ return mod as unknown as ExpoAudioModule;
+ } catch {
+ return null;
+ }
+}
diff --git a/frontend/src/infrastructure/audio/MockAudioPlayback.ts b/frontend/src/infrastructure/audio/MockAudioPlayback.ts
deleted file mode 100644
index f59131bc..00000000
--- a/frontend/src/infrastructure/audio/MockAudioPlayback.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import type {
- AudioPlaybackPort,
- AudioPlaybackReceipt,
- AudioPlaybackRequest,
-} from '../../features/reminder/application/interfaces';
-
-/** 固定音频能力:远程语音合成不可用,本地兜底音效可用。 */
-export class MockAudioPlayback implements AudioPlaybackPort {
- async isTtsAvailable(): Promise {
- return false;
- }
-
- async playTts(_request: AudioPlaybackRequest): Promise {
- return {
- playback_id: 'mock-tts-playback-001',
- played: false,
- used_local_fallback: false,
- };
- }
-
- async playLocalFallback(_request: AudioPlaybackRequest): Promise {
- return {
- playback_id: 'mock-local-sound-001',
- played: true,
- used_local_fallback: true,
- };
- }
-
- async stop(_scheduleId: string): Promise {
- return Promise.resolve();
- }
-}
diff --git a/frontend/src/infrastructure/audio/audioDataUri.ts b/frontend/src/infrastructure/audio/audioDataUri.ts
new file mode 100644
index 00000000..94976c5d
--- /dev/null
+++ b/frontend/src/infrastructure/audio/audioDataUri.ts
@@ -0,0 +1,39 @@
+const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
+
+const MIME_BY_FORMAT: Record = {
+ aac: 'audio/aac',
+ m4a: 'audio/mp4',
+ mp3: 'audio/mpeg',
+ mpeg: 'audio/mpeg',
+ oga: 'audio/ogg',
+ ogg: 'audio/ogg',
+ wav: 'audio/wav',
+ wave: 'audio/wav',
+};
+
+export function encodeBase64(bytes: Uint8Array): string {
+ let output = '';
+ for (let index = 0; index < bytes.length; index += 3) {
+ const first = bytes[index] ?? 0;
+ const hasSecond = index + 1 < bytes.length;
+ const hasThird = index + 2 < bytes.length;
+ const second = hasSecond ? bytes[index + 1]! : 0;
+ const third = hasThird ? bytes[index + 2]! : 0;
+ const value = (first << 16) | (second << 8) | third;
+
+ output += BASE64_ALPHABET[(value >>> 18) & 63];
+ output += BASE64_ALPHABET[(value >>> 12) & 63];
+ output += hasSecond ? BASE64_ALPHABET[(value >>> 6) & 63] : '=';
+ output += hasThird ? BASE64_ALPHABET[value & 63] : '=';
+ }
+ return output;
+}
+
+export function buildAudioDataUri(bytes: Uint8Array, audioFormat: string): string {
+ const normalizedFormat = audioFormat.trim().toLowerCase().replace(/^\./, '');
+ if (!/^[a-z0-9][a-z0-9.+-]{0,31}$/.test(normalizedFormat)) {
+ throw new Error('Unsupported reminder audio format');
+ }
+ const mime = MIME_BY_FORMAT[normalizedFormat] ?? `audio/${normalizedFormat}`;
+ return `data:${mime};base64,${encodeBase64(bytes)}`;
+}
diff --git a/frontend/src/infrastructure/audio/index.ts b/frontend/src/infrastructure/audio/index.ts
index 8cc9460f..48529eb5 100644
--- a/frontend/src/infrastructure/audio/index.ts
+++ b/frontend/src/infrastructure/audio/index.ts
@@ -1 +1,2 @@
-export { MockAudioPlayback } from './MockAudioPlayback';
+export { ExpoAudioPlayback } from './ExpoAudioPlayback';
+export { buildAudioDataUri, encodeBase64 } from './audioDataUri';
diff --git a/frontend/src/infrastructure/location/.gitkeep b/frontend/src/infrastructure/location/.gitkeep
deleted file mode 100644
index 8b137891..00000000
--- a/frontend/src/infrastructure/location/.gitkeep
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/frontend/src/infrastructure/location/ExpoLocationMonitor.ts b/frontend/src/infrastructure/location/ExpoLocationMonitor.ts
new file mode 100644
index 00000000..12a2081f
--- /dev/null
+++ b/frontend/src/infrastructure/location/ExpoLocationMonitor.ts
@@ -0,0 +1,253 @@
+import * as Location from 'expo-location';
+import { AppState, type AppStateStatus, type NativeEventSubscription } from 'react-native';
+
+import type {
+ LocationMonitorEvent,
+ LocationMonitorPort,
+ LocationRebuildTarget,
+ LocationWatchHandle,
+ LocationWatchRequest,
+} from '../../features/reminder/application/interfaces';
+import type { LocationSample } from '../../features/reminder/domain';
+
+import {
+ GEOFENCE_TASK_NAME,
+ drainPendingGeofenceEvents,
+ subscribeGeofenceTaskEvents,
+ type GeofenceTaskPayload,
+} from './geofenceTask';
+import type { LocationProvider } from './LocationProvider';
+
+type ActiveWatch = {
+ listener_id: string;
+ request: LocationWatchRequest;
+ listener: (event: LocationMonitorEvent) => void;
+};
+
+/** ≈1.1km,足够超出任何合理的围栏半径,用来给 exit 事件合成一个"明显在圈外"的采样点。 */
+const OUTSIDE_OFFSET_DEGREES = 0.01;
+
+/**
+ * 基于 expo-location 系统原生地理围栏(Android GeofencingClient / iOS
+ * CLCircularRegion)的适配器:不依赖百度账号/Key,围栏进出判断交给系统,比
+ * NativeLocationMonitor 那套连续定位轮询 + 应用侧 Haversine 更省电、后台也更可靠。
+ *
+ * 系统只会在真正穿越边界时回调 enter/exit,不会在注册那一刻告诉你当前在圈内还是圈
+ * 外,所以 watch() 里仍然主动取一次当前定位、强制送一条初始采样,让
+ * LocalReminderApplication 的 armed 状态机能定出正确的起始值;之后的进出全靠
+ * geofenceTask 的系统回调,不再轮询。
+ *
+ * LocalReminderApplication.applyLocationSample() 不看这里传的 phase,只用 sample
+ * 坐标自己重新跑一遍 Haversine 判断,所以 enter/exit 时合成的坐标(区域中心 /
+ * 中心外一段距离)足够,不需要为了拿"真实"坐标再多发一次定位请求。
+ */
+export class ExpoLocationMonitor implements LocationMonitorPort, LocationProvider {
+ private readonly watches = new Map();
+ private readonly scheduleToListener = new Map();
+ private lastSample: LocationSample | null = null;
+ private syncChain: Promise = Promise.resolve();
+ private unsubscribeTask: (() => void) | null;
+ private readonly appStateSub: NativeEventSubscription;
+
+ constructor() {
+ this.unsubscribeTask = subscribeGeofenceTaskEvents(this.handleTaskEvent);
+ this.appStateSub = AppState.addEventListener('change', this.handleAppState);
+ }
+
+ async watch(
+ request: LocationWatchRequest,
+ listener: (event: LocationMonitorEvent) => void,
+ ): Promise {
+ const existingId = this.scheduleToListener.get(request.schedule_id);
+ if (existingId != null) {
+ await this.removeWatch(existingId, false);
+ }
+
+ const listener_id = `location-${request.schedule_id}`;
+ this.watches.set(listener_id, { listener_id, request: { ...request }, listener });
+ this.scheduleToListener.set(request.schedule_id, listener_id);
+ await this.enqueueSync();
+
+ const sample = await this.getCurrentSample();
+ if (sample != null) {
+ listener({ schedule_id: request.schedule_id, sample, phase: 'inside' });
+ }
+ await this.replayPendingEvents();
+
+ return { listener_id, schedule_id: request.schedule_id };
+ }
+
+ async unwatch(listenerId: string): Promise {
+ await this.removeWatch(listenerId, true);
+ }
+
+ async rebuild(
+ targets: readonly LocationRebuildTarget[],
+ listener: (event: LocationMonitorEvent) => void,
+ ): Promise {
+ for (const listenerId of [...this.watches.keys()]) {
+ await this.removeWatch(listenerId, false);
+ }
+
+ // 直接灌 Map,不逐个调用 watch():watch() 自己的 enqueueSync()+getCurrentSample()
+ // 是为单条增量注册设计的,N 个 target 各跑一次会把 startGeofencingAsync 的区域
+ // 列表越注册越长(O(N²) 总区域数)、定位请求也打 N 次;这里全部灌完只同步一次、
+ // 取一次定位,再扇给每个刚注册的 watch。
+ const handles: LocationWatchHandle[] = [];
+ for (const target of targets) {
+ const listener_id = `location-${target.schedule_id}`;
+ this.watches.set(listener_id, {
+ listener_id,
+ request: {
+ schedule_id: target.schedule_id,
+ center: target.center,
+ radius_meters: target.radius_meters,
+ mode: target.mode,
+ background: target.background,
+ },
+ listener,
+ });
+ this.scheduleToListener.set(target.schedule_id, listener_id);
+ handles.push({ listener_id, schedule_id: target.schedule_id });
+ }
+ await this.enqueueSync();
+
+ const sample = await this.getCurrentSample();
+ if (sample != null) {
+ for (const handle of handles) {
+ listener({ schedule_id: handle.schedule_id, sample, phase: 'inside' });
+ }
+ }
+
+ // App 进程被杀掉期间,headless task 攒下的围栏事件在这里补上——watches/
+ // scheduleToListener 刚灌好,handleTaskEvent 才查得到对应的 watch。
+ await this.replayPendingEvents();
+
+ return handles;
+ }
+
+ async getLastSample(): Promise {
+ return this.lastSample;
+ }
+
+ async getCurrentSample(): Promise {
+ try {
+ const { status } = await Location.getForegroundPermissionsAsync();
+ if (status !== 'granted') return this.lastSample;
+ const position = await Location.getCurrentPositionAsync({});
+ const sample = toSample(position);
+ this.lastSample = sample;
+ return sample;
+ } catch {
+ return this.lastSample;
+ }
+ }
+
+ dispose(): void {
+ this.unsubscribeTask?.();
+ this.unsubscribeTask = null;
+ this.appStateSub.remove();
+ void Location.hasStartedGeofencingAsync(GEOFENCE_TASK_NAME)
+ .then((started) => (started ? Location.stopGeofencingAsync(GEOFENCE_TASK_NAME) : undefined))
+ .catch(() => undefined);
+ }
+
+ private readonly handleAppState = (state: AppStateStatus): void => {
+ if (state !== 'active' || this.watches.size === 0) return;
+ void this.enqueueSync();
+ };
+
+ /** 补上 headless task 期间(没有订阅者时)攒下的围栏事件,按正常路径重放一遍。 */
+ private async replayPendingEvents(): Promise {
+ const pending = await drainPendingGeofenceEvents();
+ for (const payload of pending) {
+ this.handleTaskEvent(payload);
+ }
+ }
+
+ private readonly handleTaskEvent = (payload: GeofenceTaskPayload): void => {
+ const listenerId = this.scheduleToListener.get(payload.schedule_id);
+ const watch = listenerId != null ? this.watches.get(listenerId) : undefined;
+ if (watch == null) return;
+
+ const sample: LocationSample =
+ payload.event === 'enter'
+ ? {
+ latitude: payload.latitude,
+ longitude: payload.longitude,
+ accuracy_meters: payload.radius,
+ observed_at: payload.observed_at,
+ }
+ : {
+ latitude: payload.latitude + OUTSIDE_OFFSET_DEGREES,
+ longitude: payload.longitude,
+ accuracy_meters: payload.radius,
+ observed_at: payload.observed_at,
+ };
+ this.lastSample = sample;
+ watch.listener({
+ schedule_id: watch.request.schedule_id,
+ sample,
+ phase: payload.event === 'enter' ? 'entered' : 'left',
+ });
+ };
+
+ private async removeWatch(listenerId: string, sync: boolean): Promise {
+ const watch = this.watches.get(listenerId);
+ if (watch == null) return;
+ this.watches.delete(listenerId);
+ this.scheduleToListener.delete(watch.request.schedule_id);
+ if (sync) {
+ await this.enqueueSync();
+ }
+ }
+
+ private enqueueSync(): Promise {
+ this.syncChain = this.syncChain.then(
+ () => this.syncRegions(),
+ () => this.syncRegions(),
+ );
+ return this.syncChain;
+ }
+
+ private async syncRegions(): Promise {
+ if (this.watches.size === 0) {
+ const started = await Location.hasStartedGeofencingAsync(GEOFENCE_TASK_NAME).catch(
+ () => false,
+ );
+ if (started) {
+ await Location.stopGeofencingAsync(GEOFENCE_TASK_NAME).catch(() => undefined);
+ }
+ return;
+ }
+
+ const { status: foreground } = await Location.getForegroundPermissionsAsync();
+ if (foreground !== 'granted') return;
+ const { status: background } = await Location.getBackgroundPermissionsAsync();
+ if (background !== 'granted') return;
+
+ const regions: Location.LocationRegion[] = [...this.watches.values()].map((watch) => ({
+ identifier: watch.request.schedule_id,
+ latitude: watch.request.center.latitude,
+ longitude: watch.request.center.longitude,
+ radius: watch.request.radius_meters,
+ notifyOnEnter: true,
+ notifyOnExit: true,
+ }));
+
+ try {
+ await Location.startGeofencingAsync(GEOFENCE_TASK_NAME, regions);
+ } catch {
+ // 系统围栏注册失败(比如权限被收回);下次 watch()/rebuild() 会再重试。
+ }
+ }
+}
+
+function toSample(position: Location.LocationObject): LocationSample {
+ return {
+ latitude: position.coords.latitude,
+ longitude: position.coords.longitude,
+ accuracy_meters: position.coords.accuracy ?? 0,
+ observed_at: new Date(position.timestamp).toISOString(),
+ };
+}
diff --git a/frontend/src/infrastructure/location/ExpoLocationProvider.ts b/frontend/src/infrastructure/location/ExpoLocationProvider.ts
index f9907df1..c0ce1b77 100644
--- a/frontend/src/infrastructure/location/ExpoLocationProvider.ts
+++ b/frontend/src/infrastructure/location/ExpoLocationProvider.ts
@@ -4,27 +4,76 @@ import type { LocationSample } from '../../features/reminder/domain';
import type { LocationProvider } from './LocationProvider';
+const LAST_KNOWN_MAX_AGE_MS = 60_000;
+const LAST_KNOWN_REQUIRED_ACCURACY_METERS = 200;
+
/**
- * 真实定位实现。权限被拒绝或定位失败都返回 null 而不是抛错——调用方(目前是
- * session.hello 的位置字段)拿不到就不带这个字段,不应该因为定位失败连不上。
+ * 真实定位实现。语音握手优先使用短时间内的缓存位置,避免等待 GPS 冷启动;同时
+ * 在后台刷新当前位置,让下一次连接获得更新的位置。权限被拒绝或定位失败都返回
+ * null 而不是抛错,调用方拿不到就不带 session.hello 的位置字段,不影响连通性。
*/
export class ExpoLocationProvider implements LocationProvider {
+ private freshSampleInFlight: Promise | null = null;
+
async getCurrentSample(): Promise {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') {
+ console.warn('[location-search] foreground location permission is unavailable', { status });
return null;
}
+ const cached = await this.getRecentSample();
+ if (cached !== null) {
+ void this.getFreshSample();
+ return cached;
+ }
+
+ return this.getFreshSample();
+ }
+
+ private async getRecentSample(): Promise {
try {
- const position = await Location.getCurrentPositionAsync({});
- return {
- accuracy_meters: position.coords.accuracy ?? 0,
- latitude: position.coords.latitude,
- longitude: position.coords.longitude,
- observed_at: new Date(position.timestamp).toISOString(),
- };
- } catch {
+ const position = await Location.getLastKnownPositionAsync({
+ maxAge: LAST_KNOWN_MAX_AGE_MS,
+ requiredAccuracy: LAST_KNOWN_REQUIRED_ACCURACY_METERS,
+ });
+ return position === null ? null : toSample(position);
+ } catch (error) {
+ console.warn('[location-search] failed to read cached location', {
+ errorType: error instanceof Error ? error.name : typeof error,
+ });
return null;
}
}
+
+ private getFreshSample(): Promise {
+ if (this.freshSampleInFlight !== null) {
+ return this.freshSampleInFlight;
+ }
+
+ const request = Location.getCurrentPositionAsync({})
+ .then(toSample)
+ .catch((error) => {
+ console.warn('[location-search] failed to acquire current location', {
+ errorType: error instanceof Error ? error.name : typeof error,
+ });
+ return null;
+ });
+ this.freshSampleInFlight = request;
+ void request.finally(() => {
+ if (this.freshSampleInFlight === request) {
+ this.freshSampleInFlight = null;
+ }
+ });
+ return request;
+ }
+}
+
+function toSample(position: Location.LocationObject): LocationSample {
+ return {
+ accuracy_meters: position.coords.accuracy ?? 0,
+ latitude: position.coords.latitude,
+ longitude: position.coords.longitude,
+ observed_at: new Date(position.timestamp).toISOString(),
+ };
}
diff --git a/frontend/src/infrastructure/location/LocationProvider.ts b/frontend/src/infrastructure/location/LocationProvider.ts
index 84933700..723c463a 100644
--- a/frontend/src/infrastructure/location/LocationProvider.ts
+++ b/frontend/src/infrastructure/location/LocationProvider.ts
@@ -1,6 +1,6 @@
-import type { LocationSample } from '../../features/reminder/domain';
+import type { LocationObservation } from '../../contracts/reminder';
/** 获取一次当前位置样本的平台适配器。 */
export interface LocationProvider {
- getCurrentSample(): Promise;
+ getCurrentSample(): Promise;
}
diff --git a/frontend/src/infrastructure/location/MockLocationMonitor.ts b/frontend/src/infrastructure/location/MockLocationMonitor.ts
deleted file mode 100644
index b5fb4e65..00000000
--- a/frontend/src/infrastructure/location/MockLocationMonitor.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import type {
- LocationMonitorEvent,
- LocationMonitorPort,
- LocationWatchHandle,
- LocationWatchRequest,
-} from '../../features/reminder/application/interfaces';
-import type { LocalReminderSchedule, LocationSample } from '../../features/reminder/domain';
-
-import type { LocationProvider } from './LocationProvider';
-
-const MOCK_SAMPLE: LocationSample = {
- latitude: 31.2304,
- longitude: 121.4737,
- accuracy_meters: 12,
- observed_at: '2026-08-07T01:00:00.000Z',
-};
-
-function isLocationSchedule(schedule: LocalReminderSchedule): boolean {
- return schedule.schedule_type === 'location' && schedule.status === 'active';
-}
-
-/** 固定定位能力适配器,不访问平台定位接口。 */
-export class MockLocationMonitor implements LocationMonitorPort, LocationProvider {
- async watch(
- request: LocationWatchRequest,
- _listener: (event: LocationMonitorEvent) => void,
- ): Promise {
- return {
- listener_id: `mock-location-listener-${request.schedule_id}`,
- schedule_id: request.schedule_id,
- };
- }
-
- async unwatch(_listenerId: string): Promise {
- return Promise.resolve();
- }
-
- async rebuild(
- schedules: readonly LocalReminderSchedule[],
- _listener: (event: LocationMonitorEvent) => void,
- ): Promise {
- return schedules.filter(isLocationSchedule).map((schedule) => ({
- listener_id: `mock-location-listener-${schedule.id}`,
- schedule_id: schedule.id,
- }));
- }
-
- async getLastSample(): Promise {
- return { ...MOCK_SAMPLE };
- }
-
- async getCurrentSample(): Promise {
- return { ...MOCK_SAMPLE };
- }
-}
-
-export { MOCK_SAMPLE as MOCK_LOCATION_SAMPLE };
diff --git a/frontend/src/infrastructure/location/NativeLocationMonitor.ts b/frontend/src/infrastructure/location/NativeLocationMonitor.ts
new file mode 100644
index 00000000..323fc535
--- /dev/null
+++ b/frontend/src/infrastructure/location/NativeLocationMonitor.ts
@@ -0,0 +1,223 @@
+import { AppState, type AppStateStatus, type NativeEventSubscription } from 'react-native';
+
+import type {
+ LocationMonitorEvent,
+ LocationMonitorPort,
+ LocationRebuildTarget,
+ LocationWatchHandle,
+ LocationWatchRequest,
+} from '../../features/reminder/application/interfaces';
+import type { LocationSample } from '../../features/reminder/domain';
+import { distanceMeters } from '../../features/reminder/domain/geofence';
+import type { LocationObservation } from '../../contracts/reminder';
+
+import type { LocationProvider } from './LocationProvider';
+import {
+ baiduGetCurrentPosition,
+ baiduInit,
+ baiduStartUpdating,
+ baiduStopUpdating,
+ isBaiduLocationAvailable,
+ subscribeBaiduLocation,
+ type BaiduLocationSample,
+} from './native/BaiduLocationBridge';
+
+type ActiveWatch = {
+ listener_id: string;
+ request: LocationWatchRequest;
+ listener: (event: LocationMonitorEvent) => void;
+ inside: boolean | null;
+};
+
+/**
+ * 基于百度定位 SDK 的围栏/定位适配器:
+ * - 连续定位采样(非 Google Geofencing)
+ * - 应用侧 Haversine 计算进出圈边沿
+ */
+export class NativeLocationMonitor implements LocationMonitorPort, LocationProvider {
+ private readonly watches = new Map();
+ private readonly scheduleToListener = new Map();
+ private lastSample: LocationSample | null = null;
+ private syncChain: Promise = Promise.resolve();
+ private unsubscribeLocation: (() => void) | null = null;
+ private started = false;
+ private readonly appStateSub: NativeEventSubscription;
+
+ constructor() {
+ this.appStateSub = AppState.addEventListener('change', this.handleAppState);
+ }
+
+ async watch(
+ request: LocationWatchRequest,
+ listener: (event: LocationMonitorEvent) => void,
+ ): Promise {
+ const existingId = this.scheduleToListener.get(request.schedule_id);
+ if (existingId != null) {
+ await this.removeWatch(existingId, false);
+ }
+
+ const listener_id = `location-${request.schedule_id}`;
+ this.watches.set(listener_id, {
+ listener_id,
+ request: { ...request },
+ listener,
+ inside: null,
+ });
+ this.scheduleToListener.set(request.schedule_id, listener_id);
+ await this.enqueueSync();
+
+ const sample = await this.getCurrentSample();
+ const watch = this.watches.get(listener_id);
+ if (sample != null && watch != null) {
+ this.emitForWatch(watch, sample, /*forcePhase*/ true);
+ }
+
+ return { listener_id, schedule_id: request.schedule_id };
+ }
+
+ async unwatch(listenerId: string): Promise {
+ await this.removeWatch(listenerId, true);
+ }
+
+ async rebuild(
+ targets: readonly LocationRebuildTarget[],
+ listener: (event: LocationMonitorEvent) => void,
+ ): Promise {
+ for (const listenerId of [...this.watches.keys()]) {
+ await this.removeWatch(listenerId, false);
+ }
+ await this.enqueueSync();
+
+ const handles: LocationWatchHandle[] = [];
+ for (const target of targets) {
+ handles.push(
+ await this.watch(
+ {
+ schedule_id: target.schedule_id,
+ center: target.center,
+ radius_meters: target.radius_meters,
+ mode: target.mode,
+ background: target.background,
+ },
+ listener,
+ ),
+ );
+ }
+ return handles;
+ }
+
+ async getLastSample(): Promise {
+ return this.lastSample;
+ }
+
+ async getCurrentSample(): Promise {
+ if (!isBaiduLocationAvailable()) return this.lastSample;
+ try {
+ if (!this.started) {
+ await baiduInit(null);
+ }
+ const current = await baiduGetCurrentPosition();
+ if (current == null) return this.lastSample;
+ const sample = toSample(current);
+ this.lastSample = sample;
+ return sample;
+ } catch {
+ return this.lastSample;
+ }
+ }
+
+ dispose(): void {
+ this.unsubscribeLocation?.();
+ this.unsubscribeLocation = null;
+ this.appStateSub.remove();
+ void baiduStopUpdating();
+ this.started = false;
+ }
+
+ private readonly handleAppState = (state: AppStateStatus): void => {
+ if (state !== 'active' || this.watches.size === 0) return;
+ void this.enqueueSync();
+ };
+
+ private async removeWatch(listenerId: string, sync: boolean): Promise {
+ const watch = this.watches.get(listenerId);
+ if (watch == null) return;
+ this.watches.delete(listenerId);
+ this.scheduleToListener.delete(watch.request.schedule_id);
+ if (sync) {
+ await this.enqueueSync();
+ }
+ }
+
+ private enqueueSync(): Promise {
+ this.syncChain = this.syncChain.then(
+ () => this.syncMonitoring(),
+ () => this.syncMonitoring(),
+ );
+ return this.syncChain;
+ }
+
+ private async syncMonitoring(): Promise {
+ if (this.watches.size === 0) {
+ this.unsubscribeLocation?.();
+ this.unsubscribeLocation = null;
+ if (this.started) {
+ await baiduStopUpdating();
+ this.started = false;
+ }
+ return;
+ }
+
+ if (!isBaiduLocationAvailable()) {
+ return;
+ }
+
+ if (this.unsubscribeLocation == null) {
+ this.unsubscribeLocation = subscribeBaiduLocation((payload) => {
+ const sample = toSample(payload);
+ this.lastSample = sample;
+ for (const watch of this.watches.values()) {
+ this.emitForWatch(watch, sample, false);
+ }
+ });
+ }
+
+ await baiduInit(null);
+ await baiduStartUpdating(5_000);
+ this.started = true;
+ }
+
+ private emitForWatch(watch: ActiveWatch, sample: LocationSample, forcePhase: boolean): void {
+ const inside = distanceMeters(watch.request.center, sample) <= watch.request.radius_meters;
+ const previous = watch.inside;
+ watch.inside = inside;
+
+ let phase: LocationMonitorEvent['phase'];
+ if (previous == null || forcePhase) {
+ phase = inside ? 'inside' : 'outside';
+ } else if (previous === inside) {
+ phase = inside ? 'inside' : 'outside';
+ } else {
+ phase = inside ? 'entered' : 'left';
+ }
+
+ if (!forcePhase && previous === inside) {
+ return;
+ }
+
+ watch.listener({
+ schedule_id: watch.request.schedule_id,
+ sample,
+ phase,
+ });
+ }
+}
+
+function toSample(payload: BaiduLocationSample): LocationSample {
+ return {
+ latitude: payload.latitude,
+ longitude: payload.longitude,
+ accuracy_meters: payload.accuracy ?? 0,
+ observed_at: payload.observedAt || new Date().toISOString(),
+ };
+}
diff --git a/frontend/src/infrastructure/location/geofenceTask.ts b/frontend/src/infrastructure/location/geofenceTask.ts
new file mode 100644
index 00000000..4d1f3239
--- /dev/null
+++ b/frontend/src/infrastructure/location/geofenceTask.ts
@@ -0,0 +1,258 @@
+import * as Location from 'expo-location';
+import type { SQLiteDatabase } from 'expo-sqlite';
+import * as TaskManager from 'expo-task-manager';
+
+export const GEOFENCE_TASK_NAME = 'timeflow-geofence';
+
+export type GeofenceTaskPayload = {
+ schedule_id: string;
+ event: 'enter' | 'exit';
+ latitude: number;
+ longitude: number;
+ radius: number;
+ observed_at: string;
+};
+
+type GeofenceTaskListener = (payload: GeofenceTaskPayload) => void;
+
+const listeners = new Set();
+
+/** 没有订阅者时(App 进程已死,Expo 只把 JS 引擎拉起来跑这一个 headless task,
+ * AppRoot/ExpoLocationMonitor 那套 React 生命周期根本没启动),通知失败的事件落这里,
+ * 等真正的会话起来后由 drainPendingGeofenceEvents() 取走重放,而不是直接丢掉。 */
+const PENDING_EVENTS_KEY = 'timeflow-pending-geofence-events';
+const TIMEFLOW_DATABASE_NAME = 'timeflow.db';
+
+type HeadlessScheduleRow = {
+ id: string;
+ title: string;
+ location_name: string | null;
+ schedule_type: string;
+ reminder_type: string | null;
+ reminder_disposition_state: string | null;
+ snoozed_until: string | null;
+ geofence_armed: number;
+ status: string;
+};
+
+/** 订阅系统围栏任务回调;须在应用入口尽早 import 本模块以完成 defineTask。 */
+export function subscribeGeofenceTaskEvents(listener: GeofenceTaskListener): () => void {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+}
+
+/** 懒加载:expo-sqlite/kv-store 的默认导出是模块加载时就构造的单例,顶层 import
+ * 会在测试环境(没有真实原生模块)里直接抛错——这个仓库里原生相关的按需依赖
+ * 一律走动态 import,参照 ExpoAudioPlayback.ts 的 loadExpoAudio()。 */
+async function loadStorage(): Promise {
+ try {
+ const mod = await import('expo-sqlite/kv-store');
+ return mod.Storage;
+ } catch {
+ return null;
+ }
+}
+
+/** 取出并清空 headless 期间攒下的围栏事件;调用方负责按订阅时的逻辑重放它们。 */
+export async function drainPendingGeofenceEvents(): Promise {
+ const storage = await loadStorage();
+ if (storage == null) return [];
+ const raw = await storage.getItem(PENDING_EVENTS_KEY);
+ if (raw == null) return [];
+ await storage.removeItem(PENDING_EVENTS_KEY);
+ try {
+ const parsed: unknown = JSON.parse(raw);
+ return Array.isArray(parsed) ? (parsed as GeofenceTaskPayload[]) : [];
+ } catch {
+ return [];
+ }
+}
+
+async function persistPendingEvent(payload: GeofenceTaskPayload): Promise {
+ const storage = await loadStorage();
+ if (storage == null) return;
+ const raw = await storage.getItem(PENDING_EVENTS_KEY);
+ const pending: GeofenceTaskPayload[] = [];
+ if (raw != null) {
+ try {
+ const parsed: unknown = JSON.parse(raw);
+ if (Array.isArray(parsed)) pending.push(...(parsed as GeofenceTaskPayload[]));
+ } catch {
+ // 上一份坏了就当没有,不阻塞这次事件的持久化。
+ }
+ }
+ pending.push(payload);
+ await storage.setItem(PENDING_EVENTS_KEY, JSON.stringify(pending));
+}
+
+async function emit(payload: GeofenceTaskPayload): Promise {
+ if (listeners.size === 0) {
+ let delivered = false;
+ try {
+ delivered = await deliverHeadlessGeofenceEvent(payload);
+ } catch {
+ delivered = false;
+ }
+ if (!delivered) {
+ await persistPendingEvent(payload);
+ }
+ return;
+ }
+ for (const listener of listeners) {
+ listener(payload);
+ }
+}
+
+/**
+ * Deliver a notification while Expo has only started this headless task.
+ *
+ * AppRoot/LocalReminderApplication is not alive in this path, so the task must keep
+ * the same edge-triggered armed state in SQLite instead of notifying on every enter.
+ * Returning false leaves the event in the existing pending queue for replay when the
+ * normal application session starts.
+ */
+async function deliverHeadlessGeofenceEvent(payload: GeofenceTaskPayload): Promise {
+ const database = await openHeadlessDatabase();
+ if (database == null) return false;
+
+ if (payload.event === 'exit') {
+ await database.runAsync(
+ `UPDATE local_schedules
+ SET geofence_armed = 1
+ WHERE id = ?
+ AND status = 'active'
+ AND schedule_type = 'location'
+ AND geofence_armed = 0`,
+ payload.schedule_id,
+ );
+ return true;
+ }
+
+ const schedule = await database.getFirstAsync(
+ `SELECT id, title, location_name, schedule_type, reminder_type,
+ reminder_disposition_state, snoozed_until, geofence_armed, status
+ FROM local_schedules
+ WHERE id = ?`,
+ payload.schedule_id,
+ );
+ if (
+ schedule == null ||
+ schedule.status !== 'active' ||
+ schedule.schedule_type !== 'location' ||
+ schedule.geofence_armed !== 1 ||
+ schedule.reminder_disposition_state === 'pending' ||
+ schedule.reminder_disposition_state === 'confirmed' ||
+ (schedule.reminder_disposition_state === 'snoozed' &&
+ schedule.snoozed_until != null &&
+ Date.parse(schedule.snoozed_until) > Date.parse(payload.observed_at))
+ ) {
+ return true;
+ }
+
+ 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,
+ });
+
+ await database.runAsync(
+ `UPDATE local_schedules
+ SET geofence_armed = 0,
+ reminder_disposition_state = 'pending',
+ next_trigger_at = NULL,
+ disposition_updated_at = ?,
+ sync_status = 'pending'
+ WHERE id = ?
+ AND status = 'active'
+ AND schedule_type = 'location'
+ AND geofence_armed = 1
+ AND (reminder_disposition_state IS NULL OR reminder_disposition_state = 'snoozed')`,
+ payload.observed_at,
+ schedule.id,
+ );
+ return true;
+}
+
+async function openHeadlessDatabase(): Promise {
+ try {
+ const { openDatabaseAsync } = await import('expo-sqlite');
+ return await openDatabaseAsync(TIMEFLOW_DATABASE_NAME);
+ } catch {
+ return null;
+ }
+}
+
+async function loadNotifications(): Promise {
+ try {
+ return await import('expo-notifications');
+ } catch {
+ return null;
+ }
+}
+
+async function ensureAndroidChannel(
+ notifications: typeof import('expo-notifications'),
+): Promise {
+ await notifications.setNotificationChannelAsync('timeflow-reminders', {
+ name: '日程提醒',
+ importance: notifications.AndroidImportance.DEFAULT,
+ vibrationPattern: [0, 180],
+ lightColor: '#D7F36A',
+ });
+}
+
+if (!TaskManager.isTaskDefined(GEOFENCE_TASK_NAME)) {
+ TaskManager.defineTask(GEOFENCE_TASK_NAME, async ({ data, error }) => {
+ if (error) return;
+
+ const payload = data as
+ | {
+ eventType?: Location.GeofencingEventType;
+ region?: Location.LocationRegion;
+ }
+ | undefined;
+ const region = payload?.region;
+ if (region?.identifier == null) return;
+
+ const eventType = payload?.eventType;
+ const event =
+ eventType === Location.GeofencingEventType.Enter
+ ? 'enter'
+ : eventType === Location.GeofencingEventType.Exit
+ ? 'exit'
+ : null;
+ if (event == null) return;
+
+ await emit({
+ schedule_id: region.identifier,
+ event,
+ latitude: region.latitude,
+ longitude: region.longitude,
+ radius: region.radius,
+ observed_at: new Date().toISOString(),
+ });
+ });
+}
diff --git a/frontend/src/infrastructure/location/index.ts b/frontend/src/infrastructure/location/index.ts
index ef2d2b9d..1461aae2 100644
--- a/frontend/src/infrastructure/location/index.ts
+++ b/frontend/src/infrastructure/location/index.ts
@@ -1,2 +1,10 @@
-export { MockLocationMonitor, MOCK_LOCATION_SAMPLE } from './MockLocationMonitor';
+export { NativeLocationMonitor } from './NativeLocationMonitor';
+export { ExpoLocationMonitor } from './ExpoLocationMonitor';
export type { LocationProvider } from './LocationProvider';
+export {
+ isBaiduLocationAvailable,
+ baiduInit,
+ baiduStartUpdating,
+ baiduStopUpdating,
+ subscribeBaiduLocation,
+} from './native/BaiduLocationBridge';
diff --git a/frontend/src/infrastructure/location/native/BaiduLocationBridge.ts b/frontend/src/infrastructure/location/native/BaiduLocationBridge.ts
new file mode 100644
index 00000000..fb5e8ea9
--- /dev/null
+++ b/frontend/src/infrastructure/location/native/BaiduLocationBridge.ts
@@ -0,0 +1,83 @@
+import { NativeEventEmitter, NativeModules, Platform } from 'react-native';
+
+export type BaiduLocationSample = {
+ latitude: number;
+ longitude: number;
+ accuracy: number;
+ observedAt: string;
+ locType?: number;
+};
+
+type TimeflowBaiduLocationNative = {
+ setAgreePrivacy: (agree: boolean) => Promise;
+ init: (ak: string | null) => Promise;
+ startUpdating: (intervalMs: number) => Promise;
+ stopUpdating: () => Promise;
+ getCurrentPosition: () => Promise;
+ addListener: (eventName: string) => void;
+ removeListeners: (count: number) => void;
+};
+
+const Native = NativeModules.TimeflowBaiduLocation as TimeflowBaiduLocationNative | undefined;
+
+function getNative(): TimeflowBaiduLocationNative | undefined {
+ return NativeModules.TimeflowBaiduLocation as TimeflowBaiduLocationNative | undefined;
+}
+
+export function isBaiduLocationAvailable(): boolean {
+ return Platform.OS === 'android' && getNative() != null;
+}
+
+export async function baiduSetAgreePrivacy(agree: boolean): Promise {
+ const native = getNative();
+ if (native == null) return;
+ await native.setAgreePrivacy(agree);
+}
+
+export async function baiduInit(ak: string | null = null): Promise {
+ const native = getNative();
+ if (native == null) return false;
+ await native.setAgreePrivacy(true);
+ return native.init(ak);
+}
+
+export async function baiduStartUpdating(intervalMs = 5_000): Promise {
+ const native = getNative();
+ if (native == null) return false;
+ return native.startUpdating(intervalMs);
+}
+
+export async function baiduStopUpdating(): Promise {
+ const native = getNative();
+ if (native == null) return;
+ await native.stopUpdating();
+}
+
+export async function baiduGetCurrentPosition(): Promise {
+ const native = getNative();
+ if (native == null) return null;
+ return native.getCurrentPosition();
+}
+
+export function subscribeBaiduLocation(
+ listener: (sample: BaiduLocationSample) => void,
+): () => void {
+ const native = getNative();
+ if (native == null || Platform.OS !== 'android') {
+ return () => {};
+ }
+ const emitter = new NativeEventEmitter(native as never);
+ const sub = emitter.addListener('TimeflowBaiduLocation', (payload: BaiduLocationSample) => {
+ if (
+ payload == null ||
+ typeof payload.latitude !== 'number' ||
+ typeof payload.longitude !== 'number'
+ ) {
+ return;
+ }
+ listener(payload);
+ });
+ return () => sub.remove();
+}
+
+export { Native as TimeflowBaiduLocationNativeModule };
diff --git a/frontend/src/infrastructure/notifications/.gitkeep b/frontend/src/infrastructure/notifications/.gitkeep
deleted file mode 100644
index 8b137891..00000000
--- a/frontend/src/infrastructure/notifications/.gitkeep
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/frontend/src/infrastructure/notifications/ExpoSystemNotification.ts b/frontend/src/infrastructure/notifications/ExpoSystemNotification.ts
new file mode 100644
index 00000000..5623c600
--- /dev/null
+++ b/frontend/src/infrastructure/notifications/ExpoSystemNotification.ts
@@ -0,0 +1,72 @@
+import type {
+ SystemNotificationPort,
+ SystemNotificationReceipt,
+ SystemNotificationRequest,
+} from '../../features/reminder/application/interfaces';
+
+type NotificationsModule = typeof import('expo-notifications');
+
+let channelReady: Promise | null = null;
+
+async function loadNotifications(): Promise {
+ try {
+ return await import('expo-notifications');
+ } catch {
+ return null;
+ }
+}
+
+async function ensureAndroidChannel(Notifications: NotificationsModule): Promise {
+ if (channelReady != null) {
+ await channelReady;
+ return;
+ }
+ channelReady = (async () => {
+ await Notifications.setNotificationChannelAsync('timeflow-reminders', {
+ name: '日程提醒',
+ importance: Notifications.AndroidImportance.DEFAULT,
+ vibrationPattern: [0, 180],
+ lightColor: '#D7F36A',
+ });
+ })();
+ await channelReady;
+}
+
+/** 基于 expo-notifications 的轻度提醒系统通知。 */
+export class ExpoSystemNotification implements SystemNotificationPort {
+ async show(request: SystemNotificationRequest): Promise {
+ const Notifications = await loadNotifications();
+ if (Notifications == null) {
+ return { notification_id: request.notification_id, shown: false };
+ }
+
+ await ensureAndroidChannel(Notifications);
+ Notifications.setNotificationHandler({
+ handleNotification: async () => ({
+ shouldShowBanner: true,
+ shouldShowList: true,
+ shouldPlaySound: false,
+ shouldSetBadge: false,
+ }),
+ });
+
+ await Notifications.scheduleNotificationAsync({
+ identifier: request.notification_id,
+ content: {
+ title: request.title,
+ body: request.body,
+ sound: false,
+ },
+ trigger: null,
+ });
+
+ return { notification_id: request.notification_id, shown: true };
+ }
+
+ async cancel(notificationId: string): Promise {
+ const Notifications = await loadNotifications();
+ if (Notifications == null) return;
+ await Notifications.dismissNotificationAsync(notificationId).catch(() => undefined);
+ await Notifications.cancelScheduledNotificationAsync(notificationId).catch(() => undefined);
+ }
+}
diff --git a/frontend/src/infrastructure/notifications/MockAlarmScheduler.ts b/frontend/src/infrastructure/notifications/MockAlarmScheduler.ts
deleted file mode 100644
index 68b447cf..00000000
--- a/frontend/src/infrastructure/notifications/MockAlarmScheduler.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import type {
- AlarmScheduleReceipt,
- AlarmScheduleRequest,
- AlarmSchedulerPort,
-} from '../../features/reminder/application/interfaces';
-
-/** 固定闹钟适配器,始终标记为已调度(进程内占位 id)。 */
-export class MockAlarmScheduler implements AlarmSchedulerPort {
- async schedule(request: AlarmScheduleRequest): Promise {
- return {
- alarm_id: `mock-alarm-${request.schedule_id}`,
- schedule_id: request.schedule_id,
- scheduled: true,
- };
- }
-
- async cancel(_alarmId: string | null): Promise<{ cancelled: boolean }> {
- return { cancelled: true };
- }
-
- async rebuild(
- requests: readonly AlarmScheduleRequest[],
- ): Promise {
- return Promise.all(requests.map((request) => this.schedule(request)));
- }
-}
diff --git a/frontend/src/infrastructure/notifications/MockDeviceCapability.ts b/frontend/src/infrastructure/notifications/MockDeviceCapability.ts
deleted file mode 100644
index cda5590c..00000000
--- a/frontend/src/infrastructure/notifications/MockDeviceCapability.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import type {
- DeviceCapabilityPort,
- DeviceCapabilityStatus,
- DevicePermission,
-} from '../../features/reminder/application/interfaces';
-
-const MOCK_PERMISSIONS: Readonly> = {
- notifications: true,
- exact_alarm: true,
- overlay: false,
- full_screen: true,
- battery_optimization: false,
- location_foreground: true,
- location_background: false,
-};
-
-const MOCK_STATUS: DeviceCapabilityStatus = {
- platform: 'android',
- supported: true,
- permissions: MOCK_PERMISSIONS,
- background_execution: false,
-};
-
-/** 原生模块接入前使用的固定设备能力状态。 */
-export class MockDeviceCapability implements DeviceCapabilityPort {
- async getStatus(): Promise {
- return { ...MOCK_STATUS, permissions: { ...MOCK_STATUS.permissions } };
- }
-
- async requestPermission(permission: DevicePermission): Promise {
- // 与 getStatus() 保持一致:返回该权限的固定值,不假装 grant 成功。
- return MOCK_PERMISSIONS[permission];
- }
-
- async openSettings(_permission: DevicePermission): Promise {
- return true;
- }
-}
-
-export { MOCK_STATUS as MOCK_DEVICE_CAPABILITY_STATUS };
diff --git a/frontend/src/infrastructure/notifications/MockNotificationChannels.ts b/frontend/src/infrastructure/notifications/MockNotificationChannels.ts
deleted file mode 100644
index d42c7a99..00000000
--- a/frontend/src/infrastructure/notifications/MockNotificationChannels.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import type {
- PopupPort,
- PopupReceipt,
- PopupRequest,
- SystemNotificationPort,
- SystemNotificationReceipt,
- SystemNotificationRequest,
- VibrationPort,
-} from '../../features/reminder/application/interfaces';
-
-export class MockSystemNotification implements SystemNotificationPort {
- async show(request: SystemNotificationRequest): Promise {
- return { notification_id: request.notification_id, shown: true };
- }
-
- async cancel(_notificationId: string): Promise {
- return Promise.resolve();
- }
-}
-
-export class MockPopup implements PopupPort {
- async show(request: PopupRequest): Promise {
- return { popup_id: request.popup_id, visible: true };
- }
-
- async dismiss(_popupId: string): Promise {
- return Promise.resolve();
- }
-}
-
-export class MockVibration implements VibrationPort {
- async vibrate(): Promise {
- return Promise.resolve();
- }
-
- async stop(): Promise {
- return Promise.resolve();
- }
-}
diff --git a/frontend/src/infrastructure/notifications/MockReminderDelivery.ts b/frontend/src/infrastructure/notifications/MockReminderDelivery.ts
deleted file mode 100644
index f8115703..00000000
--- a/frontend/src/infrastructure/notifications/MockReminderDelivery.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import type { ReminderDeliveryPort } from '../../features/reminder/application/interfaces';
-import type {
- ReminderDeliveryReceipt,
- ReminderDeliveryRequest,
-} from '../../features/reminder/domain';
-
-const MOCK_RECEIPT: ReminderDeliveryReceipt = {
- delivery_id: 'mock-delivery-001',
- schedule_id: 'mock-schedule-time-001',
- delivered_at: '2026-08-07T01:00:00.000Z',
- channels: ['system_notification'],
- used_fallback_audio: false,
-};
-
-/** 系统通知送达使用的固定通知边界。 */
-export class MockReminderDelivery implements ReminderDeliveryPort {
- async deliver(request: ReminderDeliveryRequest): Promise {
- return { ...MOCK_RECEIPT, schedule_id: request.schedule_id };
- }
-
- async dismiss(_scheduleId: string): Promise {
- return Promise.resolve();
- }
-}
-
-export { MOCK_RECEIPT as MOCK_REMINDER_DELIVERY_RECEIPT };
diff --git a/frontend/src/infrastructure/notifications/MockReminderRecovery.ts b/frontend/src/infrastructure/notifications/MockReminderRecovery.ts
deleted file mode 100644
index 34be1b00..00000000
--- a/frontend/src/infrastructure/notifications/MockReminderRecovery.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import type {
- ReminderRecoveryPort,
- ReminderRecoveryReceipt,
-} from '../../features/reminder/application/interfaces';
-
-/** 固定重启恢复适配器,后续可替换为安卓启动接收器。 */
-export class MockReminderRecovery implements ReminderRecoveryPort {
- async registerForRestart(): Promise {
- return { registered: true, recovery_id: 'mock-recovery-001' };
- }
-
- async restoreAfterRestart(): Promise {
- return { registered: true, recovery_id: 'mock-recovery-001' };
- }
-}
diff --git a/frontend/src/infrastructure/notifications/NativeAlarmScheduler.ts b/frontend/src/infrastructure/notifications/NativeAlarmScheduler.ts
index e2008346..bc8ad7b4 100644
--- a/frontend/src/infrastructure/notifications/NativeAlarmScheduler.ts
+++ b/frontend/src/infrastructure/notifications/NativeAlarmScheduler.ts
@@ -1,4 +1,6 @@
import type {
+ AlarmNativeDisposition,
+ AlarmNativeEvent,
AlarmScheduleReceipt,
AlarmScheduleRequest,
AlarmSchedulerPort,
@@ -7,10 +9,14 @@ import {
isTimeflowAlarmAvailable,
nativeAreAlarmPermissionsGranted,
nativeCancelAlarm,
+ nativeCancelAllAlarms,
+ nativeConsumeAlarmDispositions,
nativeScheduleAlarm,
+ nativeStopAlarmRinging,
+ subscribeNativeAlarmEvents,
} from './native/TimeflowAlarmBridge';
-/** Android TimeflowAlarm 适配器;无法挂上时返回 scheduled: false。 */
+/** Android TimeflowAlarm 适配器;无法挂上时返回 scheduled=false。 */
export class NativeAlarmScheduler implements AlarmSchedulerPort {
async schedule(request: AlarmScheduleRequest): Promise {
if (!isTimeflowAlarmAvailable()) {
@@ -22,44 +28,81 @@ export class NativeAlarmScheduler implements AlarmSchedulerPort {
return unscheduled(request.schedule_id);
}
- try {
- const ready = await nativeAreAlarmPermissionsGranted();
- if (!ready) {
- return unscheduled(request.schedule_id);
- }
-
- const alarmId = await nativeScheduleAlarm(triggerAtMillis, request.title);
- if (alarmId == null || alarmId.length === 0) {
- return unscheduled(request.schedule_id);
- }
+ const ready = await nativeAreAlarmPermissionsGranted();
+ if (!ready) {
+ return unscheduled(request.schedule_id);
+ }
- return {
- alarm_id: alarmId,
- schedule_id: request.schedule_id,
- scheduled: true,
- };
- } catch {
+ const alarmId = await nativeScheduleAlarm(triggerAtMillis, request.title, request.schedule_id);
+ if (alarmId == null || alarmId.length === 0) {
return unscheduled(request.schedule_id);
}
+
+ return {
+ alarm_id: alarmId,
+ schedule_id: request.schedule_id,
+ scheduled: true,
+ };
}
async cancel(alarmId: string | null): Promise<{ cancelled: boolean }> {
if (alarmId == null || alarmId.length === 0) {
return { cancelled: false };
}
- const cancelled = await nativeCancelAlarm(alarmId);
- return { cancelled };
+ await nativeCancelAlarm(alarmId);
+ return { cancelled: true };
}
async rebuild(
requests: readonly AlarmScheduleRequest[],
): Promise {
+ // 冷启动 registrations 为空时也要清掉 SharedPreferences 里的孤儿闹钟。
+ await nativeCancelAllAlarms();
const receipts: AlarmScheduleReceipt[] = [];
for (const request of requests) {
receipts.push(await this.schedule(request));
}
return receipts;
}
+
+ async stopRinging(): Promise {
+ await nativeStopAlarmRinging();
+ }
+
+ subscribe(listener: (event: AlarmNativeEvent) => void): () => void {
+ return subscribeNativeAlarmEvents((payload) => {
+ listener({
+ type: payload.type,
+ schedule_id: payload.scheduleId,
+ alarm_id: payload.alarmId,
+ title: payload.title,
+ at: new Date(payload.atMillis || Date.now()).toISOString(),
+ });
+ });
+ }
+
+ async consumeNativeDispositions(): Promise {
+ const rows = await nativeConsumeAlarmDispositions();
+ return rows
+ .map((row) => {
+ const state =
+ row.state === 'confirmed'
+ ? 'confirmed'
+ : row.state === 'pending'
+ ? 'pending'
+ : row.state === 'snoozed'
+ ? 'snoozed'
+ : null;
+ if (state == null || !row.scheduleId) return null;
+ return {
+ schedule_id: row.scheduleId,
+ alarm_id: row.alarmId ?? '',
+ state,
+ updated_at: new Date(row.updatedAtMillis || Date.now()).toISOString(),
+ } satisfies AlarmNativeDisposition;
+ })
+ .filter((row): row is AlarmNativeDisposition => row != null);
+ }
}
function unscheduled(scheduleId: string): AlarmScheduleReceipt {
diff --git a/frontend/src/infrastructure/notifications/NativeDeviceCapability.ts b/frontend/src/infrastructure/notifications/NativeDeviceCapability.ts
index b1ce7a66..e7d73d6d 100644
--- a/frontend/src/infrastructure/notifications/NativeDeviceCapability.ts
+++ b/frontend/src/infrastructure/notifications/NativeDeviceCapability.ts
@@ -1,4 +1,4 @@
-import { Platform } from 'react-native';
+import { AppState, Linking, Platform, type AppStateStatus } from 'react-native';
import type {
DeviceCapabilityPort,
@@ -22,22 +22,37 @@ const SETTINGS_KIND: Partial<
notifications: 'app',
};
-/** 基于 TimeflowAlarm 原生模块的设备权限适配器。 */
+/** 基于 TimeflowAlarm + expo-location 的设备权限适配器。 */
export class NativeDeviceCapability implements DeviceCapabilityPort {
async getStatus(): Promise {
const platform = toPlatform();
+ const location = await readLocationPermissions();
+
if (!isTimeflowAlarmAvailable()) {
- return unsupportedStatus(platform);
+ return {
+ platform,
+ supported: false,
+ permissions: {
+ ...emptyPermissions(false),
+ location_foreground: location.foreground,
+ location_background: location.background,
+ },
+ background_execution: false,
+ };
}
- let status;
- try {
- status = await nativeGetAlarmPermissionStatus();
- } catch {
- return unsupportedStatus(platform);
- }
+ const status = await nativeGetAlarmPermissionStatus();
if (status == null) {
- return unsupportedStatus(platform);
+ return {
+ platform,
+ supported: false,
+ permissions: {
+ ...emptyPermissions(false),
+ location_foreground: location.foreground,
+ location_background: location.background,
+ },
+ background_execution: false,
+ };
}
return {
@@ -49,8 +64,8 @@ export class NativeDeviceCapability implements DeviceCapabilityPort {
overlay: status.overlay,
full_screen: status.fullScreen,
battery_optimization: status.battery,
- location_foreground: false,
- location_background: false,
+ location_foreground: location.foreground,
+ location_background: location.background,
},
background_execution: status.battery,
};
@@ -58,22 +73,33 @@ export class NativeDeviceCapability implements DeviceCapabilityPort {
async requestPermission(permission: DevicePermission): Promise {
if (permission === 'notifications') {
+ return nativeRequestNotificationPermission();
+ }
+ if (permission === 'location_foreground' || permission === 'location_background') {
+ return requestLocationPermission(permission);
+ }
+ return this.openSettings(permission);
+ }
+
+ async openSettings(permission: DevicePermission): Promise {
+ if (permission === 'location_foreground' || permission === 'location_background') {
try {
- return await nativeRequestNotificationPermission();
+ await Linking.openSettings();
+ return true;
} catch {
return false;
}
}
- return this.openSettings(permission);
+ const kind = SETTINGS_KIND[permission] ?? 'app';
+ return nativeOpenAlarmPermissionSettings(kind);
}
- async openSettings(permission: DevicePermission): Promise {
- const kind = SETTINGS_KIND[permission] ?? 'app';
- try {
- return await nativeOpenAlarmPermissionSettings(kind);
- } catch {
- return false;
- }
+ onAppActive(listener: () => void): () => void {
+ const onChange = (state: AppStateStatus) => {
+ if (state === 'active') listener();
+ };
+ const subscription = AppState.addEventListener('change', onChange);
+ return () => subscription.remove();
}
}
@@ -84,15 +110,6 @@ function toPlatform(): DeviceCapabilityStatus['platform'] {
return 'unknown';
}
-function unsupportedStatus(platform: DeviceCapabilityStatus['platform']): DeviceCapabilityStatus {
- return {
- platform,
- supported: false,
- permissions: emptyPermissions(false),
- background_execution: false,
- };
-}
-
function emptyPermissions(value: boolean): Readonly> {
return {
notifications: value,
@@ -104,3 +121,61 @@ function emptyPermissions(value: boolean): Readonly {
+ try {
+ const Location = await import('expo-location');
+ const foreground = await Location.getForegroundPermissionsAsync();
+ const background = await Location.getBackgroundPermissionsAsync();
+ return {
+ foreground: foreground.status === Location.PermissionStatus.GRANTED,
+ background: background.status === Location.PermissionStatus.GRANTED,
+ };
+ } catch {
+ return { foreground: false, background: false };
+ }
+}
+
+async function requestLocationPermission(
+ permission: 'location_foreground' | 'location_background',
+): Promise {
+ try {
+ const Location = await import('expo-location');
+ if (permission === 'location_foreground') {
+ const current = await Location.getForegroundPermissionsAsync();
+ if (current.status === Location.PermissionStatus.GRANTED) {
+ return true;
+ }
+ // 系统不再弹授权框时不要空等,交给上层 openSettings。
+ if (current.canAskAgain === false) {
+ return false;
+ }
+ const result = await Location.requestForegroundPermissionsAsync();
+ return result.status === Location.PermissionStatus.GRANTED;
+ }
+
+ const foreground = await Location.getForegroundPermissionsAsync();
+ if (foreground.status !== Location.PermissionStatus.GRANTED) {
+ if (foreground.canAskAgain === false) {
+ return false;
+ }
+ const requested = await Location.requestForegroundPermissionsAsync();
+ if (requested.status !== Location.PermissionStatus.GRANTED) {
+ return false;
+ }
+ }
+
+ const currentBackground = await Location.getBackgroundPermissionsAsync();
+ if (currentBackground.status === Location.PermissionStatus.GRANTED) {
+ return true;
+ }
+ if (currentBackground.canAskAgain === false) {
+ return false;
+ }
+
+ const background = await Location.requestBackgroundPermissionsAsync();
+ return background.status === Location.PermissionStatus.GRANTED;
+ } catch {
+ return false;
+ }
+}
diff --git a/frontend/src/infrastructure/notifications/ReactNativeAlertDialog.ts b/frontend/src/infrastructure/notifications/ReactNativeAlertDialog.ts
new file mode 100644
index 00000000..ac779b7a
--- /dev/null
+++ b/frontend/src/infrastructure/notifications/ReactNativeAlertDialog.ts
@@ -0,0 +1,25 @@
+import { Alert } from 'react-native';
+
+import type {
+ AlertDialogPort,
+ AlertDialogRequest,
+} from '../../features/reminder/application/interfaces';
+
+/** 系统 Alert 对话框适配器;presentation 通过 AlertDialogPort 使用,不直接依赖 RN。 */
+export class ReactNativeAlertDialog implements AlertDialogPort {
+ async show(request: AlertDialogRequest): Promise {
+ Alert.alert(
+ request.title,
+ request.message,
+ request.buttons.map((button) => ({
+ text: button.text,
+ style: button.style,
+ onPress: button.onPress,
+ })),
+ {
+ cancelable: request.cancelable ?? true,
+ onDismiss: request.onDismiss,
+ },
+ );
+ }
+}
diff --git a/frontend/src/infrastructure/notifications/ReactNativeVibration.ts b/frontend/src/infrastructure/notifications/ReactNativeVibration.ts
new file mode 100644
index 00000000..7bec320b
--- /dev/null
+++ b/frontend/src/infrastructure/notifications/ReactNativeVibration.ts
@@ -0,0 +1,38 @@
+import { Platform, Vibration } from 'react-native';
+
+import type { VibrationPort } from '../../features/reminder/application/interfaces';
+
+/** 震动 / 间隔,确认或 teardown 前循环。 */
+const REPEAT_PATTERN = [0, 700, 350, 700];
+
+/** React Native Vibration 适配器:提醒展示期间持续震动。 */
+export class ReactNativeVibration implements VibrationPort {
+ private iosTimer: ReturnType | null = null;
+
+ async vibrate(): Promise {
+ this.clearIosTimer();
+ Vibration.cancel();
+
+ if (Platform.OS === 'android') {
+ // 第二个参数 true = 按 pattern 循环,直到 cancel。
+ Vibration.vibrate(REPEAT_PATTERN, true);
+ return;
+ }
+
+ Vibration.vibrate(REPEAT_PATTERN);
+ this.iosTimer = setInterval(() => {
+ Vibration.vibrate(REPEAT_PATTERN);
+ }, 2_000);
+ }
+
+ async stop(): Promise {
+ this.clearIosTimer();
+ Vibration.cancel();
+ }
+
+ private clearIosTimer(): void {
+ if (this.iosTimer == null) return;
+ clearInterval(this.iosTimer);
+ this.iosTimer = null;
+ }
+}
diff --git a/frontend/src/infrastructure/notifications/index.ts b/frontend/src/infrastructure/notifications/index.ts
index 2ad4c8f1..b34e52ac 100644
--- a/frontend/src/infrastructure/notifications/index.ts
+++ b/frontend/src/infrastructure/notifications/index.ts
@@ -1,16 +1,18 @@
-export { MockAlarmScheduler } from './MockAlarmScheduler';
export { NativeAlarmScheduler } from './NativeAlarmScheduler';
export { NativeDeviceCapability } from './NativeDeviceCapability';
-export { MockPopup, MockSystemNotification, MockVibration } from './MockNotificationChannels';
-export { MockReminderRecovery } from './MockReminderRecovery';
-export { MockReminderDelivery, MOCK_REMINDER_DELIVERY_RECEIPT } from './MockReminderDelivery';
-export { MockDeviceCapability, MOCK_DEVICE_CAPABILITY_STATUS } from './MockDeviceCapability';
+export { ReactNativeVibration } from './ReactNativeVibration';
+export { ReactNativeAlertDialog } from './ReactNativeAlertDialog';
+export { ExpoSystemNotification } from './ExpoSystemNotification';
export {
isTimeflowAlarmAvailable,
nativeAreAlarmPermissionsGranted,
nativeCancelAlarm,
+ nativeCancelAllAlarms,
+ nativeConsumeAlarmDispositions,
nativeGetAlarmPermissionStatus,
nativeOpenAlarmPermissionSettings,
nativeRequestNotificationPermission,
nativeScheduleAlarm,
+ nativeStopAlarmRinging,
+ subscribeNativeAlarmEvents,
} from './native/TimeflowAlarmBridge';
diff --git a/frontend/src/infrastructure/notifications/native/TimeflowAlarmBridge.ts b/frontend/src/infrastructure/notifications/native/TimeflowAlarmBridge.ts
index d571a321..07550801 100644
--- a/frontend/src/infrastructure/notifications/native/TimeflowAlarmBridge.ts
+++ b/frontend/src/infrastructure/notifications/native/TimeflowAlarmBridge.ts
@@ -1,4 +1,4 @@
-import { NativeModules, Platform } from 'react-native';
+import { NativeEventEmitter, NativeModules, Platform } from 'react-native';
export type NativeAlarmPermissionStatus = {
exactAlarm: boolean;
@@ -8,66 +8,148 @@ export type NativeAlarmPermissionStatus = {
battery: boolean;
};
+export type NativeAlarmEventPayload = {
+ type: 'fired' | 'dismissed' | 'snoozed';
+ scheduleId: string;
+ alarmId: string;
+ title: string;
+ atMillis: number;
+};
+
+export type NativeAlarmDispositionPayload = {
+ scheduleId: string;
+ alarmId: string;
+ state: string;
+ updatedAtMillis: number;
+};
+
type TimeflowAlarmNative = {
- schedule: (triggerAtMillis: number, title?: string | null) => Promise<{ alarmId: string }>;
+ schedule: (
+ triggerAtMillis: number,
+ title?: string | null,
+ scheduleId?: string | null,
+ ) => Promise<{ alarmId: string; scheduleId?: string }>;
cancel: (alarmId: string) => Promise;
+ cancelAll: () => Promise;
+ stopRinging: () => Promise;
+ consumeNativeDispositions: () => Promise;
getPermissionStatus: () => Promise;
openPermissionSettings: (
kind: 'exactAlarm' | 'overlay' | 'fullScreen' | 'battery' | 'app',
) => Promise;
requestNotificationPermission: () => Promise;
+ addListener?: (eventName: string) => void;
+ removeListeners?: (count: number) => void;
};
-const NativeAlarm = NativeModules.TimeflowAlarm as TimeflowAlarmNative | undefined;
+const EVENT_NAME = 'TimeflowAlarmEvent';
+
+function getNativeAlarm(): TimeflowAlarmNative | undefined {
+ return NativeModules.TimeflowAlarm as TimeflowAlarmNative | undefined;
+}
export function isTimeflowAlarmAvailable(): boolean {
- return Platform.OS === 'android' && NativeAlarm != null;
+ return Platform.OS === 'android' && getNativeAlarm() != null;
}
export async function nativeScheduleAlarm(
triggerAtMillis: number,
title: string,
+ scheduleId?: string,
): Promise {
- if (!isTimeflowAlarmAvailable() || NativeAlarm == null) return null;
+ const native = getNativeAlarm();
+ if (!isTimeflowAlarmAvailable() || native == null) return null;
try {
- const result = await NativeAlarm.schedule(triggerAtMillis, title);
- const alarmId = result?.alarmId;
- if (alarmId == null || alarmId.length === 0) return null;
- return alarmId;
+ const result = await native.schedule(triggerAtMillis, title, scheduleId ?? '');
+ return result.alarmId;
} catch {
return null;
}
}
-export async function nativeCancelAlarm(alarmId: string | null | undefined): Promise {
- if (!isTimeflowAlarmAvailable() || NativeAlarm == null || !alarmId) return false;
+export async function nativeCancelAlarm(alarmId: string | null | undefined): Promise {
+ const native = getNativeAlarm();
+ if (!isTimeflowAlarmAvailable() || native == null || !alarmId) return;
try {
- return Boolean(await NativeAlarm.cancel(alarmId));
+ await native.cancel(alarmId);
} catch {
- return false;
+ // 尽力取消,忽略失败。
+ }
+}
+
+export async function nativeCancelAllAlarms(): Promise {
+ const native = getNativeAlarm();
+ if (!isTimeflowAlarmAvailable() || native == null) return;
+ try {
+ await native.cancelAll();
+ } catch {
+ // 尽力全部取消,忽略失败。
+ }
+}
+
+export async function nativeStopAlarmRinging(): Promise {
+ const native = getNativeAlarm();
+ if (!isTimeflowAlarmAvailable() || native == null) return;
+ try {
+ await native.stopRinging();
+ } catch {
+ // 尽力停铃,忽略失败。
+ }
+}
+
+export async function nativeConsumeAlarmDispositions(): Promise {
+ const native = getNativeAlarm();
+ if (!isTimeflowAlarmAvailable() || native == null) return [];
+ try {
+ return await native.consumeNativeDispositions();
+ } catch {
+ return [];
}
}
export async function nativeGetAlarmPermissionStatus(): Promise {
- if (!isTimeflowAlarmAvailable() || NativeAlarm == null) return null;
- return NativeAlarm.getPermissionStatus();
+ const native = getNativeAlarm();
+ if (!isTimeflowAlarmAvailable() || native == null) return null;
+ return native.getPermissionStatus();
}
export async function nativeOpenAlarmPermissionSettings(
kind: 'exactAlarm' | 'overlay' | 'fullScreen' | 'battery' | 'app',
): Promise {
- if (!isTimeflowAlarmAvailable() || NativeAlarm == null) return false;
- return NativeAlarm.openPermissionSettings(kind);
+ const native = getNativeAlarm();
+ if (!isTimeflowAlarmAvailable() || native == null) return false;
+ return native.openPermissionSettings(kind);
}
export async function nativeRequestNotificationPermission(): Promise {
- if (!isTimeflowAlarmAvailable() || NativeAlarm == null) return false;
- return NativeAlarm.requestNotificationPermission();
+ const native = getNativeAlarm();
+ if (!isTimeflowAlarmAvailable() || native == null) return false;
+ try {
+ return await native.requestNotificationPermission();
+ } catch {
+ return false;
+ }
}
export async function nativeAreAlarmPermissionsGranted(): Promise {
const status = await nativeGetAlarmPermissionStatus();
if (status == null) return false;
- // 挂闹钟的最低要求:精确闹钟 + 通知;悬浮窗/全屏/电池影响展示,不阻塞调度。
return status.exactAlarm && status.notifications;
}
+
+export function subscribeNativeAlarmEvents(
+ listener: (event: NativeAlarmEventPayload) => void,
+): () => void {
+ const native = getNativeAlarm();
+ if (!isTimeflowAlarmAvailable() || native == null) {
+ return () => undefined;
+ }
+ const emitter = new NativeEventEmitter(native as never);
+ const subscription = emitter.addListener(EVENT_NAME, (payload: NativeAlarmEventPayload) => {
+ if (payload?.type !== 'fired' && payload?.type !== 'dismissed' && payload?.type !== 'snoozed') {
+ return;
+ }
+ listener(payload);
+ });
+ return () => subscription.remove();
+}
diff --git a/frontend/src/infrastructure/time/IntervalTimeListener.ts b/frontend/src/infrastructure/time/IntervalTimeListener.ts
new file mode 100644
index 00000000..5099d98e
--- /dev/null
+++ b/frontend/src/infrastructure/time/IntervalTimeListener.ts
@@ -0,0 +1,38 @@
+import type {
+ LocalTimeTick,
+ TimeListenerHandle,
+ TimeListenerOptions,
+ TimeListenerPort,
+} from '../../features/reminder/application/interfaces';
+
+const DEFAULT_INTERVAL_MS = 30_000;
+
+/** 进程内周期时间观测,驱动 handleTime。 */
+export class IntervalTimeListener implements TimeListenerPort {
+ private readonly timers = new Map>();
+ private sequence = 0;
+
+ constructor(private readonly intervalMs: number = DEFAULT_INTERVAL_MS) {}
+
+ async start(
+ listener: (tick: LocalTimeTick) => void,
+ _options?: TimeListenerOptions,
+ ): Promise {
+ const listenerId = `interval-time-listener-${++this.sequence}`;
+ const emit = () => {
+ listener({ observed_at: new Date().toISOString() });
+ };
+ // 不在 start 同步打首 tick,交给调用方完成 rebuild 后再自然周期触发。
+ const timer = setInterval(emit, this.intervalMs);
+ this.timers.set(listenerId, timer);
+ return { listener_id: listenerId };
+ }
+
+ async stop(listenerId: string): Promise {
+ const timer = this.timers.get(listenerId);
+ if (timer != null) {
+ clearInterval(timer);
+ this.timers.delete(listenerId);
+ }
+ }
+}
diff --git a/frontend/src/infrastructure/time/index.ts b/frontend/src/infrastructure/time/index.ts
new file mode 100644
index 00000000..299256c7
--- /dev/null
+++ b/frontend/src/infrastructure/time/index.ts
@@ -0,0 +1 @@
+export { IntervalTimeListener } from './IntervalTimeListener';
diff --git a/frontend/src/shared/time/.gitkeep b/frontend/src/shared/time/.gitkeep
deleted file mode 100644
index 8b137891..00000000
--- a/frontend/src/shared/time/.gitkeep
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/frontend/src/shared/time/MockClock.ts b/frontend/src/shared/time/MockClock.ts
deleted file mode 100644
index edc7ff1c..00000000
--- a/frontend/src/shared/time/MockClock.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-/** 用于确定性测试和模拟组合的最小时钟端口。 */
-export interface Clock {
- nowIso(): string;
-}
-
-export class MockClock implements Clock {
- constructor(private readonly value = '2026-08-07T01:00:00.000Z') {}
-
- nowIso(): string {
- return this.value;
- }
-}
diff --git a/frontend/src/shared/time/MockTimeListener.ts b/frontend/src/shared/time/MockTimeListener.ts
deleted file mode 100644
index e77e36ec..00000000
--- a/frontend/src/shared/time/MockTimeListener.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import type {
- LocalTimeTick,
- TimeListenerHandle,
- TimeListenerOptions,
- TimeListenerPort,
-} from '../../features/reminder/application/interfaces';
-
-/** 固定时间监听边界,替换前不会发出平台事件。 */
-export class MockTimeListener implements TimeListenerPort {
- async start(
- _listener: (tick: LocalTimeTick) => void,
- _options?: TimeListenerOptions,
- ): Promise {
- return { listener_id: 'mock-time-listener-001' };
- }
-
- async stop(_listenerId: string): Promise {
- return Promise.resolve();
- }
-}
diff --git a/frontend/src/shared/time/format.ts b/frontend/src/shared/time/format.ts
new file mode 100644
index 00000000..71609c15
--- /dev/null
+++ b/frontend/src/shared/time/format.ts
@@ -0,0 +1,19 @@
+/**
+ * 无业务语义的时间工具(附录 B.5)。
+ * 不承载日程/提醒触发规则。
+ */
+export function toIsoUtc(date: Date = new Date()): string {
+ return date.toISOString();
+}
+
+export function formatLocalDateTime(
+ iso: string,
+ timeZone = 'Asia/Shanghai',
+ locale = 'zh-CN',
+): string {
+ try {
+ return new Date(iso).toLocaleString(locale, { hour12: false, timeZone });
+ } catch {
+ return iso;
+ }
+}
diff --git a/frontend/src/shared/time/index.ts b/frontend/src/shared/time/index.ts
index 1924ce7a..00679748 100644
--- a/frontend/src/shared/time/index.ts
+++ b/frontend/src/shared/time/index.ts
@@ -1,3 +1 @@
-export { MockClock } from './MockClock';
-export type { Clock } from './MockClock';
-export { MockTimeListener } from './MockTimeListener';
+export { formatLocalDateTime, toIsoUtc } from './format';
diff --git a/frontend/src/types/.gitkeep b/frontend/src/types/.gitkeep
deleted file mode 100644
index 8b137891..00000000
--- a/frontend/src/types/.gitkeep
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/frontend/src/types/expo-audio.d.ts b/frontend/src/types/expo-audio.d.ts
new file mode 100644
index 00000000..6638cb05
--- /dev/null
+++ b/frontend/src/types/expo-audio.d.ts
@@ -0,0 +1,16 @@
+declare module 'expo-audio' {
+ export function createAudioPlayer(source?: string | number | null): {
+ pause: () => void;
+ replace: (source: string | number) => void;
+ play: () => void;
+ volume: number;
+ loop: boolean;
+ };
+
+ export function setAudioModeAsync(mode: Record): Promise;
+}
+
+declare module '*.mp3' {
+ const asset: number;
+ export default asset;
+}
diff --git a/frontend/tests/integration/localScheduleWriter.test.ts b/frontend/tests/integration/localScheduleWriter.test.ts
new file mode 100644
index 00000000..5988a996
--- /dev/null
+++ b/frontend/tests/integration/localScheduleWriter.test.ts
@@ -0,0 +1,81 @@
+import initSqlJs, { type SqlJsStatic } from 'sql.js';
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { LocalScheduleWriter } from '../../src/features/assistant/data/local/LocalScheduleWriter';
+import type { AppliedCommand } from '../../src/features/assistant/domain/ConversationTurn';
+import { ScheduleLocalRepository } from '../../src/features/schedule/data';
+import { SqliteLocalScheduleReader } from '../../src/features/reminder/data/local/SqliteLocalScheduleReader';
+import { migrateScheduleDatabase } from '../../src/infrastructure/database/migrations';
+import { SqlJsExpoDatabase } from '../helpers/sqliteTestDatabase';
+
+function appliedCommand(overrides: Partial = {}): AppliedCommand {
+ return {
+ operation: 'create_schedule',
+ status: 'applied',
+ schedule: {
+ id: 'schedule-a',
+ schedule_type: 'time',
+ schedule_kind: 'once',
+ title: 'Team sync',
+ is_all_day: false,
+ timezone: 'Asia/Shanghai',
+ start_time: '2026-08-12T07:00:00Z',
+ revision: 1,
+ },
+ ...overrides,
+ };
+}
+
+describe('LocalScheduleWriter refresh wiring', () => {
+ let sql: SqlJsStatic;
+ let database: SqlJsExpoDatabase;
+ let repository: ScheduleLocalRepository;
+
+ beforeAll(async () => {
+ sql = await initSqlJs();
+ });
+
+ beforeEach(async () => {
+ database = new SqlJsExpoDatabase(new sql.Database());
+ await migrateScheduleDatabase(database.asSQLiteDatabase());
+ repository = new ScheduleLocalRepository(database.asSQLiteDatabase());
+ });
+
+ afterEach(() => {
+ database.close();
+ });
+
+ it('refreshes the attached schedule reader after a successful write', async () => {
+ const reader = new SqliteLocalScheduleReader();
+ reader.attach(repository, 'account-a');
+ const listener = vi.fn();
+ reader.subscribe(listener);
+
+ const writer = new LocalScheduleWriter(repository, reader);
+ await writer.applyCommandResult('account-a', appliedCommand());
+
+ expect(listener).toHaveBeenCalledTimes(1);
+ expect(await reader.getReminderSchedule('schedule-a')).toMatchObject({ title: 'Team sync' });
+ });
+
+ it('does not refresh when the command was not applied', async () => {
+ const reader = new SqliteLocalScheduleReader();
+ reader.attach(repository, 'account-a');
+ const listener = vi.fn();
+ reader.subscribe(listener);
+
+ const writer = new LocalScheduleWriter(repository, reader);
+ await writer.applyCommandResult('account-a', appliedCommand({ status: 'rejected' }));
+
+ expect(listener).not.toHaveBeenCalled();
+ expect(await reader.getReminderSchedule('schedule-a')).toBeNull();
+ });
+
+ it('works without a schedule reader wired in', async () => {
+ const writer = new LocalScheduleWriter(repository);
+ await expect(writer.applyCommandResult('account-a', appliedCommand())).resolves.toBeUndefined();
+
+ const stored = await repository.getSchedule('account-a', 'schedule-a');
+ expect(stored?.title).toBe('Team sync');
+ });
+});
diff --git a/frontend/tests/integration/sqliteLocalScheduleReader.test.ts b/frontend/tests/integration/sqliteLocalScheduleReader.test.ts
new file mode 100644
index 00000000..79413277
--- /dev/null
+++ b/frontend/tests/integration/sqliteLocalScheduleReader.test.ts
@@ -0,0 +1,132 @@
+import initSqlJs, { type SqlJsStatic } from 'sql.js';
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { ScheduleLocalRepository, type CloudScheduleRow } from '../../src/features/schedule/data';
+import { SqliteLocalScheduleReader } from '../../src/features/reminder/data/local/SqliteLocalScheduleReader';
+import { migrateScheduleDatabase } from '../../src/infrastructure/database/migrations';
+import { SqlJsExpoDatabase } from '../helpers/sqliteTestDatabase';
+
+function cloudSchedule(overrides: Partial = {}): CloudScheduleRow {
+ return {
+ id: 'schedule-a',
+ account_id: 'account-a',
+ schedule_type: 'time',
+ schedule_kind: 'once',
+ title: 'Original title',
+ is_all_day: 0,
+ start_time: '2026-08-12T07:00:00Z',
+ end_time: null,
+ timezone: 'Asia/Shanghai',
+ recurrence_rule: null,
+ location_name: null,
+ latitude: null,
+ longitude: null,
+ reminder_type: 'before_start',
+ reminder_trigger_at: null,
+ reminder_offset_minutes: 15,
+ reminder_strength: 'medium',
+ reminder_disposition_state: null,
+ status: 'active',
+ cloud_revision: 1,
+ updated_at: '2026-08-11T07:00:00Z',
+ ...overrides,
+ };
+}
+
+describe('SqliteLocalScheduleReader', () => {
+ let sql: SqlJsStatic;
+ let database: SqlJsExpoDatabase;
+ let repository: ScheduleLocalRepository;
+ let reader: SqliteLocalScheduleReader;
+
+ beforeAll(async () => {
+ sql = await initSqlJs();
+ });
+
+ beforeEach(async () => {
+ database = new SqlJsExpoDatabase(new sql.Database());
+ await migrateScheduleDatabase(database.asSQLiteDatabase());
+ repository = new ScheduleLocalRepository(database.asSQLiteDatabase());
+ reader = new SqliteLocalScheduleReader();
+ });
+
+ afterEach(() => {
+ database.close();
+ });
+
+ it('returns nothing before attach()', async () => {
+ expect(await reader.listReminderSchedules()).toEqual([]);
+ expect(await reader.getReminderSchedule('schedule-a')).toBeNull();
+ });
+
+ it('maps a stored row onto the reminder domain shape once attached', async () => {
+ await repository.applyCloudSchedule(cloudSchedule());
+ reader.attach(repository, 'account-a');
+
+ const schedules = await reader.listReminderSchedules();
+ expect(schedules).toHaveLength(1);
+ expect(schedules[0]).toMatchObject({
+ id: 'schedule-a',
+ account_id: 'account-a',
+ title: 'Original title',
+ geofence_radius_meters: 200,
+ reminder: {
+ reminder_type: 'before_start',
+ reminder_offset_minutes: 15,
+ reminder_strength: 'medium',
+ },
+ runtime: {
+ geofence_armed: false,
+ recorded_location: null,
+ sync_status: 'synced',
+ },
+ status: 'active',
+ });
+
+ expect(await reader.getReminderSchedule('schedule-a')).toMatchObject({ id: 'schedule-a' });
+ expect(await reader.getReminderSchedule('missing')).toBeNull();
+ });
+
+ it('scopes reads to the attached account', async () => {
+ await repository.applyCloudSchedule(cloudSchedule());
+ reader.attach(repository, 'account-b');
+
+ expect(await reader.listReminderSchedules()).toEqual([]);
+ });
+
+ it('stops returning data after detach()', async () => {
+ await repository.applyCloudSchedule(cloudSchedule());
+ reader.attach(repository, 'account-a');
+ expect(await reader.listReminderSchedules()).toHaveLength(1);
+
+ reader.detach();
+
+ expect(await reader.listReminderSchedules()).toEqual([]);
+ });
+
+ it('notifies subscribers with a fresh read on refresh()', async () => {
+ reader.attach(repository, 'account-a');
+ const listener = vi.fn();
+ reader.subscribe(listener);
+
+ await repository.applyCloudSchedule(cloudSchedule());
+ await reader.refresh();
+
+ expect(listener).toHaveBeenCalledTimes(1);
+ const [notified] = listener.mock.calls[0] as [readonly { id: string }[]];
+ expect(notified).toHaveLength(1);
+ expect(notified[0].id).toBe('schedule-a');
+ });
+
+ it('stops notifying an unsubscribed listener', async () => {
+ reader.attach(repository, 'account-a');
+ const listener = vi.fn();
+ const unsubscribe = reader.subscribe(listener);
+ unsubscribe();
+
+ await repository.applyCloudSchedule(cloudSchedule());
+ await reader.refresh();
+
+ expect(listener).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/tests/integration/sqliteReminderStateStore.test.ts b/frontend/tests/integration/sqliteReminderStateStore.test.ts
new file mode 100644
index 00000000..018b057c
--- /dev/null
+++ b/frontend/tests/integration/sqliteReminderStateStore.test.ts
@@ -0,0 +1,170 @@
+import initSqlJs, { type SqlJsStatic } from 'sql.js';
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { ScheduleLocalRepository, type CloudScheduleRow } from '../../src/features/schedule/data';
+import { SqliteReminderStateStore } from '../../src/features/reminder/data/local/SqliteReminderStateStore';
+import { migrateScheduleDatabase } from '../../src/infrastructure/database/migrations';
+import { SqlJsExpoDatabase } from '../helpers/sqliteTestDatabase';
+
+const START_TIME = '2026-08-10T07:00:00Z'; // 2026-08-10 15:00 Asia/Shanghai (Monday)
+const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1_000;
+
+function recurringSchedule(overrides: Partial = {}): CloudScheduleRow {
+ return {
+ id: 'schedule-a',
+ account_id: 'account-a',
+ schedule_type: 'time',
+ schedule_kind: 'recurring',
+ title: '周会',
+ is_all_day: 0,
+ start_time: START_TIME,
+ end_time: null,
+ timezone: 'Asia/Shanghai',
+ // Asia/Shanghai 没有 DST,整周推进不用担心跨夏令时的偏差。
+ recurrence_rule: 'FREQ=WEEKLY;COUNT=4',
+ location_name: null,
+ latitude: null,
+ longitude: null,
+ reminder_type: null,
+ reminder_trigger_at: null,
+ reminder_offset_minutes: null,
+ reminder_strength: null,
+ reminder_disposition_state: null,
+ status: 'active',
+ cloud_revision: 1,
+ updated_at: '2026-08-09T00:00:00Z',
+ ...overrides,
+ };
+}
+
+describe('SqliteReminderStateStore', () => {
+ let sql: SqlJsStatic;
+ let database: SqlJsExpoDatabase;
+ let repository: ScheduleLocalRepository;
+ let store: SqliteReminderStateStore;
+
+ beforeAll(async () => {
+ sql = await initSqlJs();
+ });
+
+ beforeEach(async () => {
+ database = new SqlJsExpoDatabase(new sql.Database());
+ await migrateScheduleDatabase(database.asSQLiteDatabase());
+ repository = new ScheduleLocalRepository(database.asSQLiteDatabase());
+ store = new SqliteReminderStateStore();
+ store.attach(repository, 'account-a');
+ });
+
+ afterEach(() => {
+ database.close();
+ vi.useRealTimers();
+ });
+
+ it('returns null before attach()', async () => {
+ const detached = new SqliteReminderStateStore();
+ expect(await detached.read('schedule-a')).toBeNull();
+ });
+
+ it('passes through a once schedule unchanged, including a null next_trigger_at', async () => {
+ await repository.applyCloudSchedule(
+ recurringSchedule({ schedule_kind: 'once', recurrence_rule: null }),
+ );
+
+ const runtime = await store.read('schedule-a');
+
+ expect(runtime?.next_trigger_at).toBeNull();
+ expect(runtime?.reminder_disposition_state).toBeNull();
+ });
+
+ it('leaves a recurring schedule alone once it already has an occurrence cursor', async () => {
+ await repository.applyCloudSchedule(recurringSchedule());
+ await repository.updateReminderRuntime('account-a', 'schedule-a', {
+ reminder_disposition_state: 'pending',
+ next_trigger_at: '2026-08-17T07:00:00Z',
+ snoozed_until: null,
+ geofence_armed: 0,
+ disposition_updated_at: '2026-08-17T07:00:00Z',
+ sync_status: 'synced',
+ });
+
+ const runtime = await store.read('schedule-a');
+
+ expect(runtime?.next_trigger_at).toBe('2026-08-17T07:00:00Z');
+ expect(runtime?.reminder_disposition_state).toBe('pending');
+ });
+
+ it('computes the next occurrence and resets disposition when the cursor is null', async () => {
+ await repository.applyCloudSchedule(recurringSchedule());
+ // confirmInternal 的实际写法:确认之后把 disposition 设成 confirmed,同时把
+ // occurrence 光标清空,这就是它触发"该往前挪一格了"的方式。
+ await repository.updateReminderRuntime('account-a', 'schedule-a', {
+ reminder_disposition_state: 'confirmed',
+ next_trigger_at: null,
+ snoozed_until: null,
+ geofence_armed: 0,
+ disposition_updated_at: '2026-08-10T07:00:00Z',
+ sync_status: 'synced',
+ });
+ // "现在" 落在第 2 次和第 3 次发生之间:应该拿到第 3 次,不是回退到 start_time
+ // 或者停在第 2 次。
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date(Date.parse(START_TIME) + ONE_WEEK_MS * 1.5));
+
+ const runtime = await store.read('schedule-a');
+
+ const expectedThirdOccurrence = new Date(
+ Date.parse(START_TIME) + ONE_WEEK_MS * 2,
+ ).toISOString();
+ expect(runtime?.next_trigger_at).toBe(expectedThirdOccurrence);
+ // 新 occurrence 的 disposition 必须重置,否则 canDeliver() 会因为上一次是
+ // confirmed 就永远拦住这条日程,重复提醒响一次之后就再也不会响了。
+ expect(runtime?.reminder_disposition_state).toBeNull();
+ expect(runtime?.disposition_updated_at).toBeNull();
+ });
+
+ it('leaves the cursor null once the recurring series has run out of occurrences', async () => {
+ await repository.applyCloudSchedule(recurringSchedule());
+ await repository.updateReminderRuntime('account-a', 'schedule-a', {
+ reminder_disposition_state: 'confirmed',
+ next_trigger_at: null,
+ snoozed_until: null,
+ geofence_armed: 0,
+ disposition_updated_at: '2026-08-31T07:00:00Z',
+ sync_status: 'synced',
+ });
+ // COUNT=4:第 4 次之后系列结束,"现在"设在最后一次之后。
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date(Date.parse(START_TIME) + ONE_WEEK_MS * 10));
+
+ const runtime = await store.read('schedule-a');
+
+ expect(runtime?.next_trigger_at).toBeNull();
+ });
+
+ it('write() persists a runtime patch and setDisposition() preserves the current cursor', async () => {
+ await repository.applyCloudSchedule(recurringSchedule());
+ await store.write('schedule-a', {
+ reminder_disposition_state: 'pending',
+ next_trigger_at: '2026-08-17T07:00:00Z',
+ snoozed_until: null,
+ geofence_armed: false,
+ disposition_updated_at: '2026-08-17T07:00:00Z',
+ sync_status: 'pending',
+ recorded_location: null,
+ });
+
+ await store.setDisposition('schedule-a', {
+ schedule_id: 'schedule-a',
+ state: 'confirmed',
+ updated_at: '2026-08-17T07:05:00Z',
+ snoozed_until: null,
+ sync_status: 'pending',
+ });
+
+ const stored = await repository.getSchedule('account-a', 'schedule-a');
+ expect(stored?.reminder_disposition_state).toBe('confirmed');
+ // setDisposition 本身不该动 occurrence 光标,只有 confirmInternal 自己那次
+ // 显式的 write() 才会把它清空。
+ expect(stored?.next_trigger_at).toBe('2026-08-17T07:00:00Z');
+ });
+});
diff --git a/frontend/tests/unit/app/AppRoot.test.tsx b/frontend/tests/unit/app/AppRoot.test.tsx
index 2368b47c..ce522c1a 100644
--- a/frontend/tests/unit/app/AppRoot.test.tsx
+++ b/frontend/tests/unit/app/AppRoot.test.tsx
@@ -9,7 +9,12 @@ import { openTimeflowDatabase } from '../../../src/infrastructure/database';
jest.mock('../../../src/infrastructure/database', () => ({
openTimeflowDatabase: jest.fn<() => Promise>().mockResolvedValue({}),
}));
-jest.mock('../../../src/features/schedule/data', () => ({ ScheduleLocalRepository: jest.fn() }));
+jest.mock('../../../src/features/schedule/data', () => ({
+ ScheduleLocalRepository: jest.fn().mockImplementation(() => ({
+ getSchedule: jest.fn<() => Promise>().mockResolvedValue(null),
+ listSchedules: jest.fn<() => Promise>().mockResolvedValue([]),
+ })),
+}));
jest.mock('../../../src/features/schedule/application', () => ({
SqliteScheduleClientService: jest.fn(),
}));
diff --git a/frontend/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts b/frontend/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts
index 221c66f6..814b8571 100644
--- a/frontend/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts
+++ b/frontend/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts
@@ -444,6 +444,137 @@ describe('AssistantContinuousConversationService', () => {
service.dispose();
});
+ it('stops immediately and drops queued chunks when TTS is canceled', async () => {
+ const fake = createFakeConnection();
+ const deps = createDeps({ connection: fake.connection });
+ const service = new AssistantContinuousConversationService({ accountId: 'acc_001' }, deps);
+ const calls: string[] = [];
+ let resolveFirst: () => void = () => {};
+ (deps.playback.pushChunk as jest.Mock)
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ calls.push('push-1');
+ resolveFirst = resolve;
+ }),
+ )
+ .mockImplementationOnce(async () => {
+ calls.push('push-2');
+ });
+ (deps.playback.stop as jest.Mock).mockImplementation(async () => {
+ calls.push('stop');
+ });
+
+ await startListening(fake, service);
+ fake.emitMessage({
+ audio_id: 'audio_001',
+ conversation_id: 'conv_001',
+ payload: {
+ format: 'pcm_s16le',
+ purpose: 'reply',
+ sample_rate_hz: 24000,
+ speech_text: '',
+ },
+ type: 'voice.tts.start',
+ } as AssistantServerMessage);
+ await flushAsync();
+ fake.emitAudioFrame(new ArrayBuffer(4));
+ fake.emitAudioFrame(new ArrayBuffer(4));
+ await flushAsync();
+
+ fake.emitMessage({
+ audio_id: 'audio_001',
+ conversation_id: 'conv_001',
+ type: 'voice.tts.canceled',
+ } as AssistantServerMessage);
+ expect(calls).toEqual(['push-1', 'stop']);
+
+ resolveFirst();
+ await flushAsync();
+
+ expect(calls).toEqual(['push-1', 'stop']);
+ expect(service.getState()).toEqual({ conversationId: 'conv_001', phase: 'interrupted' });
+ service.dispose();
+ });
+
+ it('ignores the canceled stream end that arrives after interruption', async () => {
+ const fake = createFakeConnection();
+ const deps = createDeps({ connection: fake.connection });
+ const service = new AssistantContinuousConversationService({ accountId: 'acc_001' }, deps);
+
+ await startListening(fake, service);
+ fake.emitMessage({
+ audio_id: 'audio_001',
+ conversation_id: 'conv_001',
+ payload: {
+ format: 'pcm_s16le',
+ purpose: 'reply',
+ sample_rate_hz: 24000,
+ speech_text: '',
+ },
+ type: 'voice.tts.start',
+ } as AssistantServerMessage);
+ fake.emitMessage({
+ audio_id: 'audio_001',
+ conversation_id: 'conv_001',
+ type: 'voice.tts.canceled',
+ } as AssistantServerMessage);
+ fake.emitMessage({
+ audio_id: 'audio_001',
+ conversation_id: 'conv_001',
+ type: 'voice.tts.end',
+ } as AssistantServerMessage);
+ await flushAsync();
+
+ expect(deps.playback.endStream).not.toHaveBeenCalled();
+ expect(service.getState()).toEqual({ conversationId: 'conv_001', phase: 'interrupted' });
+ service.dispose();
+ });
+
+ it('does not stop a newer TTS when a late cancellation belongs to the old audio', async () => {
+ const fake = createFakeConnection();
+ const deps = createDeps({ connection: fake.connection });
+ const service = new AssistantContinuousConversationService({ accountId: 'acc_001' }, deps);
+
+ await startListening(fake, service);
+ const startMessage = (audioId: string): AssistantServerMessage =>
+ ({
+ audio_id: audioId,
+ conversation_id: 'conv_001',
+ payload: {
+ format: 'pcm_s16le',
+ purpose: 'reply',
+ sample_rate_hz: 24000,
+ speech_text: '',
+ },
+ type: 'voice.tts.start',
+ }) as AssistantServerMessage;
+
+ fake.emitMessage(startMessage('audio_001'));
+ fake.emitMessage({
+ audio_id: 'audio_001',
+ conversation_id: 'conv_001',
+ type: 'voice.tts.end',
+ } as AssistantServerMessage);
+ fake.emitMessage(startMessage('audio_002'));
+ fake.emitMessage({
+ audio_id: 'audio_001',
+ conversation_id: 'conv_001',
+ type: 'voice.tts.canceled',
+ } as AssistantServerMessage);
+ // 兼容尚未升级的后端:旧实现会把这种晚到的取消发成空 id,不能把新流停掉。
+ fake.emitMessage({
+ audio_id: '',
+ conversation_id: 'conv_001',
+ type: 'voice.tts.canceled',
+ } as AssistantServerMessage);
+ await flushAsync();
+
+ expect(deps.playback.stop).not.toHaveBeenCalled();
+ expect(service.getState()).toEqual({ conversationId: 'conv_001', phase: 'speaking' });
+ service.dispose();
+ });
+
it('handleClose() unsubscribes from the shared connection before nulling it, even when a real disconnect races endTurn()', async () => {
const fake = createFakeConnection();
const deps = createDeps({ connection: fake.connection });
diff --git a/frontend/tests/unit/features/assistant/application/AssistantConversationService.test.ts b/frontend/tests/unit/features/assistant/application/AssistantConversationService.test.ts
index 86d743c9..02a266de 100644
--- a/frontend/tests/unit/features/assistant/application/AssistantConversationService.test.ts
+++ b/frontend/tests/unit/features/assistant/application/AssistantConversationService.test.ts
@@ -68,6 +68,9 @@ function createFakeConnection() {
return {
closeCalls,
connection,
+ emitAudioFrame: (chunk: ArrayBuffer) => {
+ for (const handler of audioHandlers) handler(chunk);
+ },
emitClose: (event: { code: number; reason: string }) => {
for (const handler of closeHandlers) handler(event);
},
@@ -170,6 +173,34 @@ describe('AssistantConversationService', () => {
expect(service.getState()).toEqual({ message: '连接已断开(1006)', phase: 'error' });
});
+ it('unsubscribes the old Push-to-talk listeners after a mode-switch disconnect', async () => {
+ const fake = createFakeConnection();
+ const deps = createDeps({ connection: fake.connection });
+ const service = new AssistantConversationService({ accountId: 'acc_001' }, deps);
+
+ await completeStreamStart(fake, service.startTurn());
+ // AuthenticatedWebSocketClient 切到 continuous 时会关闭当前 push_to_talk 连接。
+ fake.emitClose({ code: 1000, reason: '' });
+ expect(fake.unsubscribeCalls).toEqual({ audio: 1, close: 1, message: 1 });
+
+ // 模拟新连接上的 TTS:旧服务绝不能再收到它,否则会与连续对话重叠播放。
+ fake.emitMessage({
+ audio_id: 'audio_002',
+ conversation_id: 'conv_002',
+ payload: {
+ format: 'pcm_s16le',
+ purpose: 'reply',
+ sample_rate_hz: 24000,
+ speech_text: '',
+ },
+ type: 'voice.tts.start',
+ } as AssistantServerMessage);
+ fake.emitAudioFrame(new ArrayBuffer(4));
+
+ expect(deps.playback.startStream).not.toHaveBeenCalled();
+ expect(deps.playback.pushChunk).not.toHaveBeenCalled();
+ });
+
it('endTurn does not hang waiting on a startTurn that never got voice.stream.started', async () => {
const fake = createFakeConnection();
const deps = createDeps({ connection: fake.connection });
diff --git a/frontend/tests/unit/features/reminder/useReminderPermissionsOnLaunch.test.ts b/frontend/tests/unit/features/reminder/useReminderPermissionsOnLaunch.test.ts
deleted file mode 100644
index 831b5244..00000000
--- a/frontend/tests/unit/features/reminder/useReminderPermissionsOnLaunch.test.ts
+++ /dev/null
@@ -1,310 +0,0 @@
-import { act, renderHook } from '@testing-library/react-native';
-import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals';
-import { Alert, AppState, Platform, type AlertButton } from 'react-native';
-
-import type {
- DeviceCapabilityPort,
- DeviceCapabilityStatus,
- DevicePermission,
-} from '../../../../src/features/reminder/application/interfaces';
-import { useReminderPermissionsOnLaunch } from '../../../../src/features/reminder/presentation/useReminderPermissionsOnLaunch';
-
-const ALERT_OPTIONS = expect.objectContaining({
- cancelable: true,
- onDismiss: expect.any(Function),
-});
-
-function deniedPermissions(): Record {
- return {
- notifications: false,
- exact_alarm: false,
- overlay: false,
- full_screen: false,
- battery_optimization: false,
- location_foreground: false,
- location_background: false,
- };
-}
-
-function grantedPermissions(): Record {
- return {
- notifications: true,
- exact_alarm: true,
- overlay: true,
- full_screen: true,
- battery_optimization: true,
- location_foreground: true,
- location_background: true,
- };
-}
-
-type FakeDevice = DeviceCapabilityPort & { status: DeviceCapabilityStatus };
-
-function createDevice(
- status: Partial & Pick = {
- platform: 'android',
- },
-): FakeDevice {
- const device: FakeDevice = {
- status: {
- platform: status.platform,
- supported: status.supported ?? status.platform === 'android',
- permissions: { ...deniedPermissions(), ...status.permissions },
- background_execution: status.background_execution ?? true,
- },
- getStatus: jest.fn(async () => device.status),
- requestPermission: jest.fn(async (permission: DevicePermission) => {
- device.status = {
- ...device.status,
- permissions: { ...device.status.permissions, [permission]: true },
- };
- return true;
- }),
- openSettings: jest.fn(async () => true),
- };
- return device;
-}
-
-let alertButtons: readonly AlertButton[] = [];
-let dismissAlertCallback: (() => void) | undefined;
-const appStateListeners: ((state: string) => void)[] = [];
-
-async function flush(ms = 0): Promise {
- await act(async () => {
- if (ms > 0) {
- jest.advanceTimersByTime(ms);
- }
- for (let index = 0; index < 8; index += 1) {
- await Promise.resolve();
- }
- });
-}
-
-async function press(label: string): Promise {
- await act(async () => {
- alertButtons.find((button) => button.text === label)?.onPress?.();
- await Promise.resolve();
- await Promise.resolve();
- });
-}
-
-async function dismissAlert(): Promise {
- await act(async () => {
- dismissAlertCallback?.();
- await Promise.resolve();
- await Promise.resolve();
- });
-}
-
-describe('useReminderPermissionsOnLaunch', () => {
- beforeEach(() => {
- alertButtons = [];
- appStateListeners.length = 0;
- Platform.OS = 'android';
- dismissAlertCallback = undefined;
- jest.spyOn(Alert, 'alert').mockImplementation((_title, _message, buttons, options) => {
- alertButtons = buttons ?? [];
- dismissAlertCallback = options?.onDismiss;
- });
- jest.spyOn(AppState, 'addEventListener').mockImplementation((_event, listener) => {
- appStateListeners.push(listener as (state: string) => void);
- return { remove: jest.fn() } as ReturnType