Skip to content
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package com.timeflow.alarm;

import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.content.Context;
import android.content.Intent;
import android.os.Build;

final class AlarmContract {
static final String ACTION_FIRE_ALARM = "com.timeflow.FIRE_ALARM";
Expand All @@ -11,10 +14,14 @@ final class AlarmContract {
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 处理。 */
/** 响铃要不要震动/弹全屏止铃界面、声音走哪个档位;由 JS 侧按提醒强度换算后传入。 */
static final String EXTRA_VIBRATE = "vibrate";
static final String EXTRA_SOUND = "sound";
static final String EXTRA_SOUND_TIER = "sound_tier";
static final String EXTRA_FULL_SCREEN = "full_screen";
/** 声音档位取值:不出声 / 一次性短提示音 / 循环语音直到用户处理。 */
static final String SOUND_TIER_NONE = "none";
static final String SOUND_TIER_PING = "ping";
static final String SOUND_TIER_FULL = "full";
static final String EXTRA_EVENT_TYPE = "event_type";
static final String EVENT_FIRED = "fired";
static final String EVENT_DISMISSED = "dismissed";
Expand All @@ -37,6 +44,32 @@ final class AlarmContract {
private AlarmContract() {
}

/**
* AlarmSoundService 和 AlarmReceiver(前台服务启动失败时的兜底通知)都要用同一个
* 静音渠道——渠道级声音/震动创建后不可变,抽在这里避免两边各建一次、参数还可能
* 对不上。
*/
static void ensureChannel(Context context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return;
}
NotificationManager manager =
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
if (manager == null || manager.getNotificationChannel(CHANNEL_ID) != null) {
return;
}
NotificationChannel channel = new NotificationChannel(
CHANNEL_ID, "Timeflow", NotificationManager.IMPORTANCE_HIGH
);
channel.setDescription("日程闹钟提醒");
// 震动/声音都改成手动控制(AlarmSoundService 里的 startVibration/startPing/
// startBundledSpeech),渠道级别的这两项创建后就改不了,留 true/有声就没法
// 让某条闹钟单独静音。
channel.enableVibration(false);
channel.setSound(null, null);
manager.createNotificationChannel(channel);
}

/**
* AlarmSoundService 和 RingActivity 都要从触发 Intent 里读同一组字段、
* 补同样的 legacy id / scheduleId 反查 / 默认标题——抽在这里,避免两边分别写、
Expand All @@ -49,7 +82,7 @@ static final class ExtractedExtras {
final String speechText;
final int requestCode;
final boolean vibrate;
final boolean sound;
final String soundTier;
final boolean fullScreen;

private ExtractedExtras(
Expand All @@ -59,7 +92,7 @@ private ExtractedExtras(
String speechText,
int requestCode,
boolean vibrate,
boolean sound,
String soundTier,
boolean fullScreen
) {
this.alarmId = alarmId;
Expand All @@ -68,7 +101,7 @@ private ExtractedExtras(
this.speechText = speechText;
this.requestCode = requestCode;
this.vibrate = vibrate;
this.sound = sound;
this.soundTier = soundTier;
this.fullScreen = fullScreen;
}

Expand All @@ -78,9 +111,14 @@ static ExtractedExtras from(Context context, Intent intent) {
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,保留改动前的全响铃行为。
// 缺省按老行为处理:兼容没有带这几个 extra 的旧闹钟/测试 Intent,
// 保留改动前"全响铃"(震动 + 循环语音 + 全屏)的默认行为。
boolean vibrate = intent == null || intent.getBooleanExtra(EXTRA_VIBRATE, true);
boolean sound = intent == null || intent.getBooleanExtra(EXTRA_SOUND, true);
String soundTier = intent == null ? SOUND_TIER_FULL
: intent.getStringExtra(EXTRA_SOUND_TIER);
if (soundTier == null || soundTier.isEmpty()) {
soundTier = SOUND_TIER_FULL;
}
boolean fullScreen = intent == null || intent.getBooleanExtra(EXTRA_FULL_SCREEN, true);
if (alarmId == null || alarmId.isEmpty()) {
alarmId = "legacy-" + requestCode;
Expand All @@ -95,14 +133,7 @@ static ExtractedExtras from(Context context, Intent intent) {
speechText = ReminderSpeechFormatter.format(title, System.currentTimeMillis());
}
return new ExtractedExtras(
alarmId,
scheduleId,
title,
speechText,
requestCode,
vibrate,
sound,
fullScreen
alarmId, scheduleId, title, speechText, requestCode, vibrate, soundTier, fullScreen
);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ class AlarmModule(private val reactContext: ReactApplicationContext) :
title: String?,
scheduleId: String?,
vibrate: Boolean,
sound: Boolean,
soundTier: String?,
fullScreen: Boolean,
speechText: String?,
promise: Promise,
Expand All @@ -57,15 +57,15 @@ class AlarmModule(private val reactContext: ReactApplicationContext) :
Log.i(
NAME,
"schedule triggerAtMillis=$triggerAtMillis title=$title scheduleId=$scheduleId " +
"vibrate=$vibrate sound=$sound fullScreen=$fullScreen",
"vibrate=$vibrate soundTier=$soundTier fullScreen=$fullScreen",
)
val alarmId = AlarmScheduler.schedule(
reactContext,
triggerAtMillis.toLong(),
title ?: "日程提醒",
scheduleId ?: "",
vibrate,
sound,
soundTier ?: AlarmContract.SOUND_TIER_FULL,
fullScreen,
speechText ?: "",
)
Expand Down Expand Up @@ -116,6 +116,57 @@ class AlarmModule(private val reactContext: ReactApplicationContext) :
}
}

/**
* 提醒守护后台任务(reminderGuardTask.ts)判断"这条时间型日程原生闹钟当初有没有
* 挂上"用——JS 内存里的 registrations 在守护任务可能运行的独立/headless 上下文里
* 拿不到,只有 AlarmScheduler 持久化的挂钟列表是跨上下文都能查的真相来源。
*/
@ReactMethod
fun hasArmedAlarm(scheduleId: String?, promise: Promise) {
try {
if (scheduleId.isNullOrEmpty()) {
promise.resolve(false)
return
}
val armed = AlarmScheduler.loadAlarms(reactContext).any { it.scheduleId == scheduleId }
promise.resolve(armed)
} catch (error: Exception) {
promise.reject("HAS_ARMED_ALARM_FAILED", error.message, error)
}
}

@ReactMethod
fun presentNow(
alarmId: String?,
scheduleId: String?,
title: String?,
vibrate: Boolean,
soundTier: String?,
fullScreen: Boolean,
promise: Promise,
) {
try {
val resolvedAlarmId = alarmId?.takeIf { it.isNotEmpty() } ?: "geofence-${System.currentTimeMillis()}"
val resolvedScheduleId = scheduleId ?: ""
val resolvedTitle = title?.takeIf { it.isNotEmpty() } ?: "日程提醒"
AlarmSoundService.start(
reactContext,
resolvedAlarmId,
resolvedScheduleId,
AlarmScheduler.immediateRequestCode(resolvedAlarmId),
resolvedTitle,
vibrate,
soundTier ?: AlarmContract.SOUND_TIER_FULL,
fullScreen,
)
Log.i(NAME, "presentNow requested alarmId=$resolvedAlarmId scheduleId=$resolvedScheduleId")
promise.resolve(true)
} catch (error: Exception) {
Log.w(NAME, "presentNow failed", error)
promise.reject("PRESENT_NOW_FAILED", error.message, error)
}
}

@ReactMethod
fun peekNativeDispositions(promise: Promise) {
try {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.timeflow.alarm;

import android.app.Notification;
import android.app.NotificationManager;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
Expand Down Expand Up @@ -41,8 +43,8 @@ public void onReceive(Context context, Intent intent) {
intent.getBooleanExtra(AlarmContract.EXTRA_VIBRATE, true)
)
.putExtra(
AlarmContract.EXTRA_SOUND,
intent.getBooleanExtra(AlarmContract.EXTRA_SOUND, true)
AlarmContract.EXTRA_SOUND_TIER,
intent.getStringExtra(AlarmContract.EXTRA_SOUND_TIER)
)
.putExtra(
AlarmContract.EXTRA_FULL_SCREEN,
Expand All @@ -61,6 +63,34 @@ public void onReceive(Context context, Intent intent) {
// 原来这里完全没兜底,抛出去要么被系统吞掉、要么让这条广播直接崩溃退出,
// 日志里连个痕迹都留不下。
Log.w(TAG, "startForegroundService failed for alarmId=" + alarmId, exception);
postFallbackNotification(context, title);
}
}

/**
* 前台服务起不来时的最后一道兜底:发一条普通通知,不需要 startForegroundService
* 那一套权限,只要 POST_NOTIFICATIONS 就行——比"什么痕迹都不留"好,哪怕全屏止铃页
* 和声音/震动都没能展示出来,用户至少能在通知栏看到这条提醒。
*/
private void postFallbackNotification(Context context, String title) {
try {
AlarmContract.ensureChannel(context);
NotificationManager manager =
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
if (manager == null) {
return;
}
Notification notification = new Notification.Builder(context, AlarmContract.CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
.setContentTitle(title == null || title.isEmpty() ? "日程提醒" : title)
.setContentText("点击打开 Timeflow 处理")
.setCategory(Notification.CATEGORY_ALARM)
.setPriority(Notification.PRIORITY_MAX)
.setAutoCancel(true)
.build();
manager.notify((int) (System.currentTimeMillis() & 0x7fffffff), notification);
} catch (RuntimeException ignored) {
// 尽力而为;这已经是最后一道兜底,失败就真的没办法了。
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public static final class AlarmRecord {
public final String speechText;
public final boolean legacy;
public final boolean vibrate;
public final boolean sound;
public final String soundTier;
public final boolean fullScreen;

AlarmRecord(
Expand All @@ -44,7 +44,7 @@ public static final class AlarmRecord {
String speechText,
boolean legacy,
boolean vibrate,
boolean sound,
String soundTier,
boolean fullScreen
) {
this.alarmId = alarmId;
Expand All @@ -55,7 +55,8 @@ public static final class AlarmRecord {
this.speechText = speechText == null ? "" : speechText;
this.legacy = legacy;
this.vibrate = vibrate;
this.sound = sound;
this.soundTier = soundTier == null || soundTier.isEmpty()
? AlarmContract.SOUND_TIER_FULL : soundTier;
this.fullScreen = fullScreen;
}
}
Expand All @@ -66,7 +67,7 @@ public static String schedule(
String title,
String scheduleId,
boolean vibrate,
boolean sound,
String soundTier,
boolean fullScreen,
String speechText
) {
Expand Down Expand Up @@ -99,7 +100,7 @@ public static String schedule(
speechText == null ? "" : speechText,
false,
vibrate,
sound,
soundTier,
fullScreen
);

Expand All @@ -119,22 +120,28 @@ public static String schedule(
String speechText
) {
return schedule(
context, triggerAtMillis, title, scheduleId, true, true, true, speechText
context, triggerAtMillis, title, scheduleId, true,
AlarmContract.SOUND_TIER_FULL, true, speechText
);
}

/** @deprecated 请改用包含响铃通道和 speechText 的完整签名。 */
/** @deprecated 请改用 {@link #schedule(Context, long, String, String, boolean, String, boolean, String)}。 */
@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,
AlarmContract.SOUND_TIER_FULL, true, ""
);
}

/** @deprecated 请改用包含响铃通道和 speechText 的完整签名。 */
/** @deprecated 请改用 {@link #schedule(Context, long, String, String, boolean, String, boolean, String)}。 */
@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, AlarmContract.SOUND_TIER_FULL, true, ""
);
}

/**
Expand Down Expand Up @@ -187,7 +194,7 @@ private static void rearm(Context context, AlarmManager alarmManager, AlarmRecor
.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)
.putExtra(AlarmContract.EXTRA_SOUND_TIER, record.soundTier)
.putExtra(AlarmContract.EXTRA_FULL_SCREEN, record.fullScreen)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);
PendingIntent showPendingIntent = PendingIntent.getActivity(
Expand Down Expand Up @@ -281,7 +288,7 @@ public static List<AlarmRecord> loadAlarms(Context context) {
object.optString("speech_text", ""),
legacy,
object.optBoolean("vibrate", true),
object.optBoolean("sound", true),
object.optString("sound_tier", AlarmContract.SOUND_TIER_FULL),
object.optBoolean("full_screen", true)
));
}
Expand Down Expand Up @@ -334,6 +341,12 @@ static String scheduleIdForAlarm(Context context, String alarmId) {
return "";
}

/** Stable positive notification/foreground-service id for an immediate alarm. */
static int immediateRequestCode(String alarmId) {
int requestCode = alarmId == null ? 1 : alarmId.hashCode() & 0x7fffffff;
return requestCode == 0 ? 1 : requestCode;
}

private static void cancelPendingIntent(Context context, AlarmRecord record) {
AlarmManager alarmManager =
(AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
Expand Down Expand Up @@ -376,7 +389,7 @@ private static JSONObject toJson(AlarmRecord alarm) throws JSONException {
object.put("speech_text", alarm.speechText);
object.put("legacy", alarm.legacy);
object.put("vibrate", alarm.vibrate);
object.put("sound", alarm.sound);
object.put("sound_tier", alarm.soundTier);
object.put("full_screen", alarm.fullScreen);
return object;
}
Expand All @@ -394,7 +407,7 @@ private static PendingIntent buildAlarmBroadcastPendingIntent(
.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_SOUND_TIER, record.soundTier)
.putExtra(AlarmContract.EXTRA_FULL_SCREEN, record.fullScreen);
if (!record.legacy) {
intent.setData(alarmUri(record.alarmId));
Expand Down
Loading
Loading