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) {}
+}