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/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/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/tsconfig.json b/frontend/tsconfig.json
index db2da661..0d62f629 100644
--- a/frontend/tsconfig.json
+++ b/frontend/tsconfig.json
@@ -2,5 +2,6 @@
"extends": "./node_modules/expo/tsconfig.base.json",
"compilerOptions": {
"strict": true
- }
+ },
+ "exclude": ["scripts"]
}