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,
+ );
+}