diff --git a/README.md b/README.md
index f74fba35..26477410 100644
--- a/README.md
+++ b/README.md
@@ -2466,13 +2466,13 @@ Support levels:
UNNotificationTrigger
- - Ignored on Android
+ - Only `nextTriggerDate` is evaluated but not the `repeat` value
- | 🟠|
+ 🟡 |
UNUserNotificationCenter
@@ -2481,7 +2481,13 @@ Support levels:
func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool
var delegate: (any UNUserNotificationCenterDelegate)?
func add(_ request: UNNotificationRequest) async throws
- The `add` function ignores all scheduling and repeat options and simply delivers the notification immediately.
+ The `add` function ignores all repeat options and and simply delivers the notification either immediately or after the next trigger date is reached.
+ func pendingNotificationRequests() async -> [UNNotificationRequest]
+ func removePendingNotificationRequests(withIdentifiers identifiers: [String])
+ func removeAllPendingNotificationRequests()
+ func deliveredNotifications() async -> [UNNotification]
+ func removeDeliveredNotifications(withIdentifiers identifiers: [String])
+ func removeAllDeliveredNotifications()
|
diff --git a/Sources/SkipUI/Skip/skip.yml b/Sources/SkipUI/Skip/skip.yml
index 500796b7..8981e62b 100644
--- a/Sources/SkipUI/Skip/skip.yml
+++ b/Sources/SkipUI/Skip/skip.yml
@@ -18,6 +18,7 @@ settings:
- 'version("androidx-activity", "1.11.0")'
- 'version("androidx-lifecycle-process", "2.9.2")'
- 'version("androidx-material3-adaptive", "1.1.0")'
+ - 'version("androidx-work", "2.11.1")'
# the version for these libraries is derived from the kotlin-bom declared is skip-model/Sources/SkipModel/Skip/skip.yml
- 'library("androidx-core-ktx", "androidx.core", "core-ktx").withoutVersion()'
@@ -36,6 +37,8 @@ settings:
- 'library("androidx-lifecycle-process", "androidx.lifecycle", "lifecycle-process").versionRef("androidx-lifecycle-process")'
- 'library("androidx-compose-material3-adaptive", "androidx.compose.material3.adaptive", "adaptive").versionRef("androidx-material3-adaptive")'
+ - 'library("androidx-work-runtime", "androidx.work", "work-runtime-ktx").versionRef("androidx-work")'
+
- 'library("coil-compose", "io.coil-kt.coil3", "coil-compose").versionRef("coil")'
- 'library("coil-network-okhttp", "io.coil-kt.coil3", "coil-network-okhttp").versionRef("coil")'
- 'library("coil-svg", "io.coil-kt.coil3", "coil-svg").versionRef("coil")'
@@ -75,6 +78,7 @@ build:
- 'api(libs.androidx.appcompat.resources)'
- 'api(libs.androidx.activity.compose)'
- 'api(libs.androidx.lifecycle.process)'
+ - 'api(libs.androidx.work.runtime)'
- 'implementation(libs.coil.compose)'
- 'implementation(libs.coil.network.okhttp)'
- 'implementation(libs.coil.svg)'
@@ -86,4 +90,3 @@ build:
- 'androidTestImplementation(libs.androidx.compose.ui.test.junit4)'
- 'testImplementation(libs.androidx.compose.ui.test.manifest)'
- 'androidTestImplementation(libs.androidx.compose.ui.test.manifest)'
-
diff --git a/Sources/SkipUI/SkipUI/UIKit/UserNotifications.swift b/Sources/SkipUI/SkipUI/UIKit/UserNotifications.swift
index 87435ee7..84e2bb56 100644
--- a/Sources/SkipUI/SkipUI/UIKit/UserNotifications.swift
+++ b/Sources/SkipUI/SkipUI/UIKit/UserNotifications.swift
@@ -19,6 +19,14 @@ import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
+import androidx.work.Data
+import androidx.work.Worker
+import androidx.work.WorkManager
+import androidx.work.WorkerParameters
+import androidx.work.ListenableWorker
+import androidx.work.OneTimeWorkRequestBuilder
+import java.util.HashSet
+import java.util.concurrent.TimeUnit
import kotlin.random.Random
#endif
@@ -92,102 +100,171 @@ public final class UNUserNotificationCenter {
// SKIP @bridge
public func add(_ request: UNNotificationRequest) async throws {
- guard let delegate else {
- return
- }
+ guard let delegate else { return }
+
let notification = UNNotification(request: request, date: Date.now)
let options = await delegate.userNotificationCenter(self, willPresent: notification)
- guard options.contains(.banner) || options.contains(.alert) else {
- return
- }
+ guard options.contains(.banner) || options.contains(.alert) else { return }
+
#if SKIP
- guard let activity = UIApplication.shared.androidActivity else {
- return
+ guard let activity = UIApplication.shared.androidActivity else { return }
+
+ // Build the data which should be displayed in the notification.
+ let dataBuilder = Data.Builder()
+ .putString("title", request.content.title)
+ .putString("body", request.content.body)
+ .putInt("id", request.identifier.hashValue)
+
+ if let imageAttachment = request.content.attachments.first(where: { $0.type == "public.image" }) {
+ dataBuilder.putString("image_url", imageAttachment.url.absoluteString)
}
- let intent = Intent(activity, type(of: activity).java)
- intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
- let extras = android.os.Bundle()
+
for (key, value) in request.content.userInfo {
if let s = value as? String {
- extras.putString(key.toString(), s)
+ dataBuilder.putString(key.toString(), s)
} else if let b = value as? Bool {
- extras.putBoolean(key.toString(), b)
+ dataBuilder.putBoolean(key.toString(), b)
} else if let i = value as? Int {
- extras.putInt(key.toString(), i)
+ dataBuilder.putInt(key.toString(), i)
} else if let d = value as? Double {
- extras.putDouble(key.toString(), d)
+ dataBuilder.putDouble(key.toString(), d)
} else {
- extras.putString(key.toString(), value.toString())
+ dataBuilder.putString(key.toString(), value.toString())
}
}
- intent.putExtras(extras)
-
- // SKIP INSERT: val pendingFlags = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
- let pendingIntent = PendingIntent.getActivity(activity, 0, intent, pendingFlags)
-
- let channelID = "tools.skip.firebase.messaging" // Match AndroidManifest.xml
- let notificationBuilder = NotificationCompat.Builder(activity, channelID)
- .setContentTitle(request.content.title)
- .setContentText(request.content.body)
- .setAutoCancel(true)
- .setContentIntent(pendingIntent)
- let application = activity.application
- if let imageAttachment = request.content.attachments.first(where: { $0.type == "public.image" }) {
- notificationBuilder.setSmallIcon(IconCompat.createWithContentUri(imageAttachment.url.absoluteString))
- } else {
- let packageName = application.getPackageName()
-
- // Notification icon: must be a resource with transparent background and white logo
- // eg: to be used as a default icon must be added in the AndroidManifest.xml with the following code:
- //
-
- let iconNotificationIdentifier = "ic_notification"
- let resourceFolder = "drawable"
-
- var resId = application.resources.getIdentifier(iconNotificationIdentifier, resourceFolder, packageName)
-
- // Check if the resource is found, otherwise fallback to use the default app icon (eg. ic_launcher)
- if resId == 0 {
- resId = application.resources.getIdentifier("ic_launcher", "mipmap", packageName)
- }
-
- notificationBuilder.setSmallIcon(IconCompat.createWithResource(application, resId))
+
+ // Get the next trigger date (if any).
+ let nextDate = (request.trigger as? UNCalendarNotificationTrigger)?.nextTriggerDate() ??
+ (request.trigger as? UNTimeIntervalNotificationTrigger)?.nextTriggerDate()
+ let delayMillis = nextDate != nil ? max(0, nextDate!.currentTimeMillis - System.currentTimeMillis()) : 0
+
+ // Get the work manager.
+ let workManager = WorkManager.getInstance(activity)
+
+ // Add the notification work request to the work manager.
+ let workData = dataBuilder.build()
+ let workRequest = OneTimeWorkRequestBuilder()
+ .setInitialDelay(delayMillis, TimeUnit.MILLISECONDS)
+ .setInputData(workData)
+ .addTag(request.identifier)
+ .build()
+ workManager.enqueue(workRequest)
+
+ // Add the notification identifier to the shared preferences to be able to cancel it later.
+ if let triggerMillis = nextDate?.currentTimeMillis {
+ let preferences = activity.getSharedPreferences("__skip_usernotifications", Context.MODE_PRIVATE)
+ let ids = HashSet(preferences.getStringSet("ids", HashSet()) ?? HashSet())
+ ids.add(request.identifier)
+ preferences.edit()
+ .putStringSet("ids", ids)
+ .putLong("date_" + request.identifier, triggerMillis)
+ .apply()
}
-
- let manager = activity.getSystemService(Context.NOTIFICATION_SERVICE) as! NotificationManager
- let appName = application.packageManager.getApplicationLabel(application.applicationInfo)
- let channel = NotificationChannel(channelID, appName, NotificationManager.IMPORTANCE_DEFAULT)
- manager.createNotificationChannel(channel)
- manager.notify(Random.nextInt(), notificationBuilder.build())
#endif
}
- @available(*, unavailable)
- public func getPendingNotificationRequests() async -> [Any /* UNNotificationRequest */] {
+ // SKIP @bridge
+ public func pendingNotificationRequests() async -> [Any /* UNNotificationRequest */] {
+ #if SKIP
+ return getPendingNotificationRequests()
+ #else
fatalError()
+ #endif
}
- @available(*, unavailable)
- public func removePendingNotificationRequests(withIdentifiers: [String]) {
+ // SKIP @bridge
+ public func removePendingNotificationRequests(withIdentifiers identifiers: [String]) {
+ #if SKIP
+ guard let activity = UIApplication.shared.androidActivity else { return }
+
+ // Get all notification identifiers from the shared preferences.
+ let preferences = activity.getSharedPreferences("__skip_usernotifications", Context.MODE_PRIVATE)
+ let editor = preferences.edit()
+ let ids = HashSet(preferences.getStringSet("ids", HashSet()) ?? HashSet())
+
+ // Get the work manager.
+ let workManager = WorkManager.getInstance(activity)
+
+ // Cancel all pending notifications using the work manager.
+ let now = System.currentTimeMillis()
+ for identifier in identifiers {
+ let triggerTime = preferences.getLong("date_" + identifier, 0)
+ if triggerTime > now {
+ workManager.cancelAllWorkByTag(identifier)
+ ids.remove(identifier)
+ editor.remove("date_" + identifier)
+ }
+ }
+
+ // Update the notification identifiers in the shared preferences.
+ editor.putStringSet("ids", ids)
+ editor.apply()
+ #else
+ fatalError()
+ #endif
}
- @available(*, unavailable)
+ // SKIP @bridge
public func removeAllPendingNotificationRequests() {
+ #if SKIP
+ let pendingNotifications = getPendingNotificationRequests()
+ let identifiers = pendingNotifications.compactMap { ($0 as? UNNotificationRequest)?.identifier }
+ removePendingNotificationRequests(withIdentifiers: identifiers)
+ #else
+ fatalError()
+ #endif
}
- @available(*, unavailable)
- public func getDeliveredNotifications() async -> [Any /* UNNotification */] {
+ // SKIP @bridge
+ public func deliveredNotifications() async -> [Any /* UNNotification */] {
+ #if SKIP
+ return getDeliveredNotifications()
+ #else
fatalError()
+ #endif
}
- @available(*, unavailable)
- public func removeDeliveredNotifications(withIdentifiers: [String]) {
+ // SKIP @bridge
+ public func removeDeliveredNotifications(withIdentifiers identifiers: [String]) {
+ #if SKIP
+ guard let activity = UIApplication.shared.androidActivity else { return }
+
+ // Get all notification identifiers from the shared preferences.
+ let preferences = activity.getSharedPreferences("__skip_usernotifications", Context.MODE_PRIVATE)
+ let editor = preferences.edit()
+ let ids = HashSet(preferences.getStringSet("ids", HashSet()) ?? HashSet())
+
+ // Get the notification manager.
+ let notificationManager = activity.getSystemService(Context.NOTIFICATION_SERVICE) as! NotificationManager
+
+ // Cancel all delivered notifications using the notification manager.
+ let now = System.currentTimeMillis()
+ for identifier in identifiers {
+ let triggerTime = preferences.getLong("date_" + identifier, 0)
+ if triggerTime < now {
+ notificationManager.cancel(identifier.hashValue)
+ ids.remove(identifier)
+ editor.remove("date_" + identifier)
+ }
+ }
+
+ // Update the notification identifiers in the shared preferences.
+ editor.putStringSet("ids", ids)
+ editor.apply()
+ #else
+ fatalError()
+ #endif
}
- @available(*, unavailable)
+ // SKIP @bridge
public func removeAllDeliveredNotifications() {
+ #if SKIP
+ let deliveredNotifications = getDeliveredNotifications()
+ let identifiers = deliveredNotifications.compactMap { ($0 as? UNNotification)?.request.identifier }
+ removeDeliveredNotifications(withIdentifiers: identifiers)
+ #else
+ fatalError()
+ #endif
}
@available(*, unavailable)
@@ -198,6 +275,43 @@ public final class UNUserNotificationCenter {
public func getNotificationCategories() async -> Set {
fatalError()
}
+
+ #if SKIP
+ private func getAllNotificationRequests() -> [(id: String, timestamp: Long)] {
+ guard let activity = UIApplication.shared.androidActivity else { return [] }
+ let preferences = activity.getSharedPreferences("__skip_usernotifications", Context.MODE_PRIVATE)
+ let ids = preferences.getStringSet("ids", nil) ?? java.util.HashSet()
+
+ var all: [(id: String, timestamp: Long)] = []
+ let iterator = ids.iterator()
+ while iterator.hasNext() {
+ let id = iterator.next() as! String
+ let time = preferences.getLong("date_" + id, 0)
+ all.append((id: id, timestamp: time))
+ }
+
+ return all
+ }
+
+ private func getPendingNotificationRequests() -> [Any /* UNNotificationRequest */] {
+ let now = Date().currentTimeMillis
+ return getAllNotificationRequests()
+ .filter { $0.timestamp > now }
+ .map {
+ UNNotificationRequest(identifier: $0.id, content: UNMutableNotificationContent(), trigger: nil)
+ }
+ }
+
+ private func getDeliveredNotifications() -> [Any /* UNNotification */] {
+ let now = Date().currentTimeMillis
+ return getAllNotificationRequests()
+ .filter { $0.timestamp <= now }
+ .map {
+ let request = UNNotificationRequest(identifier: $0.id, content: UNMutableNotificationContent(), trigger: nil)
+ return UNNotification(request: request, date: Date(timeIntervalSince1970: Double($0.timestamp) / 1000.0))
+ }
+ }
+ #endif
}
// SKIP @bridge
@@ -258,7 +372,8 @@ public final class UNNotificationRequest : @unchecked Sendable {
// SKIP @bridge
public let content: UNNotificationContent
public let trigger: UNNotificationTrigger?
-
+
+ // SKIP @bridge
public init(identifier: String, content: UNNotificationContent, trigger: UNNotificationTrigger?) {
self.identifier = identifier
self.content = content
@@ -313,21 +428,21 @@ public struct UNNotificationPresentationOptions : OptionSet {
// SKIP @bridge
public class UNNotificationContent {
// SKIP @bridge
- public internal(set) var title: String
+ public var title: String
// SKIP @bridge
- public internal(set) var subtitle: String
+ public var subtitle: String
// SKIP @bridge
- public internal(set) var body: String
- public internal(set) var badge: NSNumber?
+ public var body: String
+ public var badge: NSNumber?
// SKIP @bridge
public var bridgedBadge: Int? {
return badge?.intValue
}
// SKIP @bridge
- public internal(set) var sound: UNNotificationSound?
+ public var sound: UNNotificationSound?
// SKIP @bridge
- public internal(set) var launchImageName: String
- public internal(set) var userInfo: [AnyHashable: Any]
+ public var launchImageName: String
+ public var userInfo: [AnyHashable: Any]
// SKIP @bridge
public var bridgedUserInfo: [AnyHashable: Any] {
return userInfo.filter { entry in
@@ -336,19 +451,19 @@ public class UNNotificationContent {
}
}
// SKIP @bridge
- public internal(set) var attachments: [UNNotificationAttachment]
+ public var attachments: [UNNotificationAttachment]
// SKIP @bridge
- public internal(set) var categoryIdentifier: String
+ public var categoryIdentifier: String
// SKIP @bridge
- public internal(set) var threadIdentifier: String
+ public var threadIdentifier: String
// SKIP @bridge
- public internal(set) var targetContentIdentifier: String?
+ public var targetContentIdentifier: String?
// SKIP @bridge
- public internal(set) var summaryArgument: String
+ public var summaryArgument: String
// SKIP @bridge
- public internal(set) var summaryArgumentCount: Int
+ public var summaryArgumentCount: Int
// SKIP @bridge
- public internal(set) var filterCriteria: String?
+ public var filterCriteria: String?
public init(title: String = "", subtitle: String = "", body: String = "", badge: NSNumber? = nil, sound: UNNotificationSound? = UNNotificationSound.default, launchImageName: String = "", userInfo: [AnyHashable: Any] = [:], attachments: [UNNotificationAttachment] = [], categoryIdentifier: String = "", threadIdentifier: String = "", targetContentIdentifier: String? = nil, summaryArgument: String = "", summaryArgumentCount: Int = 0, filterCriteria: String? = nil) {
self.title = title
@@ -509,30 +624,70 @@ public class UNNotificationAttachment {
}
}
+// SKIP @bridge
public class UNNotificationTrigger {
+
+ // SKIP @bridge
public let repeats: Bool
+ // SKIP @bridge
public init(repeats: Bool) {
self.repeats = repeats
}
}
+// SKIP @bridge
public final class UNTimeIntervalNotificationTrigger: UNNotificationTrigger {
+
+ // SKIP @bridge
public let timeInterval: TimeInterval
+ // SKIP @bridge
public init(timeInterval: TimeInterval, repeats: Bool) {
self.timeInterval = timeInterval
super.init(repeats: repeats)
}
+
+ // SKIP @bridge
+ public func nextTriggerDate() -> Date? {
+ let now = Date()
+ return now.addingTimeInterval(self.timeInterval)
+ }
}
+// SKIP @bridge
public final class UNCalendarNotificationTrigger: UNNotificationTrigger {
- public let dateComponents: DateComponents
-
- public init(dateComponents: DateComponents, repeats: Bool) {
+
+ // SKIP @bridge
+ public let dateComponents: Any /* DateComponents */
+
+ // SKIP @bridge
+ public init(dateMatching dateComponents: Any /* DateComponents */, repeats: Bool) {
self.dateComponents = dateComponents
super.init(repeats: repeats)
}
+
+ // SKIP @bridge
+ public func nextTriggerDate() -> Date? {
+ let calendar = Calendar.current
+ let now = Date()
+
+ guard let nextDate = calendar.nextDate(
+ after: now,
+ matching: self.dateComponents as! DateComponents,
+ matchingPolicy: .nextTime,
+ repeatedTimePolicy: .first,
+ direction: .forward
+ ) else {
+ return nil
+ }
+
+ if !self.repeats && nextDate <= now {
+ return nil
+ }
+
+ return nextDate
+ }
}
public final class UNLocationNotificationTrigger: UNNotificationTrigger {
@@ -672,4 +827,77 @@ public class UNNotificationSettings : NSObject {
}
}
+#if SKIP
+public class NotificationWorker : Worker {
+ public init(context: Context, params: WorkerParameters) {
+ super.init(context, params)
+ }
+
+ public override func doWork() -> ListenableWorker.Result {
+ // Get the data which should be displayed in the notification.
+ let inputData = getInputData()
+ let id = inputData.getInt("id", 0)
+ let title = inputData.getString("title") ?? ""
+ let body = inputData.getString("body") ?? ""
+ let imageAttachmentUrl = inputData.getString("image_url")
+
+ let context = getApplicationContext()
+ let intent = context.getPackageManager().getLaunchIntentForPackage(context.getPackageName())
+ intent?.addFlags(android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP)
+
+ let bundle = android.os.Bundle()
+ let allData = inputData.keyValueMap
+ for (key, value) in allData where key != "title" && key != "body" && key != "id" && key != "image_url" {
+ if let s = value as? String {
+ bundle.putString(key, s)
+ } else if let b = value as? Bool {
+ bundle.putBoolean(key, b)
+ } else if let i = value as? Int {
+ bundle.putInt(key, i)
+ } else if let d = value as? Double {
+ bundle.putDouble(key, d)
+ } else {
+ bundle.putString(key, value.toString())
+ }
+ }
+ intent?.putExtras(bundle)
+
+ // Create the notification channel.
+ let channelID = "tools.skip.firebase.messaging" // Match AndroidManifest.xml
+ let notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as! NotificationManager
+ if Build.VERSION.SDK_INT >= Build.VERSION_CODES.O {
+ let appName = context.getApplicationInfo().loadLabel(context.getPackageManager()).toString()
+ notificationManager.createNotificationChannel(NotificationChannel(channelID, appName, NotificationManager.IMPORTANCE_DEFAULT))
+ }
+
+ // Build the notification.
+ let pendingIntent = PendingIntent.getActivity(context, id, intent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT)
+ let builder = NotificationCompat.Builder(context, channelID)
+ .setContentTitle(title)
+ .setContentText(body)
+ .setAutoCancel(true)
+ .setContentIntent(pendingIntent)
+
+ // Update the notification icon.
+ if let url = imageAttachmentUrl {
+ builder.setSmallIcon(IconCompat.createWithContentUri(url))
+ } else {
+ // Notification icon: must be a resource with transparent background and white logo
+ // eg: to be used as a default icon must be added in the AndroidManifest.xml with the following code:
+ //
+ var resId = context.getResources().getIdentifier("ic_notification", "drawable", context.getPackageName())
+ if resId == 0 {
+ resId = context.getResources().getIdentifier("ic_launcher", "mipmap", context.getPackageName())
+ }
+ builder.setSmallIcon(IconCompat.createWithResource(context, resId))
+ }
+
+ // Display the notification.
+ notificationManager.notify(id, builder.build())
+ return ListenableWorker.Result.success()
+ }
+}
+#endif
#endif