diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts index bf44c7f..2b7e561 100644 --- a/mobile/android/app/build.gradle.kts +++ b/mobile/android/app/build.gradle.kts @@ -10,6 +10,7 @@ android { ndkVersion = flutter.ndkVersion compileOptions { + isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } @@ -19,7 +20,7 @@ android { applicationId = "com.example.hepan_everyday" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion + minSdk = 24 targetSdk = flutter.targetSdkVersion // Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION // is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions) @@ -38,6 +39,10 @@ android { } } +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") +} + kotlin { compilerOptions { jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 2f21230..6f2cde9 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,23 @@ + + + + + + + + + + + + + + + + if (call.method == "requestAccess") { + if (allowed()) result.success(true) + else if (permissionResult != null) result.error("BUSY", "日历权限请求进行中", null) + else { + permissionResult = result + activity.requestPermissions(arrayOf(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR), code) + } + } else if (call.method !in setOf("listCalendars", "ownedEvents", "saveEvent", "deleteEvent")) { + result.notImplemented() + } else worker.execute { + try { + check(allowed()) { "日历权限未获允许" } + val args = (call.arguments as? Map<*, *>) ?: emptyMap() + val value: Any? = when(call.method) { + "listCalendars" -> calendars() + "ownedEvents" -> ownedEvents(args["calendarId"] as String) + "saveEvent" -> save(args) + "deleteEvent" -> { delete(args["id"] as String, args["key"] as String); null } + else -> null + } + activity.runOnUiThread { result.success(value) } + } catch (_: Exception) { + activity.runOnUiThread { result.error("CALENDAR", "日历操作失败,请检查访问权限及所选日历", null) } + } + } + } + } + fun onPermissionResult(requestCode: Int) { + if (requestCode == code) { permissionResult?.success(allowed()); permissionResult = null } + } + fun close() { + channel.setMethodCallHandler(null) + permissionResult?.success(false); permissionResult = null + worker.shutdown() + } + private fun calendars(): List> { + val rows = mutableListOf>() + resolver.query(Calendars.CONTENT_URI, arrayOf(Calendars._ID, Calendars.CALENDAR_DISPLAY_NAME), + "${Calendars.CALENDAR_ACCESS_LEVEL} >= ?", arrayOf(Calendars.CAL_ACCESS_CONTRIBUTOR.toString()), null)?.use { + while(it.moveToNext()) rows.add(mapOf("id" to it.getLong(0).toString(), "name" to (it.getString(1) ?: "手机日历"))) + } ?: error("日历服务不可用") + return rows + } + private fun marker(key: String) = prefix + Uri.encode(key) + private fun ownedEvents(calendarId: String): List> { + require(calendarId.toLong() > 0) + val rows = mutableListOf>() + resolver.query(Events.CONTENT_URI, arrayOf(Events._ID, Events.CUSTOM_APP_URI), + "${Events.CALENDAR_ID}=? AND ${Events.CUSTOM_APP_PACKAGE}=? AND ${Events.DELETED}=0", + arrayOf(calendarId, owner), null)?.use { + while(it.moveToNext()) { + val uri = it.getString(1) ?: continue + if(uri.startsWith(prefix)) rows.add(mapOf("id" to it.getLong(0).toString(), "key" to Uri.decode(uri.substring(prefix.length)), + "calendarId" to calendarId, "owned" to true)) + } + } ?: error("日历服务不可用") + return rows + } + private fun ownership(id: String, key: String, calendarId: String? = null): Boolean { + require(id.toLong() > 0) + resolver.query(ContentUris.withAppendedId(Events.CONTENT_URI, id.toLong()), + arrayOf(Events.CUSTOM_APP_PACKAGE, Events.CUSTOM_APP_URI, Events.CALENDAR_ID, Events.DELETED), null,null,null)?.use { + return it.moveToFirst() && it.getString(0) == owner && it.getString(1) == marker(key) && it.getInt(3)==0 && + (calendarId == null || it.getLong(2).toString() == calendarId) + } + return false + } + private fun save(args: Map<*, *>): String { + val calendarId = args["calendarId"] as String + check(calendars().any { it["id"] == calendarId }) { "日历不可写入" } + val key = args["key"] as String + require(key.isNotBlank() && key.length<=512) + val start = (args["startMs"] as Number).toLong() + val end = (args["endMs"] as Number).toLong() + require(end>start) + val values = ContentValues().apply { + put(Events.CALENDAR_ID, calendarId.toLong()) + put(Events.TITLE, args["title"] as String) + put(Events.EVENT_LOCATION, args["room"] as String) + put(Events.DESCRIPTION, args["description"] as String) + put(Events.DTSTART, start); put(Events.DTEND, end) + put(Events.EVENT_TIMEZONE, "Asia/Shanghai") + put(Events.CUSTOM_APP_PACKAGE, owner); put(Events.CUSTOM_APP_URI, marker(key)) + } + val id = args["eventId"] as? String + if (id != null && ownership(id, key, calendarId)) { + val count = resolver.update(ContentUris.withAppendedId(Events.CONTENT_URI,id.toLong()), values, + "${Events.CUSTOM_APP_PACKAGE}=? AND ${Events.CUSTOM_APP_URI}=? AND ${Events.CALENDAR_ID}=?", + arrayOf(owner, marker(key), calendarId)) + check(count==1) { "日程已被修改" } + return id + } + return ContentUris.parseId(resolver.insert(Events.CONTENT_URI, values) ?: error("日程写入失败")).toString() + } + private fun delete(id: String, key: String) { + if (!ownership(id,key)) return + resolver.delete(ContentUris.withAppendedId(Events.CONTENT_URI,id.toLong()), + "${Events.CUSTOM_APP_PACKAGE}=? AND ${Events.CUSTOM_APP_URI}=?", arrayOf(owner,marker(key))) + } +} diff --git a/mobile/android/app/src/main/kotlin/com/example/hepan_everyday/MainActivity.kt b/mobile/android/app/src/main/kotlin/com/example/hepan_everyday/MainActivity.kt index 50aefdd..ce6993d 100644 --- a/mobile/android/app/src/main/kotlin/com/example/hepan_everyday/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/com/example/hepan_everyday/MainActivity.kt @@ -1,5 +1,20 @@ package com.example.hepan_everyday import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine -class MainActivity : FlutterActivity() +class MainActivity : FlutterActivity() { + private var calendar: CalendarBridge? = null + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + calendar = CalendarBridge(this, flutterEngine.dartExecutor.binaryMessenger) + } + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + calendar?.onPermissionResult(requestCode) + } + override fun onDestroy() { + calendar?.close() + super.onDestroy() + } +} diff --git a/mobile/android/app/src/main/res/drawable/ic_stat_calendar.xml b/mobile/android/app/src/main/res/drawable/ic_stat_calendar.xml new file mode 100644 index 0000000..39c7843 --- /dev/null +++ b/mobile/android/app/src/main/res/drawable/ic_stat_calendar.xml @@ -0,0 +1,3 @@ + + + diff --git a/mobile/lib/app/app_controller.dart b/mobile/lib/app/app_controller.dart index 001075a..8021cdd 100644 --- a/mobile/lib/app/app_controller.dart +++ b/mobile/lib/app/app_controller.dart @@ -140,21 +140,33 @@ class AppController extends ChangeNotifier { }); Future clearCourses() => _run(() async { // Preserve ownership mapping until cleanup has actually succeeded. - await _save(snapshot.copyWith(courses: [], clearMetadata: true)); + await _save( + snapshot.copyWith( + courses: [], + clearMetadata: true, + calendarCleanupPending: true, + ), + ); isDemo = false; - await notifications.cancelOwned(); - await calendar.deleteOwned(snapshot.calendarEventIds); - await _save(snapshot.copyWith(calendarEventIds: {})); + try { + await notifications.cancelOwned(); + } catch (_) { + reminderResult = const ReminderResult( + ReminderStatus.error, + message: '提醒清理失败,可重试', + ); + } + await _cleanupCalendar(); }); Future disableCalendar({required bool deleteEvents}) => _run(() async { await _save( snapshot.copyWith( settings: snapshot.settings.copyWith(calendarEnabled: false), + calendarCleanupPending: deleteEvents || snapshot.calendarCleanupPending, ), ); if (deleteEvents) { - await calendar.deleteOwned(snapshot.calendarEventIds); - await _save(snapshot.copyWith(calendarEventIds: {})); + await _cleanupCalendar(); } calendarSyncError = null; }); @@ -166,6 +178,9 @@ class AppController extends ChangeNotifier { calendarSyncError = '日历权限未获允许'; return; } + if (snapshot.calendarCleanupPending) { + await _cleanupCalendar(); + } final calendars = await calendar.listWritableCalendars(); if (!calendars.any((c) => c.id == calendarId)) { calendarSyncError = '请选择可写入的日历'; @@ -200,6 +215,26 @@ class AppController extends ChangeNotifier { await _reconcile(); }); Future refreshServices() => _run(_reconcile); + Future retryCalendarCleanup() => _run(() async { + if (await calendar.requestAccess()) { + await _cleanupCalendar(); + } else { + calendarSyncError = '日历权限未获允许,课程日程尚未清理'; + } + }); + Future _cleanupCalendar() async { + try { + await calendar.deleteOwned(snapshot.calendarEventIds); + await _save( + snapshot.copyWith(calendarEventIds: {}, calendarCleanupPending: false), + ); + calendarSyncError = null; + } catch (_) { + calendarSyncError = '课程日程尚未清理,请允许日历访问后重试'; + rethrow; + } + } + Future _reconcile() async { if (!storageReady) { return; @@ -220,6 +255,13 @@ class AppController extends ChangeNotifier { message: '提醒设置未完成,可重试', ); } + if (snapshot.calendarCleanupPending) { + try { + await _cleanupCalendar(); + } catch (_) { + return; + } + } if (settings.calendarEnabled && settings.calendarId != null) { try { final ids = await calendar.sync( diff --git a/mobile/lib/domain/app_snapshot.dart b/mobile/lib/domain/app_snapshot.dart index 22b137c..c366f04 100644 --- a/mobile/lib/domain/app_snapshot.dart +++ b/mobile/lib/domain/app_snapshot.dart @@ -18,6 +18,7 @@ class AppSnapshot { this.settings = const AppSettings(), List courses = const [], this.importMetadata, + this.calendarCleanupPending = false, Map calendarEventIds = const {}, }) : courses = List.unmodifiable(courses), calendarEventIds = Map.unmodifiable(calendarEventIds); @@ -25,12 +26,14 @@ class AppSnapshot { final List courses; final ImportMetadata? importMetadata; final Map calendarEventIds; + final bool calendarCleanupPending; AppSnapshot copyWith({ AppSettings? settings, List? courses, ImportMetadata? importMetadata, Map? calendarEventIds, bool clearMetadata = false, + bool? calendarCleanupPending, }) => AppSnapshot( settings: settings ?? this.settings, courses: courses ?? this.courses, @@ -38,6 +41,8 @@ class AppSnapshot { ? null : importMetadata ?? this.importMetadata, calendarEventIds: calendarEventIds ?? this.calendarEventIds, + calendarCleanupPending: + calendarCleanupPending ?? this.calendarCleanupPending, ); Map toJson() => { 'schemaVersion': 1, @@ -66,6 +71,7 @@ class AppSnapshot { 'parserVersion': importMetadata!.parserVersion, }, 'calendarEventIds': calendarEventIds, + 'calendarCleanupPending': calendarCleanupPending, }; static AppSnapshot fromJson(Map json) { try { @@ -93,6 +99,8 @@ class AppSnapshot { ); final metadata = json['importMetadata']; return AppSnapshot( + calendarCleanupPending: + (json['calendarCleanupPending'] ?? false) as bool, courses: values, settings: AppSettings( semester: term, diff --git a/mobile/lib/features/profile/settings_page.dart b/mobile/lib/features/profile/settings_page.dart index 8eb8799..6f996b4 100644 --- a/mobile/lib/features/profile/settings_page.dart +++ b/mobile/lib/features/profile/settings_page.dart @@ -275,6 +275,11 @@ class _SettingsPageState extends State { color: Color(0xFFB45D54), ), ), + if (c.snapshot.calendarCleanupPending) + TextButton( + onPressed: c.retryCalendarCleanup, + child: const Text('重试清理课程日程'), + ), ], ), ), diff --git a/mobile/lib/services/android_calendar_service.dart b/mobile/lib/services/android_calendar_service.dart new file mode 100644 index 0000000..46efc7a --- /dev/null +++ b/mobile/lib/services/android_calendar_service.dart @@ -0,0 +1,150 @@ +import 'package:flutter/services.dart'; + +import '../domain/course_occurrence.dart'; +import '../domain/course.dart'; +import 'calendar_service.dart'; + +class CalendarRecord { + const CalendarRecord({ + required this.id, + required this.key, + required this.calendarId, + required this.owned, + }); + final String id, key, calendarId; + final bool owned; +} + +class CalendarEventDraft { + const CalendarEventDraft({ + required this.key, + required this.calendarId, + required this.title, + required this.room, + required this.description, + required this.startMs, + required this.endMs, + }); + final String key, calendarId, title, room, description; + final int startMs, endMs; + Map toMap() => { + 'key': key, + 'calendarId': calendarId, + 'title': title, + 'room': room, + 'description': description, + 'startMs': startMs, + 'endMs': endMs, + 'timeZone': 'Asia/Shanghai', + }; +} + +abstract class CalendarGateway { + Future requestAccess(); + Future> calendars(); + Future> ownedEvents(String calendarId); + Future save(CalendarEventDraft event, String? eventId); + Future delete(String eventId, String expectedKey); +} + +class AndroidCalendarService extends CalendarService { + AndroidCalendarService(this.gateway); + final CalendarGateway gateway; + @override + Future requestAccess() => gateway.requestAccess(); + @override + Future> listWritableCalendars() => gateway.calendars(); + @override + Future> sync( + List occurrences, + String calendarId, + Map knownEventIds, + ) async { + if (!(await gateway.calendars()).any((c) => c.id == calendarId)) { + throw StateError('所选日历不可写入'); + } + final existing = (await gateway.ownedEvents(calendarId)) + .where((e) => e.owned && e.calendarId == calendarId) + .toList(); + final result = {}; + for (final occurrence in occurrences.where( + (e) => e.course.source != CourseSource.demo, + )) { + final matches = existing.where((e) => e.key == occurrence.key).toList(); + matches.sort( + (a, b) => (a.id == knownEventIds[occurrence.key] ? -1 : 0).compareTo( + b.id == knownEventIds[occurrence.key] ? -1 : 0, + ), + ); + result[occurrence.key] = await gateway.save( + CalendarEventDraft( + key: occurrence.key, + calendarId: calendarId, + title: occurrence.course.name, + room: occurrence.course.room, + description: '任课老师:${occurrence.course.teacher}\n河畔Everyday', + startMs: occurrence.start.millisecondsSinceEpoch, + endMs: occurrence.end.millisecondsSinceEpoch, + ), + matches.isEmpty ? null : matches.first.id, + ); + } + for (final old in existing) { + if (result[old.key] != old.id) { + await gateway.delete(old.id, old.key); + } + } + return result; + } + + @override + Future deleteOwned(Map eventIds) async { + for (final entry in eventIds.entries) { + await gateway.delete(entry.value, entry.key); + } + } +} + +class MethodCalendarGateway extends CalendarGateway { + static const channel = MethodChannel('hepan_everyday/calendar'); + @override + Future requestAccess() async => + await channel.invokeMethod('requestAccess') ?? false; + @override + Future> calendars() async { + final rows = + await channel.invokeMethod>('listCalendars') ?? []; + return rows + .map((r) => DeviceCalendar(r['id'] as String, r['name'] as String)) + .toList(); + } + + @override + Future> ownedEvents(String calendarId) async { + final rows = + await channel.invokeMethod>('ownedEvents', { + 'calendarId': calendarId, + }) ?? + []; + return rows + .map( + (r) => CalendarRecord( + id: r['id'] as String, + key: r['key'] as String, + calendarId: r['calendarId'] as String, + owned: r['owned'] as bool, + ), + ) + .toList(); + } + + @override + Future save(CalendarEventDraft event, String? eventId) async => + (await channel.invokeMethod('saveEvent', { + ...event.toMap(), + 'eventId': eventId, + }))!; + @override + Future delete(String eventId, String expectedKey) => channel + .invokeMethod('deleteEvent', {'id': eventId, 'key': expectedKey}); +} diff --git a/mobile/lib/services/local_notification_service.dart b/mobile/lib/services/local_notification_service.dart new file mode 100644 index 0000000..d7da089 --- /dev/null +++ b/mobile/lib/services/local_notification_service.dart @@ -0,0 +1,230 @@ +import 'package:timezone/timezone.dart' as tz; + +import 'dart:convert'; + +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; + +import '../domain/course.dart'; +import '../domain/app_settings.dart'; +import '../domain/course_occurrence.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 permissions({required bool request}); + Future> pendingIds(); + Future schedule(ReminderRequest value); + Future 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 = {}; + @override + Future reconcile( + List occurrences, + AppSettings settings, + DateTime now, + ) async { + if (!settings.remindersEnabled || settings.semester == null) { + await cancelOwned(); + return const ReminderResult(ReminderStatus.off); + } + final access = await gateway.permissions(request: false); + if (!access.notifications) { + await cancelOwned(); + return const ReminderResult( + ReminderStatus.permissionDenied, + message: '尚未允许通知权限,请在设置中开启。', + ); + } + final upcoming = + occurrences + .where( + (e) => + e.course.source != CourseSource.demo && + e.start + .subtract(Duration(minutes: settings.reminderMinutes)) + .isAfter(now), + ) + .toList() + ..sort((a, b) { + final order = a.start.compareTo(b.start); + return order == 0 ? a.key.compareTo(b.key) : order; + }); + final limited = upcoming.length > 400; + final selected = upcoming.take(400).toList() + ..sort((a, b) => a.key.compareTo(b.key)); + final desired = {}; + for (final event in selected) { + var id = notificationId(event.key); + while (desired.containsKey(id)) { + id = (id + 1) & 0x7FFFFFFF; + } + desired[id] = ReminderRequest( + id: id, + occurrenceKey: event.key, + fireAt: event.start.subtract( + Duration(minutes: settings.reminderMinutes), + ), + title: event.course.name, + body: '${event.course.room} · ${event.course.teacher}', + exact: access.exactAlarm, + ); + } + final pending = await gateway.pendingIds(); + for (final old in pending.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) && + _fingerprints[entry.key] == fingerprint) { + continue; + } + if (pending.contains(entry.key)) await gateway.cancel(entry.key); + await gateway.schedule(request); + _fingerprints[entry.key] = fingerprint; + } + final message = [ + if (!access.exactAlarm) '未允许精确提醒,系统可能延迟送达。', + if (limited) '已安排最近 400 次提醒,后续提醒在再次打开时补充。', + ].join(' '); + return ReminderResult( + access.exactAlarm ? ReminderStatus.exact : ReminderStatus.inexact, + scheduledCount: desired.length, + message: message, + ); + } + + @override + Future requestPermissions() async { + await gateway.permissions(request: true); + } + + @override + Future cancelOwned() async { + for (final id in await gateway.pendingIds()) { + await gateway.cancel(id); + } + _fingerprints.clear(); + } +} + +class PluginReminderGateway extends ReminderGateway { + PluginReminderGateway({this.onTap}); + final void Function(String key)? onTap; + final _plugin = FlutterLocalNotificationsPlugin(); + Future? _initializing; + Future initialize() => _initializing ??= () async { + await _plugin.initialize( + settings: const InitializationSettings( + android: AndroidInitializationSettings('ic_stat_calendar'), + ), + onDidReceiveNotificationResponse: (response) => _select(response.payload), + ); + final launch = await _plugin.getNotificationAppLaunchDetails(); + if (launch?.didNotificationLaunchApp == true) { + _select(launch?.notificationResponse?.payload); + } + }(); + void _select(String? payload) { + if (payload?.startsWith('hepan:') == true) { + onTap?.call(payload!.substring(6)); + } + } + + AndroidFlutterLocalNotificationsPlugin? get _android => _plugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >(); + @override + Future permissions({required bool request}) async { + await initialize(); + if (request) { + await _android?.requestNotificationsPermission(); + await _android?.requestExactAlarmsPermission(); + } + return ReminderPermissions( + await _android?.areNotificationsEnabled() ?? false, + await _android?.canScheduleExactNotifications() ?? false, + ); + } + + @override + Future> pendingIds() async { + await initialize(); + final pending = await _plugin.pendingNotificationRequests(); + final active = await _plugin.getActiveNotifications(); + return { + for (final item in pending) + if (item.payload?.startsWith('hepan:') == true) item.id, + for (final item in active) + if (item.tag == 'hepan-course' && item.id != null) item.id!, + }; + } + + @override + Future cancel(int id) async { + await initialize(); + await _plugin.cancel(id: id, tag: 'hepan-course'); + } + + @override + Future 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, + ), + ), + ); + } +} diff --git a/mobile/test/app/app_controller_test.dart b/mobile/test/app/app_controller_test.dart index e610f8a..2af7e17 100644 --- a/mobile/test/app/app_controller_test.dart +++ b/mobile/test/app/app_controller_test.dart @@ -35,6 +35,63 @@ void main() { ); }); tearDown(() => controller.dispose()); + test( + 'failed calendar cleanup is retried after reopening while sync is off', + () async { + final provider = CleanupCalendar()..failDelete = true; + final first = AppController( + store, + notifications, + provider, + controller.clock, + ); + await first.initialize(); + await first.disableCalendar(deleteEvents: true); + expect(first.lastError, isNotNull); + first.dispose(); + provider.failDelete = false; + final reopened = AppController( + store, + notifications, + provider, + controller.clock, + ); + await reopened.initialize(); + expect(provider.deletions, 2); + expect(reopened.snapshot.settings.calendarEnabled, isFalse); + reopened.dispose(); + }, + ); + test( + 'notification cleanup failure does not prevent calendar cleanup', + () async { + final broken = AppController( + store, + BrokenCancellation(), + calendar, + controller.clock, + ); + await broken.initialize(); + await broken.clearCourses(); + expect(calendar.deletions, 1); + expect(broken.snapshot.courses, isEmpty); + broken.dispose(); + }, + ); + test( + 'denied calendar permission and unavailable calendars stay off', + () async { + await controller.initialize(); + calendar.access = false; + await controller.enableCalendarFromUserAction('local'); + expect(controller.snapshot.settings.calendarEnabled, isFalse); + expect(controller.calendarSyncError, isNotNull); + calendar.access = true; + await controller.enableCalendarFromUserAction('readonly-or-missing'); + expect(controller.snapshot.settings.calendarEnabled, isFalse); + expect(calendar.synchronizations, 0); + }, + ); test('a new successful demo action clears a stale save error', () async { await controller.initialize(); store.failSave = true; @@ -121,3 +178,19 @@ class DamagedStore extends MemoryStore { @override Future load() async => throw const FormatException('damaged'); } + +class CleanupCalendar extends FakeCalendar { + bool failDelete = false; + @override + Future deleteOwned(Map ids) async { + deletions++; + if (failDelete) { + throw StateError('denied'); + } + } +} + +class BrokenCancellation extends FakeNotifications { + @override + Future cancelOwned() async => throw StateError('denied'); +} diff --git a/mobile/test/services/calendar_service_test.dart b/mobile/test/services/calendar_service_test.dart new file mode 100644 index 0000000..03425fc --- /dev/null +++ b/mobile/test/services/calendar_service_test.dart @@ -0,0 +1,144 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:timezone/data/latest.dart' as tzdata; +import 'package:hepan_everyday/services/android_calendar_service.dart'; +import 'package:hepan_everyday/services/calendar_service.dart'; +import 'package:hepan_everyday/domain/schedule_engine.dart'; + +import '../domain/schedule_engine_test.dart' show course; + +class CalendarProvider extends CalendarGateway { + bool access = true, failAfterFirst = false; + final records = {}; + final drafts = {}; + int inserts = 0; + @override + Future requestAccess() async => access; + @override + Future> calendars() async => [ + const DeviceCalendar('local', '专用测试日历'), + ]; + @override + Future> ownedEvents(String calendarId) async { + if (!access) throw StateError('permission denied'); + return records.values.where((r) => r.calendarId == calendarId).toList(); + } + + @override + Future save(CalendarEventDraft event, String? eventId) async { + if (failAfterFirst && inserts >= 1) { + throw StateError('provider unavailable'); + } + final id = eventId ?? '${++inserts}'; + records[id] = CalendarRecord( + id: id, + key: event.key, + calendarId: event.calendarId, + owned: true, + ); + drafts[id] = event; + return id; + } + + @override + Future delete(String eventId, String expectedKey) async { + final r = records[eventId]; + if (r?.owned == true && r?.key == expectedKey) { + records.remove(eventId); + drafts.remove(eventId); + } + } +} + +void main() { + setUpAll(tzdata.initializeTimeZones); + final term = Semester(startDate: DateTime(2026, 9, 9), totalWeeks: 5); + late CalendarProvider provider; + late AndroidCalendarService service; + setUp(() { + provider = CalendarProvider(); + service = AndroidCalendarService(provider); + }); + test( + 'repeat import creates one event with UTC instants and school metadata', + () async { + final events = ScheduleEngine.occurrences([course(last: 2)], term); + final ids = await service.sync(events, 'local', {}); + expect(ids, hasLength(1)); + await service.sync(events, 'local', ids); + expect(provider.records, hasLength(1)); + expect(provider.inserts, 1); + final event = provider.drafts.values.single; + expect( + event.startMs, + DateTime.utc(2026, 9, 9, 0, 30).millisecondsSinceEpoch, + ); + expect( + event.endMs, + DateTime.utc(2026, 9, 9, 2, 5).millisecondsSinceEpoch, + ); + expect(event.description, contains('示例教师')); + }, + ); + test( + 'lost mapping after an interrupted sync recovers via owned event identity', + () async { + final events = ScheduleEngine.occurrences([ + course(id: 'a'), + course(id: 'b'), + ], term); + provider.failAfterFirst = true; + await expectLater(service.sync(events, 'local', {}), throwsStateError); + expect(provider.records, hasLength(1)); + provider.failAfterFirst = false; + await service.sync(events, 'local', {}); + expect(provider.records, hasLength(2)); + expect(provider.inserts, 2); + }, + ); + test( + 'manual deletion is repaired and unrelated events are preserved', + () async { + final events = ScheduleEngine.occurrences([course()], term); + final ids = await service.sync(events, 'local', {}); + expect(ids, hasLength(1)); + provider.records.clear(); + provider.records['private'] = const CalendarRecord( + id: 'private', + key: 'private', + calendarId: 'local', + owned: false, + ); + final updated = await service.sync(events, 'local', ids); + expect(updated, hasLength(1)); + expect(provider.records, hasLength(2)); + await service.deleteOwned(updated); + expect(provider.records.keys, ['private']); + }, + ); + test( + 'changed ownership marker cannot be deleted using an old stored mapping', + () async { + provider.records['17'] = const CalendarRecord( + id: '17', + key: 'math/2026-09-09', + calendarId: 'local', + owned: false, + ); + await service.deleteOwned({'math/2026-09-09': '17'}); + expect(provider.records.keys, ['17']); + }, + ); + test('replacing timetable removes stale owned events within selected calendar only', () async { + provider.records['other'] = const CalendarRecord( + id: 'other', + key: 'old', + calendarId: 'different', + owned: true, + ); + final events = ScheduleEngine.occurrences([course()], term); + final ids = await service.sync(events, 'local', {}); + expect(ids, hasLength(1)); + await service.sync([], 'local', ids); + expect(provider.records.keys, ['other']); + }); +} diff --git a/mobile/test/services/notification_service_test.dart b/mobile/test/services/notification_service_test.dart new file mode 100644 index 0000000..553b775 --- /dev/null +++ b/mobile/test/services/notification_service_test.dart @@ -0,0 +1,153 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:timezone/data/latest.dart' as tzdata; +import 'package:hepan_everyday/services/local_notification_service.dart'; +import 'package:hepan_everyday/services/notification_service.dart'; +import 'package:hepan_everyday/domain/schedule_engine.dart'; +import 'package:hepan_everyday/domain/app_settings.dart'; + +import '../domain/schedule_engine_test.dart' show course; + +class AlarmGateway extends ReminderGateway { + ReminderPermissions permission = const ReminderPermissions(true, true); + final alarms = {}; + int permissionRequests = 0; + @override + Future permissions({required bool request}) async { + if (request) permissionRequests++; + return permission; + } + + @override + Future> pendingIds() async => alarms.keys.toSet(); + @override + Future schedule(ReminderRequest value) async { + alarms[value.id] = value; + } + + @override + Future cancel(int id) async { + alarms.remove(id); + } +} + +void main() { + setUpAll(tzdata.initializeTimeZones); + final term = Semester(startDate: DateTime(2026, 9, 9), totalWeeks: 5); + final settings = AppSettings(semester: term); + late AlarmGateway gateway; + late LocalNotificationService service; + setUp(() { + gateway = AlarmGateway(); + service = LocalNotificationService(gateway); + }); + test( + 'continuous course schedules one alarm fifteen minutes before school start', + () async { + final events = ScheduleEngine.occurrences([course(last: 2)], term); + final result = await service.reconcile( + events, + settings, + DateTime.utc(2026, 9, 9), + ); + expect(result.scheduledCount, 1); + expect(gateway.alarms, hasLength(1)); + expect( + gateway.alarms.values.single.fireAt.toUtc(), + DateTime.utc(2026, 9, 9, 0, 15), + ); + expect(gateway.alarms.values.single.id, 1794407394); + expect(gateway.permissionRequests, 0); + }, + ); + test( + 'repeat updates replace one alarm and use changed advance time', + () async { + final events = ScheduleEngine.occurrences([course()], term); + await service.reconcile(events, settings, DateTime.utc(2026, 9, 9)); + await service.reconcile( + events, + settings.copyWith(reminderMinutes: 5), + DateTime.utc(2026, 9, 9), + ); + expect(gateway.alarms, hasLength(1)); + expect( + gateway.alarms.values.single.fireAt.toUtc(), + DateTime.utc(2026, 9, 9, 0, 25), + ); + }, + ); + test( + 'past reminder, demo and disabled settings remove pending own alarms', + () async { + final events = ScheduleEngine.occurrences([course()], term); + await service.reconcile(events, settings, DateTime.utc(2026, 9, 9)); + expect(gateway.alarms, hasLength(1)); + await service.reconcile( + events, + settings, + DateTime.utc(2026, 9, 9, 0, 20), + ); + expect(gateway.alarms, isEmpty); + await service.reconcile(events, settings, DateTime.utc(2026, 9, 9)); + expect(gateway.alarms, hasLength(1)); + await service.reconcile( + events, + settings.copyWith(remindersEnabled: false), + DateTime.utc(2026, 9, 9), + ); + expect(gateway.alarms, isEmpty); + }, + ); + test('denied notifications and denied exact alarm have distinct effective status', () async { + final events = ScheduleEngine.occurrences([course()], term); + gateway.permission = const ReminderPermissions(false, false); + expect( + (await service.reconcile( + events, + settings, + DateTime.utc(2026, 9, 9), + )).status, + ReminderStatus.permissionDenied, + ); + expect(gateway.alarms, isEmpty); + gateway.permission = const ReminderPermissions(true, false); + expect( + (await service.reconcile( + events, + settings, + DateTime.utc(2026, 9, 9), + )).status, + ReminderStatus.inexact, + ); + expect(gateway.alarms.values.single.exact, isFalse); + }); + test('permission revoked after scheduling cancels previous alarms', () async { + final events = ScheduleEngine.occurrences([course()], term); + await service.reconcile(events, settings, DateTime.utc(2026, 9, 9)); + gateway.permission = const ReminderPermissions(false, false); + final result = await service.reconcile( + events, + settings, + DateTime.utc(2026, 9, 9), + ); + expect(result.status, ReminderStatus.permissionDenied); + expect(gateway.alarms, isEmpty); + }); + test( + 'large semester keeps at most four hundred earliest reminders', + () async { + final many = ScheduleEngine.occurrences( + List.generate(501, (i) => course(id: 'class-$i')), + term, + ); + final result = await service.reconcile( + many, + settings, + DateTime.utc(2026, 9, 9), + ); + expect(result.scheduledCount, 400); + expect(gateway.alarms, hasLength(400)); + expect(result.message, contains('后续')); + }, + ); +}