From f2584e86ebe9984370a77ffeb0a75971ce991d2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=80=E6=9D=A1=E5=9B=BA=E6=89=A7=E7=9A=84=E9=B1=BC?= <1504947133@qq.com> Date: Fri, 21 Aug 2026 14:02:14 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(reminder):=20=E5=93=8D=E9=93=83?= =?UTF-8?q?=E7=94=A8=E7=B3=BB=E7=BB=9F=20TTS=20=E5=90=88=E6=88=90=E8=AF=AD?= =?UTF-8?q?=E9=9F=B3=E6=92=AD=E6=8A=A5=E6=97=A5=E7=A8=8B=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ReminderSpeechFormatter / reminderSpeech:按「标题,时间到了,现在已经X点X分了」生成播报文案 - speech_text 贯穿 JS → 闹钟模块 → 持久化 → 触发全链路 - 安卓端把原打包音频循环播放换成系统 TextToSpeech 合成播报(优先中文,缺失回退系统默认),按闹钟声道循环 - 新增 ReminderSpeechFormatterTest 覆盖整点/格式/超长标题等边界 --- .../com/timeflow/alarm/AlarmContract.java | 11 +- .../java/com/timeflow/alarm/AlarmModule.kt | 2 + .../com/timeflow/alarm/AlarmScheduler.java | 22 ++- .../com/timeflow/alarm/AlarmSoundService.java | 187 +++++++++++------- .../alarm/ReminderSpeechFormatter.java | 61 ++++++ .../alarm/ReminderSpeechFormatterTest.java | 65 ++++++ .../application/LocalReminderApplication.ts | 16 +- .../interfaces/AlarmSchedulerPort.ts | 1 + .../src/features/reminder/domain/index.ts | 2 + .../reminder/domain/reminderSpeech.ts | 61 ++++++ .../notifications/NativeAlarmScheduler.ts | 7 +- .../native/TimeflowAlarmBridge.ts | 9 +- .../nativeAlarmScheduler.test.ts | 23 ++- 13 files changed, 381 insertions(+), 86 deletions(-) create mode 100644 frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/ReminderSpeechFormatter.java create mode 100644 frontend/modules/timeflow-alarm/android/src/test/java/com/timeflow/alarm/ReminderSpeechFormatterTest.java create mode 100644 frontend/src/features/reminder/domain/reminderSpeech.ts diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmContract.java b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmContract.java index 55be21a1..d9fe494d 100644 --- a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmContract.java +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmContract.java @@ -10,6 +10,7 @@ final class AlarmContract { static final String EXTRA_REQUEST_CODE = "request_code"; static final String EXTRA_TITLE = "alarm_title"; static final String EXTRA_SCHEDULE_ID = "schedule_id"; + static final String EXTRA_SPEECH_TEXT = "speech_text"; static final String EXTRA_EVENT_TYPE = "event_type"; static final String EVENT_FIRED = "fired"; static final String EVENT_DISMISSED = "dismissed"; @@ -41,12 +42,14 @@ static final class ExtractedExtras { final String alarmId; final String scheduleId; final String title; + final String speechText; final int requestCode; - private ExtractedExtras(String alarmId, String scheduleId, String title, int requestCode) { + private ExtractedExtras(String alarmId, String scheduleId, String title, String speechText, int requestCode) { this.alarmId = alarmId; this.scheduleId = scheduleId; this.title = title; + this.speechText = speechText; this.requestCode = requestCode; } @@ -55,6 +58,7 @@ static ExtractedExtras from(Context context, Intent intent) { String alarmId = intent == null ? null : intent.getStringExtra(EXTRA_ALARM_ID); String scheduleId = intent == null ? null : intent.getStringExtra(EXTRA_SCHEDULE_ID); String title = intent == null ? null : intent.getStringExtra(EXTRA_TITLE); + String speechText = intent == null ? null : intent.getStringExtra(EXTRA_SPEECH_TEXT); if (alarmId == null || alarmId.isEmpty()) { alarmId = "legacy-" + requestCode; } @@ -64,7 +68,10 @@ static ExtractedExtras from(Context context, Intent intent) { if (title == null || title.isEmpty()) { title = "日程提醒"; } - return new ExtractedExtras(alarmId, scheduleId, title, requestCode); + if (speechText == null || speechText.isEmpty()) { + speechText = ReminderSpeechFormatter.format(title, System.currentTimeMillis()); + } + return new ExtractedExtras(alarmId, scheduleId, title, speechText, requestCode); } } } diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmModule.kt b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmModule.kt index b7084457..4640da5d 100644 --- a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmModule.kt +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmModule.kt @@ -47,6 +47,7 @@ class AlarmModule(private val reactContext: ReactApplicationContext) : triggerAtMillis: Double, title: String?, scheduleId: String?, + speechText: String?, promise: Promise, ) { try { @@ -56,6 +57,7 @@ class AlarmModule(private val reactContext: ReactApplicationContext) : triggerAtMillis.toLong(), title ?: "日程提醒", scheduleId ?: "", + speechText ?: "", ) Log.i(NAME, "scheduled alarmId=$alarmId") val result: WritableMap = Arguments.createMap() diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmScheduler.java b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmScheduler.java index 1469561f..3cdc2b1c 100644 --- a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmScheduler.java +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmScheduler.java @@ -29,6 +29,7 @@ public static final class AlarmRecord { public final long triggerAtMillis; public final int requestCode; public final String title; + public final String speechText; public final boolean legacy; AlarmRecord( @@ -37,6 +38,7 @@ public static final class AlarmRecord { long triggerAtMillis, int requestCode, String title, + String speechText, boolean legacy ) { this.alarmId = alarmId; @@ -44,6 +46,7 @@ public static final class AlarmRecord { this.triggerAtMillis = triggerAtMillis; this.requestCode = requestCode; this.title = title; + this.speechText = speechText == null ? "" : speechText; this.legacy = legacy; } } @@ -52,7 +55,8 @@ public static String schedule( Context context, long triggerAtMillis, String title, - String scheduleId + String scheduleId, + String speechText ) { if (triggerAtMillis <= System.currentTimeMillis()) { throw new IllegalArgumentException("trigger_in_past"); @@ -80,6 +84,7 @@ public static String schedule( triggerAtMillis, nextRequestCode(alarms), title == null ? "" : title, + speechText == null ? "" : speechText, false ); @@ -90,10 +95,16 @@ public static String schedule( return alarmId; } - /** @deprecated 请改用 {@link #schedule(Context, long, String, String)}。 */ + /** @deprecated 请改用 {@link #schedule(Context, long, String, String, String)}。 */ @Deprecated public static String schedule(Context context, long triggerAtMillis, String title) { - return schedule(context, triggerAtMillis, title, ""); + return schedule(context, triggerAtMillis, title, "", ""); + } + + /** @deprecated 请改用 {@link #schedule(Context, long, String, String, String)}。 */ + @Deprecated + public static String schedule(Context context, long triggerAtMillis, String title, String scheduleId) { + return schedule(context, triggerAtMillis, title, scheduleId, ""); } /** @@ -233,6 +244,7 @@ public static List loadAlarms(Context context) { triggerAt, requestCode, object.optString("title", ""), + object.optString("speech_text", ""), legacy )); } @@ -324,6 +336,7 @@ private static JSONObject toJson(AlarmRecord alarm) throws JSONException { object.put("trigger_at", alarm.triggerAtMillis); object.put("request_code", alarm.requestCode); object.put("title", alarm.title); + object.put("speech_text", alarm.speechText); object.put("legacy", alarm.legacy); return object; } @@ -338,7 +351,8 @@ private static PendingIntent buildAlarmBroadcastPendingIntent( .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); + .putExtra(AlarmContract.EXTRA_TITLE, record.title) + .putExtra(AlarmContract.EXTRA_SPEECH_TEXT, record.speechText); if (!record.legacy) { intent.setData(alarmUri(record.alarmId)); } diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmSoundService.java b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmSoundService.java index 6ba472ea..3f77853b 100644 --- a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmSoundService.java +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmSoundService.java @@ -11,35 +11,41 @@ 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.Bundle; import android.os.Handler; import android.os.IBinder; import android.os.Looper; +import android.os.PowerManager; import android.provider.Settings; +import android.speech.tts.TextToSpeech; +import android.speech.tts.UtteranceProgressListener; import android.util.Log; import android.view.Gravity; import android.view.View; import android.view.WindowManager; -import java.io.File; -import java.io.FileOutputStream; -import java.io.InputStream; import java.util.ArrayDeque; import java.util.HashSet; +import java.util.Locale; import java.util.Set; +import java.util.UUID; public final class AlarmSoundService extends Service { private static final String TAG = "AlarmSoundService"; private static final long SPEECH_REPEAT_DELAY_MILLIS = 1_500L; + private static final float TTS_SPEECH_RATE = 0.9f; + private static final float TTS_PITCH = 1.0f; private final Handler playbackHandler = new Handler(Looper.getMainLooper()); - private final Runnable replaySpeech = this::replaySpeech; + private final Runnable replaySpeech = this::scheduleTtsReplay; - private MediaPlayer mediaPlayer; + private TextToSpeech textToSpeech; + private boolean ttsReady; private boolean destroyed; - private File bundledSpeechFile; + private String currentSpeechText; + private String currentUtteranceId; private WindowManager overlayWindowManager; /** 包内可见(而非 private):AlarmSoundServiceTest 需要直接读取当前展示/排队状态。 */ View overlayView; @@ -86,6 +92,7 @@ private void presentAlarm(AlarmContract.ExtractedExtras extras) { alarmId = extras.alarmId; scheduleId = extras.scheduleId; alarmTitle = extras.title; + currentSpeechText = extras.speechText; createNotificationChannel(); Notification notification = buildNotification(alarmId, alarmTitle); @@ -104,9 +111,7 @@ private void presentAlarm(AlarmContract.ExtractedExtras extras) { AlarmNativeBridge.notifyFired(this, scheduleId, alarmId, alarmTitle); } showAlarmOverlay(alarmTitle); - if (mediaPlayer == null) { - startBundledSpeech(); - } + initTtsAndSpeak(); } catch (RuntimeException exception) { advanceOrStop(); } @@ -131,8 +136,7 @@ public void onDestroy() { destroyed = true; playbackHandler.removeCallbacksAndMessages(null); removeAlarmOverlay(); - releaseMediaPlayer(); - deleteCachedSpeechFile(); + shutdownTts(); NotificationManager manager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); if (manager != null) { @@ -342,84 +346,117 @@ private void removeAlarmOverlay() { 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); + /** + * 初始化 TextToSpeech 并开始播放动态语音。 + * 优先使用中文语音,不可用则回退到系统默认语言。 + */ + private void initTtsAndSpeak() { + if (destroyed) return; + + textToSpeech = new TextToSpeech(this, status -> { + if (destroyed) { + shutdownTts(); + return; + } + + if (status == TextToSpeech.SUCCESS) { + // 优先使用简体中文 + int result = textToSpeech.setLanguage(Locale.SIMPLIFIED_CHINESE); + if (result == TextToSpeech.LANG_MISSING_DATA || result == TextToSpeech.LANG_NOT_SUPPORTED) { + // 中文语音数据不可用,回退到系统默认语言 + textToSpeech.setLanguage(Locale.getDefault()); } + ttsReady = true; + + // 设置音频属性:闹钟用途、语音类型 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + textToSpeech.setAudioAttributes(new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_ALARM) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build()); + } + + textToSpeech.setSpeechRate(TTS_SPEECH_RATE); + textToSpeech.setPitch(TTS_PITCH); + + // 设置播报完成监听 + textToSpeech.setOnUtteranceProgressListener(new UtteranceProgressListener() { + @Override + public void onStart(String utteranceId) { + } + + @Override + public void onDone(String utteranceId) { + if (!destroyed && currentSpeechText != null) { + playbackHandler.postDelayed(replaySpeech, SPEECH_REPEAT_DELAY_MILLIS); + } + } + + @Override + public void onError(String utteranceId) { + // 错误时停止,不重试 + } + }); + + speakCurrentText(); + } else { + Log.w(TAG, "TTS init failed with status: " + status); + shutdownTts(); } - startAudioPlayback(bundledSpeechFile); - } catch (Exception exception) { - releaseMediaPlayer(); - } + }); } - private void startAudioPlayback(File audioFile) { - if (destroyed || mediaPlayer != null || audioFile == null || !audioFile.isFile()) { + /** + * 使用 TTS 播报当前日程的语音文案。 + */ + private void speakCurrentText() { + if (!ttsReady || textToSpeech == null || destroyed || currentSpeechText == null) { 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(); + // 生成唯一 utteranceId 以支持同时播报多条 + currentUtteranceId = "alarm_" + UUID.randomUUID().toString(); + + Bundle params = new Bundle(); + params.putInt(TextToSpeech.Engine.KEY_PARAM_STREAM, android.media.AudioManager.STREAM_ALARM); + + int result = textToSpeech.speak( + currentSpeechText, + TextToSpeech.QUEUE_FLUSH, + params, + currentUtteranceId + ); + + if (result == TextToSpeech.ERROR) { + Log.w(TAG, "TTS speak failed"); + shutdownTts(); } } - private void releaseMediaPlayer() { - playbackHandler.removeCallbacks(replaySpeech); - if (mediaPlayer == null) { + /** + * 延迟后重新播报语音(循环提醒)。 + */ + private void scheduleTtsReplay() { + if (destroyed || currentSpeechText == null) { return; } - mediaPlayer.setOnCompletionListener(null); - mediaPlayer.setOnErrorListener(null); - try { - mediaPlayer.stop(); - } catch (IllegalStateException ignored) { - // 播放器可能已结束或失败。 - } - mediaPlayer.release(); - mediaPlayer = null; + speakCurrentText(); } - private void deleteCachedSpeechFile() { - if (bundledSpeechFile != null) { - bundledSpeechFile.delete(); + /** + * 释放 TTS 资源。 + */ + private void shutdownTts() { + playbackHandler.removeCallbacks(replaySpeech); + if (textToSpeech != null) { + try { + textToSpeech.stop(); + textToSpeech.shutdown(); + } catch (Exception ignored) { + } + textToSpeech = null; } + ttsReady = false; } private void removeFromSavedAlarms() { 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 new file mode 100644 index 00000000..3a261976 --- /dev/null +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/ReminderSpeechFormatter.java @@ -0,0 +1,61 @@ +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/test/java/com/timeflow/alarm/ReminderSpeechFormatterTest.java b/frontend/modules/timeflow-alarm/android/src/test/java/com/timeflow/alarm/ReminderSpeechFormatterTest.java new file mode 100644 index 00000000..baae595e --- /dev/null +++ b/frontend/modules/timeflow-alarm/android/src/test/java/com/timeflow/alarm/ReminderSpeechFormatterTest.java @@ -0,0 +1,65 @@ +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/src/features/reminder/application/LocalReminderApplication.ts b/frontend/src/features/reminder/application/LocalReminderApplication.ts index 29c2b218..1df6989c 100644 --- a/frontend/src/features/reminder/application/LocalReminderApplication.ts +++ b/frontend/src/features/reminder/application/LocalReminderApplication.ts @@ -23,7 +23,7 @@ import type { ReminderTrigger, ReminderTriggerReason, } from '../domain'; -import { DEFAULT_SNOOZE_MINUTES } from '../domain'; +import { DEFAULT_SNOOZE_MINUTES, buildReminderSpeechText } from '../domain'; import { evaluateGeofence, resolveGeofenceCenter, resolveWatchMode } from '../domain/geofence'; import { resolveStrengthDeliveryPlan } from '../domain/strengthDelivery'; import { @@ -356,11 +356,13 @@ 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, + speech_text: toAlarmSpeechText(schedule, scheduledAt), }; }) .filter((request): request is NonNullable => request != null); @@ -498,6 +500,7 @@ export class LocalReminderApplication implements ReminderApplicationPort { trigger_at: snoozedUntil, title: schedule.title, exact: true, + speech_text: toAlarmSpeechText(schedule, snoozedUntil), }); if (!this.isLive(generation)) { if (receipt.scheduled) { @@ -971,11 +974,13 @@ 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, + speech_text: toAlarmSpeechText(schedule, scheduledAt), }); void this.reportPermissionGaps(schedule.id, [ 'exact_alarm', @@ -1056,6 +1061,15 @@ function toTimeReason(schedule: LocalReminderSchedule): ReminderTriggerReason { return schedule.reminder?.reminder_type === 'before_start' ? 'before_start' : 'at_time'; } +function toAlarmSpeechText(schedule: LocalReminderSchedule, scheduledAt: string): string { + return buildReminderSpeechText({ + title: schedule.title, + scheduledAt, + timezone: schedule.timezone, + isAllDay: schedule.is_all_day, + }); +} + function toLocationReason(schedule: LocalReminderSchedule): ReminderTriggerReason { return schedule.reminder?.reminder_type === 'return_to_recorded_location' ? 'return_to_recorded_location' diff --git a/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts b/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts index ceb20d4b..c8db63c6 100644 --- a/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts +++ b/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts @@ -3,6 +3,7 @@ export type AlarmScheduleRequest = { trigger_at: string; title: string; exact: boolean; + speech_text?: string; }; export type AlarmScheduleReceipt = { diff --git a/frontend/src/features/reminder/domain/index.ts b/frontend/src/features/reminder/domain/index.ts index 328cc015..9e2ac336 100644 --- a/frontend/src/features/reminder/domain/index.ts +++ b/frontend/src/features/reminder/domain/index.ts @@ -37,3 +37,5 @@ export { } from './timeWindow'; export type { StrengthDeliveryPlan } from './strengthDelivery'; export { resolveStrengthDeliveryPlan } from './strengthDelivery'; +export type { ReminderSpeechInput } from './reminderSpeech'; +export { buildReminderSpeechText } from './reminderSpeech'; diff --git a/frontend/src/features/reminder/domain/reminderSpeech.ts b/frontend/src/features/reminder/domain/reminderSpeech.ts new file mode 100644 index 00000000..da48968c --- /dev/null +++ b/frontend/src/features/reminder/domain/reminderSpeech.ts @@ -0,0 +1,61 @@ +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/infrastructure/notifications/NativeAlarmScheduler.ts b/frontend/src/infrastructure/notifications/NativeAlarmScheduler.ts index b87d11e4..c2ecbfab 100644 --- a/frontend/src/infrastructure/notifications/NativeAlarmScheduler.ts +++ b/frontend/src/infrastructure/notifications/NativeAlarmScheduler.ts @@ -34,7 +34,12 @@ export class NativeAlarmScheduler implements AlarmSchedulerPort { return unscheduled(request.schedule_id); } - const alarmId = await nativeScheduleAlarm(triggerAtMillis, request.title, request.schedule_id); + const alarmId = await nativeScheduleAlarm( + triggerAtMillis, + request.title, + request.schedule_id, + request.speech_text, + ); if (alarmId == null || alarmId.length === 0) { return unscheduled(request.schedule_id); } diff --git a/frontend/src/infrastructure/notifications/native/TimeflowAlarmBridge.ts b/frontend/src/infrastructure/notifications/native/TimeflowAlarmBridge.ts index e3e66f8c..82820ec1 100644 --- a/frontend/src/infrastructure/notifications/native/TimeflowAlarmBridge.ts +++ b/frontend/src/infrastructure/notifications/native/TimeflowAlarmBridge.ts @@ -34,6 +34,7 @@ type TimeflowAlarmNative = { triggerAtMillis: number, title?: string | null, scheduleId?: string | null, + speechText?: string | null, ) => Promise<{ alarmId: string; scheduleId?: string }>; cancel: (alarmId: string) => Promise; cancelAll: () => Promise; @@ -64,11 +65,17 @@ export async function nativeScheduleAlarm( triggerAtMillis: number, title: string, scheduleId?: string, + speechText?: string, ): Promise { const native = getNativeAlarm(); if (!isTimeflowAlarmAvailable() || native == null) return null; try { - const result = await native.schedule(triggerAtMillis, title, scheduleId ?? ''); + const result = await native.schedule( + triggerAtMillis, + title, + scheduleId ?? '', + speechText ?? '', + ); return result.alarmId; } catch { return null; diff --git a/frontend/tests/unit/infrastructure/notifications/nativeAlarmScheduler.test.ts b/frontend/tests/unit/infrastructure/notifications/nativeAlarmScheduler.test.ts index fc872352..7502ca08 100644 --- a/frontend/tests/unit/infrastructure/notifications/nativeAlarmScheduler.test.ts +++ b/frontend/tests/unit/infrastructure/notifications/nativeAlarmScheduler.test.ts @@ -62,6 +62,7 @@ type NativeAlarmMock = { triggerAtMillis: number, title?: string | null, scheduleId?: string | null, + speechText?: string | null, ) => Promise<{ alarmId: string }> >; cancel: jest.MockedFunction<(alarmId: string) => Promise>; @@ -232,7 +233,24 @@ describe('TimeflowAlarmBridge and NativeAlarmScheduler', () => { schedule_id: 'schedule-1', scheduled: true, }); - expect(native.schedule).toHaveBeenCalledWith(Date.parse(FUTURE), '晨会', 'schedule-1'); + expect(native.schedule).toHaveBeenCalledWith(Date.parse(FUTURE), '晨会', 'schedule-1', ''); + }); + + it('forwards the speech text to the native module', async () => { + const scheduler = new NativeAlarmScheduler(); + await expect(scheduler.schedule(request({ speech_text: '晨会,时间到了。' }))).resolves.toEqual( + { + alarm_id: 'alarm-1', + schedule_id: 'schedule-1', + scheduled: true, + }, + ); + expect(native.schedule).toHaveBeenCalledWith( + Date.parse(FUTURE), + '晨会', + 'schedule-1', + '晨会,时间到了。', + ); }); it('maps a native schedule rejection to unscheduled', async () => { @@ -317,12 +335,13 @@ describe('TimeflowAlarmBridge and NativeAlarmScheduler', () => { ]); expect(native.cancelAll).toHaveBeenCalledTimes(1); expect(native.schedule).toHaveBeenCalledTimes(2); - expect(native.schedule).toHaveBeenNthCalledWith(1, Date.parse(FUTURE), '晨会', 'ok'); + expect(native.schedule).toHaveBeenNthCalledWith(1, Date.parse(FUTURE), '晨会', 'ok', ''); expect(native.schedule).toHaveBeenNthCalledWith( 2, Date.parse('2026-08-13T10:00:00.000Z'), '午会', 'later', + '', ); }); From 1079e8d231d65e305d4aab19fe6ad4ed7a8c9185 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=80=E6=9D=A1=E5=9B=BA=E6=89=A7=E7=9A=84=E9=B1=BC?= <1504947133@qq.com> Date: Fri, 21 Aug 2026 14:57:59 +0800 Subject: [PATCH 2/2] fix(reminder): preserve speech text through alarm handoffs Forward the JS-generated reminder speech through receiver, full-screen activity, service, and snooze rescheduling paths. Add regression coverage for native intent forwarding and the JS formatter fallback branches. --- .../com/timeflow/alarm/AlarmReceiver.java | 4 +- .../com/timeflow/alarm/AlarmScheduler.java | 1 + .../com/timeflow/alarm/AlarmSoundService.java | 16 +++- .../java/com/timeflow/alarm/RingActivity.java | 7 +- .../alarm/AlarmIntentForwardingTest.java | 59 ++++++++++++++ .../reminder/domain/reminderSpeech.test.ts | 81 +++++++++++++++++++ 6 files changed, 162 insertions(+), 6 deletions(-) create mode 100644 frontend/modules/timeflow-alarm/android/src/test/java/com/timeflow/alarm/AlarmIntentForwardingTest.java create mode 100644 frontend/tests/unit/features/reminder/domain/reminderSpeech.test.ts diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmReceiver.java b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmReceiver.java index 81391669..591bf8ba 100644 --- a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmReceiver.java +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmReceiver.java @@ -16,6 +16,7 @@ public void onReceive(Context context, Intent intent) { String alarmId = intent.getStringExtra(AlarmContract.EXTRA_ALARM_ID); String scheduleId = intent.getStringExtra(AlarmContract.EXTRA_SCHEDULE_ID); String title = intent.getStringExtra(AlarmContract.EXTRA_TITLE); + String speechText = intent.getStringExtra(AlarmContract.EXTRA_SPEECH_TEXT); if (alarmId == null || alarmId.isEmpty()) { alarmId = "legacy-" + requestCode; } @@ -26,7 +27,8 @@ public void onReceive(Context context, Intent intent) { .putExtra(AlarmContract.EXTRA_ALARM_ID, alarmId) .putExtra(AlarmContract.EXTRA_SCHEDULE_ID, scheduleId) .putExtra(AlarmContract.EXTRA_REQUEST_CODE, requestCode) - .putExtra(AlarmContract.EXTRA_TITLE, title); + .putExtra(AlarmContract.EXTRA_TITLE, title) + .putExtra(AlarmContract.EXTRA_SPEECH_TEXT, speechText); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { context.startForegroundService(serviceIntent); } else { diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmScheduler.java b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmScheduler.java index 3cdc2b1c..57a7de1b 100644 --- a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmScheduler.java +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmScheduler.java @@ -154,6 +154,7 @@ private static void rearm(Context context, AlarmManager alarmManager, AlarmRecor .putExtra(AlarmContract.EXTRA_ALARM_ID, record.alarmId) .putExtra(AlarmContract.EXTRA_SCHEDULE_ID, record.scheduleId) .putExtra(AlarmContract.EXTRA_TITLE, record.title) + .putExtra(AlarmContract.EXTRA_SPEECH_TEXT, record.speechText) .putExtra(AlarmContract.EXTRA_REQUEST_CODE, record.requestCode) .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); PendingIntent showPendingIntent = PendingIntent.getActivity( diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmSoundService.java b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmSoundService.java index 3f77853b..4b3fa912 100644 --- a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmSoundService.java +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmSoundService.java @@ -159,13 +159,15 @@ static void start( String alarmId, String scheduleId, int requestCode, - String title + String title, + String speechText ) { 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); + .putExtra(AlarmContract.EXTRA_TITLE, title) + .putExtra(AlarmContract.EXTRA_SPEECH_TEXT, speechText); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { context.startForegroundService(intent); } else { @@ -180,6 +182,7 @@ private Notification buildNotification(String alarmId, String title) { .putExtra(AlarmContract.EXTRA_SCHEDULE_ID, scheduleId) .putExtra(AlarmContract.EXTRA_REQUEST_CODE, requestCode) .putExtra(AlarmContract.EXTRA_TITLE, title) + .putExtra(AlarmContract.EXTRA_SPEECH_TEXT, currentSpeechText) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_MULTIPLE_TASK | Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS); @@ -265,6 +268,7 @@ private void showAlarmOverlay(String title) { String targetAlarmId = alarmId; String targetScheduleId = scheduleId; String targetTitle = title; + String targetSpeechText = currentSpeechText; View content = AlarmRingUi.build( this, @@ -273,7 +277,13 @@ private void showAlarmOverlay(String title) { long triggerAt = System.currentTimeMillis() + AlarmContract.SNOOZE_MINUTES * 60_000L; try { - AlarmScheduler.schedule(this, triggerAt, targetTitle, targetScheduleId); + AlarmScheduler.schedule( + this, + triggerAt, + targetTitle, + targetScheduleId, + targetSpeechText + ); } catch (RuntimeException ignored) { // ignore } 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 a32cade2..10a6e825 100644 --- a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/RingActivity.java +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/RingActivity.java @@ -22,6 +22,7 @@ public final class RingActivity extends Activity { private String alarmId; private String scheduleId; private String alarmTitle; + private String speechText; private int requestCode; private boolean dismissNotified; @@ -34,6 +35,7 @@ protected void onCreate(Bundle savedInstanceState) { alarmId = extras.alarmId; scheduleId = extras.scheduleId; alarmTitle = extras.title; + speechText = extras.speechText; makeVisibleOverLockScreen(); matchSystemBarsToReminder(); setContentView(buildContentView()); @@ -43,7 +45,8 @@ protected void onCreate(Bundle savedInstanceState) { alarmId, scheduleId, requestCode, - alarmTitle + alarmTitle, + speechText ); } @@ -128,7 +131,7 @@ private void snoozeAndClose() { long triggerAt = System.currentTimeMillis() + AlarmContract.SNOOZE_MINUTES * 60_000L; try { - AlarmScheduler.schedule(this, triggerAt, alarmTitle, scheduleId); + AlarmScheduler.schedule(this, triggerAt, alarmTitle, scheduleId, 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 new file mode 100644 index 00000000..88b0701b --- /dev/null +++ b/frontend/modules/timeflow-alarm/android/src/test/java/com/timeflow/alarm/AlarmIntentForwardingTest.java @@ -0,0 +1,59 @@ +package com.timeflow.alarm; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import android.content.Context; +import android.content.Intent; +import android.os.Build; + +import androidx.test.core.app.ApplicationProvider; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.shadows.ShadowApplication; + +/** 闹钟触发的各个 Android Intent handoff 都必须保留 JS 生成的播报文案。 */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +public class AlarmIntentForwardingTest { + + private Context context; + + @Before + public void setUp() { + context = ApplicationProvider.getApplicationContext(); + } + + @Test + public void receiverForwardsSpeechTextToSoundService() { + String speechText = "晨会,时间到了。现在已经09点了。"; + 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); + + new AlarmReceiver().onReceive(context, incoming); + + Intent started = ShadowApplication.getInstance().getNextStartedService(); + assertNotNull(started); + assertEquals(AlarmSoundService.class.getName(), started.getComponent().getClassName()); + assertEquals(speechText, started.getStringExtra(AlarmContract.EXTRA_SPEECH_TEXT)); + } + + @Test + public void activityServiceStartForwardsSpeechText() { + String speechText = "提交报告,时间到了。"; + + AlarmSoundService.start(context, "alarm-2", "schedule-2", 202, "提交报告", speechText); + + Intent started = ShadowApplication.getInstance().getNextStartedService(); + assertNotNull(started); + assertEquals(speechText, started.getStringExtra(AlarmContract.EXTRA_SPEECH_TEXT)); + } +} diff --git a/frontend/tests/unit/features/reminder/domain/reminderSpeech.test.ts b/frontend/tests/unit/features/reminder/domain/reminderSpeech.test.ts new file mode 100644 index 00000000..2ec86f2e --- /dev/null +++ b/frontend/tests/unit/features/reminder/domain/reminderSpeech.test.ts @@ -0,0 +1,81 @@ +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('未命名日程,时间到了,请及时处理。'); + }); +});