diff --git a/frontend/app.config.js b/frontend/app.config.js index ea4eeb9a..250efe7f 100644 --- a/frontend/app.config.js +++ b/frontend/app.config.js @@ -69,7 +69,6 @@ module.exports = { defaultChannel: 'timeflow-reminders', }, ], - './plugins/withTimeflowAlarm', ], }, }; diff --git a/frontend/modules/timeflow-alarm/android/src/main/AndroidManifest.xml b/frontend/modules/timeflow-alarm/android/src/main/AndroidManifest.xml index f1e89a38..2772b8f5 100644 --- a/frontend/modules/timeflow-alarm/android/src/main/AndroidManifest.xml +++ b/frontend/modules/timeflow-alarm/android/src/main/AndroidManifest.xml @@ -9,6 +9,17 @@ + + + + + + + firedNotifiedAlarmIds = new HashSet<>(); /** @@ -66,6 +71,12 @@ public final class AlarmSoundService extends Service { */ final ArrayDeque pendingQueue = new ArrayDeque<>(); + @Override + public void onCreate() { + super.onCreate(); + initTextToSpeech(); + } + @Override public int onStartCommand(Intent intent, int flags, int startId) { AlarmContract.ExtractedExtras extras = AlarmContract.ExtractedExtras.from(this, intent); @@ -139,6 +150,7 @@ private void presentAlarm(AlarmContract.ExtractedExtras extras) { vibrateEnabled = extras.vibrate; soundTier = extras.soundTier; fullScreenEnabled = extras.fullScreen; + speechText = extras.speechText; createNotificationChannel(); Notification notification = buildNotification(alarmId, alarmTitle, fullScreenEnabled); @@ -164,6 +176,8 @@ private void presentAlarm(AlarmContract.ExtractedExtras extras) { // 那次播放/震动残留到这条本该更安静的闹钟上。 stopVibration(); stopPing(); + releaseMediaPlayer(); + stopSpeaking(); if (vibrateEnabled) { startVibration(); } @@ -171,7 +185,17 @@ private void presentAlarm(AlarmContract.ExtractedExtras extras) { showAlarmOverlay(alarmTitle); } if (AlarmContract.SOUND_TIER_FULL.equals(soundTier) && mediaPlayer == null) { - startBundledSpeech(); + if (wantsSpeech() && AlarmTtsEngine.isReady()) { + speakCurrent(); + } else { + // 引擎是 AlarmModule 构造时就抢先绑定的全局单例,真响铃这一刻通常早就 + // 绑定好了;万一还没好(比如冷启动竞态),先响打包铃保底, + // notifyWhenReady() 排一个回调,绑定成功那一刻无缝切到 TTS。 + startBundledSpeech(); + if (wantsSpeech()) { + AlarmTtsEngine.notifyWhenReady(this::maybeSwitchToSpeech); + } + } } else if (AlarmContract.SOUND_TIER_PING.equals(soundTier)) { startPing(); } @@ -209,6 +233,10 @@ public void onDestroy() { playbackHandler.removeCallbacksAndMessages(null); removeAlarmOverlay(); releaseMediaPlayer(); + // 只停这个 Service 实例正在念的这一句、摘掉它挂上去的监听器——引擎是全局单例, + // 不在这里 shutdown():下一条闹钟(甚至冷启动重新拉起这个 Service)还要复用。 + AlarmTtsEngine.stop(); + AlarmTtsEngine.setUtteranceListener(null); stopVibration(); stopPing(); deleteCachedSpeechFile(); @@ -237,7 +265,8 @@ static void start( String title, boolean vibrate, String soundTier, - boolean fullScreen + boolean fullScreen, + String speechText ) { Intent intent = new Intent(context, AlarmSoundService.class) .putExtra(AlarmContract.EXTRA_ALARM_ID, alarmId) @@ -246,7 +275,8 @@ static void start( .putExtra(AlarmContract.EXTRA_TITLE, title) .putExtra(AlarmContract.EXTRA_VIBRATE, vibrate) .putExtra(AlarmContract.EXTRA_SOUND_TIER, soundTier) - .putExtra(AlarmContract.EXTRA_FULL_SCREEN, fullScreen); + .putExtra(AlarmContract.EXTRA_FULL_SCREEN, fullScreen) + .putExtra(AlarmContract.EXTRA_SPEECH_TEXT, speechText); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { context.startForegroundService(intent); } else { @@ -264,6 +294,7 @@ private Notification buildNotification(String alarmId, String title, boolean ful .putExtra(AlarmContract.EXTRA_VIBRATE, vibrateEnabled) .putExtra(AlarmContract.EXTRA_SOUND_TIER, soundTier) .putExtra(AlarmContract.EXTRA_FULL_SCREEN, fullScreen) + .putExtra(AlarmContract.EXTRA_SPEECH_TEXT, speechText) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_MULTIPLE_TASK | Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS); @@ -345,6 +376,7 @@ private void showAlarmOverlay(String title) { boolean targetVibrate = vibrateEnabled; String targetSoundTier = soundTier; boolean targetFullScreen = fullScreenEnabled; + String targetSpeechText = speechText; View content = AlarmRingUi.build( this, @@ -361,7 +393,7 @@ private void showAlarmOverlay(String title) { targetVibrate, targetSoundTier, targetFullScreen, - "" + targetSpeechText ); } catch (RuntimeException ignored) { // ignore @@ -516,6 +548,90 @@ private void deleteCachedSpeechFile() { } } + /** + * 设备 TTS 是 best-effort:不可用就走打包铃,绝不重试到崩。引擎本身是 + * AlarmTtsEngine 持有的全局单例、在 AlarmModule 构造时就已经抢先绑定过 + * (见该类顶部注释)——这里只是兜底再确认一次已经启动过初始化,真正的 + * 绑定/初始化逻辑不在这个 Service 里。 + */ + private void initTextToSpeech() { + AlarmTtsEngine.ensureInitialized(this); + } + + /** 当前这条是否该念 TTS:high 档位且有非空文案。 */ + private boolean wantsSpeech() { + return AlarmContract.SOUND_TIER_FULL.equals(soundTier) + && speechText != null && !speechText.trim().isEmpty(); + } + + private final UtteranceProgressListener utteranceListener = new UtteranceProgressListener() { + @Override + public void onStart(String utteranceId) { + // 无需处理。 + } + + @Override + public void onDone(String utteranceId) { + playbackHandler.postDelayed(replayTts, SPEECH_REPEAT_DELAY_MILLIS); + } + + @Override + public void onError(String utteranceId) { + onSpeechError(); + } + }; + + private void speakCurrent() { + if (destroyed || !AlarmTtsEngine.isReady() || !wantsSpeech()) { + return; + } + AlarmTtsEngine.setUtteranceListener(utteranceListener); + int result = AlarmTtsEngine.speak(speechText, "reminder-high"); + // speak() 同步失败(比如引擎队列被拒绝)时不会有任何 utterance 回调, + // onDone/onError 都不会触发——这里必须自己走一次跟 onError 一样的兜底, + // 否则闹钟已经在别处停掉了打包铃保底播放器,会彻底没有声音。 + if (result != TextToSpeech.SUCCESS) { + onSpeechError(); + } + } + + private void replayTts() { + if (destroyed) { + return; + } + speakCurrent(); + } + + /** 引擎就绪后,若这条闹钟还在打包铃保底、且确实该念 TTS,就切过去。 */ + private void maybeSwitchToSpeech() { + if (destroyed || !AlarmTtsEngine.isReady() || !wantsSpeech()) { + return; + } + releaseMediaPlayer(); + speakCurrent(); + } + + /** TTS 合成失败:清掉重念回调,退回打包铃继续保底。 */ + private void onSpeechError() { + Log.w(TAG, "TextToSpeech onError alarmId=" + alarmId); + playbackHandler.post(() -> { + if (destroyed || !wantsSpeech()) { + return; + } + playbackHandler.removeCallbacks(replayTts); + if (mediaPlayer == null) { + startBundledSpeech(); + } + }); + } + + /** 停掉当前这条在念的 TTS,但不销毁引擎——引擎是全局单例,换下一条闹钟/下次 + * 冷启动都要复用同一个已经绑定好的实例。 */ + private void stopSpeaking() { + playbackHandler.removeCallbacks(replayTts); + AlarmTtsEngine.stop(); + } + private void removeFromSavedAlarms() { AlarmScheduler.removeAlarmRecord(this, alarmId, requestCode); } diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmTtsEngine.kt b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmTtsEngine.kt new file mode 100644 index 00000000..6205d79a --- /dev/null +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmTtsEngine.kt @@ -0,0 +1,121 @@ +package com.timeflow.alarm + +import android.content.Context +import android.media.AudioAttributes +import android.speech.tts.TextToSpeech +import android.speech.tts.UtteranceProgressListener +import android.util.Log +import java.util.Locale + +/** + * 全模块共用的单例 TextToSpeech 引擎。真机排查发现:AlarmSoundService 自己 + * new TextToSpeech() 时几乎总是 status=-1(ERROR)失败——这个 Service 在真实场景下 + * 经常是"App 已经在后台/被系统认为受限"的状态下才第一次启动,而 TextToSpeech 内部 + * 是 bindService() 绑定到 TTS 引擎所在的另一个 App,MIUI 之类的 ROM 会拦住后台进程 + * 新发起的这类跨进程绑定——这跟"设备到底装没装 TTS 引擎"是两回事(同一台设备在前台 + * 手动试听是正常的)。 + * + * 应对办法:改成全局单例,在 AlarmModule 构造时(React Native 加载原生模块的那一刻, + * 几乎总是应用正常启动、处于前台的时机)就抢先绑定一次,而不是拖到真的要响铃、 + * 前台状态已经不可控的那一刻。只要 App 进程本身还活着(没被系统整个杀掉),后续 + * AlarmSoundService 复用这条已经建立好的连接大概率不受"新绑定被拦"这条限制影响。 + * + * 局限:App 进程被系统整个杀掉、又被闹钟的 PendingIntent 重新拉起这种冷启动场景, + * 这个提前绑定帮不上忙——那种情况下这里同样是第一次尝试绑定,跟改之前一样可能被拦。 + * 这只解决"进程还活着、只是被认为在后台"这一种场景,不是万能药。 + */ +object AlarmTtsEngine { + private const val TAG = "AlarmTtsEngine" + + private var engine: TextToSpeech? = null + @Volatile + private var ready = false + private var initStarted = false + private var pendingReadyCallback: (() -> Unit)? = null + + /** 幂等:只有第一次调用真的会去 new TextToSpeech(),后面全是空操作。 */ + @JvmStatic + @Synchronized + fun ensureInitialized(context: Context) { + if (initStarted) { + return + } + initStarted = true + val appContext = context.applicationContext + try { + engine = TextToSpeech(appContext) { status -> onInit(status) } + } catch (error: RuntimeException) { + Log.w(TAG, "TextToSpeech unavailable", error) + engine = null + ready = false + } + } + + private fun onInit(status: Int) { + if (status != TextToSpeech.SUCCESS) { + Log.w(TAG, "init failed status=$status") + ready = false + return + } + val locale = Locale.getDefault() + val availability = engine?.isLanguageAvailable(locale) ?: TextToSpeech.LANG_NOT_SUPPORTED + if (availability == TextToSpeech.LANG_MISSING_DATA || + availability == TextToSpeech.LANG_NOT_SUPPORTED + ) { + Log.w(TAG, "language unavailable locale=$locale") + ready = false + return + } + engine?.setLanguage(locale) + engine?.setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_ALARM) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build(), + ) + ready = true + Log.i(TAG, "ready") + val callback = pendingReadyCallback + pendingReadyCallback = null + callback?.invoke() + } + + @JvmStatic + fun isReady(): Boolean = ready + + /** + * 引擎还没就绪时先记下这个回调,onInit 成功那一刻回调一次;已经就绪就立刻同步调用。 + * 只保留最新这一个——同一时间只有一条闹钟在展示(presentAlarm() 本身是互斥的), + * 不需要支持多个等待者排队。 + */ + @JvmStatic + fun notifyWhenReady(callback: Runnable) { + if (ready) { + callback.run() + return + } + pendingReadyCallback = { callback.run() } + } + + /** 每次响铃前调用,把这条闹钟自己的 onDone/onError 行为接上;引擎不存在时空操作。 */ + @JvmStatic + fun setUtteranceListener(listener: UtteranceProgressListener?) { + engine?.setOnUtteranceProgressListener(listener) + } + + /** 引擎不存在或还没就绪时返回 TextToSpeech.ERROR,调用方据此决定要不要退回打包铃。 */ + @JvmStatic + fun speak(text: String, utteranceId: String): Int { + val current = engine + if (current == null || !ready) { + return TextToSpeech.ERROR + } + return current.speak(text, TextToSpeech.QUEUE_FLUSH, null, utteranceId) + } + + /** 只停当前这句在念的话,不销毁引擎——引擎是全局单例,换下一条闹钟/下次冷启动都要复用。 */ + @JvmStatic + fun stop() { + engine?.stop() + } +} diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/ReminderSpeechFormatter.java b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/ReminderSpeechFormatter.java deleted file mode 100644 index 3a261976..00000000 --- a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/ReminderSpeechFormatter.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.timeflow.alarm; - -import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.Locale; -import java.util.concurrent.TimeUnit; - -/** - * 在没有有效 speech_text 时(老版本闹钟数据)生成兜底文案。 - * 文案格式:{标题},时间到了。现在已经{小时}点{分钟}了。 - */ -public final class ReminderSpeechFormatter { - private static final int MAX_TITLE_LENGTH = 80; - private static final String FALLBACK_TITLE = "未命名日程"; - - private ReminderSpeechFormatter() { - } - - /** - * 生成兜底语音文案。 - * - * @param title 日程标题 - * @param triggerAtMillis 触发时间戳 - * @return 语音文案 - */ - public static String format(String title, long triggerAtMillis) { - String normalizedTitle = normalizeTitle(title); - String timeText = formatTime(triggerAtMillis); - return normalizedTitle + ",时间到了。现在已经" + timeText + "了。"; - } - - private static String normalizeTitle(String title) { - if (title == null) { - return FALLBACK_TITLE; - } - String trimmed = title.trim().replaceAll("\\s+", " "); - if (trimmed.isEmpty()) { - return FALLBACK_TITLE; - } - if (trimmed.length() > MAX_TITLE_LENGTH) { - return trimmed.substring(0, MAX_TITLE_LENGTH); - } - return trimmed; - } - - private static String formatTime(long triggerAtMillis) { - try { - SimpleDateFormat sdf = new SimpleDateFormat("HH:mm", Locale.CHINA); - String timeStr = sdf.format(new Date(triggerAtMillis)); - String[] parts = timeStr.split(":"); - int hour = Integer.parseInt(parts[0]); - int minute = Integer.parseInt(parts[1]); - if (minute == 0) { - return hour + "点"; - } - return hour + "点" + minute + "分"; - } catch (Exception e) { - return "现在"; - } - } -} diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/RingActivity.java b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/RingActivity.java index c9c76dc2..3c09936f 100644 --- a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/RingActivity.java +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/RingActivity.java @@ -26,6 +26,7 @@ public final class RingActivity extends Activity { private boolean vibrate; private String soundTier; private boolean fullScreen; + private String speechText; private boolean dismissNotified; @Override @@ -40,6 +41,7 @@ protected void onCreate(Bundle savedInstanceState) { vibrate = extras.vibrate; soundTier = extras.soundTier; fullScreen = extras.fullScreen; + speechText = extras.speechText; makeVisibleOverLockScreen(); matchSystemBarsToReminder(); setContentView(buildContentView()); @@ -52,7 +54,8 @@ protected void onCreate(Bundle savedInstanceState) { alarmTitle, vibrate, soundTier, - fullScreen + fullScreen, + speechText ); } @@ -138,7 +141,8 @@ private void snoozeAndClose() { + AlarmContract.SNOOZE_MINUTES * 60_000L; try { AlarmScheduler.schedule( - this, triggerAt, alarmTitle, scheduleId, vibrate, soundTier, fullScreen, "" + this, triggerAt, alarmTitle, scheduleId, vibrate, soundTier, + fullScreen, speechText ); } catch (RuntimeException ignored) { // 尽力重新挂闹钟;即使失败也通知 JS 落 snooze 状态。 diff --git a/frontend/modules/timeflow-alarm/android/src/test/java/com/timeflow/alarm/AlarmIntentForwardingTest.java b/frontend/modules/timeflow-alarm/android/src/test/java/com/timeflow/alarm/AlarmIntentForwardingTest.java index ca691cc0..d5c81a2d 100644 --- a/frontend/modules/timeflow-alarm/android/src/test/java/com/timeflow/alarm/AlarmIntentForwardingTest.java +++ b/frontend/modules/timeflow-alarm/android/src/test/java/com/timeflow/alarm/AlarmIntentForwardingTest.java @@ -17,7 +17,7 @@ import org.robolectric.annotation.Config; import org.robolectric.shadows.ShadowApplication; -/** 闹钟触发的各个 Android Intent handoff 都必须保留响铃通道设置。 */ +/** 闹钟触发的各个 Android Intent handoff 都必须保留 JS 生成的播报文案。 */ @RunWith(RobolectricTestRunner.class) @Config(sdk = Build.VERSION_CODES.UPSIDE_DOWN_CAKE) public class AlarmIntentForwardingTest { @@ -30,12 +30,14 @@ public void setUp() { } @Test - public void receiverForwardsRingChannelsToSoundService() { + public void receiverForwardsSpeechTextToSoundService() { + String speechText = "提醒你,晨会,别忘了"; Intent incoming = new Intent(AlarmContract.ACTION_FIRE_ALARM) .putExtra(AlarmContract.EXTRA_ALARM_ID, "alarm-1") .putExtra(AlarmContract.EXTRA_SCHEDULE_ID, "schedule-1") .putExtra(AlarmContract.EXTRA_REQUEST_CODE, 101) .putExtra(AlarmContract.EXTRA_TITLE, "晨会") + .putExtra(AlarmContract.EXTRA_SPEECH_TEXT, speechText) .putExtra(AlarmContract.EXTRA_VIBRATE, false) .putExtra(AlarmContract.EXTRA_SOUND_TIER, AlarmContract.SOUND_TIER_FULL) .putExtra(AlarmContract.EXTRA_FULL_SCREEN, false); @@ -45,6 +47,7 @@ public void receiverForwardsRingChannelsToSoundService() { Intent started = ShadowApplication.getInstance().getNextStartedService(); assertNotNull(started); assertEquals(AlarmSoundService.class.getName(), started.getComponent().getClassName()); + assertEquals(speechText, started.getStringExtra(AlarmContract.EXTRA_SPEECH_TEXT)); assertFalse(started.getBooleanExtra(AlarmContract.EXTRA_VIBRATE, true)); assertEquals( AlarmContract.SOUND_TIER_FULL, @@ -54,7 +57,9 @@ public void receiverForwardsRingChannelsToSoundService() { } @Test - public void activityServiceStartForwardsRingChannels() { + public void activityServiceStartForwardsSpeechText() { + String speechText = "提醒你,提交报告,别忘了"; + AlarmSoundService.start( context, "alarm-2", @@ -63,11 +68,13 @@ public void activityServiceStartForwardsRingChannels() { "提交报告", true, AlarmContract.SOUND_TIER_NONE, - true + true, + speechText ); Intent started = ShadowApplication.getInstance().getNextStartedService(); assertNotNull(started); + assertEquals(speechText, started.getStringExtra(AlarmContract.EXTRA_SPEECH_TEXT)); assertEquals( AlarmContract.SOUND_TIER_NONE, started.getStringExtra(AlarmContract.EXTRA_SOUND_TIER) diff --git a/frontend/modules/timeflow-alarm/android/src/test/java/com/timeflow/alarm/AlarmSoundServiceTest.java b/frontend/modules/timeflow-alarm/android/src/test/java/com/timeflow/alarm/AlarmSoundServiceTest.java index 0e431fbc..e505ceae 100644 --- a/frontend/modules/timeflow-alarm/android/src/test/java/com/timeflow/alarm/AlarmSoundServiceTest.java +++ b/frontend/modules/timeflow-alarm/android/src/test/java/com/timeflow/alarm/AlarmSoundServiceTest.java @@ -24,7 +24,7 @@ /** * 覆盖 Wintercom 在 PR #265 review 里要求的场景:两条闹钟重叠到达时都应该被展示、 * 各自的 disposition 不能串。不覆盖悬浮窗按钮点击 -> snooze/dismiss 这条链路(需要真的 - * 构建 AlarmRingUi 的 View),也不覆盖 MediaPlayer 播放(跟系统资源强绑定,presentAlarm() + * 构建 AlarmRingUi 的 View),也不覆盖 MediaPlayer/TTS 播放(跟系统资源强绑定,presentAlarm() * 本身已经有 try/catch 兜底)。 * * pin 住 API 34:这个模块 compileSdk 是 35,但 API 36 常量已经在生产代码里被移除 @@ -79,6 +79,15 @@ public void secondOverlappingAlarm_isQueuedThenShownAfterFirstAdvances() { assertNotNull("第二条也应该真的建起悬浮窗,而不是被静默丢弃", service.overlayView); } + @Test + public void extractedExtras_carriesSpeechText() { + Intent intent = new Intent(context, AlarmSoundService.class) + .putExtra(AlarmContract.EXTRA_ALARM_ID, "alarm-tts") + .putExtra(AlarmContract.EXTRA_SPEECH_TEXT, "开会,在家"); + AlarmContract.ExtractedExtras extras = AlarmContract.ExtractedExtras.from(context, intent); + assertEquals("开会,在家", extras.speechText); + } + @Test public void advanceOrStop_stopsSelfWhenQueueEmpty() { AlarmSoundService service = Robolectric.buildService(AlarmSoundService.class).create().get(); diff --git a/frontend/modules/timeflow-alarm/android/src/test/java/com/timeflow/alarm/ReminderSpeechFormatterTest.java b/frontend/modules/timeflow-alarm/android/src/test/java/com/timeflow/alarm/ReminderSpeechFormatterTest.java deleted file mode 100644 index baae595e..00000000 --- a/frontend/modules/timeflow-alarm/android/src/test/java/com/timeflow/alarm/ReminderSpeechFormatterTest.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.timeflow.alarm; - -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.annotation.Config; - -import static org.junit.Assert.*; - -@RunWith(RobolectricTestRunner.class) -@Config(sdk = 28) -public class ReminderSpeechFormatterTest { - - @Test - public void format_withTitleAndTime_returnsCorrectText() { - // 15:30 -> 15点30分 - long triggerAt = createTimestamp(2024, 10, 15, 15, 30); - String result = ReminderSpeechFormatter.format("项目复盘", triggerAt); - assertEquals("项目复盘,时间到了。现在已经15点30分了。", result); - } - - @Test - public void format_withZeroMinute_omitsZero() { - // 15:00 -> 15点 - long triggerAt = createTimestamp(2024, 10, 15, 15, 0); - String result = ReminderSpeechFormatter.format("会议", triggerAt); - assertEquals("会议,时间到了。现在已经15点了。", result); - } - - @Test - public void format_withNullTitle_usesFallback() { - long triggerAt = createTimestamp(2024, 10, 15, 15, 30); - String result = ReminderSpeechFormatter.format(null, triggerAt); - assertEquals("未命名日程,时间到了。现在已经15点30分了。", result); - } - - @Test - public void format_withEmptyTitle_usesFallback() { - long triggerAt = createTimestamp(2024, 10, 15, 15, 30); - String result = ReminderSpeechFormatter.format(" ", triggerAt); - assertEquals("未命名日程,时间到了。现在已经15点30分了。", result); - } - - @Test - public void format_withLongTitle_truncatesTo80Chars() { - String longTitle = "这是一个非常非常长的标题,超过了80个字符的限制,需要被正确截断以确保语音播报的完整性测试这个功能是否正常工作"; - long triggerAt = createTimestamp(2024, 10, 15, 15, 30); - String result = ReminderSpeechFormatter.format(longTitle, triggerAt); - assertTrue("Title should be truncated to 80 chars", result.indexOf(",") <= 80); - } - - @Test - public void format_withWhitespaceInTitle_normalizes() { - long triggerAt = createTimestamp(2024, 10, 15, 15, 30); - String result = ReminderSpeechFormatter.format(" 项目 复盘 ", triggerAt); - assertEquals("项目 复盘,时间到了。现在已经15点30分了。", result); - } - - private long createTimestamp(int year, int month, int day, int hour, int minute) { - java.util.Calendar cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("Asia/Shanghai")); - cal.set(year, month - 1, day, hour, minute, 0); - cal.set(java.util.Calendar.MILLISECOND, 0); - return cal.getTimeInMillis(); - } -} diff --git a/frontend/plugins/withTimeflowAlarm.js b/frontend/plugins/withTimeflowAlarm.js deleted file mode 100644 index aebc28b1..00000000 --- a/frontend/plugins/withTimeflowAlarm.js +++ /dev/null @@ -1,29 +0,0 @@ -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/src/features/reminder/application/LocalReminderApplication.ts b/frontend/src/features/reminder/application/LocalReminderApplication.ts index 95552ad6..b16039c5 100644 --- a/frontend/src/features/reminder/application/LocalReminderApplication.ts +++ b/frontend/src/features/reminder/application/LocalReminderApplication.ts @@ -23,15 +23,10 @@ import type { ReminderTrigger, ReminderTriggerReason, } from '../domain'; -import { DEFAULT_SNOOZE_MINUTES, buildReminderSpeechText } from '../domain'; -import { - distanceMeters, - evaluateGeofence, - resolveGeofenceCenter, - resolveWatchMode, -} from '../domain/geofence'; +import { DEFAULT_SNOOZE_MINUTES } from '../domain'; +import { evaluateGeofence, resolveGeofenceCenter, resolveWatchMode } from '../domain/geofence'; import type { AlarmSoundTier } from '../domain/strengthDelivery'; -import { resolveStrengthDeliveryPlan } from '../domain/strengthDelivery'; +import { composeReminderSpeech, resolveStrengthDeliveryPlan } from '../domain/strengthDelivery'; import { isSnoozeActive, isSnoozeExpired, @@ -380,14 +375,12 @@ export class LocalReminderApplication implements ReminderApplicationPort { .map((schedule) => { const triggerAt = resolveEffectiveTriggerAt(schedule); if (triggerAt == null) return null; - const scheduledAt = schedule.start_time ?? triggerAt; return { schedule_id: schedule.id, trigger_at: triggerAt, title: schedule.title, exact: true, ...alarmRingChannels(schedule), - speech_text: toAlarmSpeechText(schedule, scheduledAt), }; }) .filter((request): request is NonNullable => request != null); @@ -562,7 +555,6 @@ export class LocalReminderApplication implements ReminderApplicationPort { title: schedule.title, exact: true, ...alarmRingChannels(schedule), - speech_text: toAlarmSpeechText(schedule, snoozedUntil), }); if (!this.isLive(generation)) { if (receipt.scheduled) { @@ -743,6 +735,7 @@ export class LocalReminderApplication implements ReminderApplicationPort { // presentNow 本身不可用(iOS、原生模块拿不到)时才回退到下面的 JS 通道。 let presentedNatively = false; if (this.dependencies.alarms.presentNow != null) { + const speechText = composeReminderSpeech(schedule); const nativeReceipt = await this.dependencies.alarms.presentNow({ alarm_id: `present-${schedule.id}-${Date.now()}`, schedule_id: schedule.id, @@ -750,6 +743,7 @@ export class LocalReminderApplication implements ReminderApplicationPort { vibrate: plan.useVibration, sound_tier: plan.alarmSoundTier, full_screen: true, + speech_text: speechText, }); if (nativeReceipt.presented) { presentedNatively = true; @@ -979,36 +973,10 @@ export class LocalReminderApplication implements ReminderApplicationPort { ): Promise { if (!this.isLive(generation)) return; const canDeliver = await this.canDeliver(schedule, sample.observed_at, generation); - // 诊断用:canDeliver 为 false 会直接 return,evaluateGeofence 根本不会跑—— - // 之前排查"进圈没反应"如果卡在这一步,日志上只会看到这一行、看不到距离/ - // transition,就是这里拦住了。定位问题排查完可以删。 - console.warn( - '[reminder] applyLocationSample', - schedule.id, - schedule.title, - 'canDeliver=', - canDeliver, - 'disposition=', - schedule.runtime.reminder_disposition_state, - ); if (!canDeliver) return; const mode = resolveWatchMode(schedule); - const center = resolveGeofenceCenter(schedule, mode); const transition = evaluateGeofence(schedule, sample, mode); - console.warn( - '[reminder] geofence eval', - schedule.id, - schedule.title, - 'distance=', - center == null ? 'no-center' : Math.round(distanceMeters(sample, center)), - 'radius=', - schedule.geofence_radius_meters, - 'was_armed=', - schedule.runtime.geofence_armed, - 'transition=', - transition, - ); if (transition === 'armed') { if (!this.isLive(generation)) return; await this.patchRuntime(schedule.id, { @@ -1139,14 +1107,12 @@ export class LocalReminderApplication implements ReminderApplicationPort { ): Promise { const triggerAt = resolveEffectiveTriggerAt(schedule); if (triggerAt == null) return null; - const scheduledAt = schedule.start_time ?? triggerAt; const receipt = await this.dependencies.alarms.schedule({ schedule_id: schedule.id, trigger_at: triggerAt, title: schedule.title, exact: true, ...alarmRingChannels(schedule), - speech_text: toAlarmSpeechText(schedule, scheduledAt), }); void this.reportPermissionGaps(schedule.id, [ 'exact_alarm', @@ -1233,18 +1199,16 @@ function alarmRingChannels(schedule: LocalReminderSchedule): { vibrate: boolean; sound_tier: AlarmSoundTier; full_screen: boolean; + speech_text: string; } { const plan = resolveStrengthDeliveryPlan(schedule.reminder?.reminder_strength ?? 'medium'); - return { vibrate: plan.useVibration, sound_tier: plan.alarmSoundTier, full_screen: true }; -} - -function toAlarmSpeechText(schedule: LocalReminderSchedule, scheduledAt: string): string { - return buildReminderSpeechText({ - title: schedule.title, - scheduledAt, - timezone: schedule.timezone, - isAllDay: schedule.is_all_day, - }); + const speechText = composeReminderSpeech(schedule); + return { + vibrate: plan.useVibration, + sound_tier: plan.alarmSoundTier, + full_screen: true, + speech_text: speechText, + }; } function toTimeReason(schedule: LocalReminderSchedule): ReminderTriggerReason { diff --git a/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts b/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts index c7bee0bd..97237aa3 100644 --- a/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts +++ b/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts @@ -9,7 +9,7 @@ export type AlarmScheduleRequest = { vibrate: boolean; sound_tier: AlarmSoundTier; full_screen: boolean; - /** JS 生成的日程播报文案;原生侧仅负责按闹钟声道朗读。 */ + /** 仅 high 强度非空:设备 TTS 念的文案(标题 + 播报时间);空则原生回退打包铃。 */ speech_text?: string; }; @@ -42,6 +42,8 @@ export type AlarmPresentationRequest = { vibrate: boolean; sound_tier: AlarmSoundTier; full_screen: boolean; + /** 仅 high 强度非空:设备 TTS 念的文案(标题 + 播报时间);空则原生回退打包铃。 */ + speech_text?: string; }; export type AlarmPresentationReceipt = { diff --git a/frontend/src/features/reminder/domain/index.ts b/frontend/src/features/reminder/domain/index.ts index 9e2ac336..c3dca92c 100644 --- a/frontend/src/features/reminder/domain/index.ts +++ b/frontend/src/features/reminder/domain/index.ts @@ -36,6 +36,4 @@ export { resolveTimeTriggerAt, } from './timeWindow'; export type { StrengthDeliveryPlan } from './strengthDelivery'; -export { resolveStrengthDeliveryPlan } from './strengthDelivery'; -export type { ReminderSpeechInput } from './reminderSpeech'; -export { buildReminderSpeechText } from './reminderSpeech'; +export { composeReminderSpeech, resolveStrengthDeliveryPlan } from './strengthDelivery'; diff --git a/frontend/src/features/reminder/domain/reminderSpeech.ts b/frontend/src/features/reminder/domain/reminderSpeech.ts deleted file mode 100644 index da48968c..00000000 --- a/frontend/src/features/reminder/domain/reminderSpeech.ts +++ /dev/null @@ -1,61 +0,0 @@ -const FALLBACK_TITLE = '未命名日程'; -const MAX_SPOKEN_TITLE_LENGTH = 80; - -export type ReminderSpeechInput = { - title: string; - scheduledAt: string | null; - timezone: string; - isAllDay: boolean; -}; - -/** 生成交给系统 TTS 的简短提醒文案,不依赖预制音频。 */ -export function buildReminderSpeechText(input: ReminderSpeechInput): string { - const title = normalizeTitle(input.title); - const scheduledTime = formatSpokenScheduleTime(input.scheduledAt, input.timezone, input.isAllDay); - - if (scheduledTime == null) { - return `${title},时间到了,请及时处理。`; - } - if (input.isAllDay) { - return `${scheduledTime},今天任务是${title}。`; - } - return `${title},时间到了。现在已经${scheduledTime}了。`; -} - -function normalizeTitle(value: string): string { - const normalized = value.replace(/\s+/g, ' ').trim(); - return (normalized || FALLBACK_TITLE).slice(0, MAX_SPOKEN_TITLE_LENGTH); -} - -function formatSpokenScheduleTime( - iso: string | null, - timezone: string, - isAllDay: boolean, -): string | null { - if (iso == null) return null; - const date = new Date(iso); - if (!Number.isFinite(date.getTime())) return null; - - try { - const formatter = new Intl.DateTimeFormat('zh-CN', { - timeZone: timezone, - year: 'numeric', - month: 'numeric', - day: 'numeric', - weekday: 'long', - hour: isAllDay ? undefined : '2-digit', - minute: isAllDay ? undefined : '2-digit', - hourCycle: 'h23', - }); - const parts = formatter.formatToParts(date); - const value = (type: string): string => parts.find((part) => part.type === type)?.value ?? ''; - const dateText = `${value('month')}月${value('day')}日`; - if (isAllDay) return dateText; - - const hour = value('hour'); - const minute = value('minute'); - return minute === '00' ? `${hour}点` : `${hour}点${minute}分`; - } catch { - return null; - } -} diff --git a/frontend/src/features/reminder/domain/strengthDelivery.ts b/frontend/src/features/reminder/domain/strengthDelivery.ts index 97c9c79d..b77a843a 100644 --- a/frontend/src/features/reminder/domain/strengthDelivery.ts +++ b/frontend/src/features/reminder/domain/strengthDelivery.ts @@ -1,4 +1,5 @@ -import type { ReminderStrength } from './reminder'; +import type { LocalReminderSchedule, ReminderStrength } from './reminder'; +import { resolveEffectiveTriggerAt } from './timeWindow'; /** 原生全屏响铃页的声音档位:none=不出声,ping=一次性短提示音,full=循环语音直到处理。 */ export type AlarmSoundTier = 'none' | 'ping' | 'full'; @@ -43,3 +44,70 @@ export function resolveStrengthDeliveryPlan(strength: ReminderStrength): Strengt }; } } + +const FALLBACK_TITLE = '未命名日程'; +const MAX_SPOKEN_TITLE_LENGTH = 80; + +/** + * 高强度提醒交给设备 TTS 念的文案:标题 + 播报时钟时间。非 high 或标题为空返回空串, + * 原生按"无文案"回退打包铃。 + * + * 播报时间必须用 resolveEffectiveTriggerAt()(实际闹钟触发时刻),不能直接读 + * schedule.start_time——before_start 类型提前于事件本身触发,snooze 后触发时刻 + * 也变成了 snoozed_until,两种情况下 start_time 都跟"现在几点了"这句播报对不上。 + */ +export function composeReminderSpeech(schedule: LocalReminderSchedule): string { + if (schedule.reminder?.reminder_strength !== 'high') return ''; + const title = normalizeSpokenTitle(schedule.title); + if (!title) return ''; + const scheduledTime = formatSpokenScheduleTime( + resolveEffectiveTriggerAt(schedule), + schedule.timezone, + schedule.is_all_day, + ); + if (scheduledTime == null) { + return `${title},时间到了,请及时处理。`; + } + if (schedule.is_all_day) { + return `${scheduledTime},今天任务是${title}。`; + } + return `${title},时间到了。现在已经${scheduledTime}了。`; +} + +function normalizeSpokenTitle(value: string): string { + const normalized = value.replace(/\s+/g, ' ').trim(); + return (normalized || FALLBACK_TITLE).slice(0, MAX_SPOKEN_TITLE_LENGTH); +} + +function formatSpokenScheduleTime( + iso: string | null, + timezone: string, + isAllDay: boolean, +): string | null { + if (iso == null) return null; + const date = new Date(iso); + if (!Number.isFinite(date.getTime())) return null; + + try { + const formatter = new Intl.DateTimeFormat('zh-CN', { + timeZone: timezone, + year: 'numeric', + month: 'numeric', + day: 'numeric', + weekday: 'long', + hour: isAllDay ? undefined : '2-digit', + minute: isAllDay ? undefined : '2-digit', + hourCycle: 'h23', + }); + const parts = formatter.formatToParts(date); + const value = (type: string): string => parts.find((part) => part.type === type)?.value ?? ''; + const dateText = `${value('month')}月${value('day')}日`; + if (isAllDay) return dateText; + + const hour = value('hour'); + const minute = value('minute'); + return minute === '00' ? `${hour}点` : `${hour}点${minute}分`; + } catch { + return null; + } +} diff --git a/frontend/src/infrastructure/location/ExpoLocationMonitor.ts b/frontend/src/infrastructure/location/ExpoLocationMonitor.ts index 670e2fdb..209e2586 100644 --- a/frontend/src/infrastructure/location/ExpoLocationMonitor.ts +++ b/frontend/src/infrastructure/location/ExpoLocationMonitor.ts @@ -21,7 +21,7 @@ import type { LocationProvider } from './LocationProvider'; type ActiveWatch = { listener_id: string; request: LocationWatchRequest; - listener: (event: LocationMonitorEvent) => void; + listener: (event: LocationMonitorEvent) => unknown; }; /** ≈1.1km,足够超出任何合理的围栏半径,用来给 exit 事件合成一个"明显在圈外"的采样点。 */ @@ -56,7 +56,7 @@ export class ExpoLocationMonitor implements LocationMonitorPort, LocationProvide async watch( request: LocationWatchRequest, - listener: (event: LocationMonitorEvent) => void, + listener: (event: LocationMonitorEvent) => unknown, ): Promise { const existingId = this.scheduleToListener.get(request.schedule_id); if (existingId != null) { @@ -70,7 +70,7 @@ export class ExpoLocationMonitor implements LocationMonitorPort, LocationProvide const sample = await this.getCurrentSample(); if (sample != null) { - listener({ schedule_id: request.schedule_id, sample, phase: 'inside' }); + await listener({ schedule_id: request.schedule_id, sample, phase: 'inside' }); } await this.replayPendingEvents(); @@ -83,7 +83,7 @@ export class ExpoLocationMonitor implements LocationMonitorPort, LocationProvide async rebuild( targets: readonly LocationRebuildTarget[], - listener: (event: LocationMonitorEvent) => void, + listener: (event: LocationMonitorEvent) => unknown, ): Promise { for (const listenerId of [...this.watches.keys()]) { await this.removeWatch(listenerId, false); @@ -115,7 +115,7 @@ export class ExpoLocationMonitor implements LocationMonitorPort, LocationProvide const sample = await this.getCurrentSample(); if (sample != null) { for (const handle of handles) { - listener({ schedule_id: handle.schedule_id, sample, phase: 'inside' }); + await listener({ schedule_id: handle.schedule_id, sample, phase: 'inside' }); } } @@ -165,11 +165,11 @@ export class ExpoLocationMonitor implements LocationMonitorPort, LocationProvide private async replayPendingEvents(): Promise { const pending = await drainPendingGeofenceEvents(); for (const payload of pending) { - this.handleTaskEvent(payload); + await this.handleTaskEvent(payload); } } - private readonly handleTaskEvent = (payload: GeofenceTaskPayload): void => { + private readonly handleTaskEvent = async (payload: GeofenceTaskPayload): Promise => { const listenerId = this.scheduleToListener.get(payload.schedule_id); const watch = listenerId != null ? this.watches.get(listenerId) : undefined; if (watch == null) return; @@ -189,7 +189,7 @@ export class ExpoLocationMonitor implements LocationMonitorPort, LocationProvide observed_at: payload.observed_at, }; this.lastSample = sample; - watch.listener({ + await watch.listener({ schedule_id: watch.request.schedule_id, sample, phase: payload.event === 'enter' ? 'entered' : 'left', diff --git a/frontend/src/infrastructure/location/reminderGuardTask.ts b/frontend/src/infrastructure/location/reminderGuardTask.ts index 71b1d72d..cca47e59 100644 --- a/frontend/src/infrastructure/location/reminderGuardTask.ts +++ b/frontend/src/infrastructure/location/reminderGuardTask.ts @@ -78,18 +78,6 @@ if (!TaskManager.isTaskDefined(GUARD_TASK_NAME)) { observed_at: new Date(location.timestamp).toISOString(), }; - // 诊断用:每次唤醒都无条件打一行,不管走哪个分支——用来确认唤醒本身有没有 - // 发生、这一刻 listeners.size 到底是不是预期的那个值。定位问题排查完可以删。 - console.warn( - '[guard] tick', - 'sample=', - sample, - 'listeners.size=', - listeners.size, - 'observed_at=', - sample?.observed_at ?? null, - ); - // headless 直查全靠这个账号 id 限定范围——local_schedules 是全应用共用的一张表, // 登出不会删旧账号的行(只有单条删除日程那一条 DELETE),账号 A 登出、账号 B // 登录后 A 的数据原样留在库里。前台路径(SqliteLocalScheduleReader -> @@ -217,21 +205,6 @@ async function runHeadlessLocationPass( } const transition = evaluateGeofence(schedule, sample, mode); - // 诊断用:把距离、原来的 armed 状态、这次判定结果都打出来——定位问题排查完 - // 可以删。距离是单独算的,跟 evaluateGeofence 内部用的是同一个 distanceMeters()。 - console.warn( - '[guard] geofence eval', - row.id, - row.title, - 'distance=', - Math.round(distanceMeters(sample, center)), - 'radius=', - DEFAULT_GEOFENCE_RADIUS_METERS, - 'was_armed=', - row.geofence_armed === 1, - 'transition=', - transition, - ); if (transition === 'armed') { const armClaim = await database.runAsync( `UPDATE local_schedules SET geofence_armed = 1 WHERE id = ? AND geofence_armed = 0`, @@ -649,7 +622,7 @@ export function resolveNextPollIntervalMs( } let nearestBoundary = Number.POSITIVE_INFINITY; for (const target of targets) { - // 用跟 evaluateGeofence()/诊断日志同一个 distanceMeters(),不要自己按 + // 用跟 evaluateGeofence() 同一个 distanceMeters(),不要自己按 // 111km/度换算——纬度越高经度 1° 对应的实际距离越短,平面近似会在高纬度 // 地区把距离算大,导致该加密轮询时没加密。 const approxMeters = distanceMeters(currentSample, target); diff --git a/frontend/src/infrastructure/notifications/NativeAlarmScheduler.ts b/frontend/src/infrastructure/notifications/NativeAlarmScheduler.ts index 7169246c..174ac92e 100644 --- a/frontend/src/infrastructure/notifications/NativeAlarmScheduler.ts +++ b/frontend/src/infrastructure/notifications/NativeAlarmScheduler.ts @@ -1,8 +1,8 @@ import type { AlarmNativeDisposition, - AlarmNativeEvent, AlarmPresentationReceipt, AlarmPresentationRequest, + AlarmNativeEvent, AlarmScheduleReceipt, AlarmScheduleRequest, AlarmSchedulerPort, @@ -13,8 +13,8 @@ import { nativeAreAlarmPermissionsGranted, nativeCancelAlarm, nativeCancelAllAlarms, - nativePeekAlarmDispositions, nativePresentAlarmNow, + nativePeekAlarmDispositions, nativeScheduleAlarm, nativeStopAlarmRinging, subscribeNativeAlarmEvents, @@ -31,6 +31,7 @@ export class NativeAlarmScheduler implements AlarmSchedulerPort { request.vibrate, request.sound_tier, request.full_screen, + request.speech_text, ); return { alarm_id: alarmId, diff --git a/frontend/src/infrastructure/notifications/index.ts b/frontend/src/infrastructure/notifications/index.ts index 3064c3fa..8396bb43 100644 --- a/frontend/src/infrastructure/notifications/index.ts +++ b/frontend/src/infrastructure/notifications/index.ts @@ -15,8 +15,8 @@ export { nativeGetAlarmPermissionStatus, nativeHasArmedAlarm, nativeOpenAlarmPermissionSettings, - nativePeekAlarmDispositions, nativePresentAlarmNow, + nativePeekAlarmDispositions, nativeRequestNotificationPermission, nativeScheduleAlarm, nativeStopAlarmRinging, diff --git a/frontend/src/infrastructure/notifications/native/TimeflowAlarmBridge.ts b/frontend/src/infrastructure/notifications/native/TimeflowAlarmBridge.ts index 6722a91a..a65633c7 100644 --- a/frontend/src/infrastructure/notifications/native/TimeflowAlarmBridge.ts +++ b/frontend/src/infrastructure/notifications/native/TimeflowAlarmBridge.ts @@ -51,6 +51,7 @@ type TimeflowAlarmNative = { vibrate: boolean, soundTier: AlarmSoundTier, fullScreen: boolean, + speechText?: string | null, ) => Promise; hasArmedAlarm: (scheduleId: string) => Promise; peekNativeDispositions: () => Promise; @@ -82,7 +83,7 @@ export async function nativeScheduleAlarm( vibrate?: boolean, soundTier?: AlarmSoundTier, fullScreen?: boolean, - speechText?: string, + speechText?: string | null, ): Promise { const native = getNativeAlarm(); if (!isTimeflowAlarmAvailable() || native == null) return null; @@ -142,11 +143,20 @@ export async function nativePresentAlarmNow( vibrate: boolean, soundTier: AlarmSoundTier, fullScreen: boolean, + speechText?: string | null, ): Promise { const native = getNativeAlarm(); if (!isTimeflowAlarmAvailable() || native == null) return false; try { - return await native.presentNow(alarmId, scheduleId, title, vibrate, soundTier, fullScreen); + return await native.presentNow( + alarmId, + scheduleId, + title, + vibrate, + soundTier, + fullScreen, + speechText ?? '', + ); } catch (error) { console.warn('[TimeflowAlarm] nativePresentAlarmNow failed', error); return false; diff --git a/frontend/tests/unit/features/reminder/application/LocalReminderApplication.test.ts b/frontend/tests/unit/features/reminder/application/LocalReminderApplication.test.ts index d044084a..28b9e67c 100644 --- a/frontend/tests/unit/features/reminder/application/LocalReminderApplication.test.ts +++ b/frontend/tests/unit/features/reminder/application/LocalReminderApplication.test.ts @@ -1249,7 +1249,12 @@ describe('LocalReminderApplication', () => { expect(receipt.channels).toEqual(['native_full_screen']); expect(presentNow).toHaveBeenCalledWith( - expect.objectContaining({ vibrate: true, sound_tier: 'full', full_screen: true }), + expect.objectContaining({ + vibrate: true, + sound_tier: 'full', + full_screen: true, + speech_text: '喝水提醒,时间到了。现在已经18点了。', + }), ); expect(deps.presenter.show).not.toHaveBeenCalled(); expect(deps.systemNotification.show).not.toHaveBeenCalled(); @@ -1334,11 +1339,24 @@ describe('LocalReminderApplication', () => { // 低=一声提示音不震动、中=一声提示音+震动、高=循环语音+震动。 const cases: [ ReminderStrength, - { vibrate: boolean; sound_tier: 'none' | 'ping' | 'full'; full_screen: boolean }, + { + vibrate: boolean; + sound_tier: 'none' | 'ping' | 'full'; + full_screen: boolean; + speech_text: string; + }, ][] = [ - ['low', { vibrate: false, sound_tier: 'ping', full_screen: true }], - ['medium', { vibrate: true, sound_tier: 'ping', full_screen: true }], - ['high', { vibrate: true, sound_tier: 'full', full_screen: true }], + ['low', { vibrate: false, sound_tier: 'ping', full_screen: true, speech_text: '' }], + ['medium', { vibrate: true, sound_tier: 'ping', full_screen: true, speech_text: '' }], + [ + 'high', + { + vibrate: true, + sound_tier: 'full', + full_screen: true, + speech_text: '喝水提醒,时间到了。现在已经18点了。', + }, + ], ]; it.each(cases)('%s strength schedules the native alarm with %j', async (strength, expected) => { const schedule = fixtureSchedule({ @@ -1362,10 +1380,7 @@ describe('LocalReminderApplication', () => { expect(registration.alarm_id).not.toBeNull(); expect(scheduleCalls).toHaveLength(1); - expect(scheduleCalls[0]).toMatchObject({ - ...expected, - speech_text: '喝水提醒,时间到了。现在已经18点了。', - }); + expect(scheduleCalls[0]).toMatchObject(expected); }); }); diff --git a/frontend/tests/unit/features/reminder/domain/reminderSpeech.test.ts b/frontend/tests/unit/features/reminder/domain/reminderSpeech.test.ts deleted file mode 100644 index 2ec86f2e..00000000 --- a/frontend/tests/unit/features/reminder/domain/reminderSpeech.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, expect, it } from '@jest/globals'; - -import { buildReminderSpeechText } from '../../../../../src/features/reminder/domain/reminderSpeech'; - -describe('buildReminderSpeechText', () => { - it('formats a timed reminder in its schedule timezone', () => { - expect( - buildReminderSpeechText({ - title: '晨会', - scheduledAt: '2026-08-13T01:05:00.000Z', - timezone: 'Asia/Shanghai', - isAllDay: false, - }), - ).toBe('晨会,时间到了。现在已经09点05分了。'); - }); - - it('formats an all-day reminder as a calendar date', () => { - expect( - buildReminderSpeechText({ - title: '提交报告', - scheduledAt: '2026-08-13T01:05:00.000Z', - timezone: 'Asia/Shanghai', - isAllDay: true, - }), - ).toBe('8月13日,今天任务是提交报告。'); - }); - - it('uses the generic wording when no schedule time is available', () => { - expect( - buildReminderSpeechText({ - title: ' 喝水 ', - scheduledAt: null, - timezone: 'Asia/Shanghai', - isAllDay: false, - }), - ).toBe('喝水,时间到了,请及时处理。'); - }); - - it('uses the generic wording for an invalid timestamp', () => { - expect( - buildReminderSpeechText({ - title: '检查', - scheduledAt: 'not-a-date', - timezone: 'Asia/Shanghai', - isAllDay: false, - }), - ).toBe('检查,时间到了,请及时处理。'); - }); - - it('uses the generic wording when the timezone cannot be resolved', () => { - expect( - buildReminderSpeechText({ - title: '提醒', - scheduledAt: '2026-08-13T01:05:00.000Z', - timezone: 'Invalid/Timezone', - isAllDay: false, - }), - ).toBe('提醒,时间到了,请及时处理。'); - }); - - it('normalizes whitespace, supplies a fallback title, and truncates long titles', () => { - const longTitle = 'a'.repeat(90); - expect( - buildReminderSpeechText({ - title: ` ${longTitle} `, - scheduledAt: null, - timezone: 'UTC', - isAllDay: false, - }), - ).toBe(`${'a'.repeat(80)},时间到了,请及时处理。`); - - expect( - buildReminderSpeechText({ - title: ' \n\t ', - scheduledAt: null, - timezone: 'UTC', - isAllDay: false, - }), - ).toBe('未命名日程,时间到了,请及时处理。'); - }); -}); diff --git a/frontend/tests/unit/features/reminder/domain/strengthDelivery.test.ts b/frontend/tests/unit/features/reminder/domain/strengthDelivery.test.ts index bda06179..8c65761a 100644 --- a/frontend/tests/unit/features/reminder/domain/strengthDelivery.test.ts +++ b/frontend/tests/unit/features/reminder/domain/strengthDelivery.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from '@jest/globals'; -import { resolveStrengthDeliveryPlan } from '../../../../../src/features/reminder/domain/strengthDelivery'; +import type { + LocalReminderSchedule, + ReminderStrength, +} from '../../../../../src/features/reminder/domain'; +import { + composeReminderSpeech, + resolveStrengthDeliveryPlan, +} from '../../../../../src/features/reminder/domain/strengthDelivery'; describe('resolveStrengthDeliveryPlan', () => { it('low: system notification only, native ring page gets a one-shot ping', () => { @@ -33,3 +40,115 @@ describe('resolveStrengthDeliveryPlan', () => { }); }); }); + +describe('composeReminderSpeech', () => { + it('high with a timed start speaks the title and the local clock time', () => { + // 2026-08-18T10:00:00.000Z == Asia/Shanghai 18:00 + expect( + composeReminderSpeech( + speechSchedule('high', ' 九点面试 ', false, '2026-08-18T10:00:00.000Z'), + ), + ).toBe('九点面试,时间到了。现在已经18点了。'); + }); + + it('high with a non-zero minute includes the minute', () => { + expect( + composeReminderSpeech(speechSchedule('high', '拿快递', false, '2026-08-18T10:30:00.000Z')), + ).toBe('拿快递,时间到了。现在已经18点30分了。'); + }); + + it('high all-day schedule speaks the date instead of a clock time', () => { + expect( + composeReminderSpeech(speechSchedule('high', '交房租', true, '2026-08-18T10:00:00.000Z')), + ).toBe('8月18日,今天任务是交房租。'); + }); + + it('high with no start_time falls back to a generic prompt', () => { + expect(composeReminderSpeech(speechSchedule('high', '开会', false, null))).toBe( + '开会,时间到了,请及时处理。', + ); + }); + + it('high with blank title falls back to a placeholder title', () => { + expect(composeReminderSpeech(speechSchedule('high', ' ', false, null))).toBe( + '未命名日程,时间到了,请及时处理。', + ); + }); + + it('non-high returns empty string', () => { + expect( + composeReminderSpeech(speechSchedule('medium', '开会', false, '2026-08-18T10:00:00.000Z')), + ).toBe(''); + expect( + composeReminderSpeech(speechSchedule('low', '开会', false, '2026-08-18T10:00:00.000Z')), + ).toBe(''); + }); + + it('before_start speaks the earlier trigger time, not the event start_time', () => { + // start_time 是事件本身的时刻(18:00),但 before_start 提前 15 分钟触发—— + // 播报应该说触发那一刻(17:45),不是事件开始的 18:00。 + const schedule = speechSchedule('high', '开会', false, '2026-08-18T10:00:00.000Z'); + schedule.reminder = { + reminder_type: 'before_start', + reminder_trigger_at: null, + reminder_offset_minutes: 15, + reminder_strength: 'high', + }; + expect(composeReminderSpeech(schedule)).toBe('开会,时间到了。现在已经17点45分了。'); + }); + + it('a snoozed reminder speaks snoozed_until, not the original start_time', () => { + // 用户延后到 19:00 后再响:播报应该说延后到的这个时刻,不是最初的 18:00, + // 否则每次响铃都报同一个已经过去的时间。 + const schedule = speechSchedule('high', '开会', false, '2026-08-18T10:00:00.000Z'); + schedule.runtime = { + ...schedule.runtime, + reminder_disposition_state: 'snoozed', + snoozed_until: '2026-08-18T11:00:00.000Z', + }; + expect(composeReminderSpeech(schedule)).toBe('开会,时间到了。现在已经19点了。'); + }); +}); + +function speechSchedule( + strength: ReminderStrength, + title: string, + isAllDay: boolean, + startTime: string | null, +): LocalReminderSchedule { + return { + id: 's1', + account_id: 'acc', + title, + schedule_type: 'time', + schedule_kind: 'once', + is_all_day: isAllDay, + start_time: startTime, + end_time: null, + timezone: 'Asia/Shanghai', + recurrence_rule: null, + location_name: null, + latitude: null, + longitude: null, + geofence_radius_meters: 200, + reminder: { + reminder_type: 'at_time', + reminder_trigger_at: null, + reminder_offset_minutes: null, + reminder_strength: strength, + }, + runtime: { + reminder_disposition_state: null, + next_trigger_at: null, + snoozed_until: null, + geofence_armed: false, + disposition_updated_at: null, + sync_status: 'pending', + recorded_location: null, + }, + status: 'active', + revision: 1, + cloud_revision: 1, + updated_at: '2026-08-18T09:00:00.000Z', + }; +} diff --git a/frontend/tests/unit/infrastructure/notifications/nativeAlarmScheduler.test.ts b/frontend/tests/unit/infrastructure/notifications/nativeAlarmScheduler.test.ts index af2f6056..40f653db 100644 --- a/frontend/tests/unit/infrastructure/notifications/nativeAlarmScheduler.test.ts +++ b/frontend/tests/unit/infrastructure/notifications/nativeAlarmScheduler.test.ts @@ -283,6 +283,20 @@ describe('TimeflowAlarmBridge and NativeAlarmScheduler', () => { ); }); + it('forwards speech_text to the native bridge', async () => { + const scheduler = new NativeAlarmScheduler(); + await scheduler.schedule(request({ speech_text: '九点面试' })); + expect(native.schedule).toHaveBeenCalledWith( + Date.parse(FUTURE), + '晨会', + 'schedule-1', + true, + 'full', + true, + '九点面试', + ); + }); + it('maps a native schedule rejection to unscheduled', async () => { native.schedule.mockRejectedValue(new Error('exact alarm denied')); const scheduler = new NativeAlarmScheduler(); @@ -420,6 +434,7 @@ describe('TimeflowAlarmBridge and NativeAlarmScheduler', () => { true, 'full', true, + '', ); }); @@ -441,6 +456,7 @@ describe('TimeflowAlarmBridge and NativeAlarmScheduler', () => { false, 'ping', true, + '', ); });