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>
This commit is contained in:
npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
2026-07-15 18:29:25 -04:00
parent 88be670406
commit e4b1e81312
14 changed files with 898 additions and 8 deletions
+4
View File
@@ -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"
+4
View File
@@ -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"
+35
View File
@@ -0,0 +1,35 @@
<?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>
<key>BuzzAppGroupIdentifier</key>
<string>$(BUZZ_APP_GROUP_IDENTIFIER)</string>
<key>BuzzKeychainAccessGroup</key>
<string>$(AppIdentifierPrefix)$(BUZZ_KEYCHAIN_ACCESS_GROUP)</string>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>NotificationService</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.usernotifications.service</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
</dict>
</dict>
</plist>
@@ -0,0 +1,14 @@
<?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>
<key>com.apple.security.application-groups</key>
<array>
<string>$(BUZZ_APP_GROUP_IDENTIFIER)</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)$(BUZZ_KEYCHAIN_ACCESS_GROUP)</string>
</array>
</dict>
</plist>
@@ -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")!
}
}
+173
View File
@@ -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 = "<group>"; };
BZZ00000000000000000007 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
BZZ00000000000000000008 /* NotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = NotificationService.entitlements; sourceTree = "<group>"; };
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 = "<group>"; };
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
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 = "<group>"; };
@@ -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 = "<group>";
@@ -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 = "<group>";
};
BZZ0000000000000000000B /* NotificationService */ = {
isa = PBXGroup;
children = (
BZZ00000000000000000006 /* NotificationService.swift */,
BZZ00000000000000000007 /* Info.plist */,
BZZ00000000000000000008 /* NotificationService.entitlements */,
);
path = NotificationService;
sourceTree = "<group>";
};
/* 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 = (
+95 -6
View File
@@ -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(
+4
View File
@@ -2,6 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BuzzAppGroupIdentifier</key>
<string>$(BUZZ_APP_GROUP_IDENTIFIER)</string>
<key>BuzzKeychainAccessGroup</key>
<string>$(AppIdentifierPrefix)$(BUZZ_KEYCHAIN_ACCESS_GROUP)</string>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
+18
View File
@@ -0,0 +1,18 @@
<?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>
<key>aps-environment</key>
<string>$(BUZZ_IOS_PUSH_ENVIRONMENT)</string>
<key>com.apple.developer.app-attest.environment</key>
<string>$(BUZZ_APP_ATTEST_ENVIRONMENT)</string>
<key>com.apple.security.application-groups</key>
<array>
<string>$(BUZZ_APP_GROUP_IDENTIFIER)</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)$(BUZZ_KEYCHAIN_ACCESS_GROUP)</string>
</array>
</dict>
</plist>
+2
View File
@@ -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();
@@ -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<List<Community>> {
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<List<Community>> {
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<List<Community>> {
final updatedList = [...current];
updatedList[index] = updated;
state = AsyncData(updatedList);
await registerBuzzPushCommunitySnapshot(updatedList);
}
}
+89
View File
@@ -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<void> registerBuzzPushCommunitySnapshot(
List<Community> 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<void>('saveCommunitySnapshot', {
'communities': [for (final snapshot in snapshots) snapshot.toJson()],
});
} on MissingPluginException {
// Flutter tests and non-Runner embeddings do not install the native bridge.
}
}
Future<BuzzPushResolution?> resolveBuzzPushPayload(
Map<String, dynamic> 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 = <NostrEvent>[];
for (final payload in eventPayloads) {
if (payload is Map) {
try {
events.add(NostrEvent.fromJson(Map<String, dynamic>.from(payload)));
} catch (_) {
// Ignore malformed relay rows and preserve the fallback notification.
}
}
}
final profiles = <String, ProfileData>{};
final profilePayloads = arguments['profiles'];
if (profilePayloads is List) {
for (final payload in profilePayloads) {
if (payload is Map) {
try {
final event = NostrEvent.fromJson(Map<String, dynamic>.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<String, dynamic>.from(args),
))?.toJson();
default:
throw MissingPluginException('Unknown buzz/push method ${call.method}');
}
});
}
+133
View File
@@ -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<String, dynamic> toJson() => {
'id': id,
'name': name,
'relayUrl': relayUrl,
if (pubkey != null) 'pubkey': pubkey,
};
factory BuzzPushCommunitySnapshot.fromJson(Map<String, dynamic> 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<String, dynamic> toJson() => {
'title': title,
'body': body,
if (subtitle != null) 'subtitle': subtitle,
if (threadIdentifier != null) 'threadIdentifier': threadIdentifier,
};
}
BuzzPushResolution? resolveBuzzPushNotification({
required List<NostrEvent> events,
required String myPubkey,
required String communityName,
String? channelName,
Map<String, ProfileData> 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<String?> values) {
for (final value in values) {
final trimmed = value?.trim();
if (trimmed != null && trimmed.isNotEmpty) return trimmed;
}
return 'Buzz';
}
@@ -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<List<String>> tags = const [],
}) {
return NostrEvent(
id: id,
pubkey: pubkey,
createdAt: createdAt,
kind: kind,
tags: tags,
content: content,
sig: '0' * 128,
);
}