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 bf51be89..8cb07915 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"; /** 响铃要不要震动/出声/弹全屏止铃界面;由 JS 侧按提醒强度换算后传入,缺省一律按 true 处理。 */ static final String EXTRA_VIBRATE = "vibrate"; static final String EXTRA_SOUND = "sound"; @@ -45,6 +46,7 @@ static final class ExtractedExtras { final String alarmId; final String scheduleId; final String title; + final String speechText; final int requestCode; final boolean vibrate; final boolean sound; @@ -54,6 +56,7 @@ private ExtractedExtras( String alarmId, String scheduleId, String title, + String speechText, int requestCode, boolean vibrate, boolean sound, @@ -62,6 +65,7 @@ private ExtractedExtras( this.alarmId = alarmId; this.scheduleId = scheduleId; this.title = title; + this.speechText = speechText; this.requestCode = requestCode; this.vibrate = vibrate; this.sound = sound; @@ -73,6 +77,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); // 缺省按 true:兼容没有带这几个 extra 的旧闹钟/测试 Intent,保留改动前的全响铃行为。 boolean vibrate = intent == null || intent.getBooleanExtra(EXTRA_VIBRATE, true); boolean sound = intent == null || intent.getBooleanExtra(EXTRA_SOUND, true); @@ -86,8 +91,18 @@ static ExtractedExtras from(Context context, Intent intent) { if (title == null || title.isEmpty()) { title = "日程提醒"; } + if (speechText == null || speechText.isEmpty()) { + speechText = ReminderSpeechFormatter.format(title, System.currentTimeMillis()); + } return new ExtractedExtras( - alarmId, scheduleId, title, requestCode, vibrate, sound, fullScreen + alarmId, + scheduleId, + title, + speechText, + requestCode, + vibrate, + sound, + fullScreen ); } } diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmModule.kt b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmModule.kt index 4dc263c3..ac53bf57 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 @@ -50,6 +50,7 @@ class AlarmModule(private val reactContext: ReactApplicationContext) : vibrate: Boolean, sound: Boolean, fullScreen: Boolean, + speechText: String?, promise: Promise, ) { try { @@ -66,6 +67,7 @@ class AlarmModule(private val reactContext: ReactApplicationContext) : vibrate, sound, fullScreen, + 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/AlarmReceiver.java b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmReceiver.java index 5fbfa1fd..f5289454 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 @@ -23,6 +23,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; } @@ -34,6 +35,7 @@ public void onReceive(Context context, Intent intent) { .putExtra(AlarmContract.EXTRA_SCHEDULE_ID, scheduleId) .putExtra(AlarmContract.EXTRA_REQUEST_CODE, requestCode) .putExtra(AlarmContract.EXTRA_TITLE, title) + .putExtra(AlarmContract.EXTRA_SPEECH_TEXT, speechText) .putExtra( AlarmContract.EXTRA_VIBRATE, intent.getBooleanExtra(AlarmContract.EXTRA_VIBRATE, true) 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 69cffbf6..26b63c44 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; public final boolean vibrate; public final boolean sound; @@ -40,6 +41,7 @@ public static final class AlarmRecord { long triggerAtMillis, int requestCode, String title, + String speechText, boolean legacy, boolean vibrate, boolean sound, @@ -50,6 +52,7 @@ public static final class AlarmRecord { this.triggerAtMillis = triggerAtMillis; this.requestCode = requestCode; this.title = title; + this.speechText = speechText == null ? "" : speechText; this.legacy = legacy; this.vibrate = vibrate; this.sound = sound; @@ -64,7 +67,8 @@ public static String schedule( String scheduleId, boolean vibrate, boolean sound, - boolean fullScreen + boolean fullScreen, + String speechText ) { if (triggerAtMillis <= System.currentTimeMillis()) { throw new IllegalArgumentException("trigger_in_past"); @@ -92,6 +96,7 @@ public static String schedule( triggerAtMillis, nextRequestCode(alarms), title == null ? "" : title, + speechText == null ? "" : speechText, false, vibrate, sound, @@ -105,18 +110,31 @@ public static String schedule( return alarmId; } - /** @deprecated 请改用 {@link #schedule(Context, long, String, String, boolean, boolean, boolean)}。 */ + /** 保留原 TTS 调度签名;未指定强度通道时沿用历史的全响铃行为。 */ + public static String schedule( + Context context, + long triggerAtMillis, + String title, + String scheduleId, + String speechText + ) { + return schedule( + context, triggerAtMillis, title, scheduleId, true, true, true, speechText + ); + } + + /** @deprecated 请改用包含响铃通道和 speechText 的完整签名。 */ @Deprecated public static String schedule( Context context, long triggerAtMillis, String title, String scheduleId ) { - return schedule(context, triggerAtMillis, title, scheduleId, true, true, true); + return schedule(context, triggerAtMillis, title, scheduleId, true, true, true, ""); } - /** @deprecated 请改用 {@link #schedule(Context, long, String, String, boolean, boolean, boolean)}。 */ + /** @deprecated 请改用包含响铃通道和 speechText 的完整签名。 */ @Deprecated public static String schedule(Context context, long triggerAtMillis, String title) { - return schedule(context, triggerAtMillis, title, "", true, true, true); + return schedule(context, triggerAtMillis, title, "", true, true, true, ""); } /** @@ -166,6 +184,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) .putExtra(AlarmContract.EXTRA_VIBRATE, record.vibrate) .putExtra(AlarmContract.EXTRA_SOUND, record.sound) @@ -259,6 +278,7 @@ public static List loadAlarms(Context context) { triggerAt, requestCode, object.optString("title", ""), + object.optString("speech_text", ""), legacy, object.optBoolean("vibrate", true), object.optBoolean("sound", true), @@ -353,6 +373,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); object.put("vibrate", alarm.vibrate); object.put("sound", alarm.sound); @@ -371,6 +392,7 @@ private static PendingIntent buildAlarmBroadcastPendingIntent( .putExtra(AlarmContract.EXTRA_SCHEDULE_ID, record.scheduleId) .putExtra(AlarmContract.EXTRA_REQUEST_CODE, record.requestCode) .putExtra(AlarmContract.EXTRA_TITLE, record.title) + .putExtra(AlarmContract.EXTRA_SPEECH_TEXT, record.speechText) .putExtra(AlarmContract.EXTRA_VIBRATE, record.vibrate) .putExtra(AlarmContract.EXTRA_SOUND, record.sound) .putExtra(AlarmContract.EXTRA_FULL_SCREEN, record.fullScreen); 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 fb1ffc91..a10573c7 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,9 +11,9 @@ 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; @@ -21,28 +21,34 @@ import android.os.Vibrator; import android.os.VibratorManager; import android.provider.Settings; +import android.speech.tts.TextToSpeech; +import android.speech.tts.UtteranceProgressListener; import android.util.Log; import android.view.Gravity; import android.view.View; import android.view.WindowManager; -import java.io.File; -import java.io.FileOutputStream; -import java.io.InputStream; import java.util.ArrayDeque; import java.util.HashSet; +import java.util.Locale; import java.util.Set; +import java.util.UUID; public final class AlarmSoundService extends Service { private static final String TAG = "AlarmSoundService"; private static final long SPEECH_REPEAT_DELAY_MILLIS = 1_500L; + private static final float TTS_SPEECH_RATE = 0.9f; + private static final float TTS_PITCH = 1.0f; private final Handler playbackHandler = new Handler(Looper.getMainLooper()); - private final Runnable replaySpeech = this::replaySpeech; + private final Runnable replaySpeech = this::scheduleTtsReplay; - private MediaPlayer mediaPlayer; + private TextToSpeech textToSpeech; + private boolean ttsReady; + private int ttsGeneration; private boolean destroyed; - private File bundledSpeechFile; + private String currentSpeechText; + private String currentUtteranceId; private WindowManager overlayWindowManager; /** 包内可见(而非 private):AlarmSoundServiceTest 需要直接读取当前展示/排队状态。 */ View overlayView; @@ -103,6 +109,7 @@ private void presentAlarm(AlarmContract.ExtractedExtras extras) { alarmId = extras.alarmId; scheduleId = extras.scheduleId; alarmTitle = extras.title; + currentSpeechText = extras.speechText; vibrateEnabled = extras.vibrate; soundEnabled = extras.sound; fullScreenEnabled = extras.fullScreen; @@ -123,8 +130,9 @@ private void presentAlarm(AlarmContract.ExtractedExtras extras) { if (firedNotifiedAlarmIds.add(alarmId)) { AlarmNativeBridge.notifyFired(this, scheduleId, alarmId, alarmTitle); } - // 换成下一条闹钟之前先清掉上一条的震动状态,避免 vibrate=false 的这条 - // 顶上来时,还在响上一条闹钟启动的那次震动。 + // 换成下一条闹钟前先清掉上一条的声音/震动状态,避免静音或不震动的 + // 新闹钟继续沿用前一条已经启动的系统资源。 + shutdownTts(); stopVibration(); if (vibrateEnabled) { startVibration(); @@ -132,8 +140,8 @@ private void presentAlarm(AlarmContract.ExtractedExtras extras) { if (fullScreenEnabled) { showAlarmOverlay(alarmTitle); } - if (soundEnabled && mediaPlayer == null) { - startBundledSpeech(); + if (soundEnabled) { + initTtsAndSpeak(); } } catch (RuntimeException exception) { // 这里原来完全静默——startForeground() 在部分厂商 ROM/系统版本上会因为 @@ -165,9 +173,8 @@ public void onDestroy() { destroyed = true; playbackHandler.removeCallbacksAndMessages(null); removeAlarmOverlay(); - releaseMediaPlayer(); + shutdownTts(); stopVibration(); - deleteCachedSpeechFile(); NotificationManager manager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); if (manager != null) { @@ -193,7 +200,8 @@ static void start( String title, boolean vibrate, boolean sound, - boolean fullScreen + boolean fullScreen, + String speechText ) { Intent intent = new Intent(context, AlarmSoundService.class) .putExtra(AlarmContract.EXTRA_ALARM_ID, alarmId) @@ -202,7 +210,8 @@ static void start( .putExtra(AlarmContract.EXTRA_TITLE, title) .putExtra(AlarmContract.EXTRA_VIBRATE, vibrate) .putExtra(AlarmContract.EXTRA_SOUND, sound) - .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 { @@ -217,6 +226,7 @@ private Notification buildNotification(String alarmId, String title, boolean ful .putExtra(AlarmContract.EXTRA_SCHEDULE_ID, scheduleId) .putExtra(AlarmContract.EXTRA_REQUEST_CODE, requestCode) .putExtra(AlarmContract.EXTRA_TITLE, title) + .putExtra(AlarmContract.EXTRA_SPEECH_TEXT, currentSpeechText) .putExtra(AlarmContract.EXTRA_VIBRATE, vibrateEnabled) .putExtra(AlarmContract.EXTRA_SOUND, soundEnabled) .putExtra(AlarmContract.EXTRA_FULL_SCREEN, fullScreen) @@ -316,6 +326,7 @@ private void showAlarmOverlay(String title) { String targetAlarmId = alarmId; String targetScheduleId = scheduleId; String targetTitle = title; + String targetSpeechText = currentSpeechText; boolean targetVibrate = vibrateEnabled; boolean targetSound = soundEnabled; boolean targetFullScreen = fullScreenEnabled; @@ -334,7 +345,8 @@ private void showAlarmOverlay(String title) { targetScheduleId, targetVibrate, targetSound, - targetFullScreen + targetFullScreen, + targetSpeechText ); } catch (RuntimeException ignored) { // ignore @@ -409,83 +421,104 @@ private void removeAlarmOverlay() { overlayWindowManager = null; } - private void startBundledSpeech() { - if (destroyed || mediaPlayer != null) { + /** 初始化系统 TTS,优先使用简体中文,不可用时回退到系统默认语言。 */ + private void initTtsAndSpeak() { + if (destroyed || !soundEnabled || currentSpeechText == 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); - } + + int generation = ++ttsGeneration; + textToSpeech = new TextToSpeech(this, status -> { + if (destroyed || generation != ttsGeneration || textToSpeech == null) { + return; + } + if (status != TextToSpeech.SUCCESS) { + Log.w(TAG, "TTS init failed with status: " + status); + shutdownTts(); + return; } - startAudioPlayback(bundledSpeechFile); - } catch (Exception exception) { - releaseMediaPlayer(); - } - } - private void startAudioPlayback(File audioFile) { - if (destroyed || mediaPlayer != null || audioFile == null || !audioFile.isFile()) { - return; - } - try { - MediaPlayer player = new MediaPlayer(); - player.setAudioAttributes(new AudioAttributes.Builder() + int languageResult = textToSpeech.setLanguage(Locale.SIMPLIFIED_CHINESE); + if (languageResult == TextToSpeech.LANG_MISSING_DATA + || languageResult == TextToSpeech.LANG_NOT_SUPPORTED) { + textToSpeech.setLanguage(Locale.getDefault()); + } + textToSpeech.setAudioAttributes(new AudioAttributes.Builder() .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; + textToSpeech.setSpeechRate(TTS_SPEECH_RATE); + textToSpeech.setPitch(TTS_PITCH); + textToSpeech.setOnUtteranceProgressListener(new UtteranceProgressListener() { + @Override + public void onStart(String utteranceId) { + } + + @Override + public void onDone(String utteranceId) { + if (!destroyed + && soundEnabled + && generation == ttsGeneration + && utteranceId.equals(currentUtteranceId)) { + playbackHandler.postDelayed(replaySpeech, SPEECH_REPEAT_DELAY_MILLIS); + } + } + + @Override + public void onError(String utteranceId) { + // 当前轮播报失败时停止循环,等待用户处理该提醒。 + } }); - player.prepare(); - mediaPlayer = player; - player.start(); - } catch (Exception exception) { - releaseMediaPlayer(); - } + ttsReady = true; + speakCurrentText(); + }); } - private void replaySpeech() { - if (destroyed || mediaPlayer == null) { + private void speakCurrentText() { + if (!ttsReady + || textToSpeech == null + || destroyed + || !soundEnabled + || currentSpeechText == null) { return; } - try { - mediaPlayer.seekTo(0); - mediaPlayer.start(); - } catch (IllegalStateException ignored) { - releaseMediaPlayer(); + + currentUtteranceId = "alarm_" + UUID.randomUUID(); + Bundle params = new Bundle(); + params.putInt(TextToSpeech.Engine.KEY_PARAM_STREAM, android.media.AudioManager.STREAM_ALARM); + int result = textToSpeech.speak( + currentSpeechText, + TextToSpeech.QUEUE_FLUSH, + params, + currentUtteranceId + ); + if (result == TextToSpeech.ERROR) { + Log.w(TAG, "TTS speak failed"); + shutdownTts(); } } - private void releaseMediaPlayer() { + private void scheduleTtsReplay() { + if (!destroyed && soundEnabled && currentSpeechText != null) { + speakCurrentText(); + } + } + + private void shutdownTts() { + ttsGeneration += 1; playbackHandler.removeCallbacks(replaySpeech); - if (mediaPlayer == null) { + TextToSpeech tts = textToSpeech; + textToSpeech = null; + ttsReady = false; + currentUtteranceId = null; + if (tts == null) { return; } - mediaPlayer.setOnCompletionListener(null); - mediaPlayer.setOnErrorListener(null); try { - mediaPlayer.stop(); - } catch (IllegalStateException ignored) { - // 播放器可能已结束或失败。 - } - mediaPlayer.release(); - mediaPlayer = null; - } - - private void deleteCachedSpeechFile() { - if (bundledSpeechFile != null) { - bundledSpeechFile.delete(); + tts.stop(); + tts.shutdown(); + } catch (RuntimeException ignored) { + // 系统 TTS 进程可能已退出;停铃仍继续释放其余资源。 } } 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/main/java/com/timeflow/alarm/RingActivity.java b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/RingActivity.java index e7b1f635..e4adcf3c 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 vibrate; private boolean sound; @@ -37,6 +38,7 @@ protected void onCreate(Bundle savedInstanceState) { alarmId = extras.alarmId; scheduleId = extras.scheduleId; alarmTitle = extras.title; + speechText = extras.speechText; vibrate = extras.vibrate; sound = extras.sound; fullScreen = extras.fullScreen; @@ -52,7 +54,8 @@ protected void onCreate(Bundle savedInstanceState) { alarmTitle, vibrate, sound, - fullScreen + fullScreen, + speechText ); } @@ -138,7 +141,14 @@ private void snoozeAndClose() { + AlarmContract.SNOOZE_MINUTES * 60_000L; try { AlarmScheduler.schedule( - this, triggerAt, alarmTitle, scheduleId, vibrate, sound, fullScreen + this, + triggerAt, + alarmTitle, + scheduleId, + vibrate, + sound, + 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 new file mode 100644 index 00000000..e8c9b285 --- /dev/null +++ b/frontend/modules/timeflow-alarm/android/src/test/java/com/timeflow/alarm/AlarmIntentForwardingTest.java @@ -0,0 +1,80 @@ +package com.timeflow.alarm; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import android.content.Context; +import android.content.Intent; +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) + .putExtra(AlarmContract.EXTRA_VIBRATE, false) + .putExtra(AlarmContract.EXTRA_SOUND, true) + .putExtra(AlarmContract.EXTRA_FULL_SCREEN, false); + + 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)); + assertFalse(started.getBooleanExtra(AlarmContract.EXTRA_VIBRATE, true)); + assertTrue(started.getBooleanExtra(AlarmContract.EXTRA_SOUND, false)); + assertFalse(started.getBooleanExtra(AlarmContract.EXTRA_FULL_SCREEN, true)); + } + + @Test + public void activityServiceStartForwardsSpeechText() { + String speechText = "提交报告,时间到了。"; + + AlarmSoundService.start( + context, + "alarm-2", + "schedule-2", + 202, + "提交报告", + true, + false, + true, + speechText + ); + + Intent started = ShadowApplication.getInstance().getNextStartedService(); + assertNotNull(started); + assertEquals(speechText, started.getStringExtra(AlarmContract.EXTRA_SPEECH_TEXT)); + assertTrue(started.getBooleanExtra(AlarmContract.EXTRA_VIBRATE, false)); + assertFalse(started.getBooleanExtra(AlarmContract.EXTRA_SOUND, true)); + assertTrue(started.getBooleanExtra(AlarmContract.EXTRA_FULL_SCREEN, false)); + } +} 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..2af33bdd 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),也不覆盖系统 TTS 播放(跟系统资源强绑定,presentAlarm() * 本身已经有 try/catch 兜底)。 * * pin 住 API 34:这个模块 compileSdk 是 35,但 API 36 常量已经在生产代码里被移除 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 26d7919b..124bfa07 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,12 +356,14 @@ 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); @@ -500,6 +502,7 @@ export class LocalReminderApplication implements ReminderApplicationPort { title: schedule.title, exact: true, ...alarmRingChannels(schedule), + speech_text: toAlarmSpeechText(schedule, snoozedUntil), }); if (!this.isLive(generation)) { if (receipt.scheduled) { @@ -973,12 +976,14 @@ 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', @@ -1069,6 +1074,15 @@ function alarmRingChannels(schedule: LocalReminderSchedule): { return { vibrate: plan.useVibration, sound: plan.useAudio, full_screen: true }; } +function toAlarmSpeechText(schedule: LocalReminderSchedule, scheduledAt: string): string { + return buildReminderSpeechText({ + title: schedule.title, + scheduledAt, + timezone: schedule.timezone, + isAllDay: schedule.is_all_day, + }); +} + function toTimeReason(schedule: LocalReminderSchedule): ReminderTriggerReason { return schedule.reminder?.reminder_type === 'before_start' ? 'before_start' : 'at_time'; } diff --git a/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts b/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts index 50846d6c..48391875 100644 --- a/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts +++ b/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts @@ -7,6 +7,8 @@ export type AlarmScheduleRequest = { vibrate: boolean; sound: boolean; full_screen: boolean; + /** JS 生成的日程播报文案;原生侧仅负责按闹钟声道朗读。 */ + 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 1be0c3eb..1faff116 100644 --- a/frontend/src/infrastructure/notifications/NativeAlarmScheduler.ts +++ b/frontend/src/infrastructure/notifications/NativeAlarmScheduler.ts @@ -41,6 +41,7 @@ export class NativeAlarmScheduler implements AlarmSchedulerPort { request.vibrate, request.sound, request.full_screen, + 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 186d7ea5..d84a5b56 100644 --- a/frontend/src/infrastructure/notifications/native/TimeflowAlarmBridge.ts +++ b/frontend/src/infrastructure/notifications/native/TimeflowAlarmBridge.ts @@ -37,6 +37,7 @@ type TimeflowAlarmNative = { vibrate?: boolean, sound?: boolean, fullScreen?: boolean, + speechText?: string | null, ) => Promise<{ alarmId: string; scheduleId?: string }>; cancel: (alarmId: string) => Promise; cancelAll: () => Promise; @@ -70,6 +71,7 @@ export async function nativeScheduleAlarm( vibrate?: boolean, sound?: boolean, fullScreen?: boolean, + speechText?: string, ): Promise { const native = getNativeAlarm(); if (!isTimeflowAlarmAvailable() || native == null) return null; @@ -81,6 +83,7 @@ export async function nativeScheduleAlarm( vibrate ?? true, sound ?? true, fullScreen ?? true, + speechText ?? '', ); return result.alarmId; } catch (error) { diff --git a/frontend/tests/unit/features/reminder/application/LocalReminderApplication.test.ts b/frontend/tests/unit/features/reminder/application/LocalReminderApplication.test.ts index bfbf0cc4..8732f571 100644 --- a/frontend/tests/unit/features/reminder/application/LocalReminderApplication.test.ts +++ b/frontend/tests/unit/features/reminder/application/LocalReminderApplication.test.ts @@ -766,7 +766,10 @@ describe('LocalReminderApplication', () => { expect(registration.alarm_id).not.toBeNull(); expect(scheduleCalls).toHaveLength(1); - expect(scheduleCalls[0]).toMatchObject(expected); + expect(scheduleCalls[0]).toMatchObject({ + ...expected, + speech_text: '喝水提醒,时间到了。现在已经18点了。', + }); }); }); 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('未命名日程,时间到了,请及时处理。'); + }); +}); diff --git a/frontend/tests/unit/infrastructure/notifications/nativeAlarmScheduler.test.ts b/frontend/tests/unit/infrastructure/notifications/nativeAlarmScheduler.test.ts index 57426336..dcc37a2e 100644 --- a/frontend/tests/unit/infrastructure/notifications/nativeAlarmScheduler.test.ts +++ b/frontend/tests/unit/infrastructure/notifications/nativeAlarmScheduler.test.ts @@ -65,6 +65,7 @@ type NativeAlarmMock = { vibrate?: boolean, sound?: boolean, fullScreen?: boolean, + speechText?: string | null, ) => Promise<{ alarmId: string }> >; cancel: jest.MockedFunction<(alarmId: string) => Promise>; @@ -245,12 +246,20 @@ describe('TimeflowAlarmBridge and NativeAlarmScheduler', () => { true, true, true, + '', ); }); - it('forwards vibrate/sound/full_screen through to the native bridge', async () => { + it('forwards ring channels and speech text through to the native bridge', async () => { const scheduler = new NativeAlarmScheduler(); - await scheduler.schedule(request({ vibrate: false, sound: false, full_screen: false })); + await scheduler.schedule( + request({ + vibrate: false, + sound: false, + full_screen: false, + speech_text: '晨会,时间到了。', + }), + ); expect(native.schedule).toHaveBeenCalledWith( Date.parse(FUTURE), '晨会', @@ -258,6 +267,7 @@ describe('TimeflowAlarmBridge and NativeAlarmScheduler', () => { false, false, false, + '晨会,时间到了。', ); }); @@ -351,6 +361,7 @@ describe('TimeflowAlarmBridge and NativeAlarmScheduler', () => { true, true, true, + '', ); expect(native.schedule).toHaveBeenNthCalledWith( 2, @@ -360,6 +371,7 @@ describe('TimeflowAlarmBridge and NativeAlarmScheduler', () => { true, true, true, + '', ); });