Implement live activity token rotation and server synchronization for push notifications

This commit is contained in:
2026-03-03 11:41:47 +01:00
parent 1021cdc6b2
commit a661098855
6 changed files with 461 additions and 156 deletions

View File

@@ -2,112 +2,152 @@ import UIKit
import background_fetch
import ActivityKit
import Flutter
import Security
@main
@objc class AppDelegate: FlutterAppDelegate {
private var methodChannel: FlutterMethodChannel?
private var methodChannel: FlutterMethodChannel?
private var tokenRotationObserver: NSObjectProtocol?
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
guard let controller = window?.rootViewController as? FlutterViewController else {
fatalError("rootViewController is not type FlutterViewController")
}
methodChannel = FlutterMethodChannel(name: "app.firka/liveactivity",
binaryMessenger: controller as! FlutterBinaryMessenger)
methodChannel?.setMethodCallHandler({
[weak self] (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
guard call.method == "createLiveActivity" || call.method == "endLiveActivity" || call.method == "updateLiveActivity" else {
result(FlutterMethodNotImplemented)
return
}
self?.handleMethodCall(call, result: result)
})
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
override func applicationWillTerminate(_ application: UIApplication) {
if #available(iOS 16.2, *) {
LiveActivityManager.stop()
}
}
private func handleMethodCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
if call.method == "createLiveActivity" {
if let args = call.arguments as? [String: Any] {
lessonDataDictionary = args
globalLessonData = LessonData(from: lessonDataDictionary)
print("swift: megkapott flutter adatok:",lessonDataDictionary)
print("Live Activity bekapcsolva az eszközön: ",checkLiveActivityFeatureAvailable())
if(checkLiveActivityFeatureAvailable()) {
createLiveActivity(with: lessonDataDictionary)
result(checkLiveActivityFeatureAvailable())
} else {
result(nil)
}
} else {
result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid iOS arguments received", details: nil))
}
} else if call.method == "updateLiveActivity" {
if let args = call.arguments as? [String: Any] {
lessonDataDictionary = args
globalLessonData = LessonData(from: lessonDataDictionary)
updateLiveActivity(with: lessonDataDictionary)
result(nil)
} else {
result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid iOS arguments received", details: nil))
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
guard let controller = window?.rootViewController as? FlutterViewController else {
fatalError("rootViewController is not type FlutterViewController")
}
} else if call.method == "endLiveActivity" {
endLiveActivity()
result(nil)
methodChannel = FlutterMethodChannel(
name: "app.firka/liveactivity",
binaryMessenger: controller as! FlutterBinaryMessenger
)
methodChannel?.setMethodCallHandler({ [weak self] (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
guard call.method == "createLiveActivity"
|| call.method == "endLiveActivity"
|| call.method == "updateLiveActivity"
else {
result(FlutterMethodNotImplemented)
return
}
self?.handleMethodCall(call, result: result)
})
// Token rotation figyelése: ha az APNs új tokent ad, értesítjük Flutter-t
tokenRotationObserver = NotificationCenter.default.addObserver(
forName: NSNotification.Name("LiveActivityTokenUpdated"),
object: nil,
queue: .main
) { [weak self] notification in
if let newToken = notification.object as? String {
let deviceId = self?.getOrCreateDeviceId() ?? ""
let bundleId = Bundle.main.bundleIdentifier ?? ""
self?.methodChannel?.invokeMethod("liveActivityTokenUpdated", arguments: [
"pushToken": newToken,
"deviceId": deviceId,
"bundleId": bundleId,
])
}
}
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}
private func createLiveActivity(with activityData: [String: Any]) -> String? {
var lessonData = LessonData(from: activityData)
print("Live Activity létrehozása...")
if #available(iOS 16.2, *) {
LiveActivityManager.create()
}
return nil
}
private func updateLiveActivity(with activityData: [String: Any]) {
let lessonData = LessonData(from: activityData)
print("swift: megkapott flutter adatok:",lessonDataDictionary)
print("Live Activity frissítés...")
if #available(iOS 16.2, *) {
LiveActivityManager.update()
}
}
override func applicationWillTerminate(_ application: UIApplication) {
if #available(iOS 16.2, *) {
LiveActivityManager.stop()
}
if let observer = tokenRotationObserver {
NotificationCenter.default.removeObserver(observer)
}
}
private func endLiveActivity() {
print("Live Activity befejezése...")
if #available(iOS 16.2, *) {
LiveActivityManager.stop()
}
}
private func checkIfLiveActivityExists() -> Bool {
if let activityID = activityID {
if #available(iOS 16.2, *) {
return LiveActivityManager.isRunning(activityID)
}
}
return false
}
private func checkLiveActivityFeatureAvailable() -> Bool {
if #available(iOS 16.2, *) {
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
return false
}
return true
}
return false
}
private func handleMethodCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
if call.method == "createLiveActivity" {
guard let args = call.arguments as? [String: Any] else {
result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid iOS arguments received", details: nil))
return
}
lessonDataDictionary = args
globalLessonData = LessonData(from: lessonDataDictionary)
print("swift: megkapott flutter adatok:", lessonDataDictionary)
print("Live Activity bekapcsolva az eszközön:", checkLiveActivityFeatureAvailable())
guard checkLiveActivityFeatureAvailable() else {
result(nil)
return
}
if #available(iOS 16.2, *) {
LiveActivityManager.create { [weak self] pushToken in
guard let self = self, let token = pushToken else {
result(nil)
return
}
let deviceId = self.getOrCreateDeviceId()
let bundleId = Bundle.main.bundleIdentifier ?? ""
result([
"pushToken": token,
"deviceId": deviceId,
"bundleId": bundleId,
])
}
} else {
result(nil)
}
} else if call.method == "updateLiveActivity" {
guard let args = call.arguments as? [String: Any] else {
result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid iOS arguments received", details: nil))
return
}
lessonDataDictionary = args
globalLessonData = LessonData(from: lessonDataDictionary)
if #available(iOS 16.2, *) {
LiveActivityManager.update()
}
result(nil)
} else if call.method == "endLiveActivity" {
if #available(iOS 16.2, *) {
LiveActivityManager.stop()
}
result(nil)
}
}
// MARK: - Keychain device ID
private func getOrCreateDeviceId() -> String {
let keychainKey = "refilc.live.device_id"
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: keychainKey,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var item: CFTypeRef?
if SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
let data = item as? Data,
let existingId = String(data: data, encoding: .utf8) {
return existingId
}
// Nem létezik generálunk egyet
let newId = UUID().uuidString
let addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: keychainKey,
kSecValueData as String: newId.data(using: .utf8)!,
]
SecItemAdd(addQuery as CFDictionary, nil)
return newId
}
// MARK: - Helpers
private func checkLiveActivityFeatureAvailable() -> Bool {
if #available(iOS 16.2, *) {
return ActivityAuthorizationInfo().areActivitiesEnabled
}
return false
}
}

