iOS client for Grain grain.social
ios photography atproto
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add push notification support via APNs

Wire up APNs token registration, unregistration on logout, and
notification tap routing through AppDelegate and PushManager.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+218
+4
Grain/API/AuthManager.swift
··· 195 195 storeTokens(tokenResponse) 196 196 } 197 197 198 + /// Callback invoked before credentials are cleared on logout. 199 + var onLogout: (() -> Void)? 200 + 198 201 /// Log out and clear all stored credentials. 199 202 func logout() { 203 + onLogout?() 200 204 TokenStorage.clear() 201 205 try? DPoP.clearKey() 202 206 isAuthenticated = false
+117
Grain/API/PushManager.swift
··· 1 + import Foundation 2 + import UIKit 3 + import UserNotifications 4 + import os 5 + 6 + private let logger = Logger(subsystem: "social.grain.grain", category: "Push") 7 + 8 + /// Manages push notification registration and token delivery to the hatk server. 9 + @Observable 10 + @MainActor 11 + final class PushManager: NSObject { 12 + private weak var authManager: AuthManager? 13 + 14 + func configure(authManager: AuthManager) { 15 + self.authManager = authManager 16 + } 17 + 18 + /// Request notification permission and register for remote notifications. 19 + func registerIfNeeded() { 20 + Task { 21 + let center = UNUserNotificationCenter.current() 22 + let settings = await center.notificationSettings() 23 + 24 + switch settings.authorizationStatus { 25 + case .notDetermined: 26 + do { 27 + let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge]) 28 + if granted { 29 + logger.info("Push permission granted") 30 + UIApplication.shared.registerForRemoteNotifications() 31 + } 32 + } catch { 33 + logger.error("Push permission request failed: \(error)") 34 + } 35 + case .authorized, .provisional: 36 + UIApplication.shared.registerForRemoteNotifications() 37 + default: 38 + logger.info("Push notifications not authorized (status: \(String(describing: settings.authorizationStatus)))") 39 + } 40 + } 41 + } 42 + 43 + /// Called when APNs returns a device token. 44 + func didRegisterForRemoteNotifications(deviceToken: Data) { 45 + let token = deviceToken.map { String(format: "%02x", $0) }.joined() 46 + logger.info("APNs token: \(token.prefix(16))...") 47 + 48 + Task { 49 + await sendTokenToServer(token: token) 50 + } 51 + } 52 + 53 + /// Called when APNs registration fails. 54 + func didFailToRegisterForRemoteNotifications(error: Error) { 55 + logger.error("APNs registration failed: \(error)") 56 + } 57 + 58 + /// Unregister the current token on logout. 59 + /// Must be called while auth context is still valid (before token storage is cleared). 60 + func unregisterToken() { 61 + guard let token = currentToken, 62 + let authManager, 63 + let auth = authManager.authContext() else { return } 64 + let client = authManager.makeClient() 65 + currentToken = nil 66 + Task { 67 + do { 68 + try await client.procedure( 69 + "dev.hatk.push.unregisterToken", 70 + input: UnregisterTokenInput(token: token), 71 + auth: auth 72 + ) 73 + logger.info("Push token unregistered from server") 74 + } catch { 75 + logger.error("Failed to unregister push token: \(error)") 76 + } 77 + } 78 + } 79 + 80 + // MARK: - Private 81 + 82 + private var currentToken: String? { 83 + get { UserDefaults.standard.string(forKey: "apns_device_token") } 84 + set { UserDefaults.standard.set(newValue, forKey: "apns_device_token") } 85 + } 86 + 87 + private func sendTokenToServer(token: String) async { 88 + currentToken = token 89 + 90 + guard let authManager, let auth = authManager.authContext() else { 91 + logger.warning("No auth context, skipping token registration") 92 + return 93 + } 94 + 95 + let client = authManager.makeClient() 96 + do { 97 + try await client.procedure( 98 + "dev.hatk.push.registerToken", 99 + input: RegisterTokenInput(token: token, platform: "apns"), 100 + auth: auth 101 + ) 102 + logger.info("Push token registered with server") 103 + } catch { 104 + logger.error("Failed to register push token: \(error)") 105 + } 106 + } 107 + 108 + } 109 + 110 + private struct RegisterTokenInput: Encodable { 111 + let token: String 112 + let platform: String 113 + } 114 + 115 + private struct UnregisterTokenInput: Encodable { 116 + let token: String 117 + }
+81
Grain/AppDelegate.swift
··· 1 + import UIKit 2 + import UserNotifications 3 + 4 + /// AppDelegate for handling push notification registration and tap callbacks. 5 + @MainActor 6 + class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { 7 + var pushManager: PushManager? 8 + var onNotificationTap: ((DeepLink) -> Void)? 9 + 10 + func application( 11 + _ application: UIApplication, 12 + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil 13 + ) -> Bool { 14 + UNUserNotificationCenter.current().delegate = self 15 + return true 16 + } 17 + 18 + func application( 19 + _ application: UIApplication, 20 + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data 21 + ) { 22 + pushManager?.didRegisterForRemoteNotifications(deviceToken: deviceToken) 23 + } 24 + 25 + func application( 26 + _ application: UIApplication, 27 + didFailToRegisterForRemoteNotificationsWithError error: Error 28 + ) { 29 + pushManager?.didFailToRegisterForRemoteNotifications(error: error) 30 + } 31 + 32 + // Show notifications even when app is in foreground 33 + func userNotificationCenter( 34 + _ center: UNUserNotificationCenter, 35 + willPresent notification: UNNotification 36 + ) async -> UNNotificationPresentationOptions { 37 + [.banner, .sound] 38 + } 39 + 40 + // Handle notification tap — route to appropriate view 41 + func userNotificationCenter( 42 + _ center: UNUserNotificationCenter, 43 + didReceive response: UNNotificationResponse 44 + ) async { 45 + let userInfo = response.notification.request.content.userInfo 46 + guard let type = userInfo["type"] as? String else { return } 47 + 48 + let deepLink: DeepLink? 49 + switch type { 50 + case "gallery-favorite", "gallery-comment", "comment-reply": 51 + if let uri = userInfo["uri"] as? String { 52 + deepLink = parseGalleryUri(uri) 53 + } else { 54 + deepLink = nil 55 + } 56 + case "follow": 57 + if let did = userInfo["did"] as? String { 58 + deepLink = .profile(did: did) 59 + } else { 60 + deepLink = nil 61 + } 62 + default: 63 + deepLink = nil 64 + } 65 + 66 + if let deepLink { 67 + await MainActor.run { 68 + onNotificationTap?(deepLink) 69 + } 70 + } 71 + } 72 + 73 + private func parseGalleryUri(_ uri: String) -> DeepLink? { 74 + // at://did:plc:xxx/social.grain.gallery/rkey 75 + let parts = uri.replacingOccurrences(of: "at://", with: "").split(separator: "/") 76 + guard parts.count >= 3 else { return nil } 77 + let did = String(parts[0]) 78 + let rkey = String(parts[2]) 79 + return .gallery(did: did, rkey: rkey) 80 + } 81 + }
+2
Grain/Grain.entitlements
··· 2 2 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 3 <plist version="1.0"> 4 4 <dict> 5 + <key>aps-environment</key> 6 + <string>development</string> 5 7 <key>com.apple.developer.associated-domains</key> 6 8 <array> 7 9 <string>applinks:grain.social</string>
+14
Grain/GrainApp.swift
··· 2 2 3 3 @main 4 4 struct GrainApp: App { 5 + @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate 5 6 @State private var authManager = AuthManager() 7 + @State private var pushManager = PushManager() 6 8 @State private var storyStatusCache = StoryStatusCache() 7 9 @State private var labelDefsCache = LabelDefinitionsCache() 8 10 @State private var pendingDeepLink: DeepLink? ··· 13 15 if authManager.isAuthenticated { 14 16 MainTabView(pendingDeepLink: $pendingDeepLink) 15 17 .environment(authManager) 18 + .environment(pushManager) 16 19 .environment(storyStatusCache) 17 20 .environment(labelDefsCache) 18 21 .tint(Color("AccentColor")) 22 + .onAppear { 23 + pushManager.configure(authManager: authManager) 24 + appDelegate.pushManager = pushManager 25 + appDelegate.onNotificationTap = { deepLink in 26 + pendingDeepLink = deepLink 27 + } 28 + authManager.onLogout = { [pushManager] in 29 + pushManager.unregisterToken() 30 + } 31 + pushManager.registerIfNeeded() 32 + } 19 33 } else { 20 34 LoginView() 21 35 .environment(authManager)