Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions ios/Runner.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
9A1B2C3D4E5F60718293A4B5 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
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 = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
Expand Down Expand Up @@ -150,6 +151,7 @@
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
9A1B2C3D4E5F60718293A4B5 /* Runner.entitlements */,
);
path = Runner;
sourceTree = "<group>";
Expand Down Expand Up @@ -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)";
Expand Down Expand Up @@ -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;
Expand All @@ -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)";
Expand Down
161 changes: 161 additions & 0 deletions ios/Runner/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -1,16 +1,177 @@
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.
// `.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
/// (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
}
}
11 changes: 11 additions & 0 deletions ios/Runner/Runner.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- APNs environment. Set to "production" for TestFlight + App Store (current).
For debug/local device builds, switch to "development" AND set the gateway's
APNS_ENV=sandbox so the token environment matches. -->
<key>aps-environment</key>
<string>production</string>
</dict>
</plist>
11 changes: 11 additions & 0 deletions lib/config/constants.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down
24 changes: 24 additions & 0 deletions lib/providers/auth_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -63,6 +64,20 @@ class AuthNotifier extends Notifier<AuthState> {
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<void> _restoreSession() async {
final token = _storage.getSessionToken();
if (token == null) return;
Expand All @@ -73,6 +88,8 @@ class AuthNotifier extends Notifier<AuthState> {
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) {
Expand Down Expand Up @@ -119,6 +136,7 @@ class AuthNotifier extends Notifier<AuthState> {
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,
Expand Down Expand Up @@ -181,6 +199,7 @@ class AuthNotifier extends Notifier<AuthState> {
currentUser: result.user,
isLoading: false,
);
_registerForPush(interactive: true);
}

Future<void> updateProfile(
Expand Down Expand Up @@ -261,11 +280,16 @@ class AuthNotifier extends Notifier<AuthState> {
// 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<void> 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();
Expand Down
33 changes: 33 additions & 0 deletions lib/services/api_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> 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<void> unregisterPushToken(String deviceId) => _guard(() async {
await _dio.delete(
'$_baseUrl${AppConstants.pushRegister}',
data: {'device_id': deviceId},
);
});

// Health
Future<bool> checkHealth() async {
try {
Expand Down
Loading
Loading