From 291d139fc9797c18600516b963ca860dbe4a6fd8 Mon Sep 17 00:00:00 2001 From: Julian Dice <19397727+windoze95@users.noreply.github.com> Date: Sun, 28 Jun 2026 04:13:04 -0500 Subject: [PATCH 1/2] Add iOS APNs push notifications Registers the device's APNs token with the backend so new-episode pushes can be delivered, and routes a tapped notification to the player. Pure platform-channel integration (no Firebase), modeled on the proven Cantinarr pattern. Native (AppDelegate): sets the UNUserNotificationCenter delegate, exposes a `nullfeed/push` method channel over the implicit engine's messenger, hex-encodes the device token and forwards it as `onApnsToken`, presents foreground notifications, and forwards taps as `onNotificationTap` (caching a cold-start tap until Dart pulls it). Adds Runner.entitlements (aps-environment) and wires CODE_SIGN_ENTITLEMENTS into all three Runner build configs. Dart: PushService owns the channel; registerForPush() (interactive sign-in) requests permission, registerIfAuthorized() (silent restore) refreshes the token without prompting at cold launch, and taps route to /player/. A stable device id is generated once and persisted in Hive. ApiService gains registerPushToken/unregisterPushToken against POST/DELETE /api/push/register, tolerating {"enabled": false}. Wired into the auth lifecycle: register on sign-in/restore, unregister on sign-out/expiry. Tests cover token -> backend registration (incl. device-id generation and dedup), tap -> route mapping (mocked channel), the API methods, and the auth wiring. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01RXMKM1rDWn8wNh93MMUtxY --- ios/Runner.xcodeproj/project.pbxproj | 5 + ios/Runner/AppDelegate.swift | 155 +++++++++++++++++++++++++ ios/Runner/Runner.entitlements | 11 ++ lib/config/constants.dart | 11 ++ lib/providers/auth_provider.dart | 24 ++++ lib/services/api_service.dart | 33 ++++++ lib/services/push_service.dart | 166 ++++++++++++++++++++++++++ lib/services/storage_service.dart | 8 ++ test/helpers/test_helpers.dart | 13 +++ test/unit/api_service_test.dart | 43 +++++++ test/unit/auth_notifier_test.dart | 16 +++ test/unit/push_service_test.dart | 167 +++++++++++++++++++++++++++ 12 files changed, 652 insertions(+) create mode 100644 ios/Runner/Runner.entitlements create mode 100644 lib/services/push_service.dart create mode 100644 test/unit/push_service_test.dart diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index a818a22..43873c0 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -55,6 +55,7 @@ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 9A1B2C3D4E5F60718293A4B5 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 912B39771348362652A57E30 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; @@ -150,6 +151,7 @@ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + 9A1B2C3D4E5F60718293A4B5 /* Runner.entitlements */, ); path = Runner; sourceTree = ""; @@ -474,6 +476,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; @@ -659,6 +662,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -681,6 +685,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index c30b367..cc4535c 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,16 +1,171 @@ import Flutter import UIKit +import UserNotifications @main @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + /// Method channel shared with the Dart `PushService`. Created once the + /// implicit Flutter engine is initialized. + private var pushChannel: FlutterMethodChannel? + + /// The most recently obtained APNs device token, hex-encoded. Cached so a + /// token that arrives before the channel exists can be re-delivered once it + /// does. + private var apnsToken: String? + + /// Routing payload from a notification tap that arrived before Dart was ready + /// to handle it (i.e. a cold start launched by the tap). Held until Dart pulls + /// it via `getInitialNotification`. + private var pendingTapPayload: [String: Any]? + + /// True once Dart has called `getInitialNotification`, meaning its tap handler + /// is wired and subsequent (warm) taps can be delivered live. + private var dartTapReady = false + override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { + // Present and route notifications natively (no flutter_local_notifications). + UNUserNotificationCenter.current().delegate = self return super.application(application, didFinishLaunchingWithOptions: launchOptions) } func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + + // The application registrar vends the implicit engine's binary messenger, + // which is the correct messenger for app-level channels (the root view + // controller may not exist yet under the UIScene lifecycle). + let messenger = engineBridge.applicationRegistrar.messenger() + let channel = FlutterMethodChannel( + name: "nullfeed/push", + binaryMessenger: messenger + ) + channel.setMethodCallHandler { [weak self] call, result in + self?.handleMethodCall(call, result: result) + } + pushChannel = channel + + // If a token arrived before the channel existed, deliver it now. + if let token = apnsToken { + channel.invokeMethod("onApnsToken", arguments: token) + } + } + + // MARK: - Dart -> Native + + private func handleMethodCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "requestPermissionAndToken": + requestPermissionAndRegister { granted in result(granted) } + case "registerIfAuthorized": + registerIfAuthorized { registered in result(registered) } + case "getInitialNotification": + // Dart pulls the cold-start tap (if any) and signals it's ready for live + // taps from here on. + dartTapReady = true + let payload = pendingTapPayload + pendingTapPayload = nil + result(payload) + default: + result(FlutterMethodNotImplemented) + } + } + + /// Requests notification authorization and, if granted, registers with APNs. + /// Shows the system prompt the first time it's called. `completion` reports + /// whether authorization was granted. + private func requestPermissionAndRegister(completion: @escaping (Bool) -> Void) { + UNUserNotificationCenter.current().requestAuthorization( + options: [.alert, .sound, .badge] + ) { granted, error in + if let error = error { + NSLog("Notification authorization error: \(error.localizedDescription)") + } + if granted { + DispatchQueue.main.async { + UIApplication.shared.registerForRemoteNotifications() + } + } + completion(granted) + } + } + + /// Registers with APNs only if notifications are already authorized, never + /// prompting. Used on silent session restore so a relaunch refreshes the + /// token without popping the permission dialog at cold launch. `completion` + /// reports whether the device was already authorized. + private func registerIfAuthorized(completion: @escaping (Bool) -> Void) { + UNUserNotificationCenter.current().getNotificationSettings { settings in + let authorized = + settings.authorizationStatus == .authorized + || settings.authorizationStatus == .provisional + if authorized { + DispatchQueue.main.async { + UIApplication.shared.registerForRemoteNotifications() + } + } + completion(authorized) + } + } + + // MARK: - APNs registration callbacks + + override func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + // Let Flutter forward the raw token to any registered plugins first. + super.application(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken) + + let token = deviceToken.map { String(format: "%02x", $0) }.joined() + apnsToken = token + pushChannel?.invokeMethod("onApnsToken", arguments: token) + } + + override func application( + _ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error + ) { + super.application(application, didFailToRegisterForRemoteNotificationsWithError: error) + NSLog("Failed to register for remote notifications: \(error.localizedDescription)") + } + + // MARK: - UNUserNotificationCenterDelegate + + override func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + // Present notifications while the app is in the foreground. + completionHandler([.banner, .sound, .badge]) + } + + /// Handles a notification tap. Routes live when Dart's handler is ready + /// (warm/background launch); otherwise caches the payload for Dart to pull on + /// startup (cold launch). + override func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + let payload = routingPayload(from: response.notification.request.content.userInfo) + if dartTapReady, let channel = pushChannel { + channel.invokeMethod("onNotificationTap", arguments: payload) + } else { + pendingTapPayload = payload + } + completionHandler() + } + + /// Extracts the tap-routing fields the Dart side understands from an APNs + /// `userInfo` dict. Custom keys are delivered as siblings of `aps`. + private func routingPayload(from userInfo: [AnyHashable: Any]) -> [String: Any] { + var payload: [String: Any] = [:] + if let type = userInfo["type"] as? String { payload["type"] = type } + if let videoID = userInfo["video_id"] as? String { payload["video_id"] = videoID } + return payload } } diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements new file mode 100644 index 0000000..fb8f05b --- /dev/null +++ b/ios/Runner/Runner.entitlements @@ -0,0 +1,11 @@ + + + + + + aps-environment + production + + diff --git a/lib/config/constants.dart b/lib/config/constants.dart index 9e19282..c7e7ff2 100644 --- a/lib/config/constants.dart +++ b/lib/config/constants.dart @@ -21,6 +21,12 @@ class AppConstants { static const String preferredQualityKey = 'preferred_quality'; static const String autoOfflineChannelsKey = 'auto_offline_channels'; + /// Stable per-install identifier sent with the APNs token so the backend can + /// upsert (and later remove) this device's push registration. Lives in the + /// settings box so it survives sign-out — it identifies the device, not the + /// session. + static const String deviceIdKey = 'device_id'; + // API paths static const String apiBase = '/api'; static const String authProfiles = '$apiBase/auth/profiles'; @@ -49,6 +55,11 @@ class AppConstants { static const String discoverRefresh = '$apiBase/discover/refresh'; static const String health = '$apiBase/health'; + /// Registers (POST) or removes (DELETE) this device's APNs push token. Auth + /// is the usual `X-User-Token` header, so the registration is scoped to the + /// signed-in profile. + static const String pushRegister = '$apiBase/push/register'; + static String authProfile(String id) => '$apiBase/auth/profiles/$id'; static String channelDetail(String id) => '$apiBase/channels/$id'; static String channelVideos(String id) => '$apiBase/channels/$id/videos'; diff --git a/lib/providers/auth_provider.dart b/lib/providers/auth_provider.dart index f2e0cb5..ef0f0fc 100644 --- a/lib/providers/auth_provider.dart +++ b/lib/providers/auth_provider.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../config/app_globals.dart'; import '../models/user.dart'; import '../services/api_service.dart'; +import '../services/push_service.dart'; import '../services/storage_service.dart'; import 'websocket_provider.dart'; @@ -63,6 +64,20 @@ class AuthNotifier extends Notifier { String _describe(Object error, String fallback) => error is ApiException ? error.message : fallback; + /// Registers this device for push once signed in (best-effort, fire-and- + /// forget). [interactive] true requests permission — showing the system + /// prompt if needed — on a user-driven sign-in; false silently refreshes the + /// token on session restore so a cold launch never pops the dialog. Also + /// drains any notification tap that cold-started the app, now that the router + /// can navigate to it. + void _registerForPush({required bool interactive}) { + final push = ref.read(pushServiceProvider); + unawaited( + interactive ? push.registerForPush() : push.registerIfAuthorized(), + ); + unawaited(push.handleInitialNotification()); + } + Future _restoreSession() async { final token = _storage.getSessionToken(); if (token == null) return; @@ -73,6 +88,8 @@ class AuthNotifier extends Notifier { final user = await _api.getMe(); if (_restoreSuperseded(token)) return; state = AuthState(profiles: state.profiles, currentUser: user); + // Silent: refresh the token but never prompt at cold launch. + _registerForPush(interactive: false); } on ApiException catch (e) { if (_restoreSuperseded(token)) return; if (e.statusCode == 401) { @@ -119,6 +136,7 @@ class AuthNotifier extends Notifier { await _storage.setSelectedUserId(result.user.id); await _storage.setSessionToken(result.token); state = state.copyWith(currentUser: result.user, isLoading: false); + _registerForPush(interactive: true); } catch (e) { state = state.copyWith( isLoading: false, @@ -181,6 +199,7 @@ class AuthNotifier extends Notifier { currentUser: result.user, isLoading: false, ); + _registerForPush(interactive: true); } Future updateProfile( @@ -261,11 +280,16 @@ class AuthNotifier extends Notifier { // 401 sees the cleared state and bails. state = AuthState(profiles: state.profiles); ref.read(webSocketServiceProvider).disconnect(); + // Best-effort: drop this device's push registration (the dead token would + // be pruned server-side anyway). + unawaited(ref.read(pushServiceProvider).unregister()); unawaited(_storage.clearSession()); return true; } Future signOut() async { + // Remove this device's push token while the session is still valid. + await ref.read(pushServiceProvider).unregister(); try { // Best-effort: delete the session server-side. await _api.logout(); diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index d717f00..a3296b7 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -595,6 +595,39 @@ class ApiService { await _dio.post('$_baseUrl${AppConstants.discoverRefresh}'); }); + // Push notifications + + /// Registers this device's APNs [token] with the backend so the signed-in + /// profile receives push notifications. The session is identified by the + /// usual `X-User-Token` header. Returns true when the backend stored the + /// token; false when push is disabled server-side (`{"enabled": false}`), + /// which is a benign no-op rather than an error. + Future registerPushToken({ + required String token, + required String deviceId, + String platform = 'ios', + }) => _guard(() async { + final response = await _dio.post( + '$_baseUrl${AppConstants.pushRegister}', + data: { + 'device_token': token, + 'device_id': deviceId, + 'platform': platform, + }, + ); + final data = response.data; + if (data is Map && data['enabled'] == false) return false; + return data is Map && data['registered'] == true; + }); + + /// Removes this device's push token from the backend (e.g. on sign-out). + Future unregisterPushToken(String deviceId) => _guard(() async { + await _dio.delete( + '$_baseUrl${AppConstants.pushRegister}', + data: {'device_id': deviceId}, + ); + }); + // Health Future checkHealth() async { try { diff --git a/lib/services/push_service.dart b/lib/services/push_service.dart new file mode 100644 index 0000000..ff17932 --- /dev/null +++ b/lib/services/push_service.dart @@ -0,0 +1,166 @@ +import 'dart:io'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../config/routes.dart'; +import 'api_service.dart'; +import 'storage_service.dart'; + +/// Bridges native APNs registration to the NullFeed backend. +/// +/// On iOS this owns a [MethodChannel] to the native `AppDelegate`, which +/// requests notification authorization, registers with APNs, and reports the +/// device token back. The token is then sent to the backend's push registry so +/// new-episode notifications can be delivered. +/// +/// This is a pure platform-channel integration (no Firebase): foreground +/// notification presentation is handled natively by the +/// `UNUserNotificationCenterDelegate`. All operations are best-effort and must +/// never block or break the auth flow, so failures are logged and swallowed. +class PushService { + PushService(this._ref) { + // Listen for tokens pushed from native (initial registration and APNs + // token rotation) and for notification taps. Re-register whenever the + // token changes. + _channel.setMethodCallHandler(_handleNativeCall); + } + + static const _channel = MethodChannel('nullfeed/push'); + + final Ref _ref; + + /// The last APNs token successfully registered with the backend, used to + /// avoid redundant registration calls when the token hasn't changed. + String? _registeredToken; + + bool get _isSupported => !kIsWeb && Platform.isIOS; + + Future _handleNativeCall(MethodCall call) async { + switch (call.method) { + case 'onApnsToken': + final token = call.arguments as String?; + if (token != null && token.isNotEmpty && token != _registeredToken) { + await _sendToken(token); + } + case 'onNotificationTap': + _routeNotification(call.arguments); + } + return null; + } + + /// Requests notification permission (showing the system prompt the first + /// time) and registers this device's APNs token with the backend. Call after + /// an interactive sign-in. A no-op on unsupported platforms. + Future registerForPush() async { + if (!_isSupported) return; + try { + final granted = + await _channel.invokeMethod('requestPermissionAndToken') ?? + false; + if (!granted) { + debugPrint('Push: notification permission not granted'); + } + // The token arrives asynchronously via onApnsToken once APNs registration + // completes; _sendToken forwards it to the backend. + } catch (e) { + debugPrint('Push: registerForPush failed: $e'); + } + } + + /// Refreshes the token registration without ever prompting — registers only + /// if notifications are already authorized. Call on silent session restore so + /// a relaunch refreshes the token but never pops the permission dialog at + /// cold launch. A no-op on unsupported platforms. + Future registerIfAuthorized() async { + if (!_isSupported) return; + try { + await _channel.invokeMethod('registerIfAuthorized'); + // If authorized, the token arrives via onApnsToken as above. + } catch (e) { + debugPrint('Push: registerIfAuthorized failed: $e'); + } + } + + /// Pulls the notification (if any) that cold-started the app and routes to it, + /// then signals the native side that subsequent (warm) taps can be delivered + /// live. Call once we're signed in and the router can navigate. No-op off iOS. + Future handleInitialNotification() async { + if (!_isSupported) return; + try { + final args = await _channel.invokeMethod('getInitialNotification'); + if (args != null) _routeNotification(args); + } catch (e) { + debugPrint('Push: getInitialNotification failed: $e'); + } + } + + /// Removes this device's push token from the backend. Best-effort; call + /// before clearing auth state on sign-out (while the session is still valid). + Future unregister() async { + if (!_isSupported) return; + try { + final deviceId = _ref.read(storageServiceProvider).getDeviceId(); + if (deviceId == null || deviceId.isEmpty) return; + await _ref.read(apiServiceProvider).unregisterPushToken(deviceId); + _registeredToken = null; + } catch (e) { + debugPrint('Push: unregister failed: $e'); + } + } + + /// Routes a tapped notification to the right screen from its custom payload. + /// New-episode pushes carry a `video_id` and open the player. + void _routeNotification(Object? arguments) { + final data = _asStringMap(arguments); + switch (data['type'] as String?) { + case 'new_episode': + final videoId = data['video_id'] as String?; + if (videoId == null || videoId.isEmpty) return; + _ref.read(routerProvider).push('/player/$videoId'); + } + } + + /// Registers [token] with the backend, attaching this device's stable id. + Future _sendToken(String token) async { + try { + final deviceId = await _deviceId(); + final registered = await _ref + .read(apiServiceProvider) + .registerPushToken(token: token, deviceId: deviceId); + if (registered) { + _registeredToken = token; + } else { + debugPrint('Push: backend reports push disabled or token not stored'); + } + } catch (e) { + debugPrint('Push: failed to send token: $e'); + } + } + + /// Returns this install's stable device id, generating and persisting one on + /// first use. + Future _deviceId() async { + final storage = _ref.read(storageServiceProvider); + final existing = storage.getDeviceId(); + if (existing != null && existing.isNotEmpty) return existing; + final id = _generateDeviceId(); + await storage.setDeviceId(id); + return id; + } + + /// A random 128-bit identifier, hex-encoded. Stable once persisted. + String _generateDeviceId() { + final rng = Random.secure(); + final bytes = List.generate(16, (_) => rng.nextInt(256)); + return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + } + + Map _asStringMap(Object? value) => + value is Map ? value.map((k, v) => MapEntry(k.toString(), v)) : const {}; +} + +/// Provides the app-wide [PushService]. +final pushServiceProvider = Provider(PushService.new); diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 40896d9..ffdb8c7 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -40,6 +40,14 @@ class StorageService { } } + // Device id (stable per install; identifies this device for push + // registration). Lives in the settings box so it survives sign-out. + String? getDeviceId() => _settings.get(AppConstants.deviceIdKey) as String?; + + Future setDeviceId(String id) async { + await _settings.put(AppConstants.deviceIdKey, id); + } + // Quality preference String getPreferredQuality() => _settings.get(AppConstants.preferredQualityKey, defaultValue: '1080p') diff --git a/test/helpers/test_helpers.dart b/test/helpers/test_helpers.dart index 46dfd31..8136fad 100644 --- a/test/helpers/test_helpers.dart +++ b/test/helpers/test_helpers.dart @@ -9,6 +9,7 @@ import 'package:nullfeed/models/user.dart'; import 'package:nullfeed/models/video.dart'; import 'package:nullfeed/models/youtube_import.dart'; import 'package:nullfeed/services/api_service.dart'; +import 'package:nullfeed/services/push_service.dart'; import 'package:nullfeed/services/storage_service.dart'; import 'package:nullfeed/services/websocket_service.dart'; @@ -18,6 +19,8 @@ class MockStorageService extends Mock implements StorageService {} class MockWebSocketService extends Mock implements WebSocketService {} +class MockPushService extends Mock implements PushService {} + /// In-memory [StorageService] for widget tests. /// /// Real Hive writes block forever inside the `testWidgets` FakeAsync zone @@ -29,6 +32,7 @@ class FakeStorageService implements StorageService { String? _serverUrl; String? _selectedUserId; String? _sessionToken; + String? _deviceId; String _preferredQuality = '1080p'; final Set _autoOfflineChannels = {}; @@ -56,6 +60,14 @@ class FakeStorageService implements StorageService { _sessionToken = token; } + @override + String? getDeviceId() => _deviceId; + + @override + Future setDeviceId(String id) async { + _deviceId = id; + } + @override String getPreferredQuality() => _preferredQuality; @@ -89,6 +101,7 @@ class FakeStorageService implements StorageService { @override Future clearAll() async { _serverUrl = null; + _deviceId = null; _preferredQuality = '1080p'; _autoOfflineChannels.clear(); await clearSession(); diff --git a/test/unit/api_service_test.dart b/test/unit/api_service_test.dart index 86153e6..6d084ab 100644 --- a/test/unit/api_service_test.dart +++ b/test/unit/api_service_test.dart @@ -188,4 +188,47 @@ void main() { expect(postsTo(adapter, (p) => p == AppConstants.wsTicket), 1); }); }); + + group('push token registration', () { + test('POSTs the device token, id and platform, returns true', () async { + final adapter = _FakeAdapter( + (_) => _json({'enabled': true, 'registered': true}), + ); + final api = apiWith(adapter); + + final ok = await api.registerPushToken(token: 'tok', deviceId: 'dev-1'); + + expect(ok, isTrue); + final req = adapter.requests.single; + expect(req.method, 'POST'); + expect(req.uri.path, AppConstants.pushRegister); + expect(req.data, { + 'device_token': 'tok', + 'device_id': 'dev-1', + 'platform': 'ios', + }); + }); + + test('returns false when push is disabled server-side', () async { + final adapter = _FakeAdapter((_) => _json({'enabled': false})); + final api = apiWith(adapter); + + expect( + await api.registerPushToken(token: 'tok', deviceId: 'dev-1'), + isFalse, + ); + }); + + test('unregister DELETEs the device id', () async { + final adapter = _FakeAdapter((_) => _json({})); + final api = apiWith(adapter); + + await api.unregisterPushToken('dev-1'); + + final req = adapter.requests.single; + expect(req.method, 'DELETE'); + expect(req.uri.path, AppConstants.pushRegister); + expect(req.data, {'device_id': 'dev-1'}); + }); + }); } diff --git a/test/unit/auth_notifier_test.dart b/test/unit/auth_notifier_test.dart index 0282a7c..245490c 100644 --- a/test/unit/auth_notifier_test.dart +++ b/test/unit/auth_notifier_test.dart @@ -4,6 +4,7 @@ import 'package:mocktail/mocktail.dart'; import 'package:nullfeed/providers/auth_provider.dart'; import 'package:nullfeed/providers/websocket_provider.dart'; import 'package:nullfeed/services/api_service.dart'; +import 'package:nullfeed/services/push_service.dart'; import 'package:nullfeed/services/storage_service.dart'; import '../helpers/test_helpers.dart'; @@ -12,15 +13,21 @@ void main() { late MockApiService api; late MockStorageService storage; late MockWebSocketService webSocket; + late MockPushService push; setUp(() { api = MockApiService(); storage = MockStorageService(); webSocket = MockWebSocketService(); + push = MockPushService(); when(() => storage.getSessionToken()).thenReturn(null); when(() => storage.setSelectedUserId(any())).thenAnswer((_) async {}); when(() => storage.setSessionToken(any())).thenAnswer((_) async {}); when(() => storage.clearSession()).thenAnswer((_) async {}); + when(() => push.registerForPush()).thenAnswer((_) async {}); + when(() => push.registerIfAuthorized()).thenAnswer((_) async {}); + when(() => push.handleInitialNotification()).thenAnswer((_) async {}); + when(() => push.unregister()).thenAnswer((_) async {}); }); ProviderContainer createContainer() { @@ -29,6 +36,7 @@ void main() { apiServiceProvider.overrideWithValue(api), storageServiceProvider.overrideWithValue(storage), webSocketServiceProvider.overrideWithValue(webSocket), + pushServiceProvider.overrideWithValue(push), ], ); addTearDown(container.dispose); @@ -65,6 +73,9 @@ void main() { expect(state.restoreFailed, isFalse); // Restore must not re-select (which would bypass PIN protection). verifyNever(() => api.selectProfile(any(), pin: any(named: 'pin'))); + // Silent restore refreshes the token without prompting. + verify(() => push.registerIfAuthorized()).called(1); + verifyNever(() => push.registerForPush()); }); test('clears the session when the token is rejected with 401', () async { @@ -178,6 +189,8 @@ void main() { expect(state.error, isNull); verify(() => storage.setSelectedUserId('u2')).called(1); verify(() => storage.setSessionToken('tok-2')).called(1); + // An interactive sign-in requests permission and registers for push. + verify(() => push.registerForPush()).called(1); }); test( @@ -283,6 +296,8 @@ void main() { verify(() => api.logout()).called(1); verify(() => webSocket.disconnect()).called(1); verify(() => storage.clearSession()).called(1); + // Sign-out drops this device's push registration. + verify(() => push.unregister()).called(1); }); test('still signs out locally when the server logout fails', () async { @@ -326,6 +341,7 @@ void main() { expect(state.profiles, hasLength(1), reason: 'profiles are kept'); verify(() => webSocket.disconnect()).called(1); verify(() => storage.clearSession()).called(1); + verify(() => push.unregister()).called(1); }); test('is a no-op when nobody is signed in', () { diff --git a/test/unit/push_service_test.dart b/test/unit/push_service_test.dart new file mode 100644 index 0000000..943b831 --- /dev/null +++ b/test/unit/push_service_test.dart @@ -0,0 +1,167 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:nullfeed/config/routes.dart'; +import 'package:nullfeed/services/api_service.dart'; +import 'package:nullfeed/services/push_service.dart'; +import 'package:nullfeed/services/storage_service.dart'; + +import '../helpers/test_helpers.dart'; + +class MockGoRouter extends Mock implements GoRouter {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('nullfeed/push'); + late MockApiService api; + late MockStorageService storage; + late MockGoRouter router; + + setUp(() { + api = MockApiService(); + storage = MockStorageService(); + router = MockGoRouter(); + }); + + ProviderContainer createContainer() { + final container = ProviderContainer( + overrides: [ + apiServiceProvider.overrideWithValue(api), + storageServiceProvider.overrideWithValue(storage), + routerProvider.overrideWithValue(router), + ], + ); + addTearDown(container.dispose); + // Reading the provider constructs the service, which registers the channel + // handler that the simulated native calls below dispatch to. + container.read(pushServiceProvider); + return container; + } + + /// Simulates the native side invoking a method on the push channel. + Future sendNative(String method, [dynamic args]) { + return TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + const StandardMethodCodec().encodeMethodCall( + MethodCall(method, args), + ), + (_) {}, + ); + } + + group('onApnsToken', () { + test('registers an APNs token from native with the backend', () async { + when(() => storage.getDeviceId()).thenReturn('dev-1'); + when( + () => api.registerPushToken( + token: any(named: 'token'), + deviceId: any(named: 'deviceId'), + ), + ).thenAnswer((_) async => true); + createContainer(); + + await sendNative('onApnsToken', 'deadbeef'); + + verify( + () => api.registerPushToken(token: 'deadbeef', deviceId: 'dev-1'), + ).called(1); + }); + + test('generates and persists a device id on first registration', () async { + when(() => storage.getDeviceId()).thenReturn(null); + when(() => storage.setDeviceId(any())).thenAnswer((_) async {}); + when( + () => api.registerPushToken( + token: any(named: 'token'), + deviceId: any(named: 'deviceId'), + ), + ).thenAnswer((_) async => true); + createContainer(); + + await sendNative('onApnsToken', 'tok'); + + final captured = + verify(() => storage.setDeviceId(captureAny())).captured.single + as String; + expect(captured, hasLength(32), reason: '128-bit id, hex-encoded'); + verify( + () => api.registerPushToken(token: 'tok', deviceId: captured), + ).called(1); + }); + + test('does not re-register an unchanged token', () async { + when(() => storage.getDeviceId()).thenReturn('dev-1'); + when( + () => api.registerPushToken( + token: any(named: 'token'), + deviceId: any(named: 'deviceId'), + ), + ).thenAnswer((_) async => true); + createContainer(); + + await sendNative('onApnsToken', 'tok-1'); + await sendNative('onApnsToken', 'tok-1'); + + verify( + () => api.registerPushToken(token: 'tok-1', deviceId: 'dev-1'), + ).called(1); + }); + + test('re-registers when the backend did not store the token', () async { + // enabled:false (push disabled server-side) returns false, so the token + // is not cached and a later identical token is retried. + when(() => storage.getDeviceId()).thenReturn('dev-1'); + when( + () => api.registerPushToken( + token: any(named: 'token'), + deviceId: any(named: 'deviceId'), + ), + ).thenAnswer((_) async => false); + createContainer(); + + await sendNative('onApnsToken', 'tok-1'); + await sendNative('onApnsToken', 'tok-1'); + + verify( + () => api.registerPushToken(token: 'tok-1', deviceId: 'dev-1'), + ).called(2); + }); + }); + + group('onNotificationTap', () { + test('routes a new-episode tap to the player', () async { + when(() => router.push(any())).thenAnswer((_) async => null); + createContainer(); + + await sendNative('onNotificationTap', { + 'type': 'new_episode', + 'video_id': 'vid-123', + }); + + verify(() => router.push('/player/vid-123')).called(1); + }); + + test('ignores a tap with no video id', () async { + createContainer(); + + await sendNative('onNotificationTap', {'type': 'new_episode'}); + + verifyNever(() => router.push(any())); + }); + + test('ignores a tap of an unknown type', () async { + createContainer(); + + await sendNative('onNotificationTap', { + 'type': 'something_else', + 'video_id': 'vid-123', + }); + + verifyNever(() => router.push(any())); + }); + }); +} From 8afe8496e0912b9c96c6dddd48a4f30201c33a9f Mon Sep 17 00:00:00 2001 From: Julian Dice <19397727+windoze95@users.noreply.github.com> Date: Sun, 28 Jun 2026 09:28:26 -0500 Subject: [PATCH 2/2] fix(ios): guard .banner presentation for the iOS 13 deployment target `UNNotificationPresentationOptions.banner`/`.list` are iOS 14+, but the app targets iOS 13, so the no-codesign release build failed to compile. Fall back to the deprecated-but-functional `.alert` below iOS 14. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01RXMKM1rDWn8wNh93MMUtxY --- ios/Runner/AppDelegate.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index cc4535c..1bc4358 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -140,7 +140,13 @@ import UserNotifications withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { // Present notifications while the app is in the foreground. - completionHandler([.banner, .sound, .badge]) + // `.banner`/`.list` are iOS 14+; the deployment target is iOS 13, so fall + // back to the (deprecated-but-functional) `.alert` on older systems. + if #available(iOS 14.0, *) { + completionHandler([.banner, .list, .sound, .badge]) + } else { + completionHandler([.alert, .sound, .badge]) + } } /// Handles a notification tap. Routes live when Dart's handler is ready