@poolse/react-native-calls 0.5.0 → 0.7.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Poolse
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -14,9 +14,11 @@ can render against it unconditionally.
14
14
  import * as calls from '@poolse/react-native-calls';
15
15
 
16
16
  await calls.configure('https://api.poolse.dev', { smallIcon: 'ic_call', ringback: 'ringback' });
17
- const stop = calls.onCallState((s) => render(s)); // s: Snapshot | null
17
+ const stop = calls.onCallState((s) => render(s)); // s: Snapshot | null
18
18
  await calls.place(conversationId, { media: 'audio', sessionToken });
19
- calls.answer(); calls.decline(); calls.hangUp();
19
+ calls.answer();
20
+ calls.decline();
21
+ calls.hangUp();
20
22
 
21
23
  // Audio output: s.audioOutputs lists the phone, the speaker and every
22
24
  // connected headset/Bluetooth device by name; s.audioOutputId is the live one.
@@ -24,7 +26,38 @@ calls.setAudioOutput(s.audioOutputs[0].id);
24
26
  calls.setSpeaker(true); // the one-tap toggle, still there
25
27
  ```
26
28
 
29
+ ### Chat notifications
30
+
31
+ poolse sends message notifications itself, with the push credentials you
32
+ set up for calls. Register the devices (Android: the FCM token; iOS: the
33
+ regular APNs token with `kind: 'alert'`, beside the VoIP one), and the
34
+ core does the rest — one notification per conversation with every
35
+ message a line in it, Reply and Mark as read straight from the
36
+ notification (no app, no session), cleared when the chat is read on
37
+ another device. Requires `expo-notifications` (a peer dependency).
38
+
39
+ ```ts
40
+ calls.setActiveConversation(conversationId); // on screen: don't notify it
41
+ calls.setActiveConversation(null); // left it, or app backgrounded
42
+
43
+ // iOS, in your expo-notifications handler:
44
+ Notifications.setNotificationHandler({
45
+ handleNotification: async (n) => {
46
+ const show = calls.shouldPresentMessage(n.request.content.data);
47
+ return {
48
+ shouldShowBanner: show,
49
+ shouldShowList: show,
50
+ shouldPlaySound: show,
51
+ shouldSetBadge: true,
52
+ };
53
+ },
54
+ });
55
+ // A tap opens the app: calls.conversationOfNotification(data) (iOS), or
56
+ // takeCallAction()/onCallAction() with type 'open' (Android).
57
+ ```
58
+
27
59
  ## Layout
60
+
28
61
  - `src/` — the TypeScript API (this is what apps import).
29
62
  - `android/src/main/java/dev/poolse/callcore/` — the pure state machine
30
63
  and ports (contract cv 1).
@@ -26,6 +26,10 @@
26
26
  android:name="dev.poolse.callcore.android.CallActionReceiver"
27
27
  android:exported="false" />
28
28
 
29
+ <receiver
30
+ android:name="dev.poolse.callcore.android.MessageActionReceiver"
31
+ android:exported="false" />
32
+
29
33
  <service
30
34
  android:name="dev.poolse.callcore.android.CallForegroundService"
31
35
  android:exported="false"
@@ -384,6 +384,18 @@ class CallCore private constructor(context: Context) {
384
384
  /** The live core if one exists — never creates (cheap reads, e.g. getCallState). */
385
385
  fun peek(): CallCore? = instance
386
386
 
387
+ /** The configured API origin (restored from disk on a cold start), or null. */
388
+ fun apiBaseUrl(context: Context): String? {
389
+ if (config == null) restoreConfig(context.applicationContext)
390
+ return config?.apiBaseUrl?.ifEmpty { null }
391
+ }
392
+
393
+ /** The configured notification icon (restored from disk on a cold start). */
394
+ fun smallIcon(context: Context): Int {
395
+ if (config == null) restoreConfig(context.applicationContext)
396
+ return config?.smallIcon ?: context.applicationInfo.icon
397
+ }
398
+
387
399
  internal fun peekConfig(): CallCoreConfig? = config
388
400
 
389
401
  internal fun requireConfig(): CallCoreConfig =
@@ -13,8 +13,8 @@ import dev.poolse.callcore.RingContext
13
13
  * everything else keep working unchanged.
14
14
  *
15
15
  * Contract: data-only messages — rings with `type: "call"`, control
16
- * pushes (`ring_ended`, `finished`) with `type: "call_event"`
17
- * (CONTRACT.md §5).
16
+ * pushes (`ring_ended`, `finished`) and the missed-call notification
17
+ * (`missed`) with `type: "call_event"` (CONTRACT.md §5).
18
18
  */
19
19
  class CallMessagingService : FirebaseMessagingService() {
20
20
 
@@ -35,6 +35,17 @@ class CallMessagingService : FirebaseMessagingService() {
35
35
  // type=call_event so old JS Android builds — which ring on ANY
36
36
  // type=call — ignore them. Everything else belongs to the host.
37
37
  val type = data["type"]
38
+ if (type == "message") {
39
+ // Chat notifications (§5.5): posted here so they appear with the
40
+ // app killed, grouped per conversation.
41
+ if (CallCore.getOrNull(this) == null) return
42
+ val conversationId = data["conversation_id"] ?: return
43
+ when (data["event"]) {
44
+ "new" -> MessageNotifications.onNew(this, data, CallCore.smallIcon(this))
45
+ "read" -> MessageNotifications.clear(this, conversationId)
46
+ }
47
+ return
48
+ }
38
49
  if (type != "call" && type != "call_event") {
39
50
  forwardToHost { it.onMessageReceived(message) }
40
51
  return
@@ -43,6 +54,11 @@ class CallMessagingService : FirebaseMessagingService() {
43
54
  // Never configured on this install: nothing can answer a call, and
44
55
  // throwing here would crash the messaging process.
45
56
  val core = CallCore.getOrNull(this) ?: return
57
+ if (data["event"] == "missed") {
58
+ // A notification, not call state: the call is already over.
59
+ MissedCallNotification.show(this, data, CallCore.smallIcon(this))
60
+ return
61
+ }
46
62
  // A type=call push without an event is a legacy-shaped ring.
47
63
  val event = data["event"]?.ifEmpty { null } ?: if (type == "call") "ring" else return
48
64
  when (event) {
@@ -0,0 +1,340 @@
1
+ package dev.poolse.callcore.android
2
+
3
+ import android.app.NotificationChannel
4
+ import android.app.NotificationManager
5
+ import android.app.PendingIntent
6
+ import android.content.BroadcastReceiver
7
+ import android.content.Context
8
+ import android.content.Intent
9
+ import android.os.Build
10
+ import android.util.Log
11
+ import androidx.core.app.NotificationCompat
12
+ import androidx.core.app.NotificationManagerCompat
13
+ import androidx.core.app.Person
14
+ import androidx.core.app.RemoteInput
15
+ import okhttp3.MediaType.Companion.toMediaType
16
+ import okhttp3.Request
17
+ import okhttp3.RequestBody.Companion.toRequestBody
18
+ import org.json.JSONArray
19
+ import org.json.JSONObject
20
+ import java.util.UUID
21
+
22
+ /**
23
+ * Chat notifications on Android (CONTRACT.md §5.5), WhatsApp-style.
24
+ *
25
+ * One notification per conversation, in MessagingStyle: each new message
26
+ * becomes a line in it ("Ana: on my way") rather than a new card, and a
27
+ * group summary collects every chat. Reply (inline text) and Mark as
28
+ * read run natively, authorized by the reply grant the push carried, so
29
+ * they work with the app killed and the phone locked.
30
+ *
31
+ * Android cannot read back the lines of a posted notification, so the
32
+ * last few lines per conversation are kept in a small preferences file
33
+ * and the notification is rebuilt from them on every message. They are
34
+ * dropped the moment the conversation is read, here or elsewhere.
35
+ */
36
+ object MessageNotifications {
37
+ const val CHANNEL_ID = "poolse_messages"
38
+ private const val GROUP_KEY = "dev.poolse.messages"
39
+ private const val SUMMARY_ID = 0x504F4F4C // "POOL"
40
+ private const val PREFS = "dev.poolse.messages"
41
+ private const val MAX_LINES = 7
42
+ internal const val KEY_REPLY = "poolse_reply_text"
43
+ internal const val ACTION_REPLY = "dev.poolse.callcore.message.REPLY"
44
+ internal const val ACTION_READ = "dev.poolse.callcore.message.READ"
45
+ internal const val EXTRA_CONVERSATION = "poolse_message_conversation"
46
+ private const val TAG = "PoolseMessages"
47
+
48
+ // A message notification that waited this long for the phone to come
49
+ // online is history.
50
+ private const val STALE_MS = 24 * 60 * 60 * 1000L
51
+
52
+ /**
53
+ * The conversation on screen in the app, set by the host. Its messages
54
+ * are not notified — the user is reading them.
55
+ */
56
+ @Volatile var activeConversationId: String? = null
57
+
58
+ /** A `type: "message", event: "new"` push. */
59
+ fun onNew(context: Context, data: Map<String, String>, smallIcon: Int) {
60
+ val conversationId = data["conversation_id"] ?: return
61
+ if (conversationId == activeConversationId) return
62
+ val sentAt = data["sent_at"]?.toLongOrNull() ?: System.currentTimeMillis()
63
+ if (System.currentTimeMillis() - sentAt > STALE_MS) return
64
+
65
+ val state = load(context, conversationId) ?: Conversation(conversationId)
66
+ state.title = data["conversation_name"]?.ifBlank { null } ?: state.title
67
+ state.group = data["group"] == "1"
68
+ data["reply_grant"]?.ifBlank { null }?.let { state.grant = it }
69
+ val messageId = data["message_id"]
70
+ if (messageId == null || state.lines.none { it.id == messageId }) {
71
+ state.lines += Line(
72
+ id = messageId,
73
+ sender = data["sender_name"]?.ifBlank { null } ?: "Someone",
74
+ senderKey = data["sender_user_id"],
75
+ text = data["text"] ?: "",
76
+ at = sentAt,
77
+ self = false,
78
+ )
79
+ while (state.lines.size > MAX_LINES) state.lines.removeAt(0)
80
+ }
81
+ save(context, state)
82
+ post(context, state, smallIcon, alert = true)
83
+ }
84
+
85
+ /** The conversation was read (here, or on another device): clear it. */
86
+ fun clear(context: Context, conversationId: String) {
87
+ prefs(context).edit().remove(key(conversationId)).apply()
88
+ runCatching { NotificationManagerCompat.from(context).cancel(notificationId(conversationId)) }
89
+ refreshSummary(context, CallCore.smallIcon(context))
90
+ }
91
+
92
+ /** The reply went through: show it as the user's line and stop the spinner. */
93
+ internal fun onReplied(context: Context, conversationId: String, text: String, ok: Boolean) {
94
+ val state = load(context, conversationId) ?: return
95
+ state.lines += Line(
96
+ id = null,
97
+ sender = "You",
98
+ senderKey = null,
99
+ text = if (ok) text else "Not sent: $text",
100
+ at = System.currentTimeMillis(),
101
+ self = true,
102
+ )
103
+ while (state.lines.size > MAX_LINES) state.lines.removeAt(0)
104
+ save(context, state)
105
+ post(context, state, CallCore.smallIcon(context), alert = false)
106
+ }
107
+
108
+ internal fun grantFor(context: Context, conversationId: String): String? =
109
+ load(context, conversationId)?.grant
110
+
111
+ // ── building ────────────────────────────────────────────────────────
112
+
113
+ private fun post(context: Context, state: Conversation, smallIcon: Int, alert: Boolean) {
114
+ ensureChannel(context)
115
+ val me = Person.Builder().setName("You").build()
116
+ val style = NotificationCompat.MessagingStyle(me)
117
+ .setGroupConversation(state.group)
118
+ if (state.group) style.conversationTitle = state.title
119
+ for (line in state.lines) {
120
+ val person = if (line.self) null else Person.Builder().setName(line.sender).setKey(line.senderKey).build()
121
+ style.addMessage(NotificationCompat.MessagingStyle.Message(line.text, line.at, person))
122
+ }
123
+
124
+ val id = notificationId(state.id)
125
+ val builder = NotificationCompat.Builder(context, CHANNEL_ID)
126
+ .setSmallIcon(smallIcon)
127
+ .setStyle(style)
128
+ .setCategory(NotificationCompat.CATEGORY_MESSAGE)
129
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
130
+ .setGroup(GROUP_KEY)
131
+ .setAutoCancel(true)
132
+ .setOnlyAlertOnce(!alert)
133
+ .setWhen(state.lines.lastOrNull()?.at ?: System.currentTimeMillis())
134
+ .setNumber(state.lines.count { !it.self })
135
+ openIntent(context, state.id, id)?.let(builder::setContentIntent)
136
+
137
+ if (state.grant != null) {
138
+ val remoteInput = RemoteInput.Builder(KEY_REPLY).setLabel("Message").build()
139
+ val reply = NotificationCompat.Action.Builder(0, "Reply", actionIntent(context, ACTION_REPLY, state.id, id + 1, mutable = true))
140
+ .addRemoteInput(remoteInput)
141
+ .setAllowGeneratedReplies(true)
142
+ .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
143
+ .setShowsUserInterface(false)
144
+ .build()
145
+ val read = NotificationCompat.Action.Builder(0, "Mark as read", actionIntent(context, ACTION_READ, state.id, id + 2, mutable = false))
146
+ .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ)
147
+ .setShowsUserInterface(false)
148
+ .build()
149
+ builder.addAction(reply).addAction(read)
150
+ }
151
+
152
+ try {
153
+ NotificationManagerCompat.from(context).notify(id, builder.build())
154
+ } catch (e: SecurityException) {
155
+ Log.w(TAG, "message notification refused (POST_NOTIFICATIONS?)", e)
156
+ return
157
+ }
158
+ refreshSummary(context, smallIcon)
159
+ }
160
+
161
+ /** The group's summary: "5 messages from 2 chats". Gone with the last chat. */
162
+ private fun refreshSummary(context: Context, smallIcon: Int) {
163
+ val all = prefs(context).all.keys.filter { it.startsWith("conv:") }
164
+ .mapNotNull { load(context, it.removePrefix("conv:")) }
165
+ val manager = NotificationManagerCompat.from(context)
166
+ if (all.isEmpty()) {
167
+ runCatching { manager.cancel(SUMMARY_ID) }
168
+ return
169
+ }
170
+ val messages = all.sumOf { c -> c.lines.count { !it.self } }
171
+ val text = if (all.size == 1) "$messages new messages" else "$messages messages from ${all.size} chats"
172
+ val summary = NotificationCompat.Builder(context, CHANNEL_ID)
173
+ .setSmallIcon(smallIcon)
174
+ .setContentTitle(text)
175
+ .setStyle(NotificationCompat.InboxStyle().setSummaryText(text))
176
+ .setGroup(GROUP_KEY)
177
+ .setGroupSummary(true)
178
+ .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
179
+ .setAutoCancel(true)
180
+ .build()
181
+ runCatching { manager.notify(SUMMARY_ID, summary) }
182
+ }
183
+
184
+ private fun openIntent(context: Context, conversationId: String, requestCode: Int): PendingIntent? {
185
+ val intent = context.packageManager.getLaunchIntentForPackage(context.packageName) ?: return null
186
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
187
+ .putExtra(MissedCallNotification.EXTRA_ACTION, MissedCallNotification.ACTION_OPEN)
188
+ .putExtra(MissedCallNotification.EXTRA_CONVERSATION_ID, conversationId)
189
+ return PendingIntent.getActivity(
190
+ context,
191
+ requestCode,
192
+ intent,
193
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
194
+ )
195
+ }
196
+
197
+ private fun actionIntent(context: Context, action: String, conversationId: String, requestCode: Int, mutable: Boolean): PendingIntent {
198
+ val intent = Intent(context, MessageActionReceiver::class.java)
199
+ .setAction(action)
200
+ .putExtra(EXTRA_CONVERSATION, conversationId)
201
+ // RemoteInput writes the typed text into the intent: it must be mutable.
202
+ val mutability = if (mutable && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
203
+ PendingIntent.FLAG_MUTABLE
204
+ } else {
205
+ PendingIntent.FLAG_IMMUTABLE
206
+ }
207
+ return PendingIntent.getBroadcast(context, requestCode, intent, PendingIntent.FLAG_UPDATE_CURRENT or mutability)
208
+ }
209
+
210
+ private fun ensureChannel(context: Context) {
211
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
212
+ val manager = context.getSystemService(NotificationManager::class.java)
213
+ if (manager.getNotificationChannel(CHANNEL_ID) != null) return
214
+ manager.createNotificationChannel(
215
+ NotificationChannel(CHANNEL_ID, "Messages", NotificationManager.IMPORTANCE_HIGH).apply {
216
+ lockscreenVisibility = NotificationCompat.VISIBILITY_PRIVATE
217
+ },
218
+ )
219
+ }
220
+
221
+ private fun notificationId(conversationId: String) = ("msg:$conversationId").hashCode()
222
+
223
+ // ── storage ─────────────────────────────────────────────────────────
224
+
225
+ internal data class Line(
226
+ val id: String?,
227
+ val sender: String,
228
+ val senderKey: String?,
229
+ val text: String,
230
+ val at: Long,
231
+ val self: Boolean,
232
+ )
233
+
234
+ internal class Conversation(
235
+ val id: String,
236
+ var title: String? = null,
237
+ var group: Boolean = false,
238
+ var grant: String? = null,
239
+ val lines: MutableList<Line> = mutableListOf(),
240
+ )
241
+
242
+ private fun prefs(context: Context) =
243
+ context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
244
+
245
+ private fun key(conversationId: String) = "conv:$conversationId"
246
+
247
+ private fun load(context: Context, conversationId: String): Conversation? {
248
+ val raw = prefs(context).getString(key(conversationId), null) ?: return null
249
+ return runCatching {
250
+ val o = JSONObject(raw)
251
+ val lines = o.optJSONArray("lines") ?: JSONArray()
252
+ Conversation(
253
+ id = conversationId,
254
+ title = o.optString("title").ifBlank { null },
255
+ group = o.optBoolean("group"),
256
+ grant = o.optString("grant").ifBlank { null },
257
+ lines = MutableList(lines.length()) { i ->
258
+ val l = lines.getJSONObject(i)
259
+ Line(
260
+ id = l.optString("id").ifBlank { null },
261
+ sender = l.optString("sender"),
262
+ senderKey = l.optString("key").ifBlank { null },
263
+ text = l.optString("text"),
264
+ at = l.optLong("at"),
265
+ self = l.optBoolean("self"),
266
+ )
267
+ },
268
+ )
269
+ }.getOrNull()
270
+ }
271
+
272
+ private fun save(context: Context, c: Conversation) {
273
+ val lines = JSONArray()
274
+ for (l in c.lines) {
275
+ lines.put(
276
+ JSONObject()
277
+ .put("id", l.id ?: "")
278
+ .put("sender", l.sender)
279
+ .put("key", l.senderKey ?: "")
280
+ .put("text", l.text)
281
+ .put("at", l.at)
282
+ .put("self", l.self),
283
+ )
284
+ }
285
+ val o = JSONObject()
286
+ .put("title", c.title ?: "")
287
+ .put("group", c.group)
288
+ .put("grant", c.grant ?: "")
289
+ .put("lines", lines)
290
+ prefs(context).edit().putString(key(c.id), o.toString()).apply()
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Reply / Mark as read, tapped on a message notification. Posts to the
296
+ * notification-action endpoints with the conversation's reply grant —
297
+ * no app, no session — on a background thread kept alive by goAsync().
298
+ */
299
+ class MessageActionReceiver : BroadcastReceiver() {
300
+ override fun onReceive(context: Context, intent: Intent) {
301
+ val conversationId = intent.getStringExtra(MessageNotifications.EXTRA_CONVERSATION) ?: return
302
+ val app = context.applicationContext
303
+ val grant = MessageNotifications.grantFor(app, conversationId)
304
+ val base = CallCore.apiBaseUrl(app)
305
+
306
+ when (intent.action) {
307
+ MessageNotifications.ACTION_REPLY -> {
308
+ val text = RemoteInput.getResultsFromIntent(intent)
309
+ ?.getCharSequence(MessageNotifications.KEY_REPLY)?.toString()?.trim().orEmpty()
310
+ if (text.isEmpty() || grant == null || base == null) return
311
+ val pending = goAsync()
312
+ Thread {
313
+ val body = JSONObject().put("body", text).put("id", UUID.randomUUID().toString())
314
+ val ok = post("$base/v1/notifications/reply", grant, body)
315
+ MessageNotifications.onReplied(app, conversationId, text, ok)
316
+ pending.finish()
317
+ }.start()
318
+ }
319
+ MessageNotifications.ACTION_READ -> {
320
+ // Optimistic: the notification goes now; the server catches up.
321
+ MessageNotifications.clear(app, conversationId)
322
+ if (grant == null || base == null) return
323
+ val pending = goAsync()
324
+ Thread {
325
+ post("$base/v1/notifications/read", grant, JSONObject())
326
+ pending.finish()
327
+ }.start()
328
+ }
329
+ }
330
+ }
331
+
332
+ private fun post(url: String, grant: String, body: JSONObject): Boolean = runCatching {
333
+ val request = Request.Builder()
334
+ .url(url)
335
+ .header("Authorization", "Bearer $grant")
336
+ .post(body.toString().toRequestBody("application/json".toMediaType()))
337
+ .build()
338
+ CallHttpClient.defaultClient().newCall(request).execute().use { it.isSuccessful }
339
+ }.onFailure { Log.w("PoolseMessages", "notification action failed", it) }.getOrDefault(false)
340
+ }
@@ -0,0 +1,103 @@
1
+ package dev.poolse.callcore.android
2
+
3
+ import android.app.NotificationChannel
4
+ import android.app.NotificationManager
5
+ import android.app.PendingIntent
6
+ import android.content.Context
7
+ import android.content.Intent
8
+ import android.os.Build
9
+ import android.util.Log
10
+ import androidx.core.app.NotificationCompat
11
+ import androidx.core.app.NotificationManagerCompat
12
+
13
+ /**
14
+ * "Jasir — Missed voice call [Call back]", posted natively from the
15
+ * missed-call push (`event: "missed"`) so it shows with the app closed.
16
+ *
17
+ * Both the tap and Call back open the app through its launch activity,
18
+ * carrying the action in extras ([EXTRA_ACTION] …). The bridge reads
19
+ * them — `takeCallAction()` on a cold start, `onCallAction` while
20
+ * running — and the host opens the conversation or places the call.
21
+ * Placing needs the user's session, which only the app has; that is
22
+ * why Call back does not dial from here.
23
+ */
24
+ object MissedCallNotification {
25
+ const val CHANNEL_ID = "poolse_missed_calls"
26
+ const val EXTRA_ACTION = "poolse_call_action"
27
+ const val EXTRA_CONVERSATION_ID = "poolse_call_action_conversation"
28
+ const val EXTRA_CALL_ID = "poolse_call_action_call"
29
+ const val EXTRA_MEDIA = "poolse_call_action_media"
30
+ const val ACTION_OPEN = "open"
31
+ const val ACTION_CALL_BACK = "call_back"
32
+ private const val TAG = "PoolseMissedCall"
33
+
34
+ // A missed call FCM held while the phone was off for days is history.
35
+ private const val STALE_MS = 24 * 60 * 60 * 1000L
36
+
37
+ fun show(context: Context, data: Map<String, String>, smallIcon: Int) {
38
+ val callId = data["call_id"] ?: return
39
+ val conversationId = data["conversation_id"] ?: return
40
+ val sentAt = data["sent_at"]?.toLongOrNull()
41
+ if (sentAt != null && System.currentTimeMillis() - sentAt > STALE_MS) return
42
+ val media = if (data["media"] == "video") "video" else "audio"
43
+ val title = data["title"]?.ifBlank { null }
44
+ ?: data["caller_name"]?.ifBlank { null }
45
+ ?: "Missed call"
46
+ val body = data["body"]?.ifBlank { null }
47
+ ?: if (media == "video") "Missed video call" else "Missed voice call"
48
+
49
+ ensureChannel(context)
50
+ val id = notificationId(callId)
51
+
52
+ fun launch(action: String, requestOffset: Int): PendingIntent? {
53
+ val intent = context.packageManager.getLaunchIntentForPackage(context.packageName) ?: return null
54
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
55
+ .putExtra(EXTRA_ACTION, action)
56
+ .putExtra(EXTRA_CONVERSATION_ID, conversationId)
57
+ .putExtra(EXTRA_CALL_ID, callId)
58
+ .putExtra(EXTRA_MEDIA, media)
59
+ return PendingIntent.getActivity(
60
+ context,
61
+ id + requestOffset,
62
+ intent,
63
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
64
+ )
65
+ }
66
+
67
+ val builder = NotificationCompat.Builder(context, CHANNEL_ID)
68
+ .setSmallIcon(smallIcon)
69
+ .setContentTitle(title)
70
+ .setContentText(body)
71
+ .setCategory(NotificationCompat.CATEGORY_MISSED_CALL)
72
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
73
+ .setAutoCancel(true)
74
+ .setWhen(sentAt ?: System.currentTimeMillis())
75
+ .setShowWhen(true)
76
+ launch(ACTION_OPEN, 0)?.let(builder::setContentIntent)
77
+ launch(ACTION_CALL_BACK, 1)?.let { builder.addAction(0, "Call back", it) }
78
+
79
+ try {
80
+ NotificationManagerCompat.from(context).notify(id, builder.build())
81
+ } catch (e: SecurityException) {
82
+ Log.w(TAG, "missed-call notification refused (POST_NOTIFICATIONS?)", e)
83
+ }
84
+ }
85
+
86
+ /** Clear it once the user acted on it from the app (its buttons do not auto-cancel). */
87
+ fun cancel(context: Context, callId: String) {
88
+ runCatching { NotificationManagerCompat.from(context).cancel(notificationId(callId)) }
89
+ }
90
+
91
+ private fun notificationId(callId: String) = ("missed:$callId").hashCode()
92
+
93
+ private fun ensureChannel(context: Context) {
94
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
95
+ val manager = context.getSystemService(NotificationManager::class.java)
96
+ if (manager.getNotificationChannel(CHANNEL_ID) != null) return
97
+ manager.createNotificationChannel(
98
+ NotificationChannel(CHANNEL_ID, "Missed calls", NotificationManager.IMPORTANCE_DEFAULT).apply {
99
+ lockscreenVisibility = NotificationCompat.VISIBILITY_PRIVATE
100
+ },
101
+ )
102
+ }
103
+ }
@@ -8,6 +8,8 @@ import android.os.Build
8
8
  import android.provider.Settings
9
9
  import android.util.Log
10
10
  import dev.poolse.callcore.android.CallCore
11
+ import dev.poolse.callcore.android.MessageNotifications
12
+ import dev.poolse.callcore.android.MissedCallNotification
11
13
  import expo.modules.kotlin.modules.Module
12
14
  import expo.modules.kotlin.modules.ModuleDefinition
13
15
 
@@ -30,7 +32,7 @@ class PoolseCallsModule : Module() {
30
32
  override fun definition() = ModuleDefinition {
31
33
  Name("PoolseCalls")
32
34
 
33
- Events("onCallState")
35
+ Events("onCallState", "onCallAction")
34
36
 
35
37
  // <CallVideoView>: renders a participant's (or our own) camera.
36
38
  View(PoolseCallVideoView::class) {
@@ -93,6 +95,21 @@ class PoolseCallsModule : Module() {
93
95
  core()?.onCallFinished(callId, reason?.ifEmpty { null })
94
96
  }
95
97
 
98
+ // A missed-call notification's tap / Call back. Cold start: the
99
+ // launch intent still carries it — the host reads it once on mount.
100
+ // Running app: the tap arrives as a new intent.
101
+ Function("takeCallAction") { appContext.currentActivity?.intent?.let(::consumeCallAction) }
102
+ OnNewIntent { intent ->
103
+ consumeCallAction(intent)?.let { sendEvent("onCallAction", it) }
104
+ }
105
+
106
+ // The chat on screen: its messages are not notified, and opening it
107
+ // clears what was already shown for it.
108
+ Function("setActiveConversation") { conversationId: String? ->
109
+ MessageNotifications.activeConversationId = conversationId
110
+ if (conversationId != null) context?.let { MessageNotifications.clear(it, conversationId) }
111
+ }
112
+
96
113
  // Android 14+: full-screen intents need a user grant for non-dialer apps.
97
114
  Function("canUseFullScreenIntent") {
98
115
  val ctx = context ?: return@Function true
@@ -130,6 +147,31 @@ class PoolseCallsModule : Module() {
130
147
 
131
148
  private fun core(): CallCore? = context?.let(CallCore::getOrNull)
132
149
 
150
+ /**
151
+ * The call action an intent carries, removed from it so a later read
152
+ * (rotation, re-mount) cannot replay it. Acting on it from the app
153
+ * clears its notification too: action buttons do not auto-cancel.
154
+ */
155
+ private fun consumeCallAction(intent: Intent): Map<String, Any?>? {
156
+ val action = intent.getStringExtra(MissedCallNotification.EXTRA_ACTION) ?: return null
157
+ val conversationId = intent.getStringExtra(MissedCallNotification.EXTRA_CONVERSATION_ID) ?: return null
158
+ val callId = intent.getStringExtra(MissedCallNotification.EXTRA_CALL_ID)
159
+ val media = intent.getStringExtra(MissedCallNotification.EXTRA_MEDIA) ?: "audio"
160
+ for (key in listOf(
161
+ MissedCallNotification.EXTRA_ACTION,
162
+ MissedCallNotification.EXTRA_CONVERSATION_ID,
163
+ MissedCallNotification.EXTRA_CALL_ID,
164
+ MissedCallNotification.EXTRA_MEDIA,
165
+ )) intent.removeExtra(key)
166
+ if (callId != null) context?.let { MissedCallNotification.cancel(it, callId) }
167
+ return mapOf(
168
+ "type" to action,
169
+ "conversationId" to conversationId,
170
+ "callId" to callId,
171
+ "media" to media,
172
+ )
173
+ }
174
+
133
175
  private companion object {
134
176
  const val TAG = "PoolseCalls"
135
177
  }