@elizaos/capacitor-screencapture 2.0.3-beta.2 → 2.0.3-beta.4

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.
@@ -3,4 +3,13 @@
3
3
  <uses-permission android:name="android.permission.RECORD_AUDIO" />
4
4
  <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
5
5
  <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" android:minSdkVersion="34" />
6
+
7
+ <application>
8
+ <!-- Android 14+ requires a running mediaProjection FGS before
9
+ getMediaProjection(); the plugin starts/stops this around a capture. -->
10
+ <service
11
+ android:name="ai.eliza.plugins.screencapture.ScreenCaptureFgService"
12
+ android:exported="false"
13
+ android:foregroundServiceType="mediaProjection" />
14
+ </application>
6
15
  </manifest>
@@ -0,0 +1,59 @@
1
+ package ai.eliza.plugins.screencapture
2
+
3
+ import android.app.Notification
4
+ import android.app.NotificationChannel
5
+ import android.app.NotificationManager
6
+ import android.app.Service
7
+ import android.content.Intent
8
+ import android.content.pm.ServiceInfo
9
+ import android.os.Build
10
+ import android.os.IBinder
11
+
12
+ /**
13
+ * Foreground service of type `mediaProjection`. Android 14+ (API 34) throws
14
+ * SecurityException: Media projections require a foreground service of type
15
+ * ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
16
+ * from `MediaProjectionManager.getMediaProjection()` unless such a service is
17
+ * already running. The screencapture plugin starts this service immediately
18
+ * after the user grants the projection consent and stops it when the projection
19
+ * is released, so a single screenshot / continuous capture works on Android 14+.
20
+ */
21
+ class ScreenCaptureFgService : Service() {
22
+ override fun onBind(intent: Intent?): IBinder? = null
23
+
24
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
25
+ val channelId = "eliza_screen_capture"
26
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
27
+ val nm = getSystemService(NotificationManager::class.java)
28
+ if (nm != null && nm.getNotificationChannel(channelId) == null) {
29
+ nm.createNotificationChannel(
30
+ NotificationChannel(
31
+ channelId,
32
+ "Screen capture",
33
+ NotificationManager.IMPORTANCE_LOW
34
+ )
35
+ )
36
+ }
37
+ }
38
+ val notification: Notification = Notification.Builder(this, channelId)
39
+ .setContentTitle("Screen sharing active")
40
+ .setContentText("This app is reading the screen.")
41
+ .setSmallIcon(android.R.drawable.ic_menu_camera)
42
+ .setOngoing(true)
43
+ .build()
44
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
45
+ startForeground(
46
+ NOTIF_ID,
47
+ notification,
48
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
49
+ )
50
+ } else {
51
+ startForeground(NOTIF_ID, notification)
52
+ }
53
+ return START_NOT_STICKY
54
+ }
55
+
56
+ companion object {
57
+ const val NOTIF_ID = 8451
58
+ }
59
+ }
@@ -7,6 +7,7 @@ import android.app.NotificationChannel
7
7
  import android.app.NotificationManager
8
8
  import android.content.Context
9
9
  import android.content.Intent
10
+ import androidx.core.content.ContextCompat
10
11
  import android.graphics.Bitmap
11
12
  import android.graphics.PixelFormat
12
13
  import android.hardware.display.DisplayManager
