@elizaos/capacitor-screencapture 2.0.3-beta.2 → 2.0.3-beta.3
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
},
|
|
412
|
+
}, 600)
|
|
413
|
+
}
|
|
358
414
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
},
|
|
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
|
-
// ──
|
|
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.
|
|
3
|
+
"version": "2.0.3-beta.3",
|
|
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": "
|
|
84
|
+
"gitHead": "f54b0f4eaed317d59fa7dbcdce20f4cdb0734420"
|
|
85
85
|
}
|