@idealyst/live-activity 1.2.114

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,39 @@
1
+ buildscript {
2
+ ext.safeExtGet = { prop, fallback ->
3
+ rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
4
+ }
5
+ }
6
+
7
+ apply plugin: 'com.android.library'
8
+ apply plugin: 'kotlin-android'
9
+
10
+ android {
11
+ namespace "io.idealyst.liveactivity"
12
+ compileSdkVersion safeExtGet('compileSdkVersion', 36)
13
+
14
+ defaultConfig {
15
+ minSdkVersion safeExtGet('minSdkVersion', 24)
16
+ targetSdkVersion safeExtGet('targetSdkVersion', 36)
17
+ }
18
+
19
+ compileOptions {
20
+ sourceCompatibility JavaVersion.VERSION_17
21
+ targetCompatibility JavaVersion.VERSION_17
22
+ }
23
+
24
+ kotlinOptions {
25
+ jvmTarget = '17'
26
+ }
27
+
28
+ sourceSets {
29
+ main {
30
+ java.srcDirs = ['src/main/java']
31
+ }
32
+ }
33
+ }
34
+
35
+ dependencies {
36
+ implementation "com.facebook.react:react-android:+"
37
+ implementation "org.jetbrains.kotlin:kotlin-stdlib:${safeExtGet('kotlinVersion', '1.9.22')}"
38
+ implementation "androidx.core:core-ktx:1.13.1"
39
+ }
@@ -0,0 +1,7 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android"
2
+ package="io.idealyst.liveactivity">
3
+
4
+ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
5
+ <uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS" />
6
+
7
+ </manifest>
@@ -0,0 +1,299 @@
1
+ package io.idealyst.liveactivity
2
+
3
+ import com.facebook.react.bridge.*
4
+ import com.facebook.react.modules.core.DeviceEventManagerModule
5
+ import org.json.JSONArray
6
+ import org.json.JSONObject
7
+ import java.util.concurrent.ConcurrentHashMap
8
+
9
+ class IdealystLiveActivityModule(private val reactContext: ReactApplicationContext) :
10
+ ReactContextBaseJavaModule(reactContext) {
11
+
12
+ override fun getName(): String = "IdealystLiveActivity"
13
+
14
+ private val liveUpdateNotification by lazy { LiveUpdateNotification(reactContext) }
15
+
16
+ // Track active activities: activityId -> { notificationId, templateType, attributes, contentState, options, startedAt }
17
+ private val activeActivities = ConcurrentHashMap<String, ActivityRecord>()
18
+
19
+ private data class ActivityRecord(
20
+ val notificationId: Int,
21
+ val templateType: String,
22
+ val attributes: JSONObject,
23
+ val contentState: JSONObject,
24
+ val options: JSONObject,
25
+ val startedAt: Long
26
+ )
27
+
28
+ // ---- Availability ----
29
+
30
+ @ReactMethod(isBlockingSynchronousMethod = true)
31
+ fun isSupported(): Boolean {
32
+ return liveUpdateNotification.isProgressStyleSupported()
33
+ }
34
+
35
+ @ReactMethod
36
+ fun isEnabled(promise: Promise) {
37
+ promise.resolve(liveUpdateNotification.canPostPromotedNotifications())
38
+ }
39
+
40
+ // ---- Lifecycle ----
41
+
42
+ @ReactMethod
43
+ fun startActivity(
44
+ templateType: String,
45
+ attributesJson: String,
46
+ contentStateJson: String,
47
+ optionsJson: String,
48
+ promise: Promise
49
+ ) {
50
+ try {
51
+ val attributes = JSONObject(attributesJson)
52
+ val contentState = JSONObject(contentStateJson)
53
+ val options = JSONObject(optionsJson)
54
+ val androidOptions = options.optJSONObject("android") ?: JSONObject()
55
+
56
+ val notificationId = androidOptions.optInt(
57
+ "notificationId",
58
+ LiveUpdateNotification.nextNotificationId()
59
+ )
60
+
61
+ liveUpdateNotification.show(
62
+ notificationId, templateType, attributes, contentState, androidOptions
63
+ )
64
+
65
+ val activityId = "android_la_$notificationId"
66
+ val now = System.currentTimeMillis()
67
+
68
+ activeActivities[activityId] = ActivityRecord(
69
+ notificationId = notificationId,
70
+ templateType = templateType,
71
+ attributes = attributes,
72
+ contentState = contentState,
73
+ options = androidOptions,
74
+ startedAt = now
75
+ )
76
+
77
+ // Emit started event
78
+ emitEvent(JSONObject().apply {
79
+ put("type", "started")
80
+ put("activityId", activityId)
81
+ put("timestamp", now)
82
+ put("payload", JSONObject().put("state", "active"))
83
+ })
84
+
85
+ // Return activity info
86
+ val info = JSONObject().apply {
87
+ put("id", activityId)
88
+ put("state", "active")
89
+ put("startedAt", now)
90
+ put("templateType", templateType)
91
+ put("attributes", attributes)
92
+ put("contentState", contentState)
93
+ }
94
+
95
+ promise.resolve(info.toString())
96
+ } catch (e: Exception) {
97
+ promise.reject("start_failed", e.message, e)
98
+ }
99
+ }
100
+
101
+ @ReactMethod
102
+ fun updateActivity(
103
+ activityId: String,
104
+ contentStateJson: String,
105
+ alertConfigJson: String?,
106
+ promise: Promise
107
+ ) {
108
+ try {
109
+ val record = activeActivities[activityId]
110
+ if (record == null) {
111
+ promise.reject("activity_not_found", "No activity found with id: $activityId")
112
+ return
113
+ }
114
+
115
+ val newContentState = JSONObject(contentStateJson)
116
+
117
+ // Merge with existing content state
118
+ val mergedState = JSONObject(record.contentState.toString())
119
+ val keys = newContentState.keys()
120
+ while (keys.hasNext()) {
121
+ val key = keys.next()
122
+ mergedState.put(key, newContentState.get(key))
123
+ }
124
+
125
+ liveUpdateNotification.update(
126
+ record.notificationId, record.templateType, mergedState, record.attributes, record.options
127
+ )
128
+
129
+ // Update stored record
130
+ activeActivities[activityId] = record.copy(contentState = mergedState)
131
+
132
+ emitEvent(JSONObject().apply {
133
+ put("type", "updated")
134
+ put("activityId", activityId)
135
+ put("timestamp", System.currentTimeMillis())
136
+ put("payload", JSONObject().put("state", "active"))
137
+ })
138
+
139
+ promise.resolve(null)
140
+ } catch (e: Exception) {
141
+ promise.reject("update_failed", e.message, e)
142
+ }
143
+ }
144
+
145
+ @ReactMethod
146
+ fun endActivity(
147
+ activityId: String,
148
+ finalContentStateJson: String?,
149
+ dismissalPolicy: String,
150
+ dismissAfter: Double,
151
+ promise: Promise
152
+ ) {
153
+ try {
154
+ val record = activeActivities[activityId]
155
+ if (record == null) {
156
+ promise.reject("activity_not_found", "No activity found with id: $activityId")
157
+ return
158
+ }
159
+
160
+ // If there's a final state, show it briefly before dismissing
161
+ if (finalContentStateJson != null) {
162
+ val finalState = JSONObject(finalContentStateJson)
163
+ val mergedState = JSONObject(record.contentState.toString())
164
+ val keys = finalState.keys()
165
+ while (keys.hasNext()) {
166
+ val key = keys.next()
167
+ mergedState.put(key, finalState.get(key))
168
+ }
169
+ liveUpdateNotification.update(
170
+ record.notificationId, record.templateType, mergedState, record.attributes, record.options
171
+ )
172
+ }
173
+
174
+ // Dismiss based on policy
175
+ when (dismissalPolicy) {
176
+ "immediate" -> {
177
+ liveUpdateNotification.dismiss(record.notificationId)
178
+ }
179
+ "afterDate" -> {
180
+ val delayMs = if (dismissAfter > 0) {
181
+ (dismissAfter - System.currentTimeMillis()).toLong().coerceAtLeast(0)
182
+ } else {
183
+ 5000L // Default 5 second delay
184
+ }
185
+ android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
186
+ liveUpdateNotification.dismiss(record.notificationId)
187
+ }, delayMs)
188
+ }
189
+ else -> {
190
+ // "default" — dismiss after a short delay (similar to iOS default)
191
+ android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
192
+ liveUpdateNotification.dismiss(record.notificationId)
193
+ }, 5000L)
194
+ }
195
+ }
196
+
197
+ activeActivities.remove(activityId)
198
+
199
+ emitEvent(JSONObject().apply {
200
+ put("type", "ended")
201
+ put("activityId", activityId)
202
+ put("timestamp", System.currentTimeMillis())
203
+ put("payload", JSONObject().put("state", "ended"))
204
+ })
205
+
206
+ promise.resolve(null)
207
+ } catch (e: Exception) {
208
+ promise.reject("end_failed", e.message, e)
209
+ }
210
+ }
211
+
212
+ @ReactMethod
213
+ fun endAllActivities(
214
+ dismissalPolicy: String,
215
+ dismissAfter: Double,
216
+ promise: Promise
217
+ ) {
218
+ try {
219
+ for ((activityId, record) in activeActivities) {
220
+ liveUpdateNotification.dismiss(record.notificationId)
221
+
222
+ emitEvent(JSONObject().apply {
223
+ put("type", "ended")
224
+ put("activityId", activityId)
225
+ put("timestamp", System.currentTimeMillis())
226
+ put("payload", JSONObject().put("state", "ended"))
227
+ })
228
+ }
229
+ activeActivities.clear()
230
+ promise.resolve(null)
231
+ } catch (e: Exception) {
232
+ promise.reject("end_failed", e.message, e)
233
+ }
234
+ }
235
+
236
+ // ---- Queries ----
237
+
238
+ @ReactMethod
239
+ fun getActivity(activityId: String, promise: Promise) {
240
+ val record = activeActivities[activityId]
241
+ if (record == null) {
242
+ promise.resolve(null)
243
+ return
244
+ }
245
+
246
+ promise.resolve(recordToJson(activityId, record).toString())
247
+ }
248
+
249
+ @ReactMethod
250
+ fun listActivities(promise: Promise) {
251
+ val array = JSONArray()
252
+ for ((activityId, record) in activeActivities) {
253
+ array.put(recordToJson(activityId, record))
254
+ }
255
+ promise.resolve(array.toString())
256
+ }
257
+
258
+ @ReactMethod
259
+ fun getPushToken(activityId: String, promise: Promise) {
260
+ // Android Live Updates don't have per-activity push tokens.
261
+ // FCM tokens are managed at the app level via @idealyst/notifications.
262
+ promise.resolve(null)
263
+ }
264
+
265
+ // ---- Events ----
266
+
267
+ @ReactMethod
268
+ fun addListener(eventName: String) {
269
+ // Required for NativeEventEmitter
270
+ }
271
+
272
+ @ReactMethod
273
+ fun removeListeners(count: Int) {
274
+ // Required for NativeEventEmitter
275
+ }
276
+
277
+ private fun emitEvent(event: JSONObject) {
278
+ try {
279
+ reactContext
280
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
281
+ .emit("IdealystLiveActivityEvent", event.toString())
282
+ } catch (e: Exception) {
283
+ // JS not ready yet — ignore
284
+ }
285
+ }
286
+
287
+ // ---- Helpers ----
288
+
289
+ private fun recordToJson(activityId: String, record: ActivityRecord): JSONObject {
290
+ return JSONObject().apply {
291
+ put("id", activityId)
292
+ put("state", "active")
293
+ put("startedAt", record.startedAt)
294
+ put("templateType", record.templateType)
295
+ put("attributes", record.attributes)
296
+ put("contentState", record.contentState)
297
+ }
298
+ }
299
+ }
@@ -0,0 +1,16 @@
1
+ package io.idealyst.liveactivity
2
+
3
+ import com.facebook.react.ReactPackage
4
+ import com.facebook.react.bridge.NativeModule
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.uimanager.ViewManager
7
+
8
+ class IdealystLiveActivityPackage : ReactPackage {
9
+ override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
10
+ return listOf(IdealystLiveActivityModule(reactContext))
11
+ }
12
+
13
+ override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
14
+ return emptyList()
15
+ }
16
+ }
@@ -0,0 +1,265 @@
1
+ package io.idealyst.liveactivity
2
+
3
+ import android.app.Notification
4
+ import android.app.NotificationChannel
5
+ import android.app.NotificationManager
6
+ import android.app.PendingIntent
7
+ import android.content.Context
8
+ import android.content.Intent
9
+ import android.os.Build
10
+ import androidx.core.app.NotificationCompat
11
+ import org.json.JSONObject
12
+
13
+ /**
14
+ * Wraps Android notification creation for Live Updates.
15
+ * Uses Notification.ProgressStyle on API 36+ (Android 16),
16
+ * falls back to standard progress notification on older versions.
17
+ */
18
+ class LiveUpdateNotification(private val context: Context) {
19
+
20
+ companion object {
21
+ const val DEFAULT_CHANNEL_ID = "idealyst_live_activity"
22
+ const val DEFAULT_CHANNEL_NAME = "Live Activities"
23
+ const val DEFAULT_CHANNEL_DESCRIPTION = "Real-time activity updates"
24
+
25
+ private var notificationIdCounter = 9000
26
+ fun nextNotificationId(): Int = notificationIdCounter++
27
+ }
28
+
29
+ private val notificationManager =
30
+ context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
31
+
32
+ init {
33
+ ensureChannel()
34
+ }
35
+
36
+ private fun ensureChannel() {
37
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
38
+ val existing = notificationManager.getNotificationChannel(DEFAULT_CHANNEL_ID)
39
+ if (existing == null) {
40
+ val channel = NotificationChannel(
41
+ DEFAULT_CHANNEL_ID,
42
+ DEFAULT_CHANNEL_NAME,
43
+ NotificationManager.IMPORTANCE_HIGH
44
+ ).apply {
45
+ description = DEFAULT_CHANNEL_DESCRIPTION
46
+ }
47
+ notificationManager.createNotificationChannel(channel)
48
+ }
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Check if the device supports Live Updates (ProgressStyle).
54
+ */
55
+ fun isProgressStyleSupported(): Boolean {
56
+ return Build.VERSION.SDK_INT >= 36
57
+ }
58
+
59
+ /**
60
+ * Check if the app can post promoted notifications (Live Updates).
61
+ */
62
+ fun canPostPromotedNotifications(): Boolean {
63
+ if (Build.VERSION.SDK_INT < 36) return false
64
+ return try {
65
+ notificationManager.canPostPromotedNotifications()
66
+ } catch (e: Exception) {
67
+ false
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Create and show a Live Update notification.
73
+ * Returns the notification ID used.
74
+ */
75
+ fun show(
76
+ notificationId: Int,
77
+ templateType: String,
78
+ attributes: JSONObject,
79
+ contentState: JSONObject,
80
+ options: JSONObject
81
+ ): Int {
82
+ val channelId = options.optString("channelId", DEFAULT_CHANNEL_ID)
83
+ val smallIconName = options.optString("smallIcon", "ic_notification")
84
+ val deepLinkUrl = options.optString("deepLinkUrl", "")
85
+
86
+ val smallIconRes = getIconResource(smallIconName)
87
+
88
+ if (Build.VERSION.SDK_INT >= 36 && canPostPromotedNotifications()) {
89
+ showProgressStyleNotification(
90
+ notificationId, templateType, attributes, contentState,
91
+ channelId, smallIconRes, deepLinkUrl
92
+ )
93
+ } else {
94
+ showFallbackNotification(
95
+ notificationId, templateType, attributes, contentState,
96
+ channelId, smallIconRes, deepLinkUrl
97
+ )
98
+ }
99
+
100
+ return notificationId
101
+ }
102
+
103
+ /**
104
+ * Update an existing Live Update notification.
105
+ */
106
+ fun update(
107
+ notificationId: Int,
108
+ templateType: String,
109
+ contentState: JSONObject,
110
+ attributes: JSONObject,
111
+ options: JSONObject
112
+ ) {
113
+ show(notificationId, templateType, attributes, contentState, options)
114
+ }
115
+
116
+ /**
117
+ * Dismiss a Live Update notification.
118
+ */
119
+ fun dismiss(notificationId: Int) {
120
+ notificationManager.cancel(notificationId)
121
+ }
122
+
123
+ /**
124
+ * Dismiss all Live Update notifications.
125
+ */
126
+ fun dismissAll() {
127
+ notificationManager.cancelAll()
128
+ }
129
+
130
+ // ---- ProgressStyle (API 36+) ----
131
+
132
+ private fun showProgressStyleNotification(
133
+ notificationId: Int,
134
+ templateType: String,
135
+ attributes: JSONObject,
136
+ contentState: JSONObject,
137
+ channelId: String,
138
+ smallIconRes: Int,
139
+ deepLinkUrl: String
140
+ ) {
141
+ if (Build.VERSION.SDK_INT < 36) return
142
+
143
+ val title = extractTitle(templateType, attributes, contentState)
144
+ val status = extractStatus(templateType, contentState)
145
+ val progress = contentState.optDouble("progress", -1.0)
146
+
147
+ val progressStyle = Notification.ProgressStyle()
148
+
149
+ if (progress in 0.0..1.0) {
150
+ progressStyle.setProgress((progress * 100).toInt())
151
+ } else {
152
+ progressStyle.setProgressIndeterminate(true)
153
+ }
154
+
155
+ val builder = Notification.Builder(context, channelId)
156
+ .setSmallIcon(smallIconRes)
157
+ .setContentTitle(title)
158
+ .setContentText(status)
159
+ .setStyle(progressStyle)
160
+ .setOngoing(true)
161
+ .setCategory(Notification.CATEGORY_PROGRESS)
162
+
163
+ if (deepLinkUrl.isNotEmpty()) {
164
+ val intent = createDeepLinkIntent(deepLinkUrl)
165
+ if (intent != null) {
166
+ val pendingIntent = PendingIntent.getActivity(
167
+ context, notificationId, intent,
168
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
169
+ )
170
+ builder.setContentIntent(pendingIntent)
171
+ }
172
+ }
173
+
174
+ notificationManager.notify(notificationId, builder.build())
175
+ }
176
+
177
+ // ---- Fallback (pre-API 36) ----
178
+
179
+ private fun showFallbackNotification(
180
+ notificationId: Int,
181
+ templateType: String,
182
+ attributes: JSONObject,
183
+ contentState: JSONObject,
184
+ channelId: String,
185
+ smallIconRes: Int,
186
+ deepLinkUrl: String
187
+ ) {
188
+ val title = extractTitle(templateType, attributes, contentState)
189
+ val status = extractStatus(templateType, contentState)
190
+ val progress = contentState.optDouble("progress", -1.0)
191
+ val indeterminate = contentState.optBoolean("indeterminate", false)
192
+
193
+ val builder = NotificationCompat.Builder(context, channelId)
194
+ .setSmallIcon(smallIconRes)
195
+ .setContentTitle(title)
196
+ .setContentText(status)
197
+ .setOngoing(true)
198
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
199
+ .setCategory(NotificationCompat.CATEGORY_PROGRESS)
200
+
201
+ if (indeterminate) {
202
+ builder.setProgress(0, 0, true)
203
+ } else if (progress in 0.0..1.0) {
204
+ builder.setProgress(100, (progress * 100).toInt(), false)
205
+ }
206
+
207
+ if (deepLinkUrl.isNotEmpty()) {
208
+ val intent = createDeepLinkIntent(deepLinkUrl)
209
+ if (intent != null) {
210
+ val pendingIntent = PendingIntent.getActivity(
211
+ context, notificationId, intent,
212
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
213
+ )
214
+ builder.setContentIntent(pendingIntent)
215
+ }
216
+ }
217
+
218
+ notificationManager.notify(notificationId, builder.build())
219
+ }
220
+
221
+ // ---- Helpers ----
222
+
223
+ private fun extractTitle(
224
+ templateType: String,
225
+ attributes: JSONObject,
226
+ contentState: JSONObject
227
+ ): String {
228
+ return when (templateType) {
229
+ "delivery" -> "${attributes.optString("startLabel", "")} → ${attributes.optString("endLabel", "")}"
230
+ "timer" -> attributes.optString("title", "Timer")
231
+ "media" -> contentState.optString("trackTitle", attributes.optString("title", "Now Playing"))
232
+ "progress" -> attributes.optString("title", "Progress")
233
+ else -> attributes.optString("title", "Live Activity")
234
+ }
235
+ }
236
+
237
+ private fun extractStatus(templateType: String, contentState: JSONObject): String {
238
+ return when (templateType) {
239
+ "delivery" -> contentState.optString("status", "In progress")
240
+ "timer" -> contentState.optString("subtitle", "Running")
241
+ "media" -> {
242
+ val artist = contentState.optString("artist", "")
243
+ val playing = if (contentState.optBoolean("isPlaying", false)) "Playing" else "Paused"
244
+ if (artist.isNotEmpty()) "$artist • $playing" else playing
245
+ }
246
+ "progress" -> contentState.optString("status", "In progress")
247
+ else -> contentState.optString("status", "")
248
+ }
249
+ }
250
+
251
+ private fun getIconResource(name: String): Int {
252
+ val res = context.resources.getIdentifier(name, "drawable", context.packageName)
253
+ return if (res != 0) res else android.R.drawable.ic_popup_sync
254
+ }
255
+
256
+ private fun createDeepLinkIntent(url: String): Intent? {
257
+ return try {
258
+ val intent = Intent(Intent.ACTION_VIEW, android.net.Uri.parse(url))
259
+ intent.setPackage(context.packageName)
260
+ intent
261
+ } catch (e: Exception) {
262
+ null
263
+ }
264
+ }
265
+ }
@@ -0,0 +1,22 @@
1
+ require "json"
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, "package.json")))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = "idealyst-live-activity"
7
+ s.version = package["version"]
8
+ s.summary = package["description"]
9
+ s.homepage = package["documentation"]
10
+ s.license = package["license"]
11
+ s.authors = { "Idealyst" => "contact@idealyst.io" }
12
+ s.source = { :git => package["repository"]["url"], :tag => s.version }
13
+
14
+ s.platforms = { :ios => "16.2" }
15
+ s.swift_version = "5.9"
16
+
17
+ s.source_files = "ios/**/*.{swift,h,m,mm}"
18
+
19
+ s.dependency "React-Core"
20
+
21
+ s.frameworks = "ActivityKit", "WidgetKit", "SwiftUI"
22
+ end
@@ -0,0 +1,2 @@
1
+ #import <React/RCTBridgeModule.h>
2
+ #import <React/RCTEventEmitter.h>