View File

@@ -10,3 +10,4 @@ import Foundation
var lessonDataDictionary: [String: Any] = [:]
var globalLessonData = LessonData(from: lessonDataDictionary)
var activityID: String? = ""
var activityPushToken: String? = nil

View File

@@ -17,7 +17,7 @@ public struct LiveActivitiesAppAttributes: ActivityAttributes, Identifiable {
var nextSubject: String
var nextRoom: String
}
public var id = UUID()
}
@@ -26,67 +26,141 @@ final class LiveActivityManager {
static let shared = LiveActivityManager()
var currentActivity: Activity<LiveActivitiesAppAttributes>?
class func create() {
Task {
/// Létrehozza a Live Activity-t pushType: .token-nel, majd visszaadja az APNs push tokent.
class func create(completion: @escaping (String?) -> Void) {
Task {
do {
let contentState = LiveActivitiesAppAttributes.ContentState(
color: globalLessonData.color,
icon: globalLessonData.icon,
index: globalLessonData.index,
title: globalLessonData.title,
subtitle: globalLessonData.subtitle,
description: globalLessonData.description,
startDate: globalLessonData.startDate,
endDate: globalLessonData.endDate,
date: globalLessonData.date,
nextSubject: globalLessonData.nextSubject,
nextRoom: globalLessonData.nextRoom
)
let activityContent = ActivityContent(
state: contentState,
staleDate: globalLessonData.endDate,
relevanceScore: 0
)
let activity = try Activity<LiveActivitiesAppAttributes>.request(
attributes: LiveActivitiesAppAttributes(),
content: activityContent,
pushType: .token
)
activityID = activity.id
print("Live Activity létrehozva. Azonosító: \(activity.id)")
// Az APNs token aszinkron érkezik megvárjuk az elsőt
for await tokenData in activity.pushTokenUpdates {
let tokenHex = tokenData.map { String(format: "%02x", $0) }.joined()
activityPushToken = tokenHex
print("Live Activity push token: \(tokenHex)")
completion(tokenHex)
// Token rotation figyelése a háttérben
Task { await monitorTokenRotation(activity: activity) }
break
}
} catch {
print("Hiba történt a Live Activity létrehozásakor: \(error)")
completion(nil)
}
}
}
/// Token rotation figyelése: ha az APNs új tokent ad ki, értesítjük a Flutter oldalt.
private class func monitorTokenRotation(activity: Activity<LiveActivitiesAppAttributes>) async {
var isFirst = true
for await tokenData in activity.pushTokenUpdates {
if isFirst { isFirst = false; continue } // Az első tokent már kezeltük
let tokenHex = tokenData.map { String(format: "%02x", $0) }.joined()
activityPushToken = tokenHex
print("Live Activity push token frissítve (rotation): \(tokenHex)")
NotificationCenter.default.post(
name: NSNotification.Name("LiveActivityTokenUpdated"),
object: tokenHex
)
}
}
class func update() {
Task {
for activity in Activity<LiveActivitiesAppAttributes>.activities {
do {
let contentState = LiveActivitiesAppAttributes.ContentState(color: globalLessonData.color, icon: globalLessonData.icon, index: globalLessonData.index, title: globalLessonData.title, subtitle: globalLessonData.subtitle, description: globalLessonData.description, startDate: globalLessonData.startDate, endDate: globalLessonData.endDate, date: globalLessonData.date, nextSubject: globalLessonData.nextSubject, nextRoom: globalLessonData.nextRoom)
let activityContent = ActivityContent(state: contentState, staleDate: globalLessonData.endDate, relevanceScore: 0)
let activity = try Activity<LiveActivitiesAppAttributes>.request(
attributes: LiveActivitiesAppAttributes(),
content: activityContent,
pushType: nil
let contentState = LiveActivitiesAppAttributes.ContentState(
color: globalLessonData.color,
icon: globalLessonData.icon,
index: globalLessonData.index,
title: globalLessonData.title,
subtitle: globalLessonData.subtitle,
description: globalLessonData.description,
startDate: globalLessonData.startDate,
endDate: globalLessonData.endDate,
date: globalLessonData.date,
nextSubject: globalLessonData.nextSubject,
nextRoom: globalLessonData.nextRoom
)
let activityContent = ActivityContent(
state: contentState,
staleDate: globalLessonData.endDate,
relevanceScore: 0
)
await activity.update(activityContent)
activityID = activity.id
print("Live Activity létrehozva. Azonosító: \(activity.id)")
print("Live Activity frissítve. Azonosító: \(activity.id)")
} catch {
print("Hiba történt a Live Activity létrehozásakor: \(error)")
print("Hiba történt a Live Activity frissítésekor: \(error)")
}
}
}
class func update() {
Task {
for activity in Activity<LiveActivitiesAppAttributes>.activities {
do {
let contentState = LiveActivitiesAppAttributes.ContentState(color: globalLessonData.color, icon: globalLessonData.icon, index: globalLessonData.index, title: globalLessonData.title, subtitle: globalLessonData.subtitle, description: globalLessonData.description, startDate: globalLessonData.startDate, endDate: globalLessonData.endDate, date: globalLessonData.date, nextSubject: globalLessonData.nextSubject, nextRoom: globalLessonData.nextRoom)
let activityContent = ActivityContent(state: contentState, staleDate: globalLessonData.endDate, relevanceScore: 0)
await activity.update(activityContent)
activityID = activity.id
print("Live Activity frissítve. Azonosító: \(activity.id)")
} catch {
print("Hiba történt a Live Activity frissítésekor: \(error)")
}
}
}
}
}
class func stop() {
if (activityID != "") {
if activityID != "" {
Task {
for activity in Activity<LiveActivitiesAppAttributes>.activities{
let contentState = LiveActivitiesAppAttributes.ContentState(color: globalLessonData.color, icon: globalLessonData.icon, index: globalLessonData.index, title: globalLessonData.title, subtitle: globalLessonData.subtitle, description: globalLessonData.description, startDate: globalLessonData.startDate, endDate: globalLessonData.endDate, date: globalLessonData.date, nextSubject: globalLessonData.nextSubject, nextRoom: globalLessonData.nextRoom)
await activity.end(ActivityContent(state: contentState, staleDate: Date.distantFuture),dismissalPolicy: .immediate)
for activity in Activity<LiveActivitiesAppAttributes>.activities {
let contentState = LiveActivitiesAppAttributes.ContentState(
color: globalLessonData.color,
icon: globalLessonData.icon,
index: globalLessonData.index,
title: globalLessonData.title,
subtitle: globalLessonData.subtitle,
description: globalLessonData.description,
startDate: globalLessonData.startDate,
endDate: globalLessonData.endDate,
date: globalLessonData.date,
nextSubject: globalLessonData.nextSubject,
nextRoom: globalLessonData.nextRoom
)
await activity.end(
ActivityContent(state: contentState, staleDate: Date.distantFuture),
dismissalPolicy: .immediate
)
}
activityID = nil
activityPushToken = nil
print("Live Activity sikeresen leállítva")
}
}
}
class func isRunning(_ activityID: String) -> Bool {
for activity in Activity<LiveActivitiesAppAttributes>.activities {
if activity.id == activityID {
return true
}
for activity in Activity<LiveActivitiesAppAttributes>.activities {
if activity.id == activityID {
return true
}
return false
}
return false
}
}

View File

@@ -3,6 +3,7 @@
import 'dart:async';
import 'package:refilc/api/providers/liveactivity/platform_channel.dart';
import 'package:refilc/api/providers/liveactivity/server_sync_provider.dart';
import 'package:refilc/helpers/subject.dart';
import 'package:refilc/models/settings.dart';
import 'package:refilc/ui/flutter_colorpicker/utils.dart';
@@ -45,6 +46,7 @@ class LiveCardProvider extends ChangeNotifier {
late Timer _timer;
late final TimetableProvider _timetable;
late final SettingsProvider _settings;
final ServerSyncProvider _serverSync = ServerSyncProvider();
late Duration _delay;
@@ -59,6 +61,16 @@ class LiveCardProvider extends ChangeNotifier {
_delay = settings.bellDelayEnabled
? Duration(seconds: settings.bellDelay)
: Duration.zero;
// Token rotation figyelése: ha iOS új APNs tokent ad ki, szinkronizáljuk a szerverrel
PlatformChannel.onTokenUpdated = (pushToken, deviceId, bundleId) {
_serverSync.refreshToken(
pushToken: pushToken,
bundleId: bundleId,
liveActivityColor: '#${settings.liveActivityColor.toHexString().substring(2)}',
);
};
update();
}
@@ -325,15 +337,15 @@ class LiveCardProvider extends ChangeNotifier {
currentState == LiveCardState.night)) {
debugPrint(
"Az első óra előtt állunk, kevesebb mint egy órával. Létrehozás...");
PlatformChannel.createLiveActivity(toMap());
hasActivityStarted = true;
_createAndSync();
} else if (!hasActivityStarted &&
((currentState == LiveCardState.duringLesson &&
currentLesson != null) ||
currentState == LiveCardState.duringBreak)) {
debugPrint("Óra van, vagy szünet, de nincs LiveActivity. létrehozás...");
PlatformChannel.createLiveActivity(toMap());
hasActivityStarted = true;
_createAndSync();
}
//UPDATE
@@ -385,6 +397,25 @@ class LiveCardProvider extends ChangeNotifier {
notifyListeners();
}
Future<void> _createAndSync() async {
final result = await PlatformChannel.createLiveActivity(toMap());
if (result != null) {
final pushToken = result['pushToken'] ?? '';
final deviceId = result['deviceId'] ?? '';
final bundleId = result['bundleId'] ?? '';
if (pushToken.isNotEmpty && deviceId.isNotEmpty) {
await _serverSync.registerAndSync(
deviceId: deviceId,
pushToken: pushToken,
bundleId: bundleId,
liveActivityColor:
'#${_settings.liveActivityColor.toHexString().substring(2)}',
todayLessons: _today(_timetable),
);
}
}
}
bool get show => currentState != LiveCardState.empty;
Duration get delay => _delay;

View File

@@ -6,16 +6,41 @@ import 'package:flutter/services.dart';
class PlatformChannel {
static const MethodChannel _channel = MethodChannel('app.firka/liveactivity');
/// Létrehozza a Live Activity-t és visszaadja az APNs push tokent (ha elérhető).
static Future<String?> createLiveActivity(
/// Callback token rotation esetén (iOS APNs új tokent ad ki).
/// A szerver szinkronizálásért felelős kód regisztrálhat ide.
static void Function(String pushToken, String deviceId, String bundleId)? onTokenUpdated;
static void _setupTokenRotationListener() {
_channel.setMethodCallHandler((call) async {
if (call.method == 'liveActivityTokenUpdated') {
final args = call.arguments as Map?;
if (args != null && onTokenUpdated != null) {
onTokenUpdated!(
args['pushToken'] as String? ?? '',
args['deviceId'] as String? ?? '',
args['bundleId'] as String? ?? '',
);
}
}
});
}
/// Létrehozza a Live Activity-t és visszaadja az APNs push tokent,
/// a device ID-t (Keychain UUID) és a bundle ID-t.
static Future<Map<String, String>?> createLiveActivity(
Map<String, dynamic> activityData) async {
if (Platform.isIOS) {
_setupTokenRotationListener();
try {
debugPrint("creating...");
final String? pushToken = await _channel.invokeMethod<String>(
debugPrint("creating live activity...");
final result = await _channel.invokeMethod<Map>(
'createLiveActivity', activityData);
debugPrint("Live Activity push token: $pushToken");
return pushToken;
if (result == null) return null;
return {
'pushToken': result['pushToken'] as String? ?? '',
'deviceId': result['deviceId'] as String? ?? '',
'bundleId': result['bundleId'] as String? ?? '',
};
} on PlatformException catch (e) {
debugPrint("Hiba történt a Live Activity létrehozásakor: ${e.message}");
}
@@ -27,7 +52,7 @@ class PlatformChannel {
Map<String, dynamic> activityData) async {
if (Platform.isIOS) {
try {
debugPrint("updating...");
debugPrint("updating live activity...");
await _channel.invokeMethod('updateLiveActivity', activityData);
} on PlatformException catch (e) {
debugPrint("Hiba történt a Live Activity frissítésekor: ${e.message}");
@@ -38,11 +63,11 @@ class PlatformChannel {
static Future<void> endLiveActivity() async {
if (Platform.isIOS) {
try {
debugPrint("finishing...");
debugPrint("ending live activity...");
await _channel.invokeMethod('endLiveActivity');
} on PlatformException catch (e) {
debugPrint("Hiba történt a Live Activity befejezésekor: ${e.message}");
}
}
}
}
}

View File

@@ -0,0 +1,134 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:refilc/helpers/subject.dart';
import 'package:refilc/utils/format.dart';
import 'package:refilc_kreta_api/models/lesson.dart';
class ServerSyncProvider {
static const String _baseUrl = 'https://live.firka.app';
String? _deviceId;
/// App indításkor hívandó: regisztrálja a device-t és feltölti a mai órarendet.
Future<void> registerAndSync({
required String deviceId,
required String pushToken,
required String bundleId,
required String liveActivityColor,
required List<Lesson> todayLessons,
}) async {
_deviceId = deviceId;
await _register(
deviceId: deviceId,
pushToken: pushToken,
bundleId: bundleId,
liveActivityColor: liveActivityColor,
);
final validLessons = _filterLessons(todayLessons);
if (validLessons.isNotEmpty) {
await _uploadSchedule(deviceId: deviceId, lessons: validLessons);
}
}
/// Token rotation esetén frissíti a tokent a szerveren.
Future<void> refreshToken({
required String pushToken,
required String bundleId,
required String liveActivityColor,
}) async {
if (_deviceId == null) return;
await _register(
deviceId: _deviceId!,
pushToken: pushToken,
bundleId: bundleId,
liveActivityColor: liveActivityColor,
);
}
Future<void> _register({
required String deviceId,
required String pushToken,
required String bundleId,
required String liveActivityColor,
}) async {
try {
final client = HttpClient();
final uri = Uri.parse('$_baseUrl/register');
final request = await client.postUrl(uri);
request.headers.contentType = ContentType.json;
request.write(jsonEncode({
'device_id': deviceId,
'apns_token': pushToken,
'bundle_id': bundleId,
'settings': {
'live_activity_color': liveActivityColor,
},
}));
final response =
await request.close().timeout(const Duration(seconds: 10));
if (response.statusCode != 200 && response.statusCode != 201) {
debugPrint('ServerSync register hiba: ${response.statusCode}');
} else {
debugPrint('ServerSync: device regisztrálva');
}
client.close();
} catch (e) {
debugPrint('ServerSync register kivétel: $e');
}
}
Future<void> _uploadSchedule({
required String deviceId,
required List<Lesson> lessons,
}) async {
try {
final now = DateTime.now();
final dateStr =
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
final lessonsJson = lessons
.map((l) => {
'index': l.lessonIndex.toString(),
'subject': l.subject.renamedTo ??
ShortSubject.resolve(subject: l.subject).capital(),
'icon': SubjectIcon.resolveName(subject: l.subject),
'room': l.room.replaceAll('_', ' '),
'description': l.description,
'start': l.start.millisecondsSinceEpoch,
'end': l.end.millisecondsSinceEpoch,
})
.toList();
final client = HttpClient();
final uri = Uri.parse('$_baseUrl/schedule');
final request = await client.postUrl(uri);
request.headers.contentType = ContentType.json;
request.write(jsonEncode({
'device_id': deviceId,
'date': dateStr,
'lessons': lessonsJson,
}));
final response =
await request.close().timeout(const Duration(seconds: 10));
if (response.statusCode != 200 && response.statusCode != 201) {
debugPrint('ServerSync schedule hiba: ${response.statusCode}');
} else {
debugPrint('ServerSync: ${lessons.length} óra feltöltve ($dateStr)');
}
client.close();
} catch (e) {
debugPrint('ServerSync schedule kivétel: $e');
}
}
List<Lesson> _filterLessons(List<Lesson> lessons) {
return lessons
.where((l) =>
l.status?.name != 'Elmaradt' && l.subject.id != '' && !l.isEmpty)
.toList()
..sort((a, b) => a.start.compareTo(b.start));
}
}