@capgo/capacitor-stream-call 0.0.2

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.
Files changed (39) hide show
  1. package/Package.swift +31 -0
  2. package/README.md +340 -0
  3. package/StreamCall.podspec +19 -0
  4. package/android/build.gradle +74 -0
  5. package/android/src/main/AndroidManifest.xml +2 -0
  6. package/android/src/main/java/ee/forgr/capacitor/streamcall/CallOverlayView.kt +281 -0
  7. package/android/src/main/java/ee/forgr/capacitor/streamcall/CustomNotificationHandler.kt +142 -0
  8. package/android/src/main/java/ee/forgr/capacitor/streamcall/IncomingCallView.kt +147 -0
  9. package/android/src/main/java/ee/forgr/capacitor/streamcall/RingtonePlayer.kt +164 -0
  10. package/android/src/main/java/ee/forgr/capacitor/streamcall/StreamCallPlugin.kt +1014 -0
  11. package/android/src/main/java/ee/forgr/capacitor/streamcall/TouchInterceptWrapper.kt +31 -0
  12. package/android/src/main/java/ee/forgr/capacitor/streamcall/UserRepository.kt +111 -0
  13. package/android/src/main/res/.gitkeep +0 -0
  14. package/android/src/main/res/values/strings.xml +7 -0
  15. package/dist/docs.json +533 -0
  16. package/dist/esm/definitions.d.ts +169 -0
  17. package/dist/esm/definitions.js +2 -0
  18. package/dist/esm/definitions.js.map +1 -0
  19. package/dist/esm/index.d.ts +4 -0
  20. package/dist/esm/index.js +7 -0
  21. package/dist/esm/index.js.map +1 -0
  22. package/dist/esm/web.d.ts +32 -0
  23. package/dist/esm/web.js +323 -0
  24. package/dist/esm/web.js.map +1 -0
  25. package/dist/plugin.cjs.js +337 -0
  26. package/dist/plugin.cjs.js.map +1 -0
  27. package/dist/plugin.js +339 -0
  28. package/dist/plugin.js.map +1 -0
  29. package/ios/Sources/StreamCallPlugin/CallOverlayView.swift +147 -0
  30. package/ios/Sources/StreamCallPlugin/CustomCallParticipantImageView.swift +60 -0
  31. package/ios/Sources/StreamCallPlugin/CustomCallView.swift +257 -0
  32. package/ios/Sources/StreamCallPlugin/CustomVideoParticipantsView.swift +107 -0
  33. package/ios/Sources/StreamCallPlugin/ParticipantsView.swift +206 -0
  34. package/ios/Sources/StreamCallPlugin/StreamCallPlugin.swift +722 -0
  35. package/ios/Sources/StreamCallPlugin/TouchInterceptView.swift +177 -0
  36. package/ios/Sources/StreamCallPlugin/UserRepository.swift +96 -0
  37. package/ios/Sources/StreamCallPlugin/WebviewNavigationDelegate.swift +68 -0
  38. package/ios/Tests/StreamCallPluginTests/StreamCallPluginTests.swift +15 -0
  39. package/package.json +96 -0
