diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index ea50261..a24b7a5 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -70,5 +70,5 @@ dependencies { testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) - implementation("com.ditto:dittochat:1.0.1") + implementation(project(":dittochat")) } \ No newline at end of file diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml index 21c12f5..bbb53f4 100644 --- a/apps/android/app/src/main/AndroidManifest.xml +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -35,6 +35,13 @@ android:usesPermissionFlags="neverForLocation" tools:targetApi="tiramisu" /> + + + +) { val navController = rememberNavController() - var roomIdState = remember { "" } + var roomIdState by remember { mutableStateOf("") } val chatViewModel = remember { ChatScreenViewModel(dittoChat.p2pStore, dittoChat) } + + // Observe notification taps. When a room ID arrives, navigate to that room. + val pendingRoomId by notificationRoomId.collectAsState() + LaunchedEffect(pendingRoomId) { + val roomId = pendingRoomId ?: return@LaunchedEffect + roomIdState = roomId + // Navigate to the chatroom destination; popUpTo prevents stacking duplicates. + navController.navigate("chatroom") { + launchSingleTop = true + } + } + NavHost(navController = navController, startDestination = "home") { composable("home") { - // Content for your Home Screen DittoChatUI(dittoChat).RoomsListView( RoomsListScreenViewModel( dittoChat, @@ -31,8 +58,6 @@ fun MyAppNavigation(dittoChat: DittoChat) { } } composable("chatroom") { - - // Content for your Detail Screen DittoChatUI(dittoChat).ChatRoomView( chatViewModel, roomId = roomIdState, @@ -43,4 +68,4 @@ fun MyAppNavigation(dittoChat: DittoChat) { } } } -} \ No newline at end of file +} diff --git a/apps/android/app/src/main/java/com/ditto/dittochatandroiddemo/MainActivity.kt b/apps/android/app/src/main/java/com/ditto/dittochatandroiddemo/MainActivity.kt index fd2d51a..3a4b1eb 100644 --- a/apps/android/app/src/main/java/com/ditto/dittochatandroiddemo/MainActivity.kt +++ b/apps/android/app/src/main/java/com/ditto/dittochatandroiddemo/MainActivity.kt @@ -1,14 +1,21 @@ package com.ditto.dittochatandroiddemo +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope import com.ditto.dittochat.DittoChat import com.ditto.dittochat.DittoChatImpl +import com.ditto.dittochat.DittoChatNotificationKey import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import live.ditto.Ditto import live.ditto.DittoIdentity @@ -25,31 +32,40 @@ class MainActivity : ComponentActivity() { lateinit var dittoChat: DittoChat lateinit var ditto: Ditto - private val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { this.ditto.refreshPermissions() } + + /** + * Emits the room ID extracted from a notification tap. Collected by [MyAppNavigation] via + * [LaunchedEffect] so the nav controller can navigate to the correct chat room. + * + * Set in both [onCreate] (cold start from tap) and [onNewIntent] (tap while app is running). + */ + val notificationRoomId = MutableStateFlow(null) + + private val requestPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { this.ditto.refreshPermissions() } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) requestPermissions() + val baseDir = File(this.filesDir, "ditto") val userDir = File(baseDir, "user_ditto_chat_demo") - - // Ensure directory exists userDir.mkdirs() + val userDependencies = DefaultAndroidDittoDependencies(this) val playgroundToken = "" val appId = "" val cloudEndpoint = "" val userId = "" - // Create user Ditto instance with appropriate identity - val userIdentity = - // Use playground identity when playground token is available - DittoIdentity.OnlinePlayground( - dependencies = userDependencies, - appId = appId, - token = playgroundToken, - enableDittoCloudSync = false, - customAuthUrl = "https://${cloudEndpoint}" - ) + + val userIdentity = DittoIdentity.OnlinePlayground( + dependencies = userDependencies, + appId = appId, + token = playgroundToken, + enableDittoCloudSync = false, + customAuthUrl = "https://${cloudEndpoint}" + ) ditto = Ditto(userDependencies, userIdentity) lifecycleScope.launch { @@ -59,17 +75,52 @@ class MainActivity : ComponentActivity() { ditto.disableSyncWithV3() ditto.startSync() - enableEdgeToEdge() + // If this Activity was launched by tapping a DittoChat notification, extract the + // room ID and make it available to the Compose navigation graph. + handleNotificationIntent(intent) setContent { - MyAppNavigation(dittoChat) + MyAppNavigation(dittoChat, notificationRoomId) } } + /** + * Called when the Activity is already running and a notification is tapped + * ([Intent.FLAG_ACTIVITY_SINGLE_TOP] is set in the PendingIntent). + */ + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + handleNotificationIntent(intent) + } + + // ----------------------------------------------------------------------------------------- + // Permission handling + // ----------------------------------------------------------------------------------------- + fun requestPermissions() { - val missing = DittoSyncPermissions(this).missingPermissions() + val missing = DittoSyncPermissions(this).missingPermissions().toMutableList() + + // Android 13+ requires POST_NOTIFICATIONS at runtime. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission( + this, Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + missing.add(Manifest.permission.POST_NOTIFICATIONS) + } + } + if (missing.isNotEmpty()) { - requestPermissionLauncher.launch(missing) + requestPermissionLauncher.launch(missing.toTypedArray()) } } -} \ No newline at end of file + + // ----------------------------------------------------------------------------------------- + // Notification deep-link + // ----------------------------------------------------------------------------------------- + + private fun handleNotificationIntent(intent: Intent?) { + val roomId = intent?.getStringExtra(DittoChatNotificationKey.ROOM_ID) ?: return + notificationRoomId.value = roomId + } +} diff --git a/apps/android/gradle/libs.versions.toml b/apps/android/gradle/libs.versions.toml index 0240dde..17b08f5 100644 --- a/apps/android/gradle/libs.versions.toml +++ b/apps/android/gradle/libs.versions.toml @@ -9,6 +9,7 @@ gson = "2.13.2" hiltAndroid = "2.57.1" hiltNavigationCompose = "1.3.0" kotlin = "2.2.10" +ksp = "2.2.10-2.0.2" coreKtx = "1.17.0" junit = "4.13.2" junitVersion = "1.3.0" @@ -56,3 +57,5 @@ android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } android-library = { id = "com.android.library", version.ref = "agp" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hiltAndroid" } diff --git a/apps/android/settings.gradle.kts b/apps/android/settings.gradle.kts index ff566eb..fb1f708 100644 --- a/apps/android/settings.gradle.kts +++ b/apps/android/settings.gradle.kts @@ -21,3 +21,8 @@ dependencyResolutionManagement { rootProject.name = "DittoChatAndroidDemo" include(":app") + +// Pull the DittoChat SDK directly from the local repository so changes to sdks/kotlin +// are reflected in the demo app immediately without publishing to Maven Central. +include(":dittochat") +project(":dittochat").projectDir = File("../../sdks/kotlin") diff --git a/apps/ios/DittoChatDemo.xcodeproj/project.pbxproj b/apps/ios/DittoChatDemo.xcodeproj/project.pbxproj index 44929b6..d4fbdc8 100644 --- a/apps/ios/DittoChatDemo.xcodeproj/project.pbxproj +++ b/apps/ios/DittoChatDemo.xcodeproj/project.pbxproj @@ -9,8 +9,6 @@ /* Begin PBXBuildFile section */ 0750235F2E985C0100AF7194 /* DittoChatCore in Frameworks */ = {isa = PBXBuildFile; productRef = 0750235E2E985C0100AF7194 /* DittoChatCore */; }; 075023612E985C0100AF7194 /* DittoChatUI in Frameworks */ = {isa = PBXBuildFile; productRef = 075023602E985C0100AF7194 /* DittoChatUI */; }; - 781173362F4E112300EB7936 /* DittoChatCore in Frameworks */ = {isa = PBXBuildFile; productRef = 781173352F4E112300EB7936 /* DittoChatCore */; }; - 781173382F4E112300EB7936 /* DittoChatUI in Frameworks */ = {isa = PBXBuildFile; productRef = 781173372F4E112300EB7936 /* DittoChatUI */; }; 7873E56D2E68F390003DC9B2 /* DittoChatCore in Frameworks */ = {isa = PBXBuildFile; productRef = 7873E56C2E68F390003DC9B2 /* DittoChatCore */; }; 7873E56F2E68F390003DC9B2 /* DittoChatUI in Frameworks */ = {isa = PBXBuildFile; productRef = 7873E56E2E68F390003DC9B2 /* DittoChatUI */; }; 7873E5782E68F47B003DC9B2 /* DittoChatCore in Frameworks */ = {isa = PBXBuildFile; productRef = 7873E5772E68F47B003DC9B2 /* DittoChatCore */; }; @@ -21,6 +19,8 @@ 7873E58A2E68F5A2003DC9B2 /* DittoChatUI in Frameworks */ = {isa = PBXBuildFile; productRef = 7873E5892E68F5A2003DC9B2 /* DittoChatUI */; }; 78E90C082E6A1E3C00B01E9B /* DittoChatCore in Frameworks */ = {isa = PBXBuildFile; productRef = 78E90C072E6A1E3C00B01E9B /* DittoChatCore */; }; 78E90C0A2E6A1E3C00B01E9B /* DittoChatUI in Frameworks */ = {isa = PBXBuildFile; productRef = 78E90C092E6A1E3C00B01E9B /* DittoChatUI */; }; + 78FDF4202F65CE2A000D1840 /* DittoChatCore in Frameworks */ = {isa = PBXBuildFile; productRef = 78FDF41F2F65CE2A000D1840 /* DittoChatCore */; }; + 78FDF4222F65CE2A000D1840 /* DittoChatUI in Frameworks */ = {isa = PBXBuildFile; productRef = 78FDF4212F65CE2A000D1840 /* DittoChatUI */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -58,9 +58,9 @@ 0750235F2E985C0100AF7194 /* DittoChatCore in Frameworks */, 7873E5882E68F5A2003DC9B2 /* DittoChatCore in Frameworks */, 7873E57D2E68F4F1003DC9B2 /* DittoChatCore in Frameworks */, - 781173382F4E112300EB7936 /* DittoChatUI in Frameworks */, + 78FDF4222F65CE2A000D1840 /* DittoChatUI in Frameworks */, 7873E57F2E68F4F1003DC9B2 /* DittoChatUI in Frameworks */, - 781173362F4E112300EB7936 /* DittoChatCore in Frameworks */, + 78FDF4202F65CE2A000D1840 /* DittoChatCore in Frameworks */, 7873E5782E68F47B003DC9B2 /* DittoChatCore in Frameworks */, 78E90C0A2E6A1E3C00B01E9B /* DittoChatUI in Frameworks */, 7873E57A2E68F47B003DC9B2 /* DittoChatUI in Frameworks */, @@ -121,8 +121,8 @@ 78E90C092E6A1E3C00B01E9B /* DittoChatUI */, 0750235E2E985C0100AF7194 /* DittoChatCore */, 075023602E985C0100AF7194 /* DittoChatUI */, - 781173352F4E112300EB7936 /* DittoChatCore */, - 781173372F4E112300EB7936 /* DittoChatUI */, + 78FDF41F2F65CE2A000D1840 /* DittoChatCore */, + 78FDF4212F65CE2A000D1840 /* DittoChatUI */, ); productName = DittoChatDemo; productReference = 7873E55D2E68F35E003DC9B2 /* DittoChatDemo.app */; @@ -153,7 +153,7 @@ mainGroup = 7873E5542E68F35E003DC9B2; minimizedProjectReferenceProxies = 1; packageReferences = ( - 781173342F4E112300EB7936 /* XCRemoteSwiftPackageReference "DittoChat" */, + 78FDF41E2F65CE2A000D1840 /* XCRemoteSwiftPackageReference "DittoChat" */, ); preferredProjectObjectVersion = 77; productRefGroup = 7873E55E2E68F35E003DC9B2 /* Products */; @@ -428,12 +428,12 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - 781173342F4E112300EB7936 /* XCRemoteSwiftPackageReference "DittoChat" */ = { + 78FDF41E2F65CE2A000D1840 /* XCRemoteSwiftPackageReference "DittoChat" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/getditto/DittoChat"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 6.0.2; + branch = "feature/FORGE-740-Chat-Notifications"; + kind = branch; }; }; /* End XCRemoteSwiftPackageReference section */ @@ -447,16 +447,6 @@ isa = XCSwiftPackageProductDependency; productName = DittoChatUI; }; - 781173352F4E112300EB7936 /* DittoChatCore */ = { - isa = XCSwiftPackageProductDependency; - package = 781173342F4E112300EB7936 /* XCRemoteSwiftPackageReference "DittoChat" */; - productName = DittoChatCore; - }; - 781173372F4E112300EB7936 /* DittoChatUI */ = { - isa = XCSwiftPackageProductDependency; - package = 781173342F4E112300EB7936 /* XCRemoteSwiftPackageReference "DittoChat" */; - productName = DittoChatUI; - }; 7873E56C2E68F390003DC9B2 /* DittoChatCore */ = { isa = XCSwiftPackageProductDependency; productName = DittoChatCore; @@ -497,6 +487,16 @@ isa = XCSwiftPackageProductDependency; productName = DittoChatUI; }; + 78FDF41F2F65CE2A000D1840 /* DittoChatCore */ = { + isa = XCSwiftPackageProductDependency; + package = 78FDF41E2F65CE2A000D1840 /* XCRemoteSwiftPackageReference "DittoChat" */; + productName = DittoChatCore; + }; + 78FDF4212F65CE2A000D1840 /* DittoChatUI */ = { + isa = XCSwiftPackageProductDependency; + package = 78FDF41E2F65CE2A000D1840 /* XCRemoteSwiftPackageReference "DittoChat" */; + productName = DittoChatUI; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 7873E5552E68F35E003DC9B2 /* Project object */; diff --git a/apps/ios/DittoChatDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apps/ios/DittoChatDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 36ea703..2476356 100644 --- a/apps/ios/DittoChatDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/apps/ios/DittoChatDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/getditto/DittoChat", "state" : { - "revision" : "f049ae31d5d742bfba06365a6d559878f75d79a0", - "version" : "6.0.2" + "branch" : "feature/FORGE-740-Chat-Notifications", + "revision" : "b5576be0a6b7b81516c215b0a8da038355923ad4" } }, { @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections", "state" : { - "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", - "version" : "1.3.0" + "revision" : "8d9834a6189db730f6264db7556a7ffb751e99ee", + "version" : "1.4.0" } } ], diff --git a/apps/ios/DittoChatDemo/ContentView.swift b/apps/ios/DittoChatDemo/ContentView.swift index cebcb39..792e49b 100644 --- a/apps/ios/DittoChatDemo/ContentView.swift +++ b/apps/ios/DittoChatDemo/ContentView.swift @@ -8,21 +8,86 @@ import SwiftUI import DittoChatUI import DittoChatCore -import DittoSwift +import DittoSwift struct ContentView: View { - @ObservedObject var viewModel: ContentViewModel = ContentViewModel() + @StateObject private var viewModel = ContentViewModel() + + /// Room ID received from a notification tap. Set via the `dittoChatOpenRoom` + /// NotificationCenter broadcast posted by AppDelegate. + @State private var pendingRoomId: String? + + /// Drives the programmatic NavigationLink to the deep-linked chat room. + @State private var isNavigatingToRoom = false var body: some View { NavigationView { - let ditto = viewModel.ditto ?? Ditto() - RoomsListScreen( - dittoChat: try! DittoChatBuilder() - .setDitto(ditto) - .setUserId(viewModel.projectMetadata.ueerId) - .build() - ) + Group { + if let dittoChat = viewModel.dittoChat { + ZStack { + RoomsListScreen(dittoChat: dittoChat) + + // Hidden NavigationLink activated when a notification is tapped. + NavigationLink( + destination: roomDestination(dittoChat: dittoChat), + isActive: $isNavigatingToRoom + ) { EmptyView() } + } + } else { + ProgressView("Starting Ditto…") + } + } + } + // Receive deep-link events broadcast by AppDelegate on notification tap. + .onReceive(NotificationCenter.default.publisher(for: .dittoChatOpenRoom)) { note in + guard let roomId = note.userInfo?["roomId"] as? String else { return } + pendingRoomId = roomId + isNavigatingToRoom = true + } + } + + /// Destination view that fetches the Room asynchronously then shows `ChatScreen`. + @ViewBuilder + private func roomDestination(dittoChat: DittoChat) -> some View { + if let roomId = pendingRoomId { + RoomDeepLinkView(roomId: roomId, dittoChat: dittoChat) + } + } +} + +// MARK: - RoomDeepLinkView + +/// Resolves a room ID to a `Room` model asynchronously and presents `ChatScreen`. +/// Shows a spinner while the lookup is in flight. +private struct RoomDeepLinkView: View { + + let roomId: String + let dittoChat: DittoChat + + @State private var room: Room? + @State private var loadFailed = false + + var body: some View { + Group { + if let room { + ChatScreen(room: room, dittoChat: dittoChat) + } else if loadFailed { + ContentUnavailableView( + "Room not found", + systemImage: "bubble.left.and.exclamationmark.bubble.right", + description: Text("This chat room could not be loaded.") + ) + } else { + ProgressView() + } + } + .task { + do { + room = try await dittoChat.readRoomById(id: roomId) + } catch { + loadFailed = true + } } } } diff --git a/apps/ios/DittoChatDemo/ContentViewModel.swift b/apps/ios/DittoChatDemo/ContentViewModel.swift index 654272d..36b69a5 100644 --- a/apps/ios/DittoChatDemo/ContentViewModel.swift +++ b/apps/ios/DittoChatDemo/ContentViewModel.swift @@ -10,9 +10,11 @@ import Combine import DittoSwift import DittoChatCore +@MainActor final class ContentViewModel: ObservableObject { @Published private(set) var ditto: Ditto? + @Published private(set) var dittoChat: DittoChat? @Published private(set) var projectMetadata: ProjectMetadata init() { @@ -24,6 +26,11 @@ final class ContentViewModel: ObservableObject { do { let dittoInstance = try dittoInstanceForProject(projectMetadata) ditto = dittoInstance + + dittoChat = try DittoChat.builder() + .setDitto(dittoInstance) + .setUserId(projectMetadata.userId) + .build() } catch { #if DEBUG print("Error setting up Ditto: \(error)") @@ -35,8 +42,7 @@ final class ContentViewModel: ObservableObject { nonisolated func dittoDirectory(forId projectId: String) throws -> URL { try FileManager.default.url( - for: - .applicationSupportDirectory, + for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true diff --git a/apps/ios/DittoChatDemo/DittoChatDemoApp.swift b/apps/ios/DittoChatDemo/DittoChatDemoApp.swift index b1e90fc..98f3d15 100644 --- a/apps/ios/DittoChatDemo/DittoChatDemoApp.swift +++ b/apps/ios/DittoChatDemo/DittoChatDemoApp.swift @@ -6,9 +6,148 @@ // import SwiftUI +import UserNotifications +import BackgroundTasks +import DittoChatCore + +// MARK: - Notification name used to route a notification tap to ContentView + +extension Notification.Name { + /// Posted by AppDelegate when the user taps a DittoChat notification. + /// `userInfo` contains `"roomId": String`. + static let dittoChatOpenRoom = Notification.Name("dittoChatOpenRoom") +} + +// MARK: - App Delegate + +/// Handles UIKit lifecycle callbacks that SwiftUI's `@main App` cannot intercept directly: +/// +/// - Sets this class as the `UNUserNotificationCenter` delegate so the SDK's local +/// notifications display correctly both in the foreground and on tap. +/// - Broadcasts a `dittoChatOpenRoom` notification when the user taps a message banner, +/// which `ContentView` observes to navigate to the appropriate room. +/// - Registers and schedules a `BGAppRefreshTask` so iOS wakes the app periodically on +/// WiFi-only networks (where no BLE event would trigger a natural wakeup). This gives +/// Ditto's sync engine time to pull in new messages and fire `DittoStoreObserver` +/// callbacks even when the app has been backgrounded for an extended period. +class AppDelegate: NSObject, UIApplicationDelegate { + + // MARK: - Task identifier + + /// Must match an entry in `BGTaskSchedulerPermittedIdentifiers` inside Info.plist. + private static let refreshTaskIdentifier = "com.ditto.DittoChatDemo.refresh" + + // MARK: - UIApplicationDelegate + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + UNUserNotificationCenter.current().delegate = self + registerBackgroundRefreshTask() + return true + } + + func applicationDidEnterBackground(_ application: UIApplication) { + scheduleAppRefresh() + } + + // MARK: - BGTaskScheduler + + /// Registers the handler that iOS calls when it wakes the app for a background refresh. + /// + /// The handler keeps Ditto's sync engine running for up to 20 seconds, which is enough + /// time for Ditto to connect to nearby peers, exchange pending documents, and fire any + /// waiting `DittoStoreObserver` callbacks — posting local notifications for new messages. + private func registerBackgroundRefreshTask() { + BGTaskScheduler.shared.register( + forTaskWithIdentifier: AppDelegate.refreshTaskIdentifier, + using: nil + ) { task in + guard let refreshTask = task as? BGAppRefreshTask else { return } + self.handleAppRefresh(refreshTask) + } + } + + /// Schedules the next background refresh. iOS decides the exact wakeup time; requesting + /// a short `earliestBeginDate` signals that responsiveness matters but does not guarantee + /// the interval. Call this both at launch and at the end of each refresh handler so iOS + /// always has a pending request queued. + func scheduleAppRefresh() { + let request = BGAppRefreshTaskRequest(identifier: AppDelegate.refreshTaskIdentifier) + // Ask iOS to wake the app no later than 3 minutes from now. + request.earliestBeginDate = Date(timeIntervalSinceNow: 3 * 60) + do { + try BGTaskScheduler.shared.submit(request) + } catch { + print("AppDelegate: could not schedule app refresh — \(error)") + } + } + + /// Called by iOS when it wakes the app for a background refresh. + /// + /// We simply give Ditto ~20 seconds of execution time. During that window Ditto's sync + /// engine reconnects to peers, syncs pending changes, and triggers any + /// `DittoStoreObserver` callbacks, which in turn post local notifications via + /// `ChatNotificationManager`. After the wait we schedule the next refresh and signal + /// completion. + private func handleAppRefresh(_ task: BGAppRefreshTask) { + scheduleAppRefresh() // always queue the next request first + + // Give Ditto time to sync before we signal completion. + let syncDeadline = DispatchTime.now() + 20 + + task.expirationHandler = { + // iOS is revoking our background time early — complete immediately. + task.setTaskCompleted(success: false) + } + + DispatchQueue.global(qos: .utility).asyncAfter(deadline: syncDeadline) { + task.setTaskCompleted(success: true) + } + } +} + +// MARK: - UNUserNotificationCenterDelegate + +extension AppDelegate: UNUserNotificationCenterDelegate { + + /// Display notifications as banners with sound even while the app is in the foreground. + /// Without this, iOS suppresses banner presentation when the app is active. + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .sound]) + } + + /// Handle a notification tap: extract the DittoChat room ID and broadcast it so + /// `ContentView` can navigate to the correct chat room. + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + let userInfo = response.notification.request.content.userInfo + if let roomId = userInfo[DittoChatNotificationKey.roomId] as? String { + NotificationCenter.default.post( + name: .dittoChatOpenRoom, + object: nil, + userInfo: ["roomId": roomId] + ) + } + completionHandler() + } +} + +// MARK: - App Entry Point @main struct DittoChatDemoApp: App { + + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + var body: some Scene { WindowGroup { ContentView() diff --git a/apps/ios/DittoChatDemo/Info.plist b/apps/ios/DittoChatDemo/Info.plist index 0c67376..afaa702 100644 --- a/apps/ios/DittoChatDemo/Info.plist +++ b/apps/ios/DittoChatDemo/Info.plist @@ -1,5 +1,34 @@ - + + + UIBackgroundModes + + bluetooth-central + bluetooth-peripheral + fetch + + + + BGTaskSchedulerPermittedIdentifiers + + com.ditto.DittoChatDemo.refresh + + diff --git a/sdks/kotlin/src/main/java/com/ditto/dittochat/ChatNotificationManager.kt b/sdks/kotlin/src/main/java/com/ditto/dittochat/ChatNotificationManager.kt new file mode 100644 index 0000000..dea7bd7 --- /dev/null +++ b/sdks/kotlin/src/main/java/com/ditto/dittochat/ChatNotificationManager.kt @@ -0,0 +1,247 @@ +package com.ditto.dittochat + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import live.ditto.Ditto +import live.ditto.DittoStoreObserver +import java.util.concurrent.ConcurrentHashMap + +/** + * Observes Ditto message collections and posts local Android notifications when new messages + * arrive in rooms the current user has access to. + * + * ## Background operation + * + * Each room is observed via a raw [DittoStoreObserver] held strongly in [roomObservers]. + * Because Ditto's Android SDK runs as a persistent background service, the observer callbacks + * fire even when the host app's UI is not in the foreground — no WorkManager or + * foreground-service boilerplate is required on the app side. + * + * [NotificationManagerCompat.notify] is thread-safe and works from any background thread. + * + * ## Filtering rules + * + * | Condition | Behaviour | + * |---|---| + * | `message.userId == currentUserId` | Skipped — don't notify for own messages | + * | `message.createdOn <= startTime` | Skipped — don't replay history on init | + * | `message.isArchived == true` | Skipped — tombstoned / deleted messages | + * | Already in `notifiedMessageIds` | Skipped — observer fires for full result set, not deltas | + * + * ## Deep-link on tap + * + * Each notification carries [DittoChatNotificationKey.ROOM_ID] and + * [DittoChatNotificationKey.MESSAGE_ID] in its [PendingIntent] extras. The host app + * reads these via [Intent.getStringExtra] in `Activity.onNewIntent` or + * `Activity.onCreate` and routes using [DittoChat.handleNotification]. + */ +internal class ChatNotificationManager( + private val context: Context, + private val ditto: Ditto, + private val localStore: LocalData +) { + + companion object { + private const val TAG = "ChatNotificationManager" + internal const val CHANNEL_ID = "ditto_chat_messages" + private const val CHANNEL_NAME = "Chat Messages" + private const val CHANNEL_DESCRIPTION = "New messages in DittoChat rooms" + } + + // One DittoStoreObserver per room. Must be retained — releasing an entry cancels the + // Ditto observation for that room. + private val roomObservers = ConcurrentHashMap() + + // Message IDs for which a notification has already been posted this session. + private val notifiedMessageIds: MutableSet = ConcurrentHashMap.newKeySet() + + // Messages with createdOn before this instant are treated as historical and never notified. + private val startTimeMs = System.currentTimeMillis() + + init { + createNotificationChannel() + } + + // ----------------------------------------------------------------------------------------- + // Room sync + // ----------------------------------------------------------------------------------------- + + /** + * Reconciles the observed set against [rooms]: + * starts observers for rooms not yet watched, cancels observers for rooms no longer present. + * + * Call this whenever [DittoChatImpl.publicRoomsFlow] emits a new list. + */ + fun syncRooms(rooms: List) { + val newIds = rooms.map { it.id }.toSet() + val currentIds = roomObservers.keys.toSet() + + // Stop watching rooms that are no longer in the list. + currentIds.subtract(newIds).forEach { stopObserving(it) } + + // Start watching rooms that are new. + rooms.filter { it.id !in currentIds }.forEach { startObserving(it) } + } + + /** + * Cancels all active observers and clears session state. + * Call from [DittoChatImpl.logout]. + */ + fun stopAll() { + roomObservers.values.forEach { it.close() } + roomObservers.clear() + notifiedMessageIds.clear() + } + + // ----------------------------------------------------------------------------------------- + // Observation + // ----------------------------------------------------------------------------------------- + + private fun startObserving(room: Room) { + if (roomObservers.containsKey(room.id)) return + + // Fetch only the most recent 50 messages; no attachment tokens needed for text preview. + val query = """ + SELECT * FROM `${room.messagesId}` + WHERE roomId == :roomId + ORDER BY createdOn DESC + LIMIT 50 + """.trimIndent() + + val roomId = room.id + val roomName = room.name + + try { + val observer = ditto.store.registerObserver( + query, + mapOf("roomId" to roomId) + ) { result -> + // Callback fires on Ditto's internal thread — safe to post notifications directly. + val currentUserId = localStore.currentUserId + + val incoming = result.items.mapNotNull { item -> + parseSafeMessage(item.value) + }.filter { msg -> + msg.userId != currentUserId // not own message + && createdAfterStart(msg.createdOn) // not historical + && !msg.isArchived // not deleted + && notifiedMessageIds.add(msg.id) // not already notified + } + + incoming.forEach { msg -> + postNotification(msg, roomName, roomId) + } + } + roomObservers[roomId] = observer + } catch (e: Exception) { + Log.w(TAG, "Failed to register observer for room $roomId: $e") + } + } + + private fun stopObserving(roomId: String) { + roomObservers.remove(roomId)?.close() + } + + // ----------------------------------------------------------------------------------------- + // Notification posting + // ----------------------------------------------------------------------------------------- + + private fun postNotification(message: Message, roomName: String, roomId: String) { + // Build a PendingIntent that opens the launcher Activity with room/message extras. + // Using the launcher intent means the SDK doesn't need to reference the host app's + // MainActivity class directly. + val launchIntent = context.packageManager + .getLaunchIntentForPackage(context.packageName) + ?.apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + putExtra(DittoChatNotificationKey.ROOM_ID, roomId) + putExtra(DittoChatNotificationKey.MESSAGE_ID, message.id) + } + + val pendingIntent = launchIntent?.let { + PendingIntent.getActivity( + context, + // Use roomId hash as request code so each room has its own back-stack entry. + roomId.hashCode(), + it, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + + val body = if (message.isImageMessage) "Sent an image" else message.text.orEmpty() + + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentTitle(roomName) + .setContentText(body) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .build() + + try { + NotificationManagerCompat.from(context).notify(message.id.hashCode(), notification) + } catch (e: SecurityException) { + // POST_NOTIFICATIONS permission has not been granted yet. + Log.w(TAG, "Cannot post notification — POST_NOTIFICATIONS not granted: $e") + } + } + + // ----------------------------------------------------------------------------------------- + // Channel setup + // ----------------------------------------------------------------------------------------- + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + CHANNEL_NAME, + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = CHANNEL_DESCRIPTION + } + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) + as NotificationManager + manager.createNotificationChannel(channel) + } + } + + // ----------------------------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------------------------- + + /** Safely parse a raw Ditto document value into a [Message], returning null on failure. */ + @Suppress("UNCHECKED_CAST") + private fun parseSafeMessage(value: Map): Message? { + return try { + val id = value["_id"] as? String ?: return null + val createdOn = value["createdOn"] as? String ?: return null + val roomId = value["roomId"] as? String ?: return null + Message( + id = id, + createdOn = createdOn, + roomId = roomId, + text = value["text"] as? String, + userId = value["userId"] as? String ?: "", + thumbnailImageToken = value["thumbnailImageToken"] as? Map, + largeImageToken = value["largeImageToken"] as? Map, + isArchived = value["isArchived"] as? Boolean ?: false + ) + } catch (e: Exception) { + null + } + } + + /** Returns true if the ISO-8601 [createdOn] string represents a time after [startTimeMs]. */ + private fun createdAfterStart(createdOn: String): Boolean { + val date = DateUtils.fromISOString(createdOn) ?: return false + return date.time > startTimeMs + } +} diff --git a/sdks/kotlin/src/main/java/com/ditto/dittochat/DittoChat.kt b/sdks/kotlin/src/main/java/com/ditto/dittochat/DittoChat.kt index 184dd99..f564bec 100644 --- a/sdks/kotlin/src/main/java/com/ditto/dittochat/DittoChat.kt +++ b/sdks/kotlin/src/main/java/com/ditto/dittochat/DittoChat.kt @@ -1,12 +1,15 @@ package com.ditto.dittochat import android.content.Context +import android.os.Bundle import com.google.gson.Gson -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch import java.util.Date import java.util.UUID import javax.inject.Inject @@ -30,10 +33,55 @@ interface DittoChat { suspend fun updateRoom(room: Room) fun logout() + + // ----------------------------------------------------------------------------------------- + // Push notifications + // ----------------------------------------------------------------------------------------- + + /** + * Optional delegate that receives callbacks for outgoing chat events so the host app + * can forward them to a push server (FCM). Held with a strong reference — the caller + * controls its lifetime. + */ + var pushNotificationDelegate: DittoChatPushNotificationDelegate? + + /** + * The FCM registration token set by [registerDeviceToken]. Null until the host app + * provides a token. + */ + val deviceToken: String? + + /** + * Stores the FCM registration token so it is available when building push payloads. + * + * Call this from your `FirebaseMessagingService.onNewToken` override: + * ```kotlin + * override fun onNewToken(token: String) { + * dittoChat.registerDeviceToken(token) + * } + * ``` + */ + fun registerDeviceToken(token: String) + + /** + * Interprets an incoming notification's extras and returns the navigation action. + * + * Pass the [Bundle] from `intent.extras` (in `Activity.onCreate` or `onNewIntent`): + * ```kotlin + * val action = dittoChat.handleNotification(intent.extras) + * when (action) { + * is DittoChatNotificationAction.OpenRoom -> navTo("chatroom/${action.roomId}") + * is DittoChatNotificationAction.OpenMessage -> navTo("chatroom/${action.roomId}") + * is DittoChatNotificationAction.None -> { } + * } + * ``` + */ + fun handleNotification(extras: Bundle?): DittoChatNotificationAction } @Singleton class DittoChatImpl private constructor( + private val context: Context, private val ditto: live.ditto.Ditto?, override val retentionPolicy: ChatRetentionPolicy, private val usersCollection: String, @@ -55,13 +103,60 @@ class DittoChatImpl private constructor( localStore.currentUserId = value } + // ----------------------------------------------------------------------------------------- + // Push notifications + // ----------------------------------------------------------------------------------------- + + override var pushNotificationDelegate: DittoChatPushNotificationDelegate? = null + override var deviceToken: String? = null + private set + + override fun registerDeviceToken(token: String) { + deviceToken = token + } + + override fun handleNotification(extras: Bundle?): DittoChatNotificationAction { + val roomId = extras?.getString(DittoChatNotificationKey.ROOM_ID) + ?: return DittoChatNotificationAction.None + val messageId = extras.getString(DittoChatNotificationKey.MESSAGE_ID) + return if (messageId != null) { + DittoChatNotificationAction.OpenMessage(roomId, messageId) + } else { + DittoChatNotificationAction.OpenRoom(roomId) + } + } + + // ----------------------------------------------------------------------------------------- + // Notification manager + // ----------------------------------------------------------------------------------------- + + private val notificationManager = ChatNotificationManager(context, ditto!!, localStore) + + /** Scope used solely to collect [publicRoomsFlow] and keep [notificationManager] in sync. */ + private val notificationScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + // ----------------------------------------------------------------------------------------- + // Init + // ----------------------------------------------------------------------------------------- + init { userId?.let { setCurrentUser(UserConfig(it)) } userEmail?.let { // Setup roles subscription if needed } + + // Keep ChatNotificationManager in sync with the live rooms list. + notificationScope.launch { + p2pStore.publicRoomsFlow.collect { rooms -> + notificationManager.syncRooms(rooms) + } + } } + // ----------------------------------------------------------------------------------------- + // DittoChat interface + // ----------------------------------------------------------------------------------------- + override suspend fun createRoom(config: RoomConfig): String { val room = p2pStore.createRoom(config.id, config.name, config.isGenerated) ?: throw Exception("Room creation failed") @@ -71,6 +166,7 @@ class DittoChatImpl private constructor( override suspend fun createMessage(config: MessageConfig) { val room = readRoomById(config.roomId) p2pStore.createMessage(room, config.message) + pushNotificationDelegate?.dittoChat(this, config.message, room) } override fun setCurrentUser(config: UserConfig) { @@ -91,6 +187,8 @@ class DittoChatImpl private constructor( } override fun logout() { + notificationManager.stopAll() + notificationScope.cancel() p2pStore.logout() } @@ -108,6 +206,7 @@ class DittoChatImpl private constructor( suspend fun createImageMessage(room: Room, imageData: ByteArray, text: String?) { p2pStore.createImageMessage(room, imageData, text) + pushNotificationDelegate?.dittoChat(this, room) } suspend fun saveEditedTextMessage(message: Message, room: Room) { @@ -167,9 +266,25 @@ class DittoChatImpl private constructor( val peerKeyString: String get() = p2pStore.peerKeyString - // Builder Pattern + // ----------------------------------------------------------------------------------------- + // Builder + // ----------------------------------------------------------------------------------------- + + /** + * Fluent builder for [DittoChatImpl]. + * + * When using Hilt, inject [Builder] directly — [Context] is provided automatically. + * Without Hilt, use the [companion factory][DittoChatImpl.builder]: + * ```kotlin + * val chat = DittoChatImpl.builder(localStore, applicationContext) + * .setDitto(ditto) + * .setUserId(userId) + * .build() + * ``` + */ class Builder @Inject constructor( - private val localStore: LocalData + private val localStore: LocalData, + @ApplicationContext private val context: Context ) { private var ditto: live.ditto.Ditto? = null var retentionPolicy: ChatRetentionPolicy = ChatRetentionPolicy(days = 30) @@ -186,6 +301,7 @@ class DittoChatImpl private constructor( private set var primaryColor: String? = null private set + private var pushNotificationDelegate: DittoChatPushNotificationDelegate? = null fun setDitto(ditto: live.ditto.Ditto) = apply { this.ditto = ditto @@ -223,6 +339,14 @@ class DittoChatImpl private constructor( this.primaryColor = color } + /** + * Sets the delegate that receives callbacks for outgoing chat events so the host app + * can forward them to a push server (FCM). + */ + fun setPushNotificationDelegate(delegate: DittoChatPushNotificationDelegate?) = apply { + this.pushNotificationDelegate = delegate + } + fun build(): DittoChatImpl { requireNotNull(ditto) { "Ditto instance is required" } @@ -234,6 +358,7 @@ class DittoChatImpl private constructor( ) return DittoChatImpl( + context = context, ditto = ditto, retentionPolicy = retentionPolicy, usersCollection = usersCollection, @@ -244,13 +369,16 @@ class DittoChatImpl private constructor( primaryColor = primaryColor, localStore = localStore, p2pStore = dittoStore - ) + ).also { impl -> + impl.pushNotificationDelegate = pushNotificationDelegate + } } } companion object { - fun builder(localStore: LocalData): Builder { - return Builder(localStore) + /** Non-Hilt factory. [context] should be the Application context. */ + fun builder(localStore: LocalData, context: Context): Builder { + return Builder(localStore, context) } } } diff --git a/sdks/kotlin/src/main/java/com/ditto/dittochat/DittoChatNotification.kt b/sdks/kotlin/src/main/java/com/ditto/dittochat/DittoChatNotification.kt new file mode 100644 index 0000000..fdab8f7 --- /dev/null +++ b/sdks/kotlin/src/main/java/com/ditto/dittochat/DittoChatNotification.kt @@ -0,0 +1,68 @@ +package com.ditto.dittochat + +/** + * Keys embedded in every local notification's [android.os.Bundle] extras and in every + * server-side push payload. Use these constants to read values from + * [android.content.Intent.getStringExtra] in the host app. + */ +object DittoChatNotificationKey { + /** The ID of the DittoChat room associated with the notification. */ + const val ROOM_ID = "dittoChatRoomId" + + /** The ID of the specific message that triggered the notification. May be absent. */ + const val MESSAGE_ID = "dittoChatMessageId" +} + +/** + * Navigation action returned by [DittoChat.handleNotification]. + * + * Use this in your notification-tap handler to route the user to the correct screen: + * + * ```kotlin + * val action = dittoChat.handleNotification(intent.extras) + * when (action) { + * is DittoChatNotificationAction.OpenRoom -> navController.navigate("chatroom/${action.roomId}") + * is DittoChatNotificationAction.OpenMessage -> navController.navigate("chatroom/${action.roomId}") + * is DittoChatNotificationAction.None -> { /* not a DittoChat notification */ } + * } + * ``` + */ +sealed class DittoChatNotificationAction { + /** Navigate to the room list item or the room's chat screen. */ + data class OpenRoom(val roomId: String) : DittoChatNotificationAction() + + /** + * Navigate to the room's chat screen and scroll to / highlight the specific message. + * [messageId] is the value of [DittoChatNotificationKey.MESSAGE_ID] in the payload. + */ + data class OpenMessage(val roomId: String, val messageId: String) : DittoChatNotificationAction() + + /** The notification payload does not contain DittoChat routing keys — ignore it. */ + object None : DittoChatNotificationAction() +} + +/** + * Optional delegate for server-side (APNs / FCM) push notification integration. + * + * Implement this interface and register it via [DittoChatImpl.Builder.setPushNotificationDelegate] + * if your backend should send push notifications when messages are created. The delegate is called + * on the coroutine dispatcher used by [DittoChatImpl] immediately after each Ditto write. + * + * Default no-op implementations are provided — override only what you need. + * + * ### Example + * ```kotlin + * class MyPushHandler : DittoChatPushNotificationDelegate { + * override fun dittoChat(dittoChat: DittoChat, didSendMessage: String, inRoom: Room) { + * MyPushServer.notifyRoom(id = inRoom.id, body = didSendMessage) + * } + * override fun dittoChat(dittoChat: DittoChat, didSendImageMessageInRoom: Room) { + * MyPushServer.notifyRoom(id = inRoom.id, body = "📷 Image") + * } + * } + * ``` + */ +interface DittoChatPushNotificationDelegate { + fun dittoChat(dittoChat: DittoChat, didSendMessage: String, inRoom: Room) {} + fun dittoChat(dittoChat: DittoChat, didSendImageMessageInRoom: Room) {} +} diff --git a/sdks/kotlin/src/main/java/com/ditto/dittochat/ui/DittoChatModule.kt b/sdks/kotlin/src/main/java/com/ditto/dittochat/ui/DittoChatModule.kt index c2d5594..0e5e69d 100644 --- a/sdks/kotlin/src/main/java/com/ditto/dittochat/ui/DittoChatModule.kt +++ b/sdks/kotlin/src/main/java/com/ditto/dittochat/ui/DittoChatModule.kt @@ -1,7 +1,6 @@ package com.ditto.dittochat.ui import android.content.Context -import com.ditto.dittochat.DittoChat import com.ditto.dittochat.DittoChatImpl import com.ditto.dittochat.LocalData import com.ditto.dittochat.LocalDataImpl @@ -11,7 +10,6 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import live.ditto.Ditto import javax.inject.Singleton @Module @@ -21,9 +19,10 @@ internal object DittoChatModule { @Provides @Singleton fun provideDittoChatBuilder( - localStore: LocalData + localStore: LocalData, + @ApplicationContext context: Context ): DittoChatImpl.Builder { - return DittoChatImpl.Builder(localStore) + return DittoChatImpl.Builder(localStore, context) } @Provides diff --git a/sdks/swift/CLAUDE.md b/sdks/swift/CLAUDE.md new file mode 100644 index 0000000..984002f --- /dev/null +++ b/sdks/swift/CLAUDE.md @@ -0,0 +1,379 @@ +# DittoChat Swift SDK — Architecture & Patterns + +## Overview + +The Swift SDK is split into two SPM library targets: + +- **DittoChatCore** — Data models, services, protocols, and the public API. No UI dependencies. +- **DittoChatUI** — SwiftUI screens and view models that consume `DittoChatCore`. + +Both targets live under `Sources/`. + +--- + +## Module Structure + +``` +Sources/ +├── DittoChatCore/ +│ ├── DittoChat.swift # Main public class + builder +│ ├── Data/ +│ │ ├── DittoService.swift # Ditto sync engine adapter +│ │ └── LocalService.swift # UserDefaults adapter +│ ├── Models/ +│ │ ├── Room.swift +│ │ ├── Message.swift +│ │ ├── ChatUser.swift +│ │ └── MessageWithUser.swift +│ ├── ErrorHandling/ +│ │ └── AppError.swift +│ └── Utilities/ +│ ├── Publishers.swift # DittoDecodable + DittoStore extensions +│ ├── Concurrency.swift # asyncMap Publisher bridge +│ └── TemporaryFile.swift +└── DittoChatUI/ + ├── DittoChatUI.swift # UI factory class + └── Screens/ + ├── ChatScreen/ # Main messaging UI (7 files) + ├── RoomsListScreen/ # Room list (3 files) + ├── RoomEditScreen/ # Room creation (2 files) + ├── MessageEditView/ # Message editing (2 files) + └── ErrorScreen/ # Error handling UI (2 files) +``` + +--- + +## Key Architectural Patterns + +### 1. Builder Pattern for Initialization + +`DittoChatBuilder` provides a fluent, chainable API. All setters return `Self` and are marked `@discardableResult`. The `build()` method validates required fields and throws `BuilderError` if any are missing. + +```swift +let chat = try DittoChat.builder() + .setDitto(ditto) // required + .setUserId("user-123") // required + .setUserEmail("u@example.com") + .setRetentionDays(30) + .setAcceptLargeImages(true) + .setPrimaryColor("#FF5733") + .build() +``` + +Entry point is `DittoChat.builder()` (static factory). Never instantiate `DittoChatBuilder` directly. + +### 2. Protocol-Based Abstraction + +Three key protocols decouple consumers from implementations: + +| Protocol | Implementation | Purpose | +|---|---|---| +| `DittoSwiftChat` | `DittoChat` | Public SDK contract | +| `DittoDataInterface` | `DittoService` | Ditto sync engine adapter | +| `LocalDataInterface` | `LocalService` | UserDefaults persistence adapter | + +Add new data sources or swap implementations by conforming to these protocols — the rest of the SDK is unaffected. + +### 3. MVVM with Combine + SwiftUI + +All ViewModels: +- Are marked `@MainActor` (thread safety for UI state) +- Inherit from `ObservableObject` +- Use `@Published` properties for reactive updates + +Views use `@StateObject` (ownership) or `@ObservedObject` (injection) to bind to ViewModels. Views never contain business logic. + +``` +DittoService (Combine publishers) + └─> DittoChat (aggregates, transforms) + └─> ViewModel @Published properties + └─> SwiftUI View re-renders +``` + +### 4. Combine for Reactive Data Streams + +All data is exposed as Combine publishers, never returned synchronously. Key patterns used: + +- `CurrentValueSubject` — state that has a current value (rooms list, current user) +- `PassthroughSubject` — event-only streams +- `combineLatest()` — merge rooms + archival state into a single stream +- `removeDuplicates()` — prevent redundant UI updates + +Publishers from `DittoService` flow up through `DittoChat` and into ViewModels via `.sink` or `.assign(to:)` in `onAppear`/`init`. + +### 5. Custom `DittoDecodable` Protocol + +Ditto returns documents as `[String: Any?]` dictionaries. Rather than using `Codable` (which Ditto doesn't natively support), models implement `DittoDecodable`: + +```swift +protocol DittoDecodable { + init(value: [String: Any?]) +} +``` + +All models (`Room`, `Message`, `ChatUser`) conform to this. The `DittoStore` extension `observePublisher(query:mapTo:)` automatically maps raw Ditto results into strongly-typed model arrays. + +### 6. Async/Await + Combine Bridging + +Long-running operations (file I/O, attachment fetches) use `async/await`. The `asyncMap` Publisher extension bridges async work back into a Combine pipeline: + +```swift +// In Concurrency.swift +extension Publisher { + @MainActor + func asyncMap( + _ transform: @escaping (Output) async throws -> T + ) -> Publishers.FlatMap, Self> +} +``` + +Use this any time you need to call an `async` function inside a `.map` on a publisher. + +--- + +## Data Models + +All models are value types (`struct`) and `Identifiable`. Key fields: + +**`Room`** — `id`, `name`, `messagesId` (Ditto collection for its messages), `createdBy`, `createdOn`, `isGenerated` + +**`Message`** — `id`, `roomId`, `text`, `userId`, `createdOn`, `largeImageToken`, `thumbnailImageToken`, `archivedMessage` (tombstone for soft deletes), `isArchived` (computed) + +**`ChatUser`** — `id`, `name`, `subscriptions: [roomId: Date?]`, `mentions: [roomId: [userId]]` + +**`MessageWithUser`** — value object composing `Message` + `ChatUser`; used in UI to avoid repeated lookups. + +--- + +## Ditto Integration + +### DQL (Ditto Query Language) + +Use DQL strings directly. Collections for chat are `rooms`, `users`, and per-room message collections (identified by `room.messagesId`). Collection names with special characters are backtick-quoted. + +```swift +// Example queries used in DittoService +"SELECT * FROM `rooms` ORDER BY createdOn ASC" +"SELECT * FROM `messages` WHERE roomId == :roomId ORDER BY createdOn ASC" +"SELECT * FROM COLLECTION `users` (`subscriptions` MAP, `mentions` MAP) WHERE _id = :id" +``` + +- Use named parameters (`:paramName`) — never interpolate values into query strings. +- `MAP` type hint is required for `subscriptions` and `mentions` fields. + +### Upserts + +Use `INSERT ... ON ID CONFLICT DO UPDATE` for all create/update operations that need idempotency (creating rooms, setting user data). + +### Attachments + +Images flow through Ditto's attachment API: +1. `ditto.store.newAttachment(path:metadata:)` — creates attachment from file path +2. Store the returned token in `largeImageToken` / `thumbnailImageToken` on the message document +3. Fetch later via `fetchAttachment(token:deliverOn:onFetchEvent:)` with progress tracking + +Deleted image messages are tombstoned by nulling out both tokens and setting `archivedMessage`. + +### Subscriptions + +Register Ditto subscriptions in `DittoService` for each collection the app needs to sync. Subscriptions must be explicitly cancelled in `logout()`. Never leave dangling subscriptions. + +--- + +## Error Handling + +| Type | Usage | +|---|---| +| `AppError` | General feature and QR code errors | +| `AttachmentError` | Image attachment failures | +| `DittoChatBuilder.BuilderError` | Missing required builder fields | + +In the UI layer, errors flow through `ErrorHandler` (an `ObservableObject`) and are surfaced via the `View.withErrorHandling()` extension modifier. ViewModels catch thrown errors and forward them to `ErrorHandler`. + +--- + +## UI Factory (`DittoChatUI`) + +`DittoChatUI` wraps a `DittoChat` instance and acts as a factory for top-level SwiftUI views: + +```swift +let ui = DittoChatUI(dittoChatCore: chat) +ui.roomsView() // AnyView — rooms list +ui.roomView(room) // AnyView — chat screen for a room +ui.readRoomById(id) // AnyView? — chat screen by room ID +``` + +Consumers embed these views into their own SwiftUI hierarchy. The views own their ViewModels via `@StateObject`. + +--- + +## Threading Conventions + +- All ViewModels and `DittoChat` are `@MainActor` — they may be accessed from the main thread only. +- `DittoService` callbacks are dispatched to `.main` via `.receive(on: DispatchQueue.main)` before being delivered to subscribers. +- Use `Task { @MainActor in ... }` when bridging from background async work back to ViewModel state. + +--- + +## Local Notifications (Incoming Messages) + +`ChatNotificationManager` (internal) automatically posts `UNUserNotification`s when new messages arrive in synced rooms. It is created inside `DittoChat.init` and requires no configuration by the host app beyond ensuring the app has the necessary background modes. + +### How it works + +``` +Ditto sync engine receives new message document + └─> DittoStoreObserver callback fires (even when app is backgrounded) + └─> ChatNotificationManager.handle(messages:roomId:roomName:) + └─> filters: not own message, createdOn > startTime, not archived, not yet notified + └─> UNUserNotificationCenter.add(request) → banner appears +``` + +Each room in `DittoChat.p2pStore.publicRoomsPublisher` gets its own `DittoStoreObserver` that watches the room's message collection. Observers are held strongly in `[roomId: DittoStoreObserver]` — releasing an entry cancels the subscription. The manager reconciles the observed set each time the rooms list changes. + +### Why raw DittoStoreObserver instead of Combine + +`Publishers.swift`'s `observePublisher` wraps `registerObserver` but does **not** retain the returned `DittoStoreObserver`. The Combine `AnyCancellable` only keeps the pipeline alive, not the underlying Ditto observation. For guaranteed background delivery, `ChatNotificationManager` calls `ditto.store.registerObserver` directly and retains each observer explicitly. + +### Background operation + +- `DittoStoreObserver` callbacks fire on Ditto's internal threads regardless of app state. +- `deliverOn: .main` is used so callbacks land on the main RunLoop, which iOS keeps alive during background execution. +- `UNUserNotificationCenter.add` is thread-safe and works from background state. +- The host app must enable the **Background Modes** capability with **Background fetch** and/or **Remote notifications** for Ditto sync to continue when backgrounded. + +### Filtering rules + +| Condition | Behaviour | +|---|---| +| `message.userId == currentUserId` | Skipped — don't notify for own messages | +| `message.createdOn <= startTime` | Skipped — don't replay history on launch | +| `message.isArchived == true` | Skipped — tombstoned / deleted messages | +| Already in `notifiedMessageIds` | Skipped — observer fires for full result set, not just deltas | + +### Foreground notifications + +By default iOS suppresses notification banners when the app is foreground. To display them, implement `UNUserNotificationCenterDelegate.userNotificationCenter(_:willPresent:withCompletionHandler:)` in the host app and call the handler with `.banner` and `.sound`. + +### Notification payload + +The `userInfo` on every posted notification contains the DittoChat navigation keys so the host app can deep-link on tap (see **Apple Push Notifications** section below): + +```swift +[ + DittoChatNotificationKey.roomId: "", + DittoChatNotificationKey.messageId: "" +] +``` + +--- + +## Apple Push Notifications (APNs) + +The SDK does not send push notifications directly — APNs requires a server-side component. The SDK provides hooks so the host app (or its push server) can react to chat events. + +### Architecture + +``` +DittoChat (creates message) + └─> DittoChatPushNotificationDelegate.didSendMessage(_:inRoom:) + └─> Host app forwards event to push server + └─> Push server sends APNs notification + └─> Host app calls DittoChat.handleNotification(userInfo:) + └─> DittoChatNotificationAction (.openRoom / .openMessage / .none) +``` + +All types live in `Sources/DittoChatCore/PushNotifications/DittoChatPushNotification.swift`. + +### Key Types + +| Type | Purpose | +|---|---| +| `DittoChatPushNotificationDelegate` | Protocol — receives callbacks when messages are sent | +| `DittoChatNotificationAction` | Enum — navigation action derived from an incoming payload | +| `DittoChatNotificationKey` | Constants — `userInfo` dictionary keys (`dittoChatRoomId`, `dittoChatMessageId`) | + +### Registering the Delegate (Builder) + +```swift +let chat = try DittoChat.builder() + .setDitto(ditto) + .setUserId("user-123") + .setPushNotificationDelegate(myHandler) // optional + .build() +``` + +`DittoChat` holds the delegate `weak` — the caller must retain it. + +### Implementing the Delegate + +Default no-op implementations are provided; only override what you need. + +```swift +class MyPushHandler: DittoChatPushNotificationDelegate { + func dittoChat(_ dittoChat: DittoChat, didSendMessage text: String, inRoom room: Room) { + // Forward to your push server with room.id and text as the notification body + MyPushServer.notifyRoom(id: room.id, body: text) + } + + func dittoChat(_ dittoChat: DittoChat, didSendImageMessageInRoom room: Room) { + MyPushServer.notifyRoom(id: room.id, body: "📷 Image") + } +} +``` + +Delegate methods are called on the main thread immediately after the Ditto write is dispatched. + +### Registering the Device Token + +Call from `application(_:didRegisterForRemoteNotificationsWithDeviceToken:)`: + +```swift +func application(_ app: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + dittoChat.registerDeviceToken(deviceToken) + // dittoChat.deviceToken now holds the hex string +} +``` + +The hex token is stored in `dittoChat.deviceToken` and can be included in payloads sent to your push server. + +### Handling Incoming Notifications + +Call from your `UNUserNotificationCenterDelegate`: + +```swift +func userNotificationCenter(_ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse) async { + let action = dittoChat.handleNotification(userInfo: response.notification.request.content.userInfo) + switch action { + case .openRoom(let id): + navigate(toRoomId: id) + case .openMessage(let roomId, let messageId): + navigate(toMessageId: messageId, inRoomId: roomId) + case .none: + break + } +} +``` + +### Push Payload Convention + +Your push server should include these keys in the `userInfo` (APN `data`) dictionary: + +```json +{ + "dittoChatRoomId": "", + "dittoChatMessageId": "" +} +``` + +`dittoChatMessageId` is optional — omitting it produces an `.openRoom` action instead of `.openMessage`. + +--- + +## Adding New Features + +1. **New model** — Add a `struct` in `DittoChatCore/Models/` conforming to `DittoDecodable` (and `Codable` if local persistence is needed). +2. **New data operation** — Add a method to `DittoDataInterface`, implement it in `DittoService`, and expose it via `DittoChat`. +3. **New screen** — Add a folder under `DittoChatUI/Screens/` with a `View` + `ViewModel` pair. The ViewModel takes `DittoChat` as a dependency. Expose via `DittoChatUI` factory if it's a top-level screen. +4. **New config option** — Add a stored property to `DittoChatBuilder`, a corresponding setter returning `Self`, and wire it through `build()` into `DittoChat`'s initializer. diff --git a/sdks/swift/Sources/DittoChatCore/DittoChat.swift b/sdks/swift/Sources/DittoChatCore/DittoChat.swift index ce69d34..0010195 100644 --- a/sdks/swift/Sources/DittoChatCore/DittoChat.swift +++ b/sdks/swift/Sources/DittoChatCore/DittoChat.swift @@ -86,9 +86,23 @@ public class DittoChat: DittoSwiftChat, ObservableObject { private var rolesCancellable: AnyCancellable? private var roles: [AdminRole] = [] + /// The delegate that receives callbacks for outgoing chat events that should trigger + /// a push notification. Held weakly — the caller is responsible for retaining it. + public weak var pushNotificationDelegate: DittoChatPushNotificationDelegate? + + /// The hex-encoded APNs device token registered via `registerDeviceToken(_:)`. + /// `nil` until the host app calls `registerDeviceToken(_:)`. + public private(set) var deviceToken: String? + private var localStore: LocalDataInterface var p2pStore: DittoDataInterface + /// Posts local `UNUserNotification`s when new messages arrive in synced rooms. + private var notificationManager: ChatNotificationManager? + + /// Retains Combine subscriptions used to keep `notificationManager` in sync with rooms. + private var cancellables = Set() + init( ditto: Ditto?, retentionPolicy: ChatRetentionPolicy, @@ -96,7 +110,8 @@ public class DittoChat: DittoSwiftChat, ObservableObject { userId: String?, userEmail: String?, acceptLargeImages: Bool, - primaryColor: String? + primaryColor: String?, + pushNotificationDelegate: DittoChatPushNotificationDelegate? ) { let localStore: LocalService = LocalService() self.acceptLargeImages = acceptLargeImages @@ -105,6 +120,21 @@ public class DittoChat: DittoSwiftChat, ObservableObject { self.p2pStore = DittoService(privateStore: localStore, ditto: ditto, usersCollection: usersCollection, chatRetentionPolicy: retentionPolicy) self.publicRoomsPublisher = p2pStore.publicRoomsPublisher.eraseToAnyPublisher() self.retentionPolicy = retentionPolicy + self.pushNotificationDelegate = pushNotificationDelegate + + // Set up local notification manager. Subscribes to each synced room's message + // collection via raw DittoStoreObserver so callbacks fire even when backgrounded. + let manager = ChatNotificationManager(ditto: ditto, localStore: localStore) + self.notificationManager = manager + + // Whenever the public rooms list changes, reconcile which rooms are being observed. + p2pStore.publicRoomsPublisher + .receive(on: DispatchQueue.main) + .sink { [weak manager] rooms in + manager?.syncRooms(rooms) + } + .store(in: &cancellables) + if let userId = userId { self.setCurrentUser(withConfig: UserConfig(id: userId)) } @@ -117,6 +147,50 @@ public class DittoChat: DittoSwiftChat, ObservableObject { } } + // MARK: - Push Notifications + + /// Registers an APNs device token with the SDK. + /// + /// Call this from `application(_:didRegisterForRemoteNotificationsWithDeviceToken:)`. + /// The token is converted to a hex string and stored in `deviceToken` so it is + /// accessible when building push payloads in your `DittoChatPushNotificationDelegate` + /// implementation. + /// + /// - Parameter deviceToken: The raw token data provided by APNs. + public func registerDeviceToken(_ deviceToken: Data) { + self.deviceToken = deviceToken.map { String(format: "%02.2hhx", $0) }.joined() + } + + /// Interprets an incoming push notification payload and returns the navigation action. + /// + /// Call this from your `UNUserNotificationCenterDelegate` implementation when a + /// DittoChat-related notification arrives. The payload must contain at least + /// `DittoChatNotificationKey.roomId` for a non-`.none` action to be returned. + /// + /// ```swift + /// func userNotificationCenter(_ center: UNUserNotificationCenter, + /// didReceive response: UNNotificationResponse) async { + /// let action = dittoChat.handleNotification(userInfo: response.notification.request.content.userInfo) + /// switch action { + /// case .openRoom(let id): navigate(toRoomId: id) + /// case .openMessage(let rId, let mId): navigate(toMessageId: mId, inRoomId: rId) + /// case .none: break + /// } + /// } + /// ``` + /// + /// - Parameter userInfo: The `userInfo` dictionary from the incoming notification. + /// - Returns: A `DittoChatNotificationAction` describing what the app should do. + public func handleNotification(userInfo: [AnyHashable: Any]) -> DittoChatNotificationAction { + guard let roomId = userInfo[DittoChatNotificationKey.roomId] as? String else { + return .none + } + if let messageId = userInfo[DittoChatNotificationKey.messageId] as? String { + return .openMessage(roomId: roomId, messageId: messageId) + } + return .openRoom(id: roomId) + } + public var currentUserId: String? { get { localStore.currentUserId } set { localStore.currentUserId = newValue } @@ -187,6 +261,9 @@ public class DittoChat: DittoSwiftChat, ObservableObject { /// Clears references to Ditto and running subscritopns as well as observers. /// Note: Make sure that you call stop sync before calling this logout function. public func logout() { + notificationManager?.stopAll() + notificationManager = nil + cancellables.removeAll() p2pStore.logout() } } @@ -199,6 +276,7 @@ public class DittoChatBuilder { private var userEmail: String? private var acceptLargeImages: Bool = true private var primaryColor: String? + private var pushNotificationDelegate: DittoChatPushNotificationDelegate? public init() {} @@ -250,6 +328,16 @@ public class DittoChatBuilder { return self } + /// Sets the delegate that receives callbacks for outgoing chat events that should + /// trigger a push notification. The delegate is held weakly by `DittoChat`. + /// + /// - Parameter delegate: An object conforming to `DittoChatPushNotificationDelegate`. + @discardableResult + public func setPushNotificationDelegate(_ delegate: DittoChatPushNotificationDelegate?) -> DittoChatBuilder { + self.pushNotificationDelegate = delegate + return self + } + @MainActor public func build() throws -> DittoChat { guard let ditto = ditto else { throw BuilderError.missingRequiredField("ditto") @@ -262,7 +350,8 @@ public class DittoChatBuilder { userId: userId, userEmail: userEmail, acceptLargeImages: acceptLargeImages, - primaryColor: primaryColor + primaryColor: primaryColor, + pushNotificationDelegate: pushNotificationDelegate ) } @@ -324,10 +413,12 @@ extension DittoChat { public func createMessage(for room: Room, text: String) { p2pStore.createMessage(for: room, text: text) + pushNotificationDelegate?.dittoChat(self, didSendMessage: text, inRoom: room) } public func createImageMessage(for room: Room, image: UIImage, text: String?) async throws { try await p2pStore.createImageMessage(for: room, image: image, text: text) + pushNotificationDelegate?.dittoChat(self, didSendImageMessageInRoom: room) } public func saveEditedTextMessage(_ message: Message, in room: Room) { diff --git a/sdks/swift/Sources/DittoChatCore/PushNotifications/ChatNotificationManager.swift b/sdks/swift/Sources/DittoChatCore/PushNotifications/ChatNotificationManager.swift new file mode 100644 index 0000000..cb02812 --- /dev/null +++ b/sdks/swift/Sources/DittoChatCore/PushNotifications/ChatNotificationManager.swift @@ -0,0 +1,183 @@ +// +// ChatNotificationManager.swift +// DittoChatCore +// +// Copyright © 2025 DittoLive Incorporated. All rights reserved. +// + +import DittoSwift +import Foundation +import UserNotifications + +/// Observes Ditto message collections and posts local `UNUserNotification`s when new +/// messages arrive in rooms the current user has access to. +/// +/// ## Background operation +/// +/// Each room uses a raw `DittoStoreObserver` (not a Combine publisher) so that callbacks +/// continue firing while the app is backgrounded. `DittoStoreObserver` objects are held +/// strongly in `roomObservers` — releasing an observer cancels its subscription, so the +/// dictionary must remain alive for as long as notifications are needed. +/// +/// `UNUserNotificationCenter.add(_:withCompletionHandler:)` works from any thread and from +/// background app state, so notifications are posted directly inside the observer callback. +/// +/// ## What gets notified +/// +/// - Only messages with `createdOn` after the manager was initialized (avoids replaying history). +/// - Only messages from other users (never from the current user's own `userId`). +/// - Never for archived / deleted messages. +/// - Each message ID is tracked in `notifiedMessageIds` so duplicate callbacks don't +/// produce duplicate banners. +@MainActor +final class ChatNotificationManager { + + // MARK: - Private State + + private weak var ditto: Ditto? + private let localStore: LocalDataInterface + + /// One `DittoStoreObserver` per observed room. Must be retained — releasing an entry + /// cancels that room's Ditto observation. + private var roomObservers: [String: DittoStoreObserver] = [:] + + /// Message IDs for which a local notification has already been posted this session. + private var notifiedMessageIds: Set = [] + + /// Messages created before this instant are treated as historical and never notified. + private let startTime = Date() + + // MARK: - Init + + init(ditto: Ditto?, localStore: LocalDataInterface) { + self.ditto = ditto + self.localStore = localStore + requestAuthorization() + } + + // MARK: - Room Sync + + /// Reconciles the observed set against `rooms`: + /// starts observers for rooms not yet watched, cancels observers for rooms no longer present. + /// + /// Call this whenever the public rooms list changes. + func syncRooms(_ rooms: [Room]) { + let newIds = Set(rooms.map(\.id)) + let currentIds = Set(roomObservers.keys) + + for roomId in currentIds.subtracting(newIds) { + stopObserving(roomId: roomId) + } + for room in rooms where !currentIds.contains(room.id) { + startObserving(room: room) + } + } + + /// Cancels all active observers and clears state. Call from `DittoChat.logout()`. + func stopAll() { + roomObservers.removeAll() // releasing DittoStoreObservers cancels each subscription + notifiedMessageIds.removeAll() + } + + // MARK: - Observation + + private func startObserving(room: Room) { + guard let ditto, roomObservers[room.id] == nil else { return } + + // Query the most recent 50 messages; no attachment tokens needed for text preview. + let query = """ + SELECT * FROM `\(room.messagesId)` + WHERE roomId == :roomId + ORDER BY createdOn DESC + LIMIT 50 + """ + + let roomId = room.id + let roomName = room.name + + do { + // deliverOn: .global() — deliver on a background thread so the callback fires + // immediately when Ditto's internal sync engine receives data, without needing + // the main RunLoop to iterate first. This is critical when the app is backgrounded: + // iOS keeps the main RunLoop alive for BLE-mode apps, but delivering directly on a + // global queue means callbacks are not queued behind any pending main-thread work. + // + // Message parsing (compactMap) happens on the background thread; only the actor- + // isolated state mutations hop to .main via DispatchQueue.main.async, which is + // lightweight and safe while the app has any background execution time. + let observer = try ditto.store.registerObserver( + query: query, + arguments: ["roomId": roomId], + deliverOn: .global(qos: .utility) + ) { [weak self] result in + // Parse off the main thread — no actor-isolated state touched here. + let messages = result.items.compactMap { Message(value: $0.value) } + // Hop to main for @MainActor state mutations and UNUserNotificationCenter. + DispatchQueue.main.async { [weak self] in + self?.handle(messages: messages, roomId: roomId, roomName: roomName) + } + } + roomObservers[room.id] = observer + } catch { + print("ChatNotificationManager: failed to register observer for room \(roomId): \(error)") + } + } + + private func stopObserving(roomId: String) { + roomObservers.removeValue(forKey: roomId) // releasing cancels the Ditto subscription + } + + // MARK: - Notification Logic + + private func handle(messages: [Message], roomId: String, roomName: String) { + let currentUserId = localStore.currentUserId + let cutoff = startTime + + let incoming = messages.filter { msg in + msg.userId != currentUserId // not from the local user + && msg.createdOn > cutoff // not historical + && !msg.isArchived // not a tombstoned / deleted message + && !notifiedMessageIds.contains(msg.id) + } + + for msg in incoming { + notifiedMessageIds.insert(msg.id) + postNotification(for: msg, roomName: roomName, roomId: roomId) + } + } + + private func postNotification(for message: Message, roomName: String, roomId: String) { + let content = UNMutableNotificationContent() + content.title = roomName + content.body = message.isImageMessage ? "Sent an image" : message.text + content.sound = .default + // Embed DittoChat navigation keys so the host app can route on tap. + content.userInfo = [ + DittoChatNotificationKey.roomId: roomId, + DittoChatNotificationKey.messageId: message.id + ] + + let request = UNNotificationRequest( + identifier: "dittochat-msg-\(message.id)", + content: content, + trigger: nil // deliver immediately + ) + + UNUserNotificationCenter.current().add(request) { error in + if let error { + print("ChatNotificationManager: failed to schedule notification: \(error)") + } + } + } + + // MARK: - Authorization + + private func requestAuthorization() { + UNUserNotificationCenter.current() + .requestAuthorization(options: [.alert, .sound, .badge]) { _, error in + if let error { + print("ChatNotificationManager: authorization error: \(error)") + } + } + } +} diff --git a/sdks/swift/Sources/DittoChatCore/PushNotifications/DittoChatPushNotification.swift b/sdks/swift/Sources/DittoChatCore/PushNotifications/DittoChatPushNotification.swift new file mode 100644 index 0000000..8717237 --- /dev/null +++ b/sdks/swift/Sources/DittoChatCore/PushNotifications/DittoChatPushNotification.swift @@ -0,0 +1,99 @@ +// +// DittoChatPushNotification.swift +// DittoChatCore +// +// Copyright © 2025 DittoLive Incorporated. All rights reserved. +// + +import Foundation + +// MARK: - Notification Payload Keys + +/// Keys used in the `userInfo` dictionary of push notifications sent by DittoChat-enabled apps. +/// +/// When your push server sends a notification for a chat event, include these keys in the +/// `userInfo` payload. Pass the received `userInfo` to `DittoChat.handleNotification(userInfo:)` +/// so the SDK can return the appropriate navigation action. +/// +/// ```json +/// { +/// "dittoChatRoomId": "", +/// "dittoChatMessageId": "" +/// } +/// ``` +public enum DittoChatNotificationKey { + /// The ID of the room associated with the notification. + public static let roomId = "dittoChatRoomId" + /// The ID of the specific message associated with the notification. + public static let messageId = "dittoChatMessageId" +} + +// MARK: - Notification Action + +/// The navigation action to take in response to an incoming push notification. +/// +/// Returned by `DittoChat.handleNotification(userInfo:)`. Use the associated values +/// to drive your app's navigation stack. +public enum DittoChatNotificationAction { + /// Navigate to the chat room with the given ID. + case openRoom(id: String) + /// Navigate to a specific message within a room. + case openMessage(roomId: String, messageId: String) + /// The notification payload did not contain recognized DittoChat keys. + case none +} + +// MARK: - Push Notification Delegate + +/// Implement this protocol to receive callbacks when local chat events occur that +/// should result in a push notification being sent to other room members. +/// +/// The SDK does not send push notifications directly — APNs requires a server-side +/// component. Instead, the SDK calls these methods so that your app (or push server) +/// can compose and deliver the notification. +/// +/// All methods are called on the main thread. Default no-op implementations are +/// provided via a protocol extension, so you only need to implement the methods +/// relevant to your use case. +/// +/// ## Usage +/// +/// ```swift +/// class MyPushHandler: DittoChatPushNotificationDelegate { +/// func dittoChat(_ dittoChat: DittoChat, didSendMessage text: String, inRoom room: Room) { +/// MyPushServer.send(body: text, toRoomId: room.id) +/// } +/// } +/// +/// let chat = try DittoChat.builder() +/// .setDitto(ditto) +/// .setUserId("user-123") +/// .setPushNotificationDelegate(myHandler) +/// .build() +/// ``` +@MainActor +public protocol DittoChatPushNotificationDelegate: AnyObject { + /// Called when the local user sends a text message. + /// + /// Use this to forward the event to your push server, which can then deliver + /// an APNs notification to other members of the room. + /// + /// - Parameters: + /// - dittoChat: The `DittoChat` instance that sent the message. + /// - text: The message text. + /// - room: The room the message was sent in. + func dittoChat(_ dittoChat: DittoChat, didSendMessage text: String, inRoom room: Room) + + /// Called when the local user sends an image message. + /// + /// - Parameters: + /// - dittoChat: The `DittoChat` instance that sent the message. + /// - room: The room the image was sent in. + func dittoChat(_ dittoChat: DittoChat, didSendImageMessageInRoom room: Room) +} + +/// Default no-op implementations. Conform to only the methods you need. +public extension DittoChatPushNotificationDelegate { + func dittoChat(_ dittoChat: DittoChat, didSendMessage text: String, inRoom room: Room) {} + func dittoChat(_ dittoChat: DittoChat, didSendImageMessageInRoom room: Room) {} +}