Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion frontend/app.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,6 @@ module.exports = {
defaultChannel: 'timeflow-reminders',
},
],
'./plugins/withTimeflowAlarm',
],
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>

<!-- targetSdk 30+ 的包可见性限制:不声明这条,TextToSpeech 内部查询"设备上有哪些
TTS 引擎"会查到空列表,new TextToSpeech(...) 稳定初始化失败(status=ERROR),
即使引擎确实装着、系统设置里试听正常也一样——真机 adb logcat 实测确认过。
官方文档明确要求声明这条:
https://developer.android.com/reference/android/speech/tts/TextToSpeech.Engine#INTENT_ACTION_TTS_SERVICE -->
<queries>
<intent>
<action android:name="android.intent.action.TTS_SERVICE"/>
</intent>
</queries>

<application>
<activity
android:name="com.timeflow.alarm.RingActivity"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ 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 侧按提醒强度换算后传入。 */
static final String EXTRA_VIBRATE = "vibrate";
static final String EXTRA_SOUND_TIER = "sound_tier";
static final String EXTRA_FULL_SCREEN = "full_screen";
/** 高强度时 JS 传的设备 TTS 文案;空/缺省则原生回退打包铃。 */
static final String EXTRA_SPEECH_TEXT = "speech_text";
/** 声音档位取值:不出声 / 一次性短提示音 / 循环语音直到用户处理。 */
static final String SOUND_TIER_NONE = "none";
static final String SOUND_TIER_PING = "ping";
Expand Down Expand Up @@ -129,9 +130,6 @@ 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, speechText, requestCode, vibrate, soundTier, fullScreen
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ class AlarmModule(private val reactContext: ReactApplicationContext) :

init {
reactContextRef = WeakReference(reactContext)
// 抢在这一刻(React Native 加载原生模块,几乎总是应用正常启动、前台)就绑定 TTS
// 引擎,而不是拖到 AlarmSoundService 真的要响铃、前台状态已经不可控的那一刻——
// 详见 AlarmTtsEngine 顶部注释里追出来的真机故障场景。
AlarmTtsEngine.ensureInitialized(reactContext)
}

override fun getName(): String = NAME
Expand Down Expand Up @@ -160,6 +164,7 @@ class AlarmModule(private val reactContext: ReactApplicationContext) :
vibrate: Boolean,
soundTier: String?,
fullScreen: Boolean,
speechText: String?,
promise: Promise,
) {
val resolvedAlarmId = alarmId?.takeIf { it.isNotEmpty() } ?: "geofence-${System.currentTimeMillis()}"
Expand All @@ -182,6 +187,7 @@ class AlarmModule(private val reactContext: ReactApplicationContext) :
vibrate,
soundTier ?: AlarmContract.SOUND_TIER_FULL,
fullScreen,
speechText ?: "",
)
Log.i(NAME, "presentNow requested alarmId=$resolvedAlarmId scheduleId=$resolvedScheduleId")
} catch (error: Exception) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,20 +111,6 @@ public static String schedule(
return alarmId;
}

/** 保留原 TTS 调度签名;未指定强度通道时沿用历史的全响铃行为。 */
public static String schedule(
Context context,
long triggerAtMillis,
String title,
String scheduleId,
String speechText
) {
return schedule(
context, triggerAtMillis, title, scheduleId, true,
AlarmContract.SOUND_TIER_FULL, true, speechText
);
}

/** @deprecated 请改用 {@link #schedule(Context, long, String, String, boolean, String, boolean, String)}。 */
@Deprecated
public static String schedule(
Expand Down Expand Up @@ -391,6 +377,7 @@ private static JSONObject toJson(AlarmRecord alarm) throws JSONException {
object.put("vibrate", alarm.vibrate);
object.put("sound_tier", alarm.soundTier);
object.put("full_screen", alarm.fullScreen);
object.put("speech_text", alarm.speechText);
return object;
}

Expand All @@ -408,7 +395,8 @@ private static PendingIntent buildAlarmBroadcastPendingIntent(
.putExtra(AlarmContract.EXTRA_SPEECH_TEXT, record.speechText)
.putExtra(AlarmContract.EXTRA_VIBRATE, record.vibrate)
.putExtra(AlarmContract.EXTRA_SOUND_TIER, record.soundTier)
.putExtra(AlarmContract.EXTRA_FULL_SCREEN, record.fullScreen);
.putExtra(AlarmContract.EXTRA_FULL_SCREEN, record.fullScreen)
.putExtra(AlarmContract.EXTRA_SPEECH_TEXT, record.speechText);
if (!record.legacy) {
intent.setData(alarmUri(record.alarmId));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
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;
Expand All @@ -40,6 +42,7 @@ public final class AlarmSoundService extends Service {

private final Handler playbackHandler = new Handler(Looper.getMainLooper());
private final Runnable replaySpeech = this::replaySpeech;
private final Runnable replayTts = this::replayTts;

private MediaPlayer mediaPlayer;
private Ringtone pingRingtone;
Expand All @@ -55,6 +58,8 @@ public final class AlarmSoundService extends Service {
private String soundTier;
private boolean fullScreenEnabled;
private int requestCode;
/** 当前这条闹钟的设备 TTS 文案;非 high 或未传入时为 null/空,走打包铃。 */
private String speechText;
/** 已经通知过 JS "fired" 的 alarmId 集合;service 实例可能被多个不同闹钟复用。 */
final Set<String> firedNotifiedAlarmIds = new HashSet<>();
/**
Expand All @@ -66,6 +71,12 @@ public final class AlarmSoundService extends Service {
*/
final ArrayDeque<AlarmContract.ExtractedExtras> 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);
Expand Down Expand Up @@ -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);
Expand All @@ -164,14 +176,26 @@ private void presentAlarm(AlarmContract.ExtractedExtras extras) {
// 那次播放/震动残留到这条本该更安静的闹钟上。
stopVibration();
stopPing();
releaseMediaPlayer();
stopSpeaking();
if (vibrateEnabled) {
startVibration();
}
if (fullScreenEnabled) {
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();
}
Expand Down Expand Up @@ -209,6 +233,10 @@ public void onDestroy() {
playbackHandler.removeCallbacksAndMessages(null);
removeAlarmOverlay();
releaseMediaPlayer();
// 只停这个 Service 实例正在念的这一句、摘掉它挂上去的监听器——引擎是全局单例,
// 不在这里 shutdown():下一条闹钟(甚至冷启动重新拉起这个 Service)还要复用。
AlarmTtsEngine.stop();
AlarmTtsEngine.setUtteranceListener(null);
stopVibration();
stopPing();
deleteCachedSpeechFile();
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -361,7 +393,7 @@ private void showAlarmOverlay(String title) {
targetVibrate,
targetSoundTier,
targetFullScreen,
""
targetSpeechText
);
} catch (RuntimeException ignored) {
// ignore
Expand Down Expand Up @@ -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);
}
Expand Down
Loading
Loading