@@ -0,0 +1,1014 @@
1
+ package ee.forgr.capacitor.streamcall
2
+
3
+ import TouchInterceptWrapper
4
+ import android.app.Activity
5
+ import android.app.Application
6
+ import android.app.KeyguardManager
7
+ import android.content.Context
8
+ import android.graphics.Color
9
+ import android.os.Build
10
+ import android.os.Bundle
11
+ import android.os.Handler
12
+ import android.os.Looper
13
+ import android.view.View
14
+ import android.view.ViewGroup
15
+ import android.widget.FrameLayout
16
+ import androidx.compose.ui.platform.ComposeView
17
+ import androidx.core.view.isVisible
18
+ import com.getcapacitor.BridgeActivity
19
+ import com.getcapacitor.JSObject
20
+ import com.getcapacitor.Plugin
21
+ import com.getcapacitor.PluginCall
22
+ import com.getcapacitor.PluginMethod
23
+ import com.getcapacitor.annotation.CapacitorPlugin
24
+ import io.getstream.android.push.firebase.FirebasePushDeviceGenerator
25
+ import io.getstream.android.push.permissions.ActivityLifecycleCallbacks
26
+ import io.getstream.video.android.core.Call
27
+ import io.getstream.video.android.core.GEO
28
+ import io.getstream.video.android.core.RingingState
29
+ import io.getstream.video.android.core.StreamVideo
30
+ import io.getstream.video.android.core.StreamVideoBuilder
31
+ import io.getstream.video.android.core.model.RejectReason
32
+ import io.getstream.video.android.core.notifications.NotificationConfig
33
+ import io.getstream.video.android.core.notifications.NotificationHandler
34
+ import io.getstream.video.android.core.sounds.emptyRingingConfig
35
+ import io.getstream.video.android.core.sounds.toSounds
36
+ import io.getstream.video.android.model.StreamCallId
37
+ import io.getstream.video.android.model.User
38
+ import io.getstream.video.android.model.streamCallId
39
+ import kotlinx.coroutines.launch
40
+ import org.openapitools.client.models.CallEndedEvent
41
+ import org.openapitools.client.models.CallMissedEvent
42
+ import org.openapitools.client.models.CallRejectedEvent
43
+ import org.openapitools.client.models.CallSessionEndedEvent
44
+ import org.openapitools.client.models.VideoEvent
45
+
46
+ // I am not a religious pearson, but at this point, I am not sure even god himself would understand this code
47
+ // It's a spaghetti-like, tangled, unreadable mess and frankly, I am deeply sorry for the code crimes commited in the Android impl
48
+ @CapacitorPlugin(name = "StreamCall")
49
+ public class StreamCallPlugin : Plugin() {
50
+ private var streamVideoClient: StreamVideo? = null
51
+ private var state: State = State.NOT_INITIALIZED
52
+ private var overlayView: ComposeView? = null
53
+ private var incomingCallView: ComposeView? = null
54
+ private var barrierView: View? = null
55
+ private var ringtonePlayer: RingtonePlayer? = null
56
+ private val mainHandler = Handler(Looper.getMainLooper())
57
+ private var savedContext: Context? = null
58
+ private var bootedToHandleCall: Boolean = false
59
+ private var initializationTime: Long = 0
60
+ private var savedActivity: Activity? = null
61
+ private var savedActivityPaused = false
62
+ private var savedCallsToEndOnResume = mutableListOf<Call>()
63
+
64
+ private enum class State {
65
+ NOT_INITIALIZED,
66
+ INITIALIZING,
67
+ INITIALIZED
68
+ }
69
+
70
+ private fun runOnMainThread(action: () -> Unit) {
71
+ mainHandler.post { action() }
72
+ }
73
+
74
+ override fun handleOnPause() {
75
+ this.ringtonePlayer.let { it?.pauseRinging() }
76
+ super.handleOnPause()
77
+ }
78
+
79
+ override fun handleOnResume() {
80
+ this.ringtonePlayer.let { it?.resumeRinging() }
81
+ super.handleOnResume()
82
+ }
83
+
84
+ override fun load() {
85
+ // general init
86
+ ringtonePlayer = RingtonePlayer(
87
+ this.activity.application,
88
+ cancelIncomingCallService = {
89
+ var streamVideoClient = this.streamVideoClient
90
+ if (streamVideoClient == null) {
91
+ android.util.Log.d("StreamCallPlugin", "StreamVideo SDK client is null, no incoming call notification can be constructed")
92
+ return@RingtonePlayer
93
+ }
94
+
95
+ try {
96
+ val callServiceClass = Class.forName("io.getstream.video.android.core.notifications.internal.service.CallService")
97
+ val companionClass = callServiceClass.declaredClasses.first { it.simpleName == "Companion" }
98
+ // Instead of getting INSTANCE, we'll get the companion object through the enclosing class
99
+ val companionField = callServiceClass.getDeclaredField("Companion")
100
+ companionField.isAccessible = true
101
+ val companionInstance = companionField.get(null)
102
+
103
+ val removeIncomingCallMethod = companionClass.getDeclaredMethod(
104
+ "removeIncomingCall",
105
+ Context::class.java,
106
+ Class.forName("io.getstream.video.android.model.StreamCallId"),
107
+ Class.forName("io.getstream.video.android.core.notifications.internal.service.CallServiceConfig")
108
+ )
109
+ removeIncomingCallMethod.isAccessible = true
110
+
111
+ // Get the default config using reflection
112
+ val defaultConfigClass = Class.forName("io.getstream.video.android.core.notifications.internal.service.DefaultCallConfigurations")
113
+ val defaultField = defaultConfigClass.getDeclaredField("INSTANCE")
114
+ val defaultInstance = defaultField.get(null)
115
+ val defaultMethod = defaultConfigClass.getDeclaredMethod("getDefault")
116
+ val defaultConfig = defaultMethod.invoke(defaultInstance)
117
+
118
+ val app = this.activity.application
119
+ val cId = streamVideoClient?.state?.ringingCall?.value?.cid?.let { StreamCallId.fromCallCid(it) }
120
+ if (app == null || cId == null || defaultConfig == null) {
121
+ android.util.Log.e("StreamCallPlugin", "Some required parameters are null - app: ${app == null}, cId: ${cId == null}, defaultConfig: ${defaultConfig == null}")
122
+ }
123
+
124
+ // Call the method
125
+ removeIncomingCallMethod.invoke(companionInstance, app, cId, defaultConfig)
126
+ } catch (e : Throwable) {
127
+ android.util.Log.e("StreamCallPlugin", "Reflecting streamNotificationManager and the config DID NOT work", e);
128
+ }
129
+ }
130
+ )
131
+ initializeStreamVideo()
132
+ setupViews()
133
+ super.load()
134
+
135
+ // Handle initial intent if present
136
+ activity?.intent?.let { handleOnNewIntent(it) }
137
+ }
138
+
139
+ override fun handleOnNewIntent(intent: android.content.Intent) {
140
+ super.handleOnNewIntent(intent)
141
+
142
+ val action = intent.action
143
+ val data = intent.data
144
+ val extras = intent.extras
145
+
146
+ if (action === "io.getstream.video.android.action.INCOMING_CALL") {
147
+ activity?.runOnUiThread {
148
+ val cid = intent.streamCallId(NotificationHandler.INTENT_EXTRA_CALL_CID)
149
+ if (cid != null) {
150
+ val call = streamVideoClient?.call(id = cid.id, type = cid.type)
151
+ // Start playing ringtone
152
+ ringtonePlayer?.startRinging()
153
+ // let's set a barrier. This will prevent the user from interacting with the webview while the calling screen is loading
154
+ // Launch a coroutine to handle the suspend function
155
+ showBarrier()
156
+
157
+ kotlinx.coroutines.GlobalScope.launch {
158
+ call?.get()
159
+ activity?.runOnUiThread {
160
+ incomingCallView?.setContent {
161
+ IncomingCallView(
162
+ streamVideo = streamVideoClient,
163
+ call = call,
164
+ onDeclineCall = { declinedCall ->
165
+ declineCall(declinedCall)
166
+ },
167
+ onAcceptCall = { acceptedCall ->
168
+ acceptCall(acceptedCall)
169
+ },
170
+ onHideIncomingCall = {
171
+ hideIncomingCall()
172
+ }
173
+ )
174
+ }
175
+ incomingCallView?.isVisible = true
176
+ hideBarrier()
177
+ }
178
+ }
179
+ }
180
+ }
181
+ } else if (action === "io.getstream.video.android.action.ACCEPT_CALL") {
182
+ // it's a strategic placed initializeStreamVideo. I want to register the even listeners
183
+ // (which are not initialized during the first load in initialization by the application class)
184
+ // initializeStreamVideo()
185
+ val cid = intent.streamCallId(NotificationHandler.INTENT_EXTRA_CALL_CID)
186
+ if (cid != null) {
187
+ val call = streamVideoClient?.call(id = cid.id, type = cid.type)
188
+ kotlinx.coroutines.GlobalScope.launch {
189
+ call?.get()
190
+ call?.let { acceptCall(it) }
191
+ }
192
+ }
193
+ }
194
+ // Log the intent information
195
+ android.util.Log.d("StreamCallPlugin", "New Intent - Action: $action")
196
+ android.util.Log.d("StreamCallPlugin", "New Intent - Data: $data")
197
+ android.util.Log.d("StreamCallPlugin", "New Intent - Extras: $extras")
198
+ }
199
+
200
+ private fun declineCall(call: Call) {
201
+ kotlinx.coroutines.GlobalScope.launch {
202
+ call.reject(RejectReason.Decline)
203
+
204
+ // Stop ringtone
205
+ ringtonePlayer?.stopRinging()
206
+
207
+ // Notify that call has ended
208
+ val data = JSObject().apply {
209
+ put("callId", call.id)
210
+ put("state", "rejected")
211
+ }
212
+ notifyListeners("callEvent", data)
213
+
214
+ hideIncomingCall()
215
+ }
216
+ }
217
+
218
+ private fun hideIncomingCall() {
219
+ activity?.runOnUiThread {
220
+ incomingCallView?.isVisible = false
221
+ // Stop ringtone if it's still playing
222
+ ringtonePlayer?.stopRinging()
223
+ // Check if device is locked using KeyguardManager
224
+ val keyguardManager = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
225
+ if (keyguardManager.isKeyguardLocked) {
226
+ activity.moveTaskToBack(true)
227
+ }
228
+ }
229
+ }
230
+
231
+ private fun showBarrier() {
232
+ activity?.runOnUiThread {
233
+ barrierView?.isVisible = true
234
+ }
235
+ }
236
+
237
+ private fun hideBarrier() {
238
+ activity?.runOnUiThread {
239
+ barrierView?.isVisible = false
240
+ }
241
+ }
242
+
243
+ // private fun remoteIncomingCallNotif() {
244
+ // CallService.removeIncomingCall(
245
+ // context,
246
+ // StreamCallId.fromCallCid(call.cid),
247
+ // StreamVideo.instance().state.callConfigRegistry.get(call.type),
248
+ // )
249
+ // }
250
+
251
+ private fun setupViews() {
252
+ val context = context
253
+ val parent = bridge?.webView?.parent as? ViewGroup ?: return
254
+
255
+ // Make WebView transparent
256
+ bridge?.webView?.setBackgroundColor(Color.TRANSPARENT)
257
+
258
+ // Create and add overlay view below WebView
259
+ overlayView = ComposeView(context).apply {
260
+ isVisible = false
261
+ layoutParams = FrameLayout.LayoutParams(
262
+ ViewGroup.LayoutParams.MATCH_PARENT,
263
+ ViewGroup.LayoutParams.MATCH_PARENT
264
+ )
265
+ setContent {
266
+ CallOverlayView(
267
+ context = context,
268
+ streamVideo = streamVideoClient,
269
+ call = null
270
+ )
271
+ }
272
+ }
273
+ parent.addView(overlayView, 0) // Add at index 0 to ensure it's below WebView
274
+
275
+ val originalContainer: ViewGroup = getBridge().webView
276
+
277
+ val wrapper = TouchInterceptWrapper(parent)
278
+ (parent.parent as ViewGroup).removeView(originalContainer)
279
+ (parent.parent as ViewGroup).addView(wrapper, 0)
280
+
281
+ // Create barrier view
282
+ barrierView = View(context).apply {
283
+ isVisible = false
284
+ layoutParams = FrameLayout.LayoutParams(
285
+ ViewGroup.LayoutParams.MATCH_PARENT,
286
+ ViewGroup.LayoutParams.MATCH_PARENT
287
+ )
288
+ setBackgroundColor(Color.parseColor("#1a242c"))
289
+ }
290
+ parent.addView(barrierView, parent.indexOfChild(bridge?.webView) + 1) // Add above WebView
291
+
292
+ // Create and add incoming call view
293
+ incomingCallView = ComposeView(context).apply {
294
+ isVisible = false
295
+ layoutParams = FrameLayout.LayoutParams(
296
+ ViewGroup.LayoutParams.MATCH_PARENT,
297
+ ViewGroup.LayoutParams.MATCH_PARENT
298
+ )
299
+ setContent {
300
+ IncomingCallView(streamVideoClient)
301
+ }
302
+ }
303
+ parent.addView(incomingCallView, parent.indexOfChild(bridge?.webView) + 2) // Add above WebView
304
+ }
305
+
306
+ @PluginMethod
307
+ fun login(call: PluginCall) {
308
+ val token = call.getString("token")
309
+ val userId = call.getString("userId")
310
+ val name = call.getString("name")
311
+
312
+ if (token == null || userId == null || name == null) {
313
+ call.reject("Missing required parameters: token, userId, or name")
314
+ return
315
+ }
316
+
317
+ val imageURL = call.getString("imageURL")
318
+
319
+ try {
320
+ // Create user object
321
+ val user = User(
322
+ id = userId,
323
+ name = name,
324
+ image = imageURL,
325
+ custom = emptyMap() // Initialize with empty map for custom data
326
+ )
327
+
328
+ val savedCredentials = SecureUserRepository.getInstance(this.context).loadCurrentUser()
329
+ val hadSavedCredentials = savedCredentials != null
330
+
331
+ // Create credentials and save them
332
+ val credentials = UserCredentials(user, token)
333
+ SecureUserRepository.getInstance(context).save(credentials)
334
+
335
+ // Initialize Stream Video with new credentials
336
+ if (!hadSavedCredentials) {
337
+ initializeStreamVideo()
338
+ }
339
+
340
+ val ret = JSObject()
341
+ ret.put("success", true)
342
+ call.resolve(ret)
343
+ } catch (e: Exception) {
344
+ call.reject("Failed to login", e)
345
+ }
346
+ }
347
+
348
+ @PluginMethod
349
+ fun logout(call: PluginCall) {
350
+ try {
351
+ // Clear stored credentials
352
+ SecureUserRepository.getInstance(context).removeCurrentUser()
353
+
354
+ // Properly cleanup the client
355
+ streamVideoClient?.let {
356
+ StreamVideo.removeClient()
357
+ }
358
+ streamVideoClient = null
359
+ state = State.NOT_INITIALIZED
360
+
361
+ val ret = JSObject()
362
+ ret.put("success", true)
363
+ call.resolve(ret)
364
+ } catch (e: Exception) {
365
+ call.reject("Failed to logout", e)
366
+ }
367
+ }
368
+
369
+ public fun initializeStreamVideo(passedContext: Context? = null, passedApplication: Application? = null) {
370
+ android.util.Log.v("StreamCallPlugin", "Attempting to initialize streamVideo")
371
+ if (state == State.INITIALIZING) {
372
+ android.util.Log.v("StreamCallPlugin", "Returning, already in the process of initializing")
373
+ return
374
+ }
375
+ state = State.INITIALIZING
376
+
377
+ if (passedContext != null) {
378
+ this.savedContext = passedContext
379
+ }
380
+ val contextToUse = passedContext ?: this.context
381
+
382
+ // Try to get user credentials from repository
383
+ val savedCredentials = SecureUserRepository.getInstance(contextToUse).loadCurrentUser()
384
+ if (savedCredentials == null) {
385
+ android.util.Log.v("StreamCallPlugin", "Saved credentials are null")
386
+ state = State.NOT_INITIALIZED
387
+ return
388
+ }
389
+
390
+ try {
391
+ // Check if we can reuse existing StreamVideo singleton client
392
+ if (StreamVideo.isInstalled) {
393
+ android.util.Log.v("StreamCallPlugin", "Found existing StreamVideo singleton client")
394
+ if (streamVideoClient == null) {
395
+ android.util.Log.v("StreamCallPlugin", "Plugin's streamVideoClient is null, reusing singleton and registering event handlers")
396
+ streamVideoClient = StreamVideo.instance()
397
+ // Register event handlers since streamVideoClient was null
398
+ registerEventHandlers()
399
+ } else {
400
+ android.util.Log.v("StreamCallPlugin", "Plugin already has streamVideoClient, skipping event handler registration")
401
+ }
402
+ state = State.INITIALIZED
403
+ initializationTime = System.currentTimeMillis()
404
+ return
405
+ }
406
+
407
+ // If we reach here, we need to create a new client
408
+ android.util.Log.v("StreamCallPlugin", "No existing StreamVideo singleton client, creating new one")
409
+
410
+ // unsafe cast, add better handling
411
+ val application = contextToUse.applicationContext as Application
412
+ android.util.Log.d("StreamCallPlugin", "No existing StreamVideo singleton client, creating new one")
413
+ val notificationHandler = CustomNotificationHandler(
414
+ application = application,
415
+ endCall = { callId ->
416
+ val activeCall = streamVideoClient?.call(callId.type, callId.id)
417
+
418
+ kotlinx.coroutines.GlobalScope.launch {
419
+ try {
420
+ android.util.Log.i(
421
+ "StreamCallPlugin",
422
+ "Attempt to endCallRaw, activeCall == null: ${activeCall == null}",
423
+ )
424
+ activeCall?.let { endCallRaw(it) }
425
+ } catch (e: Exception) {
426
+ android.util.Log.e(
427
+ "StreamCallPlugin",
428
+ "Error ending after missed call notif action",
429
+ e
430
+ )
431
+ }
432
+ }
433
+ },
434
+ incomingCall = {
435
+ if (this.savedContext != null && initializationTime != 0L) {
436
+ val contextCreatedAt = initializationTime
437
+ val now = System.currentTimeMillis()
438
+ val isWithinOneSecond = (now - contextCreatedAt) <= 1000L
439
+
440
+ android.util.Log.i(
441
+ "StreamCallPlugin",
442
+ "Time between context creation and activity created (incoming call notif): ${now - contextCreatedAt}"
443
+ )
444
+ if (isWithinOneSecond && !bootedToHandleCall) {
445
+ android.util.Log.i(
446
+ "StreamCallPlugin",
447
+ "Notification incomingCall received less than 1 second after the creation of streamVideoSDK. Booted FOR SURE in order to handle the notification"
448
+ )
449
+ }
450
+ }
451
+ }
452
+ )
453
+
454
+ val notificationConfig = NotificationConfig(
455
+ pushDeviceGenerators = listOf(FirebasePushDeviceGenerator(
456
+ providerName = "firebase",
457
+ context = contextToUse
458
+ )),
459
+ requestPermissionOnAppLaunch = { true },
460
+ notificationHandler = notificationHandler,
461
+ )
462
+
463
+ val soundsConfig = emptyRingingConfig()
464
+ soundsConfig.incomingCallSoundUri
465
+ // Initialize StreamVideo client
466
+ streamVideoClient = StreamVideoBuilder(
467
+ context = contextToUse,
468
+ apiKey = contextToUse.getString(R.string.CAPACITOR_STREAM_VIDEO_APIKEY),
469
+ geo = GEO.GlobalEdgeNetwork,
470
+ user = savedCredentials.user,
471
+ token = savedCredentials.tokenValue,
472
+ notificationConfig = notificationConfig,
473
+ sounds = soundsConfig.toSounds()
474
+ //, loggingLevel = LoggingLevel(priority = Priority.VERBOSE)
475
+ ).build()
476
+
477
+ // don't do event handler registration when activity may be null
478
+ if (passedContext != null) {
479
+ android.util.Log.w("StreamCallPlugin", "Ignoring event listeners for initializeStreamVideo")
480
+ passedApplication?.let {
481
+ registerActivityEventListener(it)
482
+ }
483
+ initializationTime = System.currentTimeMillis()
484
+ this.state = State.INITIALIZED
485
+ return
486
+ }
487
+
488
+ registerEventHandlers()
489
+
490
+ android.util.Log.v("StreamCallPlugin", "Initialization finished")
491
+ initializationTime = System.currentTimeMillis()
492
+ state = State.INITIALIZED
493
+ } catch (e: Exception) {
494
+ state = State.NOT_INITIALIZED
495
+ throw e
496
+ }
497
+ }
498
+
499
+ private fun moveAllActivitiesToBackgroundOrKill(context: Context, allowKill: Boolean = false) {
500
+ try {
501
+ if (allowKill && bootedToHandleCall && savedActivity != null) {
502
+ android.util.Log.d("StreamCallPlugin", "App was booted to handle call and allowKill is true, killing app")
503
+ savedActivity?.let { act ->
504
+ try {
505
+ // Get the ActivityManager
506
+ val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as android.app.ActivityManager
507
+ // Remove the task
508
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
509
+ val tasks = activityManager.appTasks
510
+ tasks.forEach { task ->
511
+ task.finishAndRemoveTask()
512
+ }
513
+ }
514
+ // Finish the activity
515
+ act.finish()
516
+ // Remove from recents
517
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
518
+ act.finishAndRemoveTask()
519
+ }
520
+ // Give a small delay for cleanup
521
+ Handler(Looper.getMainLooper()).postDelayed({
522
+ // Kill the process
523
+ android.os.Process.killProcess(android.os.Process.myPid())
524
+ }, 100)
525
+ } catch (e: Exception) {
526
+ android.util.Log.e("StreamCallPlugin", "Error during aggressive cleanup", e)
527
+ // Fallback to direct process kill
528
+ android.os.Process.killProcess(android.os.Process.myPid())
529
+ }
530
+ }
531
+ return
532
+ }
533
+
534
+ val intent = android.content.Intent(android.content.Intent.ACTION_MAIN).apply {
535
+ addCategory(android.content.Intent.CATEGORY_HOME)
536
+ flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK
537
+ }
538
+ context.startActivity(intent)
539
+ android.util.Log.d("StreamCallPlugin", "Moving app to background using HOME intent")
540
+ } catch (e: Exception) {
541
+ android.util.Log.e("StreamCallPlugin", "Failed to move app to background", e)
542
+ }
543
+ }
544
+
545
+ private fun registerEventHandlers() {
546
+ // Subscribe to call events
547
+ streamVideoClient?.let { client ->
548
+ client.subscribe { event: VideoEvent ->
549
+ android.util.Log.v("StreamCallPlugin", "Received an event ${event.getEventType()} $event")
550
+ when (event) {
551
+ is CallEndedEvent -> {
552
+ runOnMainThread {
553
+ android.util.Log.d("StreamCallPlugin", "Setting overlay invisible due to CallEndedEvent for call ${event.call.cid}")
554
+ overlayView?.setContent {
555
+ CallOverlayView(
556
+ context = context,
557
+ streamVideo = streamVideoClient,
558
+ call = null
559
+ )
560
+ }
561
+ overlayView?.isVisible = false
562
+ }
563
+ val data = JSObject().apply {
564
+ put("callId", event.call.cid)
565
+ put("state", "left")
566
+ }
567
+ notifyListeners("callEvent", data)
568
+ }
569
+ is CallSessionEndedEvent -> {
570
+ runOnMainThread {
571
+ android.util.Log.d("StreamCallPlugin", "Setting overlay invisible due to CallSessionEndedEvent for call ${event.call.cid}")
572
+ overlayView?.setContent {
573
+ CallOverlayView(
574
+ context = context,
575
+ streamVideo = streamVideoClient,
576
+ call = null
577
+ )
578
+ }
579
+ overlayView?.isVisible = false
580
+ }
581
+ val data = JSObject().apply {
582
+ put("callId", event.call.cid)
583
+ put("state", "left")
584
+ }
585
+ notifyListeners("callEvent", data)
586
+ }
587
+ is CallRejectedEvent -> {
588
+ runOnMainThread {
589
+ android.util.Log.d("StreamCallPlugin", "Setting overlay invisible due to CallRejectedEvent for call ${event.call.cid}")
590
+ overlayView?.setContent {
591
+ CallOverlayView(
592
+ context = context,
593
+ streamVideo = streamVideoClient,
594
+ call = null
595
+ )
596
+ }
597
+ overlayView?.isVisible = false
598
+
599
+ val keyguardManager = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
600
+ if (keyguardManager.isKeyguardLocked) {
601
+ android.util.Log.d("StreamCallPlugin", "Stop ringing and move to background")
602
+ this@StreamCallPlugin.ringtonePlayer?.stopRinging()
603
+ moveAllActivitiesToBackgroundOrKill(context)
604
+ }
605
+ }
606
+ val data = JSObject().apply {
607
+ put("callId", event.call.cid)
608
+ put("state", "rejected")
609
+ }
610
+ notifyListeners("callEvent", data)
611
+ }
612
+ is CallMissedEvent -> {
613
+ val keyguardManager = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
614
+ if (keyguardManager.isKeyguardLocked) {
615
+ android.util.Log.d("StreamCallPlugin", "Stop ringing and move to background")
616
+ this.ringtonePlayer?.stopRinging()
617
+ moveAllActivitiesToBackgroundOrKill(context)
618
+ }
619
+ }
620
+ }
621
+ val data = JSObject().apply {
622
+ put("callId", streamVideoClient?.state?.activeCall?.value?.cid)
623
+ put("state", event.getEventType())
624
+ }
625
+ notifyListeners("callEvent", data)
626
+ }
627
+
628
+ // Add call state subscription using collect
629
+ // used so that it follows the same patterns as iOS
630
+ kotlinx.coroutines.GlobalScope.launch {
631
+ client.state.activeCall.collect { call ->
632
+ android.util.Log.d("StreamCallPlugin", "Call State Update:")
633
+ android.util.Log.d("StreamCallPlugin", "- Call is null: ${call == null}")
634
+
635
+ call?.state?.let { state ->
636
+ android.util.Log.d("StreamCallPlugin", "- Session ID: ${state.session.value?.id}")
637
+ android.util.Log.d("StreamCallPlugin", "- All participants: ${state.participants}")
638
+ android.util.Log.d("StreamCallPlugin", "- Remote participants: ${state.remoteParticipants}")
639
+
640
+ // Notify that a call has started
641
+ val data = JSObject().apply {
642
+ put("callId", call.cid)
643
+ put("state", "joined")
644
+ }
645
+ notifyListeners("callEvent", data)
646
+ } ?: run {
647
+ // Notify that call has ended
648
+ val data = JSObject().apply {
649
+ put("callId", "")
650
+ put("state", "left")
651
+ }
652
+ notifyListeners("callEvent", data)
653
+ }
654
+ }
655
+ }
656
+ }
657
+ }
658
+
659
+ private fun registerActivityEventListener(application: Application) {
660
+ android.util.Log.i("StreamCallPlugin", "Registering activity event listener")
661
+ application.registerActivityLifecycleCallbacks(object: ActivityLifecycleCallbacks() {
662
+ override fun onActivityCreated(activity: Activity, bunlde: Bundle?) {
663
+ android.util.Log.d("StreamCallPlugin", "onActivityCreated called")
664
+ savedContext?.let {
665
+ if (this@StreamCallPlugin.savedActivity != null && activity is BridgeActivity) {
666
+ android.util.Log.d("StreamCallPlugin", "Activity created before, but got re-created. saving and returning")
667
+ this@StreamCallPlugin.savedActivity = activity;
668
+ return
669
+ }
670
+ if (initializationTime == 0L) {
671
+ android.util.Log.w("StreamCallPlugin", "initializationTime is zero. Not continuing with onActivityCreated")
672
+ return
673
+ }
674
+
675
+ val keyguardManager = application.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
676
+ val isLocked = keyguardManager.isKeyguardLocked
677
+
678
+ if (isLocked) {
679
+ this@StreamCallPlugin.bootedToHandleCall = true;
680
+ android.util.Log.d("StreamCallPlugin", "Detected that the app booted an activity while locked. We will kill after the call fails")
681
+ }
682
+
683
+ if (this@StreamCallPlugin.bridge == null && activity is BridgeActivity) {
684
+ this@StreamCallPlugin.savedActivity = activity
685
+ }
686
+ }
687
+ super.onActivityCreated(activity, bunlde)
688
+ }
689
+
690
+ override fun onActivityPaused(activity: Activity) {
691
+ if (activity is BridgeActivity && activity == this@StreamCallPlugin.savedActivity) {
692
+ this@StreamCallPlugin.savedActivityPaused = true
693
+ }
694
+ super.onActivityPaused(activity)
695
+ }
696
+
697
+ override fun onActivityResumed(activity: Activity) {
698
+ if (activity is BridgeActivity && activity == this@StreamCallPlugin.savedActivity) {
699
+ this@StreamCallPlugin.savedActivityPaused = false
700
+ }
701
+ for (call in this@StreamCallPlugin.savedCallsToEndOnResume) {
702
+ android.util.Log.d("StreamCallPlugin", "Trying to end call with ID ${call.id} on resume")
703
+ transEndCallRaw(call)
704
+ }
705
+ super.onActivityResumed(activity)
706
+ }
707
+ })
708
+ }
709
+
710
+ private fun acceptCall(call: Call) {
711
+ kotlinx.coroutines.GlobalScope.launch {
712
+ android.util.Log.i("StreamCallPlugin", "Attempting to accept call ${call.id}")
713
+ try {
714
+ // Stop ringtone
715
+ ringtonePlayer?.stopRinging()
716
+
717
+ // Hide incoming call view first
718
+ runOnMainThread {
719
+ android.util.Log.d("StreamCallPlugin", "Hiding incoming call view for call ${call.id}")
720
+ incomingCallView?.isVisible = false
721
+ }
722
+
723
+ // Accept the call
724
+ call.accept()
725
+
726
+ // Notify that call has started
727
+ val data = JSObject().apply {
728
+ put("callId", call.id)
729
+ put("state", "joined")
730
+ }
731
+ notifyListeners("callEvent", data)
732
+
733
+ // Show overlay view with the active call
734
+ runOnMainThread {
735
+ android.util.Log.d("StreamCallPlugin", "Setting overlay visible after accepting call ${call.id}")
736
+ overlayView?.setContent {
737
+ CallOverlayView(
738
+ context = context,
739
+ streamVideo = streamVideoClient,
740
+ call = call
741
+ )
742
+ }
743
+ overlayView?.isVisible = true
744
+ }
745
+ } catch (e: Exception) {
746
+ android.util.Log.e("StreamCallPlugin", "Error accepting call ${call.id}: ${e.message}")
747
+ runOnMainThread {
748
+ android.widget.Toast.makeText(
749
+ context,
750
+ "Failed to join call: ${e.message}",
751
+ android.widget.Toast.LENGTH_LONG
752
+ ).show()
753
+ }
754
+ }
755
+ }
756
+ }
757
+
758
+ @PluginMethod
759
+ fun setMicrophoneEnabled(call: PluginCall) {
760
+ val enabled = call.getBoolean("enabled") ?: run {
761
+ call.reject("Missing required parameter: enabled")
762
+ return
763
+ }
764
+
765
+ try {
766
+ val activeCall = streamVideoClient?.state?.activeCall
767
+ if (activeCall == null) {
768
+ call.reject("No active call")
769
+ return
770
+ }
771
+
772
+ kotlinx.coroutines.GlobalScope.launch {
773
+ try {
774
+ activeCall.value?.microphone?.setEnabled(enabled)
775
+ call.resolve(JSObject().apply {
776
+ put("success", true)
777
+ })
778
+ } catch (e: Exception) {
779
+ android.util.Log.e("StreamCallPlugin", "Error setting microphone: ${e.message}")
780
+ call.reject("Failed to set microphone: ${e.message}")
781
+ }
782
+ }
783
+ } catch (e: Exception) {
784
+ call.reject("StreamVideo not initialized")
785
+ }
786
+ }
787
+
788
+ @PluginMethod
789
+ fun setCameraEnabled(call: PluginCall) {
790
+ val enabled = call.getBoolean("enabled") ?: run {
791
+ call.reject("Missing required parameter: enabled")
792
+ return
793
+ }
794
+
795
+ try {
796
+ val activeCall = streamVideoClient?.state?.activeCall
797
+ if (activeCall == null) {
798
+ call.reject("No active call")
799
+ return
800
+ }
801
+
802
+ kotlinx.coroutines.GlobalScope.launch {
803
+ try {
804
+ activeCall.value?.camera?.setEnabled(enabled)
805
+ call.resolve(JSObject().apply {
806
+ put("success", true)
807
+ })
808
+ } catch (e: Exception) {
809
+ android.util.Log.e("StreamCallPlugin", "Error setting camera: ${e.message}")
810
+ call.reject("Failed to set camera: ${e.message}")
811
+ }
812
+ }
813
+ } catch (e: Exception) {
814
+ call.reject("StreamVideo not initialized")
815
+ }
816
+ }
817
+
818
+ suspend fun endCallRaw(call: Call) {
819
+ val callId = call.id
820
+ android.util.Log.d("StreamCallPlugin", "Attempting to end call $callId")
821
+ call.leave()
822
+ call.reject(reason = RejectReason.Cancel)
823
+
824
+ // Capture context from the overlayView
825
+ val currentContext = overlayView?.context ?: this.savedContext
826
+ if (currentContext == null) {
827
+ android.util.Log.w("StreamCallPlugin", "Cannot end call $callId because context is null")
828
+ return
829
+ }
830
+
831
+ runOnMainThread {
832
+ android.util.Log.d("StreamCallPlugin", "Setting overlay invisible after ending call $callId")
833
+
834
+
835
+ currentContext.let { ctx ->
836
+ val keyguardManager = ctx.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
837
+ if (keyguardManager.isKeyguardLocked) {
838
+ // we allow kill exclusively here
839
+ // the idea is that:
840
+ // the 'empty' instance of this plugin class gets created in application
841
+ // then, it handles a notification and setts the context (this.savedContext)
842
+ // if the context is new
843
+ moveAllActivitiesToBackgroundOrKill(ctx, true)
844
+ }
845
+ }
846
+
847
+ var savedCapacitorActivity = savedActivity
848
+ if (savedCapacitorActivity != null) {
849
+
850
+ if (savedActivityPaused) {
851
+ android.util.Log.d("StreamCallPlugin", "Activity is paused. Adding call ${call.id} to savedCallsToEndOnResume")
852
+ savedCallsToEndOnResume.add(call)
853
+ } else {
854
+ transEndCallRaw(call)
855
+ }
856
+
857
+ return@runOnMainThread
858
+ }
859
+
860
+ overlayView?.setContent {
861
+ CallOverlayView(
862
+ context = currentContext,
863
+ streamVideo = streamVideoClient,
864
+ call = null
865
+ )
866
+ }
867
+ overlayView?.isVisible = false
868
+ this@StreamCallPlugin.ringtonePlayer?.stopRinging()
869
+
870
+ // Also hide incoming call view if visible
871
+ android.util.Log.d("StreamCallPlugin", "Hiding incoming call view for call $callId")
872
+ incomingCallView?.isVisible = false
873
+ }
874
+
875
+ // Notify that call has ended
876
+ val data = JSObject().apply {
877
+ put("callId", callId)
878
+ put("state", "left")
879
+ }
880
+ notifyListeners("callEvent", data)
881
+ }
882
+
883
+ private fun transEndCallRaw(call: Call) {
884
+ val callId = call.id
885
+ var savedCapacitorActivity = savedActivity
886
+ if (savedCapacitorActivity == null) {
887
+ android.util.Log.d("StreamCallPlugin", "Cannot perform transEndCallRaw for call $callId. savedCapacitorActivity is null")
888
+ return
889
+ }
890
+ android.util.Log.d("StreamCallPlugin", "Performing a trans-instance call to end call with id $callId")
891
+ if (savedCapacitorActivity !is BridgeActivity) {
892
+ android.util.Log.e("StreamCallPlugin", "Saved activity is NOT a Capactor activity. Saved activity class: ${savedCapacitorActivity.javaClass.canonicalName}")
893
+ return
894
+ }
895
+ val plugin = savedCapacitorActivity.bridge.getPlugin("StreamCall")
896
+ if (plugin == null) {
897
+ android.util.Log.e("StreamCallPlugin", "Plugin with name StreamCall not found?????")
898
+ return
899
+ }
900
+ if (plugin.instance !is StreamCallPlugin) {
901
+ android.util.Log.e("StreamCallPlugin", "Plugin found, but invalid instance")
902
+ return
903
+ }
904
+
905
+ kotlinx.coroutines.GlobalScope.launch {
906
+ try {
907
+ (plugin.instance as StreamCallPlugin).endCallRaw(call)
908
+ } catch (e: Exception) {
909
+ android.util.Log.e("StreamCallPlugin", "Error ending call on remote instance", e)
910
+ }
911
+ }
912
+ }
913
+
914
+ @PluginMethod
915
+ fun endCall(call: PluginCall) {
916
+ try {
917
+ val activeCall = streamVideoClient?.state?.activeCall
918
+ if (activeCall == null) {
919
+ android.util.Log.w("StreamCallPlugin", "Attempted to end call but no active call found")
920
+ call.reject("No active call to end")
921
+ return
922
+ }
923
+
924
+ kotlinx.coroutines.GlobalScope.launch {
925
+ try {
926
+ activeCall.value?.let { endCallRaw(it) }
927
+ call.resolve(JSObject().apply {
928
+ put("success", true)
929
+ })
930
+ } catch (e: Exception) {
931
+ android.util.Log.e("StreamCallPlugin", "Error ending call: ${e.message}")
932
+ call.reject("Failed to end call: ${e.message}")
933
+ }
934
+ }
935
+ } catch (e: Exception) {
936
+ call.reject("StreamVideo not initialized")
937
+ }
938
+ }
939
+
940
+ @PluginMethod
941
+ fun call(call: PluginCall) {
942
+ val userId = call.getString("userId")
943
+ if (userId == null) {
944
+ call.reject("Missing required parameter: userId")
945
+ return
946
+ }
947
+
948
+ try {
949
+ if (state != State.INITIALIZED) {
950
+ call.reject("StreamVideo not initialized")
951
+ return
952
+ }
953
+
954
+ val selfUserId = streamVideoClient?.userId
955
+ if (selfUserId == null) {
956
+ call.reject("No self-user id found. Are you not logged in?")
957
+ return
958
+ }
959
+
960
+ val callType = call.getString("type") ?: "default"
961
+ val shouldRing = call.getBoolean("ring") ?: true
962
+ val callId = java.util.UUID.randomUUID().toString()
963
+
964
+ android.util.Log.d("StreamCallPlugin", "Creating call:")
965
+ android.util.Log.d("StreamCallPlugin", "- Call ID: $callId")
966
+ android.util.Log.d("StreamCallPlugin", "- Call Type: $callType")
967
+ android.util.Log.d("StreamCallPlugin", "- User ID: $userId")
968
+ android.util.Log.d("StreamCallPlugin", "- Should Ring: $shouldRing")
969
+
970
+ // Create and join call in a coroutine
971
+ kotlinx.coroutines.GlobalScope.launch {
972
+ try {
973
+ // Create the call object
974
+ val streamCall = streamVideoClient?.call(type = callType, id = callId)
975
+
976
+ android.util.Log.d("StreamCallPlugin", "Creating call with member...")
977
+ // Create the call with the member
978
+ streamCall?.create(
979
+ memberIds = listOf(userId, selfUserId),
980
+ custom = emptyMap(),
981
+ ring = shouldRing
982
+ )
983
+
984
+ // streamCall?.let {
985
+ // this@StreamCallPlugin.streamVideoClient?.state?.setActiveCall(it)
986
+ // }
987
+
988
+ android.util.Log.d("StreamCallPlugin", "Setting overlay visible for outgoing call $callId")
989
+ // Show overlay view
990
+ activity?.runOnUiThread {
991
+ overlayView?.setContent {
992
+ CallOverlayView(
993
+ context = context,
994
+ streamVideo = streamVideoClient,
995
+ call = streamCall
996
+ )
997
+ }
998
+ overlayView?.isVisible = true
999
+ }
1000
+
1001
+ // Resolve the call with success
1002
+ call.resolve(JSObject().apply {
1003
+ put("success", true)
1004
+ })
1005
+ } catch (e: Exception) {
1006
+ android.util.Log.e("StreamCallPlugin", "Error making call: ${e.message}")
1007
+ call.reject("Failed to make call: ${e.message}")
1008
+ }
1009
+ }
1010
+ } catch (e: Exception) {
1011
+ call.reject("Failed to make call: ${e.message}")
1012
+ }
1013
+ }
1014
+ }