Files
TPSchedule/mobile/lib/services/local_notification_service.dart
T

439 lines
15 KiB
Dart

import 'package:timezone/timezone.dart' as tz;
import 'dart:convert';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter/services.dart';
import '../domain/course.dart';
import '../domain/app_settings.dart';
import '../domain/course_occurrence.dart';
import 'diagnostic_log.dart';
import 'notification_service.dart';
class ReminderPermissions {
const ReminderPermissions(this.notifications, this.exactAlarm);
final bool notifications, exactAlarm;
}
class ReminderRequest {
const ReminderRequest({
required this.id,
required this.occurrenceKey,
required this.fireAt,
required this.title,
required this.body,
required this.exact,
});
final int id;
final String occurrenceKey, title, body;
final tz.TZDateTime fireAt;
final bool exact;
}
abstract class ReminderGateway {
Future<ReminderPermissions> permissions({required bool request});
Future<Set<int>> pendingIds();
Future<Set<int>> missingSystemIds(Set<int> ids) async => {};
Future<Set<int>> ownedIds() => pendingIds();
Future<String> sendTestReminder({required bool scheduled}) async =>
'当前环境不支持通知测试';
Future<Map<String, Object?>> diagnose() async => {};
Future<void> openSettings({required bool background}) async {}
Future<void> schedule(ReminderRequest value);
Future<void> cancel(int id);
}
int notificationId(String occurrenceKey) {
var hash = 0x811C9DC5;
for (final byte in utf8.encode(occurrenceKey)) {
hash = ((hash ^ byte) * 0x01000193) & 0xFFFFFFFF;
}
return hash & 0x7FFFFFFF;
}
class LocalNotificationService extends NotificationService {
LocalNotificationService(this.gateway);
final ReminderGateway gateway;
final _fingerprints = <int, String>{};
@override
Future<ReminderResult> reconcile(
List<CourseOccurrence> occurrences,
AppSettings settings,
DateTime now,
) async {
if (!settings.remindersEnabled || settings.semester == null) {
await cancelOwned();
return ReminderResult(
ReminderStatus.off,
message: settings.remindersEnabled ? '请先设置学期开始日和周数,再安排提醒。' : '课前提醒已关闭。',
);
}
final access = await gateway.permissions(request: false);
if (!access.notifications) {
await cancelOwned();
return const ReminderResult(
ReminderStatus.permissionDenied,
message:
'通知未获允许,当前没有安排课前提醒。请在“通知设置”开启TPSchedule的通知总开关和“课前提醒”类别,再点“检查提醒”。',
);
}
final upcoming =
<({CourseOccurrence event, int minutes, tz.TZDateTime fireAt})>[
for (final event in occurrences)
if (event.course.source != CourseSource.demo)
for (final minutes in settings.reminderOffsets)
if (event.start
.subtract(Duration(minutes: minutes))
.isAfter(now))
(
event: event,
minutes: minutes,
fireAt: event.start.subtract(Duration(minutes: minutes)),
),
]..sort((a, b) {
final order = a.fireAt.compareTo(b.fireAt);
if (order != 0) return order;
final courseOrder = a.event.key.compareTo(b.event.key);
return courseOrder != 0
? courseOrder
: a.minutes.compareTo(b.minutes);
});
final limited = upcoming.length > 400;
final selected = upcoming.take(400);
final desired = <int, ReminderRequest>{};
for (final reminder in selected) {
final event = reminder.event;
// Keep the original 15-minute ID; other times need independent IDs so
// Android cannot replace an earlier reminder for the same course.
var id = notificationId(
reminder.minutes == 15
? event.key
: '${event.key}/reminder/${reminder.minutes}',
);
while (desired.containsKey(id)) {
id = (id + 1) & 0x7FFFFFFF;
}
desired[id] = ReminderRequest(
id: id,
occurrenceKey: event.key,
fireAt: reminder.fireAt,
title: event.course.name,
body:
'${reminder.minutes}分钟后上课 · ${event.course.room} · ${event.course.teacher}',
exact: access.exactAlarm,
);
}
final pending = await gateway.pendingIds();
// Include overdue entries so warm-resume diagnostics survive their removal.
final missing = await gateway.missingSystemIds(pending);
for (final old in (await gateway.ownedIds()).difference(
desired.keys.toSet(),
)) {
await gateway.cancel(old);
_fingerprints.remove(old);
}
for (final entry in desired.entries) {
final request = entry.value;
final fingerprint =
'${request.fireAt.millisecondsSinceEpoch}/${request.title}/${request.body}/${request.exact}';
if (pending.contains(entry.key) &&
!missing.contains(entry.key) &&
_fingerprints[entry.key] == fingerprint) {
continue;
}
if (pending.contains(entry.key)) await gateway.cancel(entry.key);
await gateway.schedule(request);
_fingerprints[entry.key] = fingerprint;
}
final saved = await gateway.pendingIds();
if (!saved.containsAll(desired.keys)) {
throw const ReminderSetupException(
'schedule_not_saved',
'部分提醒尚未完成登记,请点击“检查提醒”后重试。',
);
}
final message = [
if (desired.isEmpty) '当前没有待提醒的课程,请核对学期日期和上课周次。',
if (desired.isNotEmpty) '已安排 ${desired.length} 次课前提醒。',
if (upcoming.isNotEmpty)
'最近一次:${upcoming.first.fireAt.month}月${upcoming.first.fireAt.day}日 '
'${upcoming.first.fireAt.hour.toString().padLeft(2, '0')}:${upcoming.first.fireAt.minute.toString().padLeft(2, '0')}。',
if (!access.exactAlarm) '未允许精确提醒,系统可能延迟送达。',
if (limited) '已安排最近 400 次提醒,后续提醒在再次打开时补充。',
].join(' ');
return ReminderResult(
access.exactAlarm ? ReminderStatus.exact : ReminderStatus.inexact,
scheduledCount: desired.length,
message: message,
);
}
@override
Future<void> requestPermissions() async {
await gateway.permissions(request: true);
}
@override
Future<void> cancelOwned() async {
for (final id in await gateway.ownedIds()) {
await gateway.cancel(id);
}
_fingerprints.clear();
}
@override
Future<String> sendTestReminder({required bool scheduled}) =>
gateway.sendTestReminder(scheduled: scheduled);
@override
Future<Map<String, Object?>> diagnose() => gateway.diagnose();
@override
Future<void> openSettings({required bool background}) =>
gateway.openSettings(background: background);
}
class PluginReminderGateway extends ReminderGateway {
PluginReminderGateway({this.onTap});
final void Function(String key)? onTap;
final _plugin = FlutterLocalNotificationsPlugin();
static const _system = MethodChannel('com.tpschedule.app/reminders');
static const _courseChannel = AndroidNotificationChannel(
'hepan_lessons',
'课前提醒',
description: '课程开始前的提醒',
importance: Importance.high,
);
String? _activeQueryWarning;
Future<void>? _initializing;
Future<void> initialize() => _initializing ??= Future<void>(() async {
try {
final ready = await _plugin.initialize(
settings: const InitializationSettings(
android: AndroidInitializationSettings('ic_stat_calendar'),
),
onDidReceiveNotificationResponse: (response) =>
_select(response.payload),
);
if (ready != true) {
throw StateError('通知服务初始化失败');
}
// Make the category visible before checking access or scheduling.
// Reusing its ID preserves the user's existing Android channel choices.
await _android?.createNotificationChannel(_courseChannel);
final launch = await _plugin.getNotificationAppLaunchDetails();
if (launch?.didNotificationLaunchApp == true) {
_select(launch?.notificationResponse?.payload);
}
} catch (_) {
_initializing = null;
rethrow;
}
});
void _select(String? payload) {
if (payload?.startsWith('hepan:') == true) {
onTap?.call(payload!.substring(6));
}
}
AndroidFlutterLocalNotificationsPlugin? get _android => _plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
@override
Future<ReminderPermissions> permissions({required bool request}) async {
await initialize();
if (request) {
await _android?.requestNotificationsPermission();
await _android?.requestExactAlarmsPermission();
}
final channels = await _android?.getNotificationChannels();
final courseChannelEnabled =
channels == null ||
!channels.any(
(channel) =>
channel.id == 'hepan_lessons' &&
channel.importance == Importance.none,
);
final appEnabled = await _android?.areNotificationsEnabled() ?? false;
DiagnosticLog.instance.add(
'reminder_access_checked',
fields: {
'result': appEnabled && courseChannelEnabled,
'code': !appEnabled
? 'app_notifications_disabled'
: !courseChannelEnabled
? 'course_channel_disabled'
: 'allowed',
},
);
return ReminderPermissions(
appEnabled && courseChannelEnabled,
await _android?.canScheduleExactNotifications() ?? false,
);
}
@override
Future<Set<int>> pendingIds() async {
await initialize();
final pending = await _plugin.pendingNotificationRequests();
return {
for (final item in pending)
if (item.payload?.startsWith('hepan:') == true) item.id,
};
}
@override
Future<Set<int>> ownedIds() async {
final owned = await pendingIds();
try {
final active = await _plugin.getActiveNotifications();
owned.addAll([
for (final item in active)
if (item.tag == 'hepan-course' && item.id != null) item.id!,
]);
_activeQueryWarning = null;
} catch (error) {
// Some OEMs deny active notification access. Future alarms must still run.
_activeQueryWarning = error is PlatformException
? error.code
: error.runtimeType.toString();
}
return owned;
}
@override
Future<Set<int>> missingSystemIds(Set<int> ids) async {
if (ids.isEmpty) return {};
try {
final missing = await _system
.invokeListMethod<int>('findMissingAlarmHandles', ids.toList())
.timeout(const Duration(seconds: 2));
return missing?.where(ids.contains).toSet() ?? {};
} catch (_) {
// Availability of this extra check must not block normal scheduling.
return {};
}
}
@override
Future<String> sendTestReminder({required bool scheduled}) async {
final access = await permissions(request: false);
if (!access.notifications) return '请先允许通知和“课前提醒”通知类别。';
if (scheduled && !access.exactAlarm) return '请先允许“闹钟与提醒”,再测试后台通知。';
const details = NotificationDetails(
android: AndroidNotificationDetails(
'hepan_lessons',
'课前提醒',
channelDescription: '课程开始前的提醒',
importance: Importance.high,
priority: Priority.high,
icon: 'ic_stat_calendar',
tag: 'hepan-test',
),
);
if (scheduled) {
await _plugin.cancel(id: -1002, tag: 'hepan-test');
await _plugin.zonedSchedule(
id: -1002,
title: 'TPSchedule 后台提醒测试',
body: '后台定时提醒已送达。',
scheduledDate: tz.TZDateTime.now(tz.getLocation('Asia/Shanghai'))
.add(const Duration(seconds: 10)),
payload: 'hepan-test:scheduled',
notificationDetails: details,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
);
final saved = await _plugin.pendingNotificationRequests();
if (!saved.any((item) => item.id == -1002)) {
throw const ReminderSetupException('test_not_saved', '测试提醒登记未完成。');
}
return '已安排测试,请按手机 Home 键回到桌面,10 秒后查看通知栏。';
}
await _plugin.show(
id: -1001,
title: 'TPSchedule 通知测试',
body: '立即通知已发送,请查看通知栏。',
notificationDetails: details,
payload: 'hepan-test:immediate',
);
return '已发送测试,请查看手机通知栏。';
}
@override
Future<Map<String, Object?>> diagnose() async {
final access = await permissions(request: false);
final pending = await pendingIds();
final channels = await _android?.getNotificationChannels();
final channel = channels
?.where((item) => item.id == 'hepan_lessons')
.firstOrNull;
final appEnabled = await _android?.areNotificationsEnabled();
Map<Object?, Object?>? system;
String? systemError;
try {
system = await _system.invokeMapMethod<Object?, Object?>('getStatus');
} catch (error) {
systemError = error is PlatformException
? error.code
: error.runtimeType.toString();
}
return {
'notifications': access.notifications,
'appNotificationsEnabled': appEnabled,
'channelExists': channels == null ? null : channel != null,
'courseChannelEnabled': channel == null
? null
: channel.importance != Importance.none,
'exactAlarm': access.exactAlarm,
'pendingCount': pending.length,
'channelImportance': channel?.importance.value,
'channelSound': channel?.playSound,
'activeQueryWarning': _activeQueryWarning,
'system': system == null
? null
: {for (final e in system.entries) '${e.key}': e.value},
'systemError': ?systemError,
};
}
@override
Future<void> openSettings({required bool background}) async {
await _system.invokeMethod<void>(
background ? 'openBackgroundSettings' : 'openNotificationSettings',
);
}
@override
Future<void> cancel(int id) async {
await initialize();
await _plugin.cancel(id: id, tag: 'hepan-course');
}
@override
Future<void> schedule(ReminderRequest value) async {
await initialize();
await _plugin.zonedSchedule(
id: value.id,
title: value.title,
body: value.body,
scheduledDate: value.fireAt,
payload: 'hepan:${value.occurrenceKey}',
androidScheduleMode: value.exact
? AndroidScheduleMode.exactAllowWhileIdle
: AndroidScheduleMode.inexactAllowWhileIdle,
notificationDetails: const NotificationDetails(
android: AndroidNotificationDetails(
'hepan_lessons',
'课前提醒',
channelDescription: '课程开始前的提醒',
importance: Importance.high,
priority: Priority.high,
icon: 'ic_stat_calendar',
tag: 'hepan-course',
visibility: NotificationVisibility.private,
),
),
);
}
}