From e4b1e81312f57edb43fbcf1bfa5fb26eec8aef2e Mon Sep 17 00:00:00 2001 From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Date: Wed, 15 Jul 2026 18:29:25 -0400 Subject: [PATCH] feat(mobile/ios): add NIP-PL notification service foundation Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> --- mobile/ios/Flutter/Debug.xcconfig | 4 + mobile/ios/Flutter/Release.xcconfig | 4 + mobile/ios/NotificationService/Info.plist | 35 +++ .../NotificationService.entitlements | 14 ++ .../NotificationService.swift | 221 ++++++++++++++++++ mobile/ios/Runner.xcodeproj/project.pbxproj | 173 ++++++++++++++ mobile/ios/Runner/AppDelegate.swift | 101 +++++++- mobile/ios/Runner/Info.plist | 4 + mobile/ios/Runner/Runner.entitlements | 18 ++ mobile/lib/main.dart | 2 + .../shared/community/community_provider.dart | 11 +- mobile/lib/shared/push/push_bridge.dart | 89 +++++++ mobile/lib/shared/push/push_models.dart | 133 +++++++++++ mobile/test/shared/push/push_models_test.dart | 97 ++++++++ 14 files changed, 898 insertions(+), 8 deletions(-) create mode 100644 mobile/ios/NotificationService/Info.plist create mode 100644 mobile/ios/NotificationService/NotificationService.entitlements create mode 100644 mobile/ios/NotificationService/NotificationService.swift create mode 100644 mobile/ios/Runner/Runner.entitlements create mode 100644 mobile/lib/shared/push/push_bridge.dart create mode 100644 mobile/lib/shared/push/push_models.dart create mode 100644 mobile/test/shared/push/push_models_test.dart diff --git a/mobile/ios/Flutter/Debug.xcconfig b/mobile/ios/Flutter/Debug.xcconfig index 70d2be7ce..1487c89fe 100644 --- a/mobile/ios/Flutter/Debug.xcconfig +++ b/mobile/ios/Flutter/Debug.xcconfig @@ -6,4 +6,8 @@ // `mobile/ios/Flutter/AppOverrides.xcconfig` containing // `BUNDLE_IDENTIFIER = your.app.id` (gitignored). BUNDLE_IDENTIFIER = com.buzz.buzzMobile +BUZZ_APP_GROUP_IDENTIFIER = group.$(BUNDLE_IDENTIFIER) +BUZZ_KEYCHAIN_ACCESS_GROUP = $(BUNDLE_IDENTIFIER) +BUZZ_IOS_PUSH_ENVIRONMENT = development +BUZZ_APP_ATTEST_ENVIRONMENT = development #include? "AppOverrides.xcconfig" diff --git a/mobile/ios/Flutter/Release.xcconfig b/mobile/ios/Flutter/Release.xcconfig index 616df8762..a6fe5aeec 100644 --- a/mobile/ios/Flutter/Release.xcconfig +++ b/mobile/ios/Flutter/Release.xcconfig @@ -7,4 +7,8 @@ BUNDLE_IDENTIFIER = com.buzz.buzzMobile CODE_SIGN_STYLE = Automatic CODE_SIGN_IDENTITY = iPhone Developer +BUZZ_APP_GROUP_IDENTIFIER = group.$(BUNDLE_IDENTIFIER) +BUZZ_KEYCHAIN_ACCESS_GROUP = $(BUNDLE_IDENTIFIER) +BUZZ_IOS_PUSH_ENVIRONMENT = production +BUZZ_APP_ATTEST_ENVIRONMENT = production #include? "AppOverrides.xcconfig" diff --git a/mobile/ios/NotificationService/Info.plist b/mobile/ios/NotificationService/Info.plist new file mode 100644 index 000000000..e66f3a950 --- /dev/null +++ b/mobile/ios/NotificationService/Info.plist @@ -0,0 +1,35 @@ + + + + + BuzzAppGroupIdentifier + $(BUZZ_APP_GROUP_IDENTIFIER) + BuzzKeychainAccessGroup + $(AppIdentifierPrefix)$(BUZZ_KEYCHAIN_ACCESS_GROUP) + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + NotificationService + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + + diff --git a/mobile/ios/NotificationService/NotificationService.entitlements b/mobile/ios/NotificationService/NotificationService.entitlements new file mode 100644 index 000000000..2187d2c03 --- /dev/null +++ b/mobile/ios/NotificationService/NotificationService.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.application-groups + + $(BUZZ_APP_GROUP_IDENTIFIER) + + keychain-access-groups + + $(AppIdentifierPrefix)$(BUZZ_KEYCHAIN_ACCESS_GROUP) + + + diff --git a/mobile/ios/NotificationService/NotificationService.swift b/mobile/ios/NotificationService/NotificationService.swift new file mode 100644 index 000000000..9f4fff172 --- /dev/null +++ b/mobile/ios/NotificationService/NotificationService.swift @@ -0,0 +1,221 @@ +import Foundation +import UserNotifications + +final class NotificationService: UNNotificationServiceExtension { + private var contentHandler: ((UNNotificationContent) -> Void)? + private var bestAttemptContent: UNMutableNotificationContent? + private var resolver: BuzzPushNotificationResolving = BuzzPushNotificationResolver() + + override func didReceive( + _ request: UNNotificationRequest, + withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void + ) { + self.contentHandler = contentHandler + guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else { + contentHandler(request.content) + return + } + bestAttemptContent = content + + resolver.resolve { [weak self] resolution in + guard let self else { return } + if let resolution { + content.title = resolution.title + content.body = resolution.body + if let subtitle = resolution.subtitle { + content.subtitle = subtitle + } + if let threadIdentifier = resolution.threadIdentifier { + content.threadIdentifier = threadIdentifier + } + } + self.finish(content) + } + } + + override func serviceExtensionTimeWillExpire() { + if let bestAttemptContent { + finish(bestAttemptContent) + } + } + + private func finish(_ content: UNNotificationContent) { + guard let contentHandler else { return } + self.contentHandler = nil + contentHandler(content) + } +} + +struct BuzzPushResolution: Decodable { + let title: String + let body: String + let subtitle: String? + let threadIdentifier: String? +} + +protocol BuzzPushNotificationResolving { + func resolve(completion: @escaping (BuzzPushResolution?) -> Void) +} + +final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { + private let snapshotFile = "push-communities.json" + private let session: URLSession + private let appGroupIdentifier: String? + + init( + session: URLSession = .shared, + appGroupIdentifier: String? = Bundle.main.object(forInfoDictionaryKey: "BuzzAppGroupIdentifier") + as? String + ) { + self.session = session + self.appGroupIdentifier = appGroupIdentifier + } + + func resolve(completion: @escaping (BuzzPushResolution?) -> Void) { + guard let community = loadCommunities().first(where: { $0.pubkey?.isEmpty == false }) else { + completion(nil) + return + } + + // The NIP-PL APNs payload intentionally carries no relay or event id. The + // service extension therefore performs a bounded catch-up against locally + // configured origins and only replaces the fixed placeholder if authoritative + // relay data is available before the NSE deadline. + let filters: [[String: Any]] = [ + [ + "kinds": [9, 40002, 45001, 45003], + "#p": [community.pubkey!], + "limit": 10, + ] + ] + guard let body = try? JSONSerialization.data(withJSONObject: filters) else { + completion(nil) + return + } + let url = URL(string: "/query", relativeTo: community.relayURL)! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = body + + session.dataTask(with: request) { data, response, _ in + guard let httpResponse = response as? HTTPURLResponse, + (200..<300).contains(httpResponse.statusCode), + let data, + let events = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]], + !events.isEmpty + else { + completion(nil) + return + } + let resolution = Self.decodeResolution( + events: events, + community: community + ) + completion(resolution) + }.resume() + } + + private static func decodeResolution(events: [[String: Any]], community: BuzzPushCommunity) + -> BuzzPushResolution? + { + guard let myPubkey = community.pubkey?.lowercased() else { return nil } + let candidates = events.compactMap(BuzzPushEvent.init(json:)).filter { event in + event.pubkey.lowercased() != myPubkey && [9, 40002, 45001, 45003].contains(event.kind) + } + guard + let event = candidates.sorted(by: { left, right in + if left.createdAt != right.createdAt { return left.createdAt > right.createdAt } + return left.id < right.id + }).first + else { return nil } + let body = previewBody(event.content) + guard !body.isEmpty else { return nil } + return BuzzPushResolution( + title: shortPubkey(event.pubkey), + body: body, + subtitle: community.name, + threadIdentifier: event.channelId ?? community.id + ) + } + + private static func previewBody(_ content: String) -> String { + var result = content.replacingOccurrences( + of: #"```[\s\S]*?```"#, + with: "[code]", + options: .regularExpression + ) + result = result.replacingOccurrences(of: #"`([^`]*)`"#, with: "$1", options: .regularExpression) + result = result.replacingOccurrences( + of: #"!\[([^\]]*)\]\([^)]*\)"#, with: "$1", options: .regularExpression) + result = result.replacingOccurrences( + of: #"\[([^\]]+)\]\([^)]*\)"#, with: "$1", options: .regularExpression) + result = result.replacingOccurrences( + of: #"https?://\S+"#, with: "[link]", options: .regularExpression) + result = result.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard result.count > 180 else { return result } + return String(result.prefix(177)).trimmingCharacters(in: .whitespacesAndNewlines) + "…" + } + + private static func shortPubkey(_ pubkey: String) -> String { + guard pubkey.count > 8 else { return pubkey } + return String(pubkey.prefix(8)) + "…" + } + + private func loadCommunities() -> [BuzzPushCommunity] { + guard let appGroupIdentifier, + let container = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupIdentifier) + else { return [] } + let url = container.appendingPathComponent(snapshotFile) + guard let data = try? Data(contentsOf: url), + let decoded = try? JSONDecoder().decode(BuzzPushSnapshot.self, from: data) + else { return [] } + return decoded.communities + } +} + +struct BuzzPushEvent { + let id: String + let pubkey: String + let createdAt: Int + let kind: Int + let tags: [[String]] + let content: String + + init?(json: [String: Any]) { + guard let id = json["id"] as? String, + let pubkey = json["pubkey"] as? String, + let createdAt = json["created_at"] as? Int, + let kind = json["kind"] as? Int, + let tags = json["tags"] as? [[String]], + let content = json["content"] as? String + else { return nil } + self.id = id + self.pubkey = pubkey + self.createdAt = createdAt + self.kind = kind + self.tags = tags + self.content = content + } + + var channelId: String? { + tags.first { $0.count >= 2 && $0[0] == "h" }?[1] + } +} + +struct BuzzPushSnapshot: Decodable { + let communities: [BuzzPushCommunity] +} + +struct BuzzPushCommunity: Decodable { + let id: String + let name: String + let relayUrl: String + let pubkey: String? + + var relayURL: URL { + URL(string: relayUrl) ?? URL(string: "http://127.0.0.1")! + } +} diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 3fe9295da..3830ff3d0 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + BZZ00000000000000000001 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BZZ00000000000000000006 /* NotificationService.swift */; }; + BZZ00000000000000000002 /* NotificationService.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = BZZ00000000000000000009 /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 33ADD70AB275E0EC81295559 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8906419FB4E98B4B12B7A56F /* Pods_Runner.framework */; }; @@ -20,6 +22,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + BZZ00000000000000000014 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = BZZ0000000000000000000E; + remoteInfo = NotificationService; + }; 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 97C146E61CF9000F007C117D /* Project object */; @@ -30,6 +39,17 @@ /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ + BZZ00000000000000000004 /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + BZZ00000000000000000002 /* NotificationService.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; 9705A1C41CF9048500538489 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -43,6 +63,11 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + BZZ00000000000000000006 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; + BZZ00000000000000000007 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + BZZ00000000000000000008 /* NotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = NotificationService.entitlements; sourceTree = ""; }; + BZZ00000000000000000009 /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + BZZ0000000000000000000A /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Runner.entitlements; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 30CE81D3D1E0B195EF2A6390 /* 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 = ""; }; @@ -70,6 +95,13 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + BZZ0000000000000000000C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 3C28C6B702C81085E6F96F2A /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -125,6 +157,7 @@ 97C146E51CF9000F007C117D = { isa = PBXGroup; children = ( + BZZ0000000000000000000B /* NotificationService */, 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, @@ -139,6 +172,7 @@ children = ( 97C146EE1CF9000F007C117D /* Runner.app */, 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + BZZ00000000000000000009 /* NotificationService.appex */, ); name = Products; sourceTree = ""; @@ -150,6 +184,7 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, 97C147021CF9000F007C117D /* Info.plist */, + BZZ0000000000000000000A /* Runner.entitlements */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, @@ -168,6 +203,16 @@ name = Frameworks; sourceTree = ""; }; + BZZ0000000000000000000B /* NotificationService */ = { + isa = PBXGroup; + children = ( + BZZ00000000000000000006 /* NotificationService.swift */, + BZZ00000000000000000007 /* Info.plist */, + BZZ00000000000000000008 /* NotificationService.entitlements */, + ); + path = NotificationService; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -200,18 +245,37 @@ 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, + BZZ00000000000000000004 /* Embed App Extensions */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, E0B5862D106D142B580309AF /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); dependencies = ( + BZZ00000000000000000013 /* PBXTargetDependency */, ); name = Runner; productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; }; + BZZ0000000000000000000E /* NotificationService */ = { + isa = PBXNativeTarget; + buildConfigurationList = BZZ0000000000000000000F /* Build configuration list for PBXNativeTarget "NotificationService" */; + buildPhases = ( + BZZ0000000000000000000D /* Sources */, + BZZ0000000000000000000C /* Frameworks */, + BZZ00000000000000000010 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = NotificationService; + productName = NotificationService; + productReference = BZZ00000000000000000009 /* NotificationService.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -222,6 +286,9 @@ LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { + BZZ0000000000000000000E = { + CreatedOnToolsVersion = 15.0; + }; 331C8080294A63A400263BE5 = { CreatedOnToolsVersion = 14.0; TestTargetID = 97C146ED1CF9000F007C117D; @@ -247,11 +314,19 @@ targets = ( 97C146ED1CF9000F007C117D /* Runner */, 331C8080294A63A400263BE5 /* RunnerTests */, + BZZ0000000000000000000E /* NotificationService */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + BZZ00000000000000000010 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 331C807F294A63A400263BE5 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -368,6 +443,14 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + BZZ0000000000000000000D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + BZZ00000000000000000001 /* NotificationService.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 331C807D294A63A400263BE5 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -389,6 +472,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + BZZ00000000000000000013 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = BZZ0000000000000000000E /* NotificationService */; + targetProxy = BZZ00000000000000000014 /* PBXContainerItemProxy */; + }; 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 97C146ED1CF9000F007C117D /* Runner */; @@ -477,6 +565,7 @@ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = ""; ENABLE_BITCODE = NO; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -659,6 +748,7 @@ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = ""; ENABLE_BITCODE = NO; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -682,6 +772,7 @@ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = ""; ENABLE_BITCODE = NO; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -695,9 +786,91 @@ }; name = Release; }; + BZZ00000000000000000011 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = NotificationService/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER).NotificationService"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + BZZ00000000000000000012 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = NotificationService/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER).NotificationService"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + BZZ00000000000000000015 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = NotificationService/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER).NotificationService"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + BZZ0000000000000000000F /* Build configuration list for PBXNativeTarget "NotificationService" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + BZZ00000000000000000011 /* Debug */, + BZZ00000000000000000012 /* Release */, + BZZ00000000000000000015 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index c1bc7a742..92ae94cf6 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -6,12 +6,23 @@ import UserNotifications @main @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { private var mediaUploadChannel: FlutterMethodChannel? + private var pushChannel: FlutterMethodChannel? + private var appGroupIdentifier: String? { + Bundle.main.object(forInfoDictionaryKey: "BuzzAppGroupIdentifier") as? String + } override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - UNUserNotificationCenter.current().requestAuthorization(options: [.badge]) { _, _ in } + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { + granted, _ in + if granted { + DispatchQueue.main.async { + application.registerForRemoteNotifications() + } + } + } return super.application(application, didFinishLaunchingWithOptions: launchOptions) } @@ -24,6 +35,81 @@ import UserNotifications mediaUploadChannel?.setMethodCallHandler { [weak self] call, result in self?.handleMediaUploadMethodCall(call, result: result) } + + pushChannel = FlutterMethodChannel( + name: "buzz/push", + binaryMessenger: engineBridge.applicationRegistrar.messenger() + ) + pushChannel?.setMethodCallHandler { [weak self] call, result in + self?.handlePushMethodCall(call, result: result) + } + } + + override func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + super.application(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken) + pushChannel?.invokeMethod( + "apnsTokenChanged", + arguments: ["token": deviceToken.map { String(format: "%02x", $0) }.joined()]) + } + + override func application( + _ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error + ) { + super.application(application, didFailToRegisterForRemoteNotificationsWithError: error) + pushChannel?.invokeMethod( + "apnsRegistrationFailed", arguments: ["message": error.localizedDescription]) + } + + private func handlePushMethodCall( + _ call: FlutterMethodCall, + result: @escaping FlutterResult + ) { + switch call.method { + case "saveCommunitySnapshot": + guard let arguments = call.arguments as? [String: Any], + let communities = arguments["communities"] as? [[String: Any]] + else { + result( + FlutterError( + code: "invalid_arguments", message: "Expected communities array.", details: nil)) + return + } + do { + try savePushCommunitySnapshot(communities) + result(nil) + } catch { + result( + FlutterError( + code: "save_failed", message: "Unable to save push community snapshot.", + details: error.localizedDescription)) + } + default: + result(FlutterMethodNotImplemented) + } + } + + private func savePushCommunitySnapshot(_ communities: [[String: Any]]) throws { + guard let appGroupIdentifier else { + throw NSError( + domain: "BuzzPush", code: 1, + userInfo: [NSLocalizedDescriptionKey: "Missing BuzzAppGroupIdentifier"]) + } + guard + let container = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupIdentifier) + else { + throw NSError( + domain: "BuzzPush", code: 2, + userInfo: [NSLocalizedDescriptionKey: "Missing App Group container"]) + } + let data = try JSONSerialization.data( + withJSONObject: ["communities": communities], options: [.sortedKeys]) + let destination = container.appendingPathComponent("push-communities.json") + try data.write(to: destination, options: [.atomic]) } private func handleMediaUploadMethodCall( @@ -156,10 +242,12 @@ import UserNotifications let sourceURL = URL(fileURLWithPath: sourcePath) let asset = AVURLAsset(url: sourceURL) - guard let exportSession = AVAssetExportSession( - asset: asset, - presetName: AVAssetExportPresetPassthrough - ) else { + guard + let exportSession = AVAssetExportSession( + asset: asset, + presetName: AVAssetExportPresetPassthrough + ) + else { result( FlutterError( code: "transcode_failed", @@ -183,7 +271,8 @@ import UserNotifications case .completed: result(outputURL.path) default: - let errorMessage = exportSession.error?.localizedDescription + let errorMessage = + exportSession.error?.localizedDescription ?? "Video transcoding failed with status \(exportSession.status.rawValue)." result( FlutterError( diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 9e4f42cb8..f99b4f1dc 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -2,6 +2,10 @@ + BuzzAppGroupIdentifier + $(BUZZ_APP_GROUP_IDENTIFIER) + BuzzKeychainAccessGroup + $(AppIdentifierPrefix)$(BUZZ_KEYCHAIN_ACCESS_GROUP) CADisableMinimumFrameDurationOnPhone CFBundleDevelopmentRegion diff --git a/mobile/ios/Runner/Runner.entitlements b/mobile/ios/Runner/Runner.entitlements new file mode 100644 index 000000000..5725dc08c --- /dev/null +++ b/mobile/ios/Runner/Runner.entitlements @@ -0,0 +1,18 @@ + + + + + aps-environment + $(BUZZ_IOS_PUSH_ENVIRONMENT) + com.apple.developer.app-attest.environment + $(BUZZ_APP_ATTEST_ENVIRONMENT) + com.apple.security.application-groups + + $(BUZZ_APP_GROUP_IDENTIFIER) + + keychain-access-groups + + $(AppIdentifierPrefix)$(BUZZ_KEYCHAIN_ACCESS_GROUP) + + + diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 833d48b20..e2423b010 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -3,10 +3,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'app.dart'; +import 'shared/push/push_bridge.dart'; import 'shared/theme/theme_provider.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + installBuzzPushMethodHandler(); // Pre-load preferences so the first frame uses the saved theme/accent. final prefs = await SharedPreferences.getInstance(); diff --git a/mobile/lib/shared/community/community_provider.dart b/mobile/lib/shared/community/community_provider.dart index d014c23b5..a084697a8 100644 --- a/mobile/lib/shared/community/community_provider.dart +++ b/mobile/lib/shared/community/community_provider.dart @@ -1,6 +1,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../auth/auth_provider.dart'; +import '../push/push_bridge.dart'; import 'community.dart'; import 'community_storage.dart'; @@ -36,11 +37,14 @@ class CommunityListNotifier extends AsyncNotifier> { final updatedList = [...current]; updatedList[existingIndex] = updated; state = AsyncData(updatedList); + await registerBuzzPushCommunitySnapshot(updatedList); return existing.id; } await storage.save(community); - state = AsyncData([...current, community]); + final updatedList = [...current, community]; + state = AsyncData(updatedList); + await registerBuzzPushCommunitySnapshot(updatedList); return community.id; } @@ -49,7 +53,9 @@ class CommunityListNotifier extends AsyncNotifier> { await storage.remove(id); final current = state.value ?? []; - state = AsyncData(current.where((w) => w.id != id).toList()); + final updatedList = current.where((w) => w.id != id).toList(); + state = AsyncData(updatedList); + await registerBuzzPushCommunitySnapshot(updatedList); // If we removed the active community, switch to another or sign out. final activeId = await storage.loadActiveId(); @@ -90,6 +96,7 @@ class CommunityListNotifier extends AsyncNotifier> { final updatedList = [...current]; updatedList[index] = updated; state = AsyncData(updatedList); + await registerBuzzPushCommunitySnapshot(updatedList); } } diff --git a/mobile/lib/shared/push/push_bridge.dart b/mobile/lib/shared/push/push_bridge.dart new file mode 100644 index 000000000..82dbd182a --- /dev/null +++ b/mobile/lib/shared/push/push_bridge.dart @@ -0,0 +1,89 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import '../community/community.dart'; +import '../relay/nostr_models.dart'; +import '../relay/relay_provider.dart'; +import 'push_models.dart'; + +const _channel = MethodChannel('buzz/push'); + +Future registerBuzzPushCommunitySnapshot( + List communities, +) async { + if (defaultTargetPlatform != TargetPlatform.iOS) return; + try { + final snapshots = [ + for (final community in communities) + BuzzPushCommunitySnapshot( + id: community.id, + name: community.name, + relayUrl: community.relayUrl, + pubkey: community.pubkey ?? pubkeyFromNsec(community.nsec), + ), + ]; + await _channel.invokeMethod('saveCommunitySnapshot', { + 'communities': [for (final snapshot in snapshots) snapshot.toJson()], + }); + } on MissingPluginException { + // Flutter tests and non-Runner embeddings do not install the native bridge. + } +} + +Future resolveBuzzPushPayload( + Map arguments, +) async { + final myPubkey = arguments['pubkey'] as String?; + final communityName = arguments['communityName'] as String? ?? 'Buzz'; + if (myPubkey == null || myPubkey.isEmpty) return null; + + final eventPayloads = arguments['events']; + if (eventPayloads is! List) return null; + final events = []; + for (final payload in eventPayloads) { + if (payload is Map) { + try { + events.add(NostrEvent.fromJson(Map.from(payload))); + } catch (_) { + // Ignore malformed relay rows and preserve the fallback notification. + } + } + } + + final profiles = {}; + final profilePayloads = arguments['profiles']; + if (profilePayloads is List) { + for (final payload in profilePayloads) { + if (payload is Map) { + try { + final event = NostrEvent.fromJson(Map.from(payload)); + final profile = ProfileData.fromEvent(event); + profiles[profile.pubkey.toLowerCase()] = profile; + } catch (_) {} + } + } + } + + return resolveBuzzPushNotification( + events: events, + myPubkey: myPubkey, + communityName: communityName, + channelName: arguments['channelName'] as String?, + profilesByPubkey: profiles, + ); +} + +void installBuzzPushMethodHandler() { + _channel.setMethodCallHandler((call) async { + switch (call.method) { + case 'resolveNotification': + final args = call.arguments; + if (args is! Map) return null; + return (await resolveBuzzPushPayload( + Map.from(args), + ))?.toJson(); + default: + throw MissingPluginException('Unknown buzz/push method ${call.method}'); + } + }); +} diff --git a/mobile/lib/shared/push/push_models.dart b/mobile/lib/shared/push/push_models.dart new file mode 100644 index 000000000..f741e5b9e --- /dev/null +++ b/mobile/lib/shared/push/push_models.dart @@ -0,0 +1,133 @@ +import '../relay/nostr_models.dart'; + +const buzzPushFallbackBody = 'Reconnect to your relay now'; + +class BuzzPushCommunitySnapshot { + final String id; + final String name; + final String relayUrl; + final String? pubkey; + + const BuzzPushCommunitySnapshot({ + required this.id, + required this.name, + required this.relayUrl, + this.pubkey, + }); + + Map toJson() => { + 'id': id, + 'name': name, + 'relayUrl': relayUrl, + if (pubkey != null) 'pubkey': pubkey, + }; + + factory BuzzPushCommunitySnapshot.fromJson(Map json) { + return BuzzPushCommunitySnapshot( + id: json['id'] as String, + name: json['name'] as String, + relayUrl: json['relayUrl'] as String, + pubkey: json['pubkey'] as String?, + ); + } +} + +class BuzzPushResolution { + final String title; + final String body; + final String? subtitle; + final String? threadIdentifier; + + const BuzzPushResolution({ + required this.title, + required this.body, + this.subtitle, + this.threadIdentifier, + }); + + Map toJson() => { + 'title': title, + 'body': body, + if (subtitle != null) 'subtitle': subtitle, + if (threadIdentifier != null) 'threadIdentifier': threadIdentifier, + }; +} + +BuzzPushResolution? resolveBuzzPushNotification({ + required List events, + required String myPubkey, + required String communityName, + String? channelName, + Map profilesByPubkey = const {}, +}) { + final normalizedPubkey = myPubkey.toLowerCase(); + final candidates = [ + for (final event in events) + if (_isUserVisiblePushEvent(event, normalizedPubkey)) event, + ]; + if (candidates.isEmpty) return null; + candidates.sort((a, b) { + final created = b.createdAt.compareTo(a.createdAt); + return created != 0 ? created : a.id.compareTo(b.id); + }); + final event = candidates.first; + final author = profilesByPubkey[event.pubkey.toLowerCase()]; + final title = _firstNonEmpty([ + author?.displayName, + author?.nip05, + _shortPubkey(event.pubkey), + ]); + final body = _previewBody(event.content); + if (body.isEmpty) return null; + return BuzzPushResolution( + title: title, + subtitle: channelName ?? communityName, + body: body, + threadIdentifier: event.channelId ?? communityName, + ); +} + +bool _isUserVisiblePushEvent(NostrEvent event, String normalizedPubkey) { + if (!EventKind.channelMessageEventKinds.contains(event.kind)) return false; + if (event.pubkey.toLowerCase() == normalizedPubkey) return false; + return true; +} + +String _previewBody(String content) { + String stripMarkdownLinks(String input) { + return input.replaceAllMapped( + RegExp(r'!?\[([^\]]*)\]\([^)]*\)'), + (match) => match.group(1) ?? '', + ); + } + + final stripped = + stripMarkdownLinks( + stripMarkdownLinks( + content + .replaceAll(RegExp(r'```[\s\S]*?```'), '[code]') + .replaceAllMapped( + RegExp(r'`([^`]*)`'), + (match) => match.group(1) ?? '', + ), + ), + ) + .replaceAll(RegExp(r'https?://\S+'), '[link]') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + if (stripped.length <= 180) return stripped; + return '${stripped.substring(0, 177).trimRight()}…'; +} + +String _shortPubkey(String pubkey) { + if (pubkey.length <= 8) return pubkey; + return '${pubkey.substring(0, 8)}…'; +} + +String _firstNonEmpty(Iterable values) { + for (final value in values) { + final trimmed = value?.trim(); + if (trimmed != null && trimmed.isNotEmpty) return trimmed; + } + return 'Buzz'; +} diff --git a/mobile/test/shared/push/push_models_test.dart b/mobile/test/shared/push/push_models_test.dart new file mode 100644 index 000000000..ed0ee46d6 --- /dev/null +++ b/mobile/test/shared/push/push_models_test.dart @@ -0,0 +1,97 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:buzz/shared/push/push_models.dart'; +import 'package:buzz/shared/relay/nostr_models.dart'; + +void main() { + test('resolves newest user-visible event into notification content', () { + final mine = 'a' * 64; + final alice = 'b' * 64; + final older = _event( + id: '1' * 64, + pubkey: alice, + createdAt: 10, + content: 'older', + ); + final newest = _event( + id: '2' * 64, + pubkey: alice, + createdAt: 20, + content: 'hello [there](https://example.com)', + tags: [ + ['h', 'chan-1'], + ], + ); + final profile = _event( + id: '3' * 64, + pubkey: alice, + kind: EventKind.contactList, + content: '{"display_name":"Alice"}', + ); + + final resolved = resolveBuzzPushNotification( + events: [older, newest], + myPubkey: mine, + communityName: 'Team', + channelName: 'mobile', + profilesByPubkey: {alice: ProfileData.fromEvent(profile)}, + ); + + expect(resolved, isNotNull); + expect(resolved!.title, 'Alice'); + expect(resolved.subtitle, 'mobile'); + expect(resolved.body, 'hello there'); + expect(resolved.threadIdentifier, 'chan-1'); + }); + + test( + 'keeps fallback when only self-authored or non-message events exist', + () { + final mine = 'a' * 64; + final resolved = resolveBuzzPushNotification( + events: [ + _event(id: '1' * 64, pubkey: mine, content: 'self'), + _event( + id: '2' * 64, + pubkey: 'b' * 64, + kind: EventKind.reaction, + content: '+', + ), + ], + myPubkey: mine, + communityName: 'Team', + ); + + expect(resolved, isNull); + }, + ); + + test('trims long previews', () { + final resolved = resolveBuzzPushNotification( + events: [_event(id: '1' * 64, pubkey: 'b' * 64, content: 'x' * 240)], + myPubkey: 'a' * 64, + communityName: 'Team', + ); + + expect(resolved!.body.length, lessThanOrEqualTo(180)); + expect(resolved.body.endsWith('…'), isTrue); + }); +} + +NostrEvent _event({ + required String id, + required String pubkey, + required String content, + int kind = EventKind.streamMessage, + int createdAt = 1, + List> tags = const [], +}) { + return NostrEvent( + id: id, + pubkey: pubkey, + createdAt: createdAt, + kind: kind, + tags: tags, + content: content, + sig: '0' * 128, + ); +}