@elizaos/capacitor-screencapture 1.0.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/ElizaosCapacitorScreencapture.podspec +18 -0
- package/android/build.gradle +50 -0
- package/android/src/main/AndroidManifest.xml +6 -0
- package/android/src/main/java/ai/eliza/plugins/screencapture/ScreenCapturePlugin.kt +777 -0
- package/dist/esm/definitions.d.ts +101 -0
- package/dist/esm/definitions.d.ts.map +1 -0
- package/dist/esm/definitions.js +1 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +6 -0
- package/dist/esm/web.d.ts +56 -0
- package/dist/esm/web.d.ts.map +1 -0
- package/dist/esm/web.js +330 -0
- package/dist/plugin.cjs.js +346 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +349 -0
- package/dist/plugin.js.map +1 -0
- package/electrobun/src/index.ts +102 -0
- package/electrobun/tsconfig.json +18 -0
- package/ios/Sources/ScreenCapturePlugin/ScreenCapturePlugin.swift +758 -0
- package/package.json +84 -0
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
package ai.eliza.plugins.screencapture
|
|
2
|
+
|
|
3
|
+
import android.Manifest
|
|
4
|
+
import android.app.Activity
|
|
5
|
+
import android.app.Notification
|
|
6
|
+
import android.app.NotificationChannel
|
|
7
|
+
import android.app.NotificationManager
|
|
8
|
+
import android.content.Context
|
|
9
|
+
import android.content.Intent
|
|
10
|
+
import android.graphics.Bitmap
|
|
11
|
+
import android.graphics.PixelFormat
|
|
12
|
+
import android.hardware.display.DisplayManager
|
|
13
|
+
import android.hardware.display.VirtualDisplay
|
|
14
|
+
import android.media.Image
|
|
15
|
+
import android.media.ImageReader
|
|
16
|
+
import android.media.MediaRecorder
|
|
17
|
+
import android.media.projection.MediaProjection
|
|
18
|
+
import android.media.projection.MediaProjectionManager
|
|
19
|
+
import android.os.Build
|
|
20
|
+
import android.os.Handler
|
|
21
|
+
import android.os.Looper
|
|
22
|
+
import android.util.Base64
|
|
23
|
+
import android.util.DisplayMetrics
|
|
24
|
+
import android.util.Log
|
|
25
|
+
import android.view.WindowManager
|
|
26
|
+
import androidx.activity.result.ActivityResult
|
|
27
|
+
import com.getcapacitor.JSArray
|
|
28
|
+
import com.getcapacitor.JSObject
|
|
29
|
+
import com.getcapacitor.Plugin
|
|
30
|
+
import com.getcapacitor.PluginCall
|
|
31
|
+
import com.getcapacitor.PluginMethod
|
|
32
|
+
import com.getcapacitor.annotation.ActivityCallback
|
|
33
|
+
import com.getcapacitor.annotation.CapacitorPlugin
|
|
34
|
+
import com.getcapacitor.annotation.Permission
|
|
35
|
+
import com.getcapacitor.annotation.PermissionCallback
|
|
36
|
+
import kotlinx.coroutines.*
|
|
37
|
+
import java.io.ByteArrayOutputStream
|
|
38
|
+
import java.io.File
|
|
39
|
+
import java.text.SimpleDateFormat
|
|
40
|
+
import java.util.*
|
|
41
|
+
|
|
42
|
+
@CapacitorPlugin(
|
|
43
|
+
name = "ScreenCapture",
|
|
44
|
+
permissions = [
|
|
45
|
+
Permission(alias = "microphone", strings = [Manifest.permission.RECORD_AUDIO])
|
|
46
|
+
]
|
|
47
|
+
)
|
|
48
|
+
class ScreenCapturePlugin : Plugin() {
|
|
49
|
+
companion object {
|
|
50
|
+
private const val TAG = "ScreenCapture"
|
|
51
|
+
private const val NOTIFICATION_CHANNEL_ID = "screen_capture_channel"
|
|
52
|
+
private const val NOTIFICATION_ID = 9001
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private var mediaProjectionManager: MediaProjectionManager? = null
|
|
56
|
+
private var mediaProjection: MediaProjection? = null
|
|
57
|
+
private var virtualDisplay: VirtualDisplay? = null
|
|
58
|
+
private var mediaRecorder: MediaRecorder? = null
|
|
59
|
+
private var imageReader: ImageReader? = null
|
|
60
|
+
|
|
61
|
+
// Recording state
|
|
62
|
+
private var isRecording = false
|
|
63
|
+
private var isPaused = false
|
|
64
|
+
private var recordingStartTime = 0L
|
|
65
|
+
private var pausedDurationMs = 0L
|
|
66
|
+
private var pauseStartTime = 0L
|
|
67
|
+
private var outputFile: File? = null
|
|
68
|
+
private var recordingTimer: Handler? = null
|
|
69
|
+
private var recordingRunnable: Runnable? = null
|
|
70
|
+
private var maxDurationMs: Long? = null
|
|
71
|
+
private var maxFileSize: Long? = null
|
|
72
|
+
|
|
73
|
+
// Pending permission flow
|
|
74
|
+
private var pendingCall: PluginCall? = null
|
|
75
|
+
private var pendingAction: String? = null
|
|
76
|
+
private var pendingRecordingOptions: RecordingConfig? = null
|
|
77
|
+
|
|
78
|
+
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
|
79
|
+
private var screenDensity = 0
|
|
80
|
+
private var screenWidth = 0
|
|
81
|
+
private var screenHeight = 0
|
|
82
|
+
|
|
83
|
+
// ── Lifecycle ────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
override fun load() {
|
|
86
|
+
super.load()
|
|
87
|
+
mediaProjectionManager =
|
|
88
|
+
context.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
|
89
|
+
|
|
90
|
+
updateScreenMetrics()
|
|
91
|
+
createNotificationChannel()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private fun updateScreenMetrics() {
|
|
95
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
96
|
+
val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
|
97
|
+
val metrics = windowManager.currentWindowMetrics
|
|
98
|
+
val bounds = metrics.bounds
|
|
99
|
+
screenWidth = bounds.width()
|
|
100
|
+
screenHeight = bounds.height()
|
|
101
|
+
val config = context.resources.configuration
|
|
102
|
+
screenDensity = config.densityDpi
|
|
103
|
+
} else {
|
|
104
|
+
val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
|
105
|
+
val metrics = DisplayMetrics()
|
|
106
|
+
@Suppress("DEPRECATION")
|
|
107
|
+
windowManager.defaultDisplay.getMetrics(metrics)
|
|
108
|
+
screenDensity = metrics.densityDpi
|
|
109
|
+
screenWidth = metrics.widthPixels
|
|
110
|
+
screenHeight = metrics.heightPixels
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Notification channel required for the foreground service on Android 14+.
|
|
116
|
+
*/
|
|
117
|
+
private fun createNotificationChannel() {
|
|
118
|
+
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
119
|
+
if (nm.getNotificationChannel(NOTIFICATION_CHANNEL_ID) != null) return
|
|
120
|
+
val channel = NotificationChannel(
|
|
121
|
+
NOTIFICATION_CHANNEL_ID,
|
|
122
|
+
"Screen Capture",
|
|
123
|
+
NotificationManager.IMPORTANCE_LOW
|
|
124
|
+
).apply {
|
|
125
|
+
description = "Used while capturing or recording the screen"
|
|
126
|
+
}
|
|
127
|
+
nm.createNotificationChannel(channel)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Plugin methods ──────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
@PluginMethod
|
|
133
|
+
fun isSupported(call: PluginCall) {
|
|
134
|
+
val features = JSArray()
|
|
135
|
+
features.put("screenshot")
|
|
136
|
+
features.put("recording")
|
|
137
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
138
|
+
features.put("system_audio")
|
|
139
|
+
}
|
|
140
|
+
features.put("microphone")
|
|
141
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
|
142
|
+
features.put("pause_resume")
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
call.resolve(JSObject().apply {
|
|
146
|
+
put("supported", true)
|
|
147
|
+
put("features", features)
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
@PluginMethod
|
|
152
|
+
fun captureScreenshot(call: PluginCall) {
|
|
153
|
+
pendingCall = call
|
|
154
|
+
pendingAction = "screenshot"
|
|
155
|
+
|
|
156
|
+
val intent = mediaProjectionManager?.createScreenCaptureIntent()
|
|
157
|
+
if (intent != null) {
|
|
158
|
+
startActivityForResult(call, intent, "handleProjectionResult")
|
|
159
|
+
} else {
|
|
160
|
+
call.reject("Screen capture not available")
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
@PluginMethod
|
|
165
|
+
fun startRecording(call: PluginCall) {
|
|
166
|
+
if (isRecording) {
|
|
167
|
+
call.reject("Recording already in progress")
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Parse recording options
|
|
172
|
+
val config = parseRecordingConfig(call)
|
|
173
|
+
pendingRecordingOptions = config
|
|
174
|
+
|
|
175
|
+
// Check mic permission if microphone capture requested
|
|
176
|
+
if (config.captureMicrophone &&
|
|
177
|
+
getPermissionState("microphone") != com.getcapacitor.PermissionState.GRANTED
|
|
178
|
+
) {
|
|
179
|
+
pendingCall = call
|
|
180
|
+
pendingAction = "recording"
|
|
181
|
+
requestPermissionForAlias("microphone", call, "handleMicPermissionResult")
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
pendingCall = call
|
|
186
|
+
pendingAction = "recording"
|
|
187
|
+
|
|
188
|
+
val intent = mediaProjectionManager?.createScreenCaptureIntent()
|
|
189
|
+
if (intent != null) {
|
|
190
|
+
startActivityForResult(call, intent, "handleProjectionResult")
|
|
191
|
+
} else {
|
|
192
|
+
call.reject("Screen capture not available")
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
@PluginMethod
|
|
197
|
+
fun stopRecording(call: PluginCall) {
|
|
198
|
+
if (!isRecording) {
|
|
199
|
+
call.reject("Not recording")
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
scope.launch {
|
|
204
|
+
val result = stopRecordingInternal()
|
|
205
|
+
if (result != null) {
|
|
206
|
+
call.resolve(result)
|
|
207
|
+
} else {
|
|
208
|
+
call.reject("Failed to stop recording")
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
@PluginMethod
|
|
214
|
+
fun pauseRecording(call: PluginCall) {
|
|
215
|
+
if (!isRecording) {
|
|
216
|
+
call.reject("Not recording")
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
if (isPaused) {
|
|
220
|
+
call.reject("Already paused")
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
|
225
|
+
try {
|
|
226
|
+
mediaRecorder?.pause()
|
|
227
|
+
isPaused = true
|
|
228
|
+
pauseStartTime = System.currentTimeMillis()
|
|
229
|
+
|
|
230
|
+
notifyListeners("recordingState", JSObject().apply {
|
|
231
|
+
put("isRecording", true)
|
|
232
|
+
put("isPaused", true)
|
|
233
|
+
put("duration", getRecordingDuration())
|
|
234
|
+
put("fileSize", outputFile?.length() ?: 0)
|
|
235
|
+
})
|
|
236
|
+
call.resolve()
|
|
237
|
+
} catch (e: Exception) {
|
|
238
|
+
Log.e(TAG, "Failed to pause recording", e)
|
|
239
|
+
notifyError("pause_failed", "Failed to pause: ${e.message}")
|
|
240
|
+
call.reject("Failed to pause recording: ${e.message}")
|
|
241
|
+
}
|
|
242
|
+
} else {
|
|
243
|
+
call.reject("Pause is not supported on this Android version (requires API 24+)")
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
@PluginMethod
|
|
248
|
+
fun resumeRecording(call: PluginCall) {
|
|
249
|
+
if (!isRecording) {
|
|
250
|
+
call.reject("Not recording")
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
if (!isPaused) {
|
|
254
|
+
call.reject("Not paused")
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
|
259
|
+
try {
|
|
260
|
+
mediaRecorder?.resume()
|
|
261
|
+
// Track paused duration for accurate timing
|
|
262
|
+
if (pauseStartTime > 0) {
|
|
263
|
+
pausedDurationMs += System.currentTimeMillis() - pauseStartTime
|
|
264
|
+
pauseStartTime = 0
|
|
265
|
+
}
|
|
266
|
+
isPaused = false
|
|
267
|
+
|
|
268
|
+
notifyListeners("recordingState", JSObject().apply {
|
|
269
|
+
put("isRecording", true)
|
|
270
|
+
put("isPaused", false)
|
|
271
|
+
put("duration", getRecordingDuration())
|
|
272
|
+
put("fileSize", outputFile?.length() ?: 0)
|
|
273
|
+
})
|
|
274
|
+
call.resolve()
|
|
275
|
+
} catch (e: Exception) {
|
|
276
|
+
Log.e(TAG, "Failed to resume recording", e)
|
|
277
|
+
notifyError("resume_failed", "Failed to resume: ${e.message}")
|
|
278
|
+
call.reject("Failed to resume recording: ${e.message}")
|
|
279
|
+
}
|
|
280
|
+
} else {
|
|
281
|
+
call.reject("Resume is not supported on this Android version (requires API 24+)")
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
@PluginMethod
|
|
286
|
+
fun getRecordingState(call: PluginCall) {
|
|
287
|
+
val duration = getRecordingDuration()
|
|
288
|
+
val fileSize = outputFile?.length() ?: 0
|
|
289
|
+
|
|
290
|
+
call.resolve(JSObject().apply {
|
|
291
|
+
put("isRecording", isRecording)
|
|
292
|
+
put("isPaused", isPaused)
|
|
293
|
+
put("duration", duration)
|
|
294
|
+
put("fileSize", fileSize)
|
|
295
|
+
})
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
@PluginMethod
|
|
299
|
+
override fun checkPermissions(call: PluginCall) {
|
|
300
|
+
val micStatus = getPermissionState("microphone")
|
|
301
|
+
call.resolve(JSObject().apply {
|
|
302
|
+
put("screenCapture", "prompt") // Always prompt for MediaProjection
|
|
303
|
+
put("microphone", permissionString(micStatus))
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
@PluginMethod
|
|
308
|
+
override fun requestPermissions(call: PluginCall) {
|
|
309
|
+
requestPermissionForAlias("microphone", call, "handlePermissionsResult")
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ── Permission callbacks ────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
@PermissionCallback
|
|
315
|
+
private fun handleMicPermissionResult(call: PluginCall) {
|
|
316
|
+
val intent = mediaProjectionManager?.createScreenCaptureIntent()
|
|
317
|
+
if (intent != null) {
|
|
318
|
+
startActivityForResult(call, intent, "handleProjectionResult")
|
|
319
|
+
} else {
|
|
320
|
+
call.reject("Screen capture not available")
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
@PermissionCallback
|
|
325
|
+
private fun handlePermissionsResult(call: PluginCall) {
|
|
326
|
+
val micStatus = getPermissionState("microphone")
|
|
327
|
+
call.resolve(JSObject().apply {
|
|
328
|
+
put("screenCapture", "prompt")
|
|
329
|
+
put("microphone", permissionString(micStatus))
|
|
330
|
+
})
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ── Activity result (MediaProjection permission) ────────────────────
|
|
334
|
+
|
|
335
|
+
@ActivityCallback
|
|
336
|
+
private fun handleProjectionResult(call: PluginCall, result: ActivityResult) {
|
|
337
|
+
if (result.resultCode != Activity.RESULT_OK || result.data == null) {
|
|
338
|
+
call.reject("Screen capture permission denied")
|
|
339
|
+
return
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
mediaProjection = mediaProjectionManager?.getMediaProjection(result.resultCode, result.data!!)
|
|
343
|
+
|
|
344
|
+
if (mediaProjection == null) {
|
|
345
|
+
call.reject("Failed to get media projection")
|
|
346
|
+
return
|
|
347
|
+
}
|
|
348
|
+
|
|
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() }
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}, Handler(Looper.getMainLooper()))
|
|
358
|
+
|
|
359
|
+
when (pendingAction) {
|
|
360
|
+
"screenshot" -> captureScreenshotInternal(call)
|
|
361
|
+
"recording" -> startRecordingInternal(call)
|
|
362
|
+
else -> call.reject("Unknown action")
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ── Screenshot capture ──────────────────────────────────────────────
|
|
367
|
+
|
|
368
|
+
private fun captureScreenshotInternal(call: PluginCall) {
|
|
369
|
+
val format = call.getString("format") ?: "png"
|
|
370
|
+
val quality = call.getInt("quality") ?: 100
|
|
371
|
+
val scale = call.getFloat("scale") ?: 1f
|
|
372
|
+
|
|
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
|
+
)
|
|
388
|
+
|
|
389
|
+
// Brief delay to allow the VirtualDisplay to render a frame
|
|
390
|
+
Handler(Looper.getMainLooper()).postDelayed({
|
|
391
|
+
try {
|
|
392
|
+
val image = imageReader?.acquireLatestImage()
|
|
393
|
+
|
|
394
|
+
if (image != null) {
|
|
395
|
+
val bitmap = imageToBitmap(image, width, height)
|
|
396
|
+
image.close()
|
|
397
|
+
|
|
398
|
+
val outputStream = ByteArrayOutputStream()
|
|
399
|
+
val compressFormat = when (format) {
|
|
400
|
+
"jpeg" -> Bitmap.CompressFormat.JPEG
|
|
401
|
+
"webp" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
402
|
+
Bitmap.CompressFormat.WEBP_LOSSY
|
|
403
|
+
} else {
|
|
404
|
+
@Suppress("DEPRECATION")
|
|
405
|
+
Bitmap.CompressFormat.WEBP
|
|
406
|
+
}
|
|
407
|
+
else -> Bitmap.CompressFormat.PNG
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
bitmap.compress(compressFormat, quality, outputStream)
|
|
411
|
+
bitmap.recycle()
|
|
412
|
+
|
|
413
|
+
val base64 = Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
|
|
414
|
+
|
|
415
|
+
cleanup()
|
|
416
|
+
|
|
417
|
+
call.resolve(JSObject().apply {
|
|
418
|
+
put("base64", base64)
|
|
419
|
+
put("format", format)
|
|
420
|
+
put("width", width)
|
|
421
|
+
put("height", height)
|
|
422
|
+
put("timestamp", System.currentTimeMillis())
|
|
423
|
+
})
|
|
424
|
+
} else {
|
|
425
|
+
cleanup()
|
|
426
|
+
call.reject("Failed to capture screenshot")
|
|
427
|
+
}
|
|
428
|
+
} catch (e: Exception) {
|
|
429
|
+
Log.e(TAG, "Screenshot capture failed", e)
|
|
430
|
+
cleanup()
|
|
431
|
+
notifyError("screenshot_failed", "Screenshot failed: ${e.message}")
|
|
432
|
+
call.reject("Screenshot failed: ${e.message}")
|
|
433
|
+
}
|
|
434
|
+
}, 250) // 250ms delay to ensure frame is rendered
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
private fun imageToBitmap(image: Image, width: Int, height: Int): Bitmap {
|
|
438
|
+
val planes = image.planes
|
|
439
|
+
val buffer = planes[0].buffer
|
|
440
|
+
val pixelStride = planes[0].pixelStride
|
|
441
|
+
val rowStride = planes[0].rowStride
|
|
442
|
+
val rowPadding = rowStride - pixelStride * width
|
|
443
|
+
|
|
444
|
+
val bitmap = Bitmap.createBitmap(
|
|
445
|
+
width + rowPadding / pixelStride,
|
|
446
|
+
height,
|
|
447
|
+
Bitmap.Config.ARGB_8888
|
|
448
|
+
)
|
|
449
|
+
bitmap.copyPixelsFromBuffer(buffer)
|
|
450
|
+
|
|
451
|
+
return if (rowPadding > 0) {
|
|
452
|
+
val cropped = Bitmap.createBitmap(bitmap, 0, 0, width, height)
|
|
453
|
+
if (cropped !== bitmap) bitmap.recycle()
|
|
454
|
+
cropped
|
|
455
|
+
} else {
|
|
456
|
+
bitmap
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ── Screen recording ────────────────────────────────────────────────
|
|
461
|
+
|
|
462
|
+
private fun startRecordingInternal(call: PluginCall) {
|
|
463
|
+
val config = pendingRecordingOptions ?: RecordingConfig()
|
|
464
|
+
pendingRecordingOptions = null
|
|
465
|
+
|
|
466
|
+
val fps = config.fps
|
|
467
|
+
val bitrate = config.bitrate ?: estimateBitrate(screenWidth, screenHeight, fps)
|
|
468
|
+
|
|
469
|
+
val fileName = "screen_${SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())}.mp4"
|
|
470
|
+
outputFile = File(context.cacheDir, fileName)
|
|
471
|
+
|
|
472
|
+
try {
|
|
473
|
+
mediaRecorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
474
|
+
MediaRecorder(context)
|
|
475
|
+
} else {
|
|
476
|
+
@Suppress("DEPRECATION")
|
|
477
|
+
MediaRecorder()
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
mediaRecorder?.apply {
|
|
481
|
+
// Audio source must be set before video source
|
|
482
|
+
if (config.captureMicrophone) {
|
|
483
|
+
setAudioSource(MediaRecorder.AudioSource.MIC)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
setVideoSource(MediaRecorder.VideoSource.SURFACE)
|
|
487
|
+
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
|
488
|
+
setVideoEncoder(MediaRecorder.VideoEncoder.H264)
|
|
489
|
+
|
|
490
|
+
if (config.captureMicrophone) {
|
|
491
|
+
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
|
492
|
+
setAudioChannels(1)
|
|
493
|
+
setAudioSamplingRate(44100)
|
|
494
|
+
setAudioEncodingBitRate(96000)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
setVideoSize(screenWidth, screenHeight)
|
|
498
|
+
setVideoFrameRate(fps)
|
|
499
|
+
setVideoEncodingBitRate(bitrate)
|
|
500
|
+
setOutputFile(outputFile?.absolutePath)
|
|
501
|
+
|
|
502
|
+
if (config.maxFileSize != null && config.maxFileSize > 0) {
|
|
503
|
+
setMaxFileSize(config.maxFileSize)
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Auto-stop callback when max file size or max duration is hit
|
|
507
|
+
setOnInfoListener { _, what, _ ->
|
|
508
|
+
if (what == MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED ||
|
|
509
|
+
what == MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED
|
|
510
|
+
) {
|
|
511
|
+
Log.d(TAG, "Recording auto-stopped (limit reached)")
|
|
512
|
+
scope.launch { stopRecordingInternal() }
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
setOnErrorListener { _, what, extra ->
|
|
517
|
+
Log.e(TAG, "MediaRecorder error: what=$what extra=$extra")
|
|
518
|
+
notifyError("recording_error", "MediaRecorder error: $what/$extra")
|
|
519
|
+
scope.launch { stopRecordingInternal() }
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
prepare()
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
virtualDisplay = mediaProjection?.createVirtualDisplay(
|
|
526
|
+
"ScreenRecording",
|
|
527
|
+
screenWidth,
|
|
528
|
+
screenHeight,
|
|
529
|
+
screenDensity,
|
|
530
|
+
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
|
|
531
|
+
mediaRecorder?.surface,
|
|
532
|
+
null,
|
|
533
|
+
null
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
mediaRecorder?.start()
|
|
537
|
+
isRecording = true
|
|
538
|
+
isPaused = false
|
|
539
|
+
recordingStartTime = System.currentTimeMillis()
|
|
540
|
+
pausedDurationMs = 0
|
|
541
|
+
pauseStartTime = 0
|
|
542
|
+
|
|
543
|
+
// Store limits for timer-based auto-stop
|
|
544
|
+
maxDurationMs = config.maxDuration?.let { (it * 1000).toLong() }
|
|
545
|
+
maxFileSize = config.maxFileSize
|
|
546
|
+
|
|
547
|
+
startRecordingTimer()
|
|
548
|
+
|
|
549
|
+
notifyListeners("recordingState", JSObject().apply {
|
|
550
|
+
put("isRecording", true)
|
|
551
|
+
put("isPaused", false)
|
|
552
|
+
put("duration", 0.0)
|
|
553
|
+
put("fileSize", 0L)
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
call.resolve()
|
|
557
|
+
|
|
558
|
+
} catch (e: Exception) {
|
|
559
|
+
Log.e(TAG, "Failed to start recording", e)
|
|
560
|
+
mediaRecorder?.release()
|
|
561
|
+
mediaRecorder = null
|
|
562
|
+
cleanup()
|
|
563
|
+
notifyError("recording_start_failed", "Failed to start recording: ${e.message}")
|
|
564
|
+
call.reject("Failed to start recording: ${e.message}")
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
private fun startRecordingTimer() {
|
|
569
|
+
recordingTimer = Handler(Looper.getMainLooper())
|
|
570
|
+
recordingRunnable = object : Runnable {
|
|
571
|
+
override fun run() {
|
|
572
|
+
if (!isRecording) return
|
|
573
|
+
|
|
574
|
+
val duration = getRecordingDuration()
|
|
575
|
+
val fileSize = outputFile?.length() ?: 0
|
|
576
|
+
|
|
577
|
+
notifyListeners("recordingState", JSObject().apply {
|
|
578
|
+
put("isRecording", true)
|
|
579
|
+
put("isPaused", isPaused)
|
|
580
|
+
put("duration", duration)
|
|
581
|
+
put("fileSize", fileSize)
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
// Check maxDuration auto-stop
|
|
585
|
+
val maxDur = maxDurationMs
|
|
586
|
+
if (maxDur != null && !isPaused) {
|
|
587
|
+
val activeDuration = (duration * 1000).toLong()
|
|
588
|
+
if (activeDuration >= maxDur) {
|
|
589
|
+
Log.d(TAG, "Max duration reached, auto-stopping")
|
|
590
|
+
scope.launch { stopRecordingInternal() }
|
|
591
|
+
return
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Check maxFileSize auto-stop
|
|
596
|
+
val maxSize = maxFileSize
|
|
597
|
+
if (maxSize != null && fileSize >= maxSize) {
|
|
598
|
+
Log.d(TAG, "Max file size reached, auto-stopping")
|
|
599
|
+
scope.launch { stopRecordingInternal() }
|
|
600
|
+
return
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
recordingTimer?.postDelayed(this, 500)
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
recordingTimer?.postDelayed(recordingRunnable!!, 500)
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
private suspend fun stopRecordingInternal(): JSObject? = withContext(Dispatchers.Main) {
|
|
610
|
+
recordingTimer?.removeCallbacks(recordingRunnable ?: return@withContext null)
|
|
611
|
+
recordingTimer = null
|
|
612
|
+
recordingRunnable = null
|
|
613
|
+
|
|
614
|
+
val duration = getRecordingDuration()
|
|
615
|
+
|
|
616
|
+
try {
|
|
617
|
+
mediaRecorder?.stop()
|
|
618
|
+
} catch (e: Exception) {
|
|
619
|
+
Log.w(TAG, "MediaRecorder.stop() failed (may be empty recording)", e)
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
mediaRecorder?.release()
|
|
623
|
+
mediaRecorder = null
|
|
624
|
+
isRecording = false
|
|
625
|
+
isPaused = false
|
|
626
|
+
pausedDurationMs = 0
|
|
627
|
+
pauseStartTime = 0
|
|
628
|
+
maxDurationMs = null
|
|
629
|
+
maxFileSize = null
|
|
630
|
+
|
|
631
|
+
cleanup()
|
|
632
|
+
|
|
633
|
+
val file = outputFile ?: return@withContext null
|
|
634
|
+
val fileSize = file.length()
|
|
635
|
+
|
|
636
|
+
notifyListeners("recordingState", JSObject().apply {
|
|
637
|
+
put("isRecording", false)
|
|
638
|
+
put("isPaused", false)
|
|
639
|
+
put("duration", duration)
|
|
640
|
+
put("fileSize", fileSize)
|
|
641
|
+
})
|
|
642
|
+
|
|
643
|
+
JSObject().apply {
|
|
644
|
+
put("path", file.absolutePath)
|
|
645
|
+
put("duration", duration)
|
|
646
|
+
put("width", screenWidth)
|
|
647
|
+
put("height", screenHeight)
|
|
648
|
+
put("fileSize", fileSize)
|
|
649
|
+
put("mimeType", "video/mp4")
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// ── Recording config parsing ────────────────────────────────────────
|
|
654
|
+
|
|
655
|
+
private data class RecordingConfig(
|
|
656
|
+
val quality: String? = null,
|
|
657
|
+
val maxDuration: Double? = null,
|
|
658
|
+
val maxFileSize: Long? = null,
|
|
659
|
+
val fps: Int = 30,
|
|
660
|
+
val bitrate: Int? = null,
|
|
661
|
+
val captureMicrophone: Boolean = false,
|
|
662
|
+
val captureSystemAudio: Boolean = false
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
private fun parseRecordingConfig(call: PluginCall): RecordingConfig {
|
|
666
|
+
val quality = call.getString("quality")
|
|
667
|
+
val maxDuration = call.getDouble("maxDuration")
|
|
668
|
+
val maxFileSize = call.getLong("maxFileSize")
|
|
669
|
+
val captureMicrophone = call.getBoolean("captureMicrophone") ?: false
|
|
670
|
+
val captureSystemAudio = call.getBoolean("captureSystemAudio") ?: false
|
|
671
|
+
|
|
672
|
+
// Apply quality presets or use explicit values
|
|
673
|
+
val presetFps: Int
|
|
674
|
+
val presetBitrate: Int?
|
|
675
|
+
when (quality?.lowercase()) {
|
|
676
|
+
"low" -> {
|
|
677
|
+
presetFps = 15
|
|
678
|
+
presetBitrate = estimateBitrate(screenWidth, screenHeight, 15) / 2
|
|
679
|
+
}
|
|
680
|
+
"medium" -> {
|
|
681
|
+
presetFps = 24
|
|
682
|
+
presetBitrate = null // use estimate
|
|
683
|
+
}
|
|
684
|
+
"high" -> {
|
|
685
|
+
presetFps = 30
|
|
686
|
+
presetBitrate = null // use estimate
|
|
687
|
+
}
|
|
688
|
+
"highest" -> {
|
|
689
|
+
presetFps = 60
|
|
690
|
+
presetBitrate = estimateBitrate(screenWidth, screenHeight, 60) * 2
|
|
691
|
+
}
|
|
692
|
+
else -> {
|
|
693
|
+
presetFps = 30
|
|
694
|
+
presetBitrate = null
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
val fps = call.getInt("fps") ?: presetFps
|
|
699
|
+
val bitrate = call.getInt("bitrate") ?: presetBitrate
|
|
700
|
+
|
|
701
|
+
return RecordingConfig(
|
|
702
|
+
quality = quality,
|
|
703
|
+
maxDuration = maxDuration,
|
|
704
|
+
maxFileSize = maxFileSize,
|
|
705
|
+
fps = fps.coerceIn(1, 60),
|
|
706
|
+
bitrate = bitrate,
|
|
707
|
+
captureMicrophone = captureMicrophone,
|
|
708
|
+
captureSystemAudio = captureSystemAudio
|
|
709
|
+
)
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Get recording duration in seconds, accounting for paused time.
|
|
716
|
+
*/
|
|
717
|
+
private fun getRecordingDuration(): Double {
|
|
718
|
+
if (!isRecording) return 0.0
|
|
719
|
+
val now = System.currentTimeMillis()
|
|
720
|
+
val totalElapsed = now - recordingStartTime
|
|
721
|
+
val currentPauseDuration = if (isPaused && pauseStartTime > 0) {
|
|
722
|
+
now - pauseStartTime
|
|
723
|
+
} else 0L
|
|
724
|
+
val activeDuration = totalElapsed - pausedDurationMs - currentPauseDuration
|
|
725
|
+
return activeDuration.toDouble() / 1000.0
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Estimate a reasonable video bitrate based on resolution and frame rate.
|
|
730
|
+
* Ported from classic ScreenRecordManager.
|
|
731
|
+
*/
|
|
732
|
+
private fun estimateBitrate(width: Int, height: Int, fps: Int): Int {
|
|
733
|
+
val pixels = width.toLong() * height.toLong()
|
|
734
|
+
val raw = (pixels * fps.toLong() * 2L).toInt()
|
|
735
|
+
return raw.coerceIn(1_000_000, 12_000_000)
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
private fun permissionString(status: com.getcapacitor.PermissionState?): String {
|
|
739
|
+
return when (status) {
|
|
740
|
+
com.getcapacitor.PermissionState.GRANTED -> "granted"
|
|
741
|
+
com.getcapacitor.PermissionState.DENIED -> "denied"
|
|
742
|
+
else -> "prompt"
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
private fun notifyError(code: String, message: String) {
|
|
747
|
+
notifyListeners("error", JSObject().apply {
|
|
748
|
+
put("code", code)
|
|
749
|
+
put("message", message)
|
|
750
|
+
})
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
private fun cleanup() {
|
|
754
|
+
virtualDisplay?.release()
|
|
755
|
+
virtualDisplay = null
|
|
756
|
+
imageReader?.close()
|
|
757
|
+
imageReader = null
|
|
758
|
+
mediaProjection?.stop()
|
|
759
|
+
mediaProjection = null
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// ── Destroy ─────────────────────────────────────────────────────────
|
|
763
|
+
|
|
764
|
+
override fun handleOnDestroy() {
|
|
765
|
+
super.handleOnDestroy()
|
|
766
|
+
if (isRecording) {
|
|
767
|
+
try {
|
|
768
|
+
mediaRecorder?.stop()
|
|
769
|
+
} catch (_: Exception) {}
|
|
770
|
+
mediaRecorder?.release()
|
|
771
|
+
mediaRecorder = null
|
|
772
|
+
isRecording = false
|
|
773
|
+
}
|
|
774
|
+
cleanup()
|
|
775
|
+
scope.cancel()
|
|
776
|
+
}
|
|
777
|
+
}
|