@@ -57,6 +58,10 @@ class ScreenCapturePlugin : Plugin() {
57
58
  private var virtualDisplay: VirtualDisplay? = null
58
59
  private var mediaRecorder: MediaRecorder? = null
59
60
  private var imageReader: ImageReader? = null
61
+ // Dimensions of the warm screenshot VirtualDisplay, so repeated captures at
62
+ // the same scale reuse it instead of re-creating it each frame.
63
+ private var screenshotVdWidth = 0
64
+ private var screenshotVdHeight = 0
60
65
 
61
66
  // Recording state
62
67
  private var isRecording = false
@@ -153,6 +158,17 @@ class ScreenCapturePlugin : Plugin() {
153
158
  pendingCall = call
154
159
  pendingAction = "screenshot"
155
160
 
161
+ // Reuse an already-granted session projection instead of re-prompting
162
+ // the system consent dialog on every capture. Continuous screen
163
+ // understanding (EPIC #9105) captures repeatedly; tearing the projection
164
+ // down + re-consenting per frame is a battery/latency/UX killer. The
165
+ // projection is kept warm across captures and only released on
166
+ // background/destroy or when the system stops it.
167
+ if (mediaProjection != null) {
168
+ captureScreenshotInternal(call)
169
+ return
170
+ }
171
+
156
172
  val intent = mediaProjectionManager?.createScreenCaptureIntent()
157
173
  if (intent != null) {
158
174
  startActivityForResult(call, intent, "handleProjectionResult")
@@ -339,27 +355,69 @@ class ScreenCapturePlugin : Plugin() {
339
355
  return
340
356
  }
341
357
 
342
- mediaProjection = mediaProjectionManager?.getMediaProjection(result.resultCode, result.data!!)
343
-
344
- if (mediaProjection == null) {
345
- call.reject("Failed to get media projection")
346
- return
358
+ // Android 14+ (API 34) throws SecurityException from getMediaProjection()
359
+ // unless a foreground service of type mediaProjection is ALREADY running.
360
+ // Start it now, then acquire the projection once it has gone foreground.
361
+ try {
362
+ ContextCompat.startForegroundService(
363
+ context,
364
+ Intent(context, ScreenCaptureFgService::class.java)
365
+ )
366
+ } catch (e: Exception) {
367
+ Log.w(TAG, "Could not start mediaProjection FGS", e)
347
368
  }
348
369
 
349
- // Register stop callback for cleanup
350
- mediaProjection?.registerCallback(object : MediaProjection.Callback() {
351
- override fun onStop() {
352
- Log.d(TAG, "MediaProjection stopped by system")
353
- if (isRecording) {
354
- scope.launch { stopRecordingInternal() }
370
+ Handler(Looper.getMainLooper()).postDelayed({
371
+ try {
372
+ mediaProjection =
373
+ mediaProjectionManager?.getMediaProjection(result.resultCode, result.data!!)
374
+ } catch (e: Exception) {
375
+ Log.e(TAG, "getMediaProjection failed", e)
376
+ stopFgService()
377
+ call.reject("Media projection failed: ${e.message}")
378
+ return@postDelayed
379
+ }
380
+ if (mediaProjection == null) {
381
+ stopFgService()
382
+ call.reject("Failed to get media projection")
383
+ return@postDelayed
384
+ }
385
+
386
+ // Register stop callback for cleanup
387
+ mediaProjection?.registerCallback(object : MediaProjection.Callback() {
388
+ override fun onStop() {
389
+ Log.d(TAG, "MediaProjection stopped by system")
390
+ if (isRecording) {
391
+ scope.launch { stopRecordingInternal() }
392
+ }
393
+ // The session projection is gone — drop our warm references so
394
+ // the next captureScreenshot re-acquires consent cleanly instead
395
+ // of using a dead projection.
396
+ virtualDisplay?.release()
397
+ virtualDisplay = null
398
+ imageReader?.close()
399
+ imageReader = null
400
+ screenshotVdWidth = 0
401
+ screenshotVdHeight = 0
402
+ mediaProjection = null
403
+ stopFgService()
355
404
  }
405
+ }, Handler(Looper.getMainLooper()))
406
+
407
+ when (pendingAction) {
408
+ "screenshot" -> captureScreenshotInternal(call)
409
+ "recording" -> startRecordingInternal(call)
410
+ else -> call.reject("Unknown action")
356
411
  }
357
- }, Handler(Looper.getMainLooper()))
412
+ }, 600)
413
+ }
358
414
 
359
- when (pendingAction) {
360
- "screenshot" -> captureScreenshotInternal(call)
361
- "recording" -> startRecordingInternal(call)
362
- else -> call.reject("Unknown action")
415
+ /** Stop the mediaProjection foreground service (idempotent). */
416
+ private fun stopFgService() {
417
+ try {
418
+ context.stopService(Intent(context, ScreenCaptureFgService::class.java))
419
+ } catch (e: Exception) {
420
+ Log.w(TAG, "stopFgService failed", e)
363
421
  }
364
422
  }
365
423
 
@@ -370,23 +428,38 @@ class ScreenCapturePlugin : Plugin() {
370
428
  val quality = call.getInt("quality") ?: 100
371
429
  val scale = call.getFloat("scale") ?: 1f
372
430
 
373
- val width = (screenWidth * scale).toInt()
374
- val height = (screenHeight * scale).toInt()
375
-
376
- imageReader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2)
377
-
378
- virtualDisplay = mediaProjection?.createVirtualDisplay(
379
- "ScreenCapture",
380
- width,
381
- height,
382
- screenDensity,
383
- DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
384
- imageReader?.surface,
385
- null,
386
- null
387
- )
431
+ val width = Math.max(1, (screenWidth * scale).toInt())
432
+ val height = Math.max(1, (screenHeight * scale).toInt())
433
+
434
+ // Reuse the warm VirtualDisplay + ImageReader when the requested size
435
+ // matches; only (re)create them when missing or the scale changed. The
436
+ // resize itself is native — the VirtualDisplay renders directly at the
437
+ // target resolution, so the agent never resizes pixels in JS.
438
+ val warm = virtualDisplay != null &&
439
+ imageReader != null &&
440
+ screenshotVdWidth == width &&
441
+ screenshotVdHeight == height
442
+ if (!warm) {
443
+ virtualDisplay?.release()
444
+ imageReader?.close()
445
+ imageReader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2)
446
+ virtualDisplay = mediaProjection?.createVirtualDisplay(
447
+ "ScreenCapture",
448
+ width,
449
+ height,
450
+ screenDensity,
451
+ DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
452
+ imageReader?.surface,
453
+ null,
454
+ null
455
+ )
456
+ screenshotVdWidth = width
457
+ screenshotVdHeight = height
458
+ }
388
459
 
389
- // Brief delay to allow the VirtualDisplay to render a frame
460
+ // A freshly-created mirror needs ~250ms to render its first frame; a warm
461
+ // display is already mirroring continuously, so a short settle suffices.
462
+ val settleMs = if (warm) 60L else 250L
390
463
  Handler(Looper.getMainLooper()).postDelayed({
391
464
  try {
392
465
  val image = imageReader?.acquireLatestImage()
@@ -412,7 +485,9 @@ class ScreenCapturePlugin : Plugin() {
412
485
 
413
486
  val base64 = Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
414
487
 
415
- cleanup()
488
+ // Keep the projection + VirtualDisplay warm for the next
489
+ // capture — do NOT cleanup() here (that stopped the
490
+ // projection and forced a re-consent every frame).
416
491
 
417
492
  call.resolve(JSObject().apply {
418
493
  put("base64", base64)
@@ -422,16 +497,36 @@ class ScreenCapturePlugin : Plugin() {
422
497
  put("timestamp", System.currentTimeMillis())
423
498
  })
424
499
  } else {
425
- cleanup()
500
+ // Transient empty frame — keep the session warm, just fail
501
+ // this one capture so the caller can retry on the next tick.
426
502
  call.reject("Failed to capture screenshot")
427
503
  }
428
504
  } catch (e: Exception) {
429
505
  Log.e(TAG, "Screenshot capture failed", e)
430
- cleanup()
506
+ // A real error may have invalidated the projection — tear it all
507
+ // down so the next capture re-acquires cleanly.
508
+ releaseProjection()
431
509
  notifyError("screenshot_failed", "Screenshot failed: ${e.message}")
432
510
  call.reject("Screenshot failed: ${e.message}")
433
511
  }
434
- }, 250) // 250ms delay to ensure frame is rendered
512
+ }, settleMs)
513
+ }
514
+
515
+ /**
516
+ * Full teardown of the warm screenshot projection + its mirror. Use on
517
+ * background/destroy or after an error invalidates the session. Recording
518
+ * has its own lifecycle via [cleanup].
519
+ */
520
+ private fun releaseProjection() {
521
+ virtualDisplay?.release()
522
+ virtualDisplay = null
523
+ imageReader?.close()
524
+ imageReader = null
525
+ screenshotVdWidth = 0
526
+ screenshotVdHeight = 0
527
+ mediaProjection?.stop()
528
+ mediaProjection = null
529
+ stopFgService()
435
530
  }
436
531
 
437
532
  private fun imageToBitmap(image: Image, width: Int, height: Int): Bitmap {
@@ -755,11 +850,28 @@ class ScreenCapturePlugin : Plugin() {
755
850
  virtualDisplay = null
756
851
  imageReader?.close()
757
852
  imageReader = null
853
+ screenshotVdWidth = 0
854
+ screenshotVdHeight = 0
758
855
  mediaProjection?.stop()
759
856
  mediaProjection = null
857
+ stopFgService()
760
858
  }
761
859
 
762
- // ── Destroy ─────────────────────────────────────────────────────────
860
+ // ── Lifecycle ───────────────────────────────────────────────────────
861
+
862
+ override fun handleOnStop() {
863
+ super.handleOnStop()
864
+ // Release the warm screenshot MediaProjection when the app is no longer
865
+ // visible. A backgrounded app must not hold a live screen-capture
866
+ // session — it drains battery, keeps the system cast/record indicator
867
+ // up, and is a privacy concern. (handleOnStop fires on full background,
868
+ // not the transient pause the consent dialog causes, so it won't tear
869
+ // down a projection mid-acquisition.) Recording owns its own projection
870
+ // lifecycle, so leave it alone while a recording is active.
871
+ if (!isRecording) {
872
+ releaseProjection()
873
+ }
874
+ }
763
875
 
764
876
  override fun handleOnDestroy() {
765
877
  super.handleOnDestroy()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elizaos/capacitor-screencapture",
3
- "version": "2.0.3-beta.2",
3
+ "version": "2.0.3-beta.4",
4
4
  "description": "Captures screenshots and records the screen across web, mobile, and desktop.",
5
5
  "keywords": [
6
6
  "screen-capture",
@@ -81,5 +81,5 @@
81
81
  "android": true
82
82
  }
83
83
  },
84
- "gitHead": "82fe0f44215954c2417328203f5bd6510985c1fc"
84
+ "gitHead": "f76f55793a0fb8d6b869dd8ddce03970de061e34"
85
85
  }