sitepong 0.2.3 → 0.2.5
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/android/build.gradle +39 -0
- package/android/src/main/java/com/sitepong/deviceid/DeviceIdHelper.kt +45 -0
- package/android/src/main/java/com/sitepong/deviceid/DeviceIdModule.kt +45 -0
- package/android/src/main/java/com/sitepong/screenrecorder/ChunkUploader.kt +78 -0
- package/android/src/main/java/com/sitepong/screenrecorder/H264Encoder.kt +163 -0
- package/android/src/main/java/com/sitepong/screenrecorder/ScreenCapture.kt +103 -0
- package/android/src/main/java/com/sitepong/screenrecorder/ScreenRecorderModule.kt +80 -0
- package/android/src/main/java/com/sitepong/screenrecorder/SensitiveViewManager.kt +19 -0
- package/app.plugin.js +426 -0
- package/dist/cdn/sitepong.min.js +5 -5
- package/dist/cdn/sitepong.min.js.map +1 -1
- package/dist/entries/rn.d.ts +188 -16
- package/dist/entries/rn.js +187 -35
- package/dist/entries/rn.js.map +1 -1
- package/dist/entries/web.js +2 -1
- package/dist/entries/web.js.map +1 -1
- package/dist/entries/web.mjs +2 -1
- package/dist/entries/web.mjs.map +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2 -1
- package/dist/index.mjs.map +1 -1
- package/dist/react/index.js +2 -1
- package/dist/react/index.js.map +1 -1
- package/dist/react/index.mjs +2 -1
- package/dist/react/index.mjs.map +1 -1
- package/expo-module.config.json +16 -0
- package/ios/DeviceId/DeviceIdModule.swift +55 -0
- package/ios/DeviceId/KeychainHelper.swift +68 -0
- package/ios/LiveActivity/ConfigStore.swift +43 -0
- package/ios/LiveActivity/DSLNode.swift +188 -0
- package/ios/LiveActivity/SitePongActivityAttributes.swift +112 -0
- package/ios/LiveActivity/SitePongLiveActivityModule.swift +250 -0
- package/ios/LiveActivity/widget/ColorHex.swift +30 -0
- package/ios/LiveActivity/widget/DSLAnimations.swift +128 -0
- package/ios/LiveActivity/widget/DSLRenderer.swift +538 -0
- package/ios/LiveActivity/widget/SitePongLiveActivityWidget.swift +393 -0
- package/ios/LiveActivity/widget/SitePongWidgetBundle.swift +84 -0
- package/ios/ScreenRecorder/ChunkUploader.swift +52 -0
- package/ios/ScreenRecorder/H264Encoder.swift +229 -0
- package/ios/ScreenRecorder/ScreenCapture.swift +149 -0
- package/ios/ScreenRecorder/ScreenRecorderModule.swift +179 -0
- package/ios/ScreenRecorder/SensitiveViewManager.swift +63 -0
- package/ios/Sitepong.podspec +57 -0
- package/ios/WatchtowerCore/Screenshotter.swift +163 -0
- package/ios/WatchtowerCore/SwiftUIModifiers.swift +78 -0
- package/ios/WatchtowerCore/Swizzling.swift +59 -0
- package/ios/WatchtowerCore/TapEvent.swift +61 -0
- package/ios/WatchtowerCore/Transport.swift +136 -0
- package/ios/WatchtowerCore/UIViewExtensions.swift +31 -0
- package/ios/WatchtowerCore/Watchtower.swift +80 -0
- package/ios/WatchtowerCore/WatchtowerEngine.swift +565 -0
- package/ios/WatchtowerCore/WatchtowerHash.swift +120 -0
- package/ios/WatchtowerCore/WatchtowerRegistry.swift +87 -0
- package/package.json +17 -10
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
apply plugin: 'com.android.library'
|
|
2
|
+
apply plugin: 'kotlin-android'
|
|
3
|
+
|
|
4
|
+
// One Android library for the whole SitePong SDK. It hosts every native Expo
|
|
5
|
+
// module that used to ship as its own package — screen recorder + device id.
|
|
6
|
+
// The Kotlin sources keep their original package names
|
|
7
|
+
// (com.sitepong.screenrecorder.*, com.sitepong.deviceid.*); the gradle
|
|
8
|
+
// `namespace` below is just the library's own R/manifest package and is
|
|
9
|
+
// independent of them.
|
|
10
|
+
group = 'com.sitepong.sdk'
|
|
11
|
+
version = '0.2.3'
|
|
12
|
+
|
|
13
|
+
android {
|
|
14
|
+
namespace "com.sitepong.sdk"
|
|
15
|
+
compileSdkVersion safeExtGet("compileSdkVersion", 34)
|
|
16
|
+
|
|
17
|
+
defaultConfig {
|
|
18
|
+
minSdkVersion safeExtGet("minSdkVersion", 26)
|
|
19
|
+
targetSdkVersion safeExtGet("targetSdkVersion", 34)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
compileOptions {
|
|
23
|
+
sourceCompatibility JavaVersion.VERSION_17
|
|
24
|
+
targetCompatibility JavaVersion.VERSION_17
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
kotlinOptions {
|
|
28
|
+
jvmTarget = "17"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
dependencies {
|
|
33
|
+
implementation project(':expo-modules-core')
|
|
34
|
+
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${safeExtGet('kotlinVersion', '1.9.0')}"
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
def safeExtGet(prop, fallback) {
|
|
38
|
+
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
|
|
39
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
package com.sitepong.deviceid
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.provider.Settings
|
|
5
|
+
import java.security.MessageDigest
|
|
6
|
+
import java.util.UUID
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Manages a persistent device identifier on Android.
|
|
10
|
+
*
|
|
11
|
+
* Combines two signals:
|
|
12
|
+
* - ANDROID_ID: Stable per app signing key since Android 8. Survives reinstalls.
|
|
13
|
+
* - SharedPreferences UUID: Persists via Auto Backup (if user has same Google account).
|
|
14
|
+
*
|
|
15
|
+
* The final device ID is SHA-256(androidId + storedUuid) for a composite stable ID.
|
|
16
|
+
*/
|
|
17
|
+
object DeviceIdHelper {
|
|
18
|
+
private const val PREFS_NAME = "sitepong_device"
|
|
19
|
+
private const val KEY_UUID = "persistent_uuid"
|
|
20
|
+
|
|
21
|
+
fun getOrCreateDeviceId(context: Context): String {
|
|
22
|
+
val androidId = getAndroidId(context)
|
|
23
|
+
val storedUuid = getOrCreateUuid(context)
|
|
24
|
+
return sha256("$androidId$storedUuid")
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
fun getAndroidId(context: Context): String {
|
|
28
|
+
return Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) ?: ""
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private fun getOrCreateUuid(context: Context): String {
|
|
32
|
+
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
33
|
+
val existing = prefs.getString(KEY_UUID, null)
|
|
34
|
+
if (existing != null) return existing
|
|
35
|
+
|
|
36
|
+
val uuid = UUID.randomUUID().toString()
|
|
37
|
+
prefs.edit().putString(KEY_UUID, uuid).apply()
|
|
38
|
+
return uuid
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private fun sha256(input: String): String {
|
|
42
|
+
val bytes = MessageDigest.getInstance("SHA-256").digest(input.toByteArray())
|
|
43
|
+
return bytes.joinToString("") { "%02x".format(it) }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
package com.sitepong.deviceid
|
|
2
|
+
|
|
3
|
+
import android.os.Build
|
|
4
|
+
import expo.modules.kotlin.modules.Module
|
|
5
|
+
import expo.modules.kotlin.modules.ModuleDefinition
|
|
6
|
+
|
|
7
|
+
class DeviceIdModule : Module() {
|
|
8
|
+
override fun definition() = ModuleDefinition {
|
|
9
|
+
Name("SitePongDeviceId")
|
|
10
|
+
|
|
11
|
+
AsyncFunction("getDeviceId") {
|
|
12
|
+
val context = appContext.reactContext ?: throw Exception("React context not available")
|
|
13
|
+
DeviceIdHelper.getOrCreateDeviceId(context)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
AsyncFunction("getDeviceSignals") {
|
|
17
|
+
val context = appContext.reactContext ?: throw Exception("React context not available")
|
|
18
|
+
val deviceId = DeviceIdHelper.getOrCreateDeviceId(context)
|
|
19
|
+
val androidId = DeviceIdHelper.getAndroidId(context)
|
|
20
|
+
val density = context.resources.displayMetrics.density
|
|
21
|
+
|
|
22
|
+
mapOf(
|
|
23
|
+
"deviceId" to deviceId,
|
|
24
|
+
"platform" to "android",
|
|
25
|
+
"osVersion" to Build.VERSION.RELEASE,
|
|
26
|
+
"model" to Build.MODEL,
|
|
27
|
+
"manufacturer" to Build.MANUFACTURER,
|
|
28
|
+
"isEmulator" to isEmulator(),
|
|
29
|
+
"screenScale" to density.toDouble(),
|
|
30
|
+
"androidId" to androidId
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private fun isEmulator(): Boolean {
|
|
36
|
+
return (Build.FINGERPRINT.startsWith("generic")
|
|
37
|
+
|| Build.FINGERPRINT.startsWith("unknown")
|
|
38
|
+
|| Build.MODEL.contains("google_sdk")
|
|
39
|
+
|| Build.MODEL.contains("Emulator")
|
|
40
|
+
|| Build.MODEL.contains("Android SDK built for x86")
|
|
41
|
+
|| Build.MANUFACTURER.contains("Genymotion")
|
|
42
|
+
|| Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")
|
|
43
|
+
|| "google_sdk" == Build.PRODUCT)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
package com.sitepong.screenrecorder
|
|
2
|
+
|
|
3
|
+
import java.io.OutputStream
|
|
4
|
+
import java.net.HttpURLConnection
|
|
5
|
+
import java.net.URL
|
|
6
|
+
import java.util.concurrent.Executors
|
|
7
|
+
import java.util.concurrent.locks.ReentrantLock
|
|
8
|
+
|
|
9
|
+
class ChunkUploader(
|
|
10
|
+
private val endpoint: String,
|
|
11
|
+
private val apiKey: String,
|
|
12
|
+
private val sessionId: String
|
|
13
|
+
) {
|
|
14
|
+
private var chunkSequence = 0
|
|
15
|
+
private val buffer = mutableListOf<ByteArray>()
|
|
16
|
+
private val lock = ReentrantLock()
|
|
17
|
+
private val executor = Executors.newSingleThreadExecutor()
|
|
18
|
+
|
|
19
|
+
fun bufferNALUnit(data: ByteArray, isKeyframe: Boolean) {
|
|
20
|
+
lock.lock()
|
|
21
|
+
try {
|
|
22
|
+
buffer.add(data)
|
|
23
|
+
} finally {
|
|
24
|
+
lock.unlock()
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
fun flushBuffer() {
|
|
29
|
+
lock.lock()
|
|
30
|
+
val dataChunks: List<ByteArray>
|
|
31
|
+
try {
|
|
32
|
+
if (buffer.isEmpty()) return
|
|
33
|
+
dataChunks = buffer.toList()
|
|
34
|
+
buffer.clear()
|
|
35
|
+
} finally {
|
|
36
|
+
lock.unlock()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
val combined = dataChunks.fold(ByteArray(0)) { acc, bytes -> acc + bytes }
|
|
40
|
+
val seq = chunkSequence++
|
|
41
|
+
|
|
42
|
+
executor.submit {
|
|
43
|
+
uploadWithRetry(combined, seq)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private fun uploadWithRetry(data: ByteArray, sequence: Int) {
|
|
48
|
+
for (attempt in 0..1) {
|
|
49
|
+
try {
|
|
50
|
+
val url = URL("$endpoint/api/replays/video")
|
|
51
|
+
val conn = url.openConnection() as HttpURLConnection
|
|
52
|
+
conn.requestMethod = "POST"
|
|
53
|
+
conn.setRequestProperty("Content-Type", "application/octet-stream")
|
|
54
|
+
conn.setRequestProperty("X-API-Key", apiKey)
|
|
55
|
+
conn.setRequestProperty("X-Session-ID", sessionId)
|
|
56
|
+
conn.setRequestProperty("X-Chunk-Sequence", sequence.toString())
|
|
57
|
+
conn.setRequestProperty("X-Trigger", "error")
|
|
58
|
+
conn.connectTimeout = 10_000
|
|
59
|
+
conn.readTimeout = 10_000
|
|
60
|
+
conn.doOutput = true
|
|
61
|
+
|
|
62
|
+
conn.outputStream.use { out: OutputStream ->
|
|
63
|
+
out.write(data)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
val responseCode = conn.responseCode
|
|
67
|
+
conn.disconnect()
|
|
68
|
+
|
|
69
|
+
if (responseCode in 200..299) return
|
|
70
|
+
if (attempt == 0) continue // retry
|
|
71
|
+
} catch (e: Exception) {
|
|
72
|
+
if (attempt == 1) {
|
|
73
|
+
// Drop the chunk — don't block recording
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
package com.sitepong.screenrecorder
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.graphics.Bitmap
|
|
5
|
+
import android.media.MediaCodec
|
|
6
|
+
import android.media.MediaCodecInfo
|
|
7
|
+
import android.media.MediaFormat
|
|
8
|
+
import android.os.Bundle
|
|
9
|
+
import java.nio.ByteBuffer
|
|
10
|
+
import java.util.concurrent.ConcurrentLinkedQueue
|
|
11
|
+
import java.util.concurrent.locks.ReentrantLock
|
|
12
|
+
|
|
13
|
+
class H264Encoder(
|
|
14
|
+
private val bitrate: Int,
|
|
15
|
+
private val bufferDurationMs: Int,
|
|
16
|
+
private val onEncodedChunk: (ByteArray, Boolean) -> Unit
|
|
17
|
+
) {
|
|
18
|
+
private var codec: MediaCodec? = null
|
|
19
|
+
private var inputSurface: android.view.Surface? = null
|
|
20
|
+
private var width = 0
|
|
21
|
+
private var height = 0
|
|
22
|
+
private var started = false
|
|
23
|
+
private var frameCount = 0L
|
|
24
|
+
private val startTimeNs = System.nanoTime()
|
|
25
|
+
|
|
26
|
+
data class NALEntry(val data: ByteArray, val isKeyframe: Boolean, val timestampMs: Long)
|
|
27
|
+
|
|
28
|
+
private val nalBuffer = ConcurrentLinkedQueue<NALEntry>()
|
|
29
|
+
private val bufferLock = ReentrantLock()
|
|
30
|
+
|
|
31
|
+
fun start(activity: Activity) {
|
|
32
|
+
val decorView = activity.window?.decorView ?: return
|
|
33
|
+
width = decorView.width
|
|
34
|
+
height = decorView.height
|
|
35
|
+
if (width <= 0 || height <= 0) return
|
|
36
|
+
|
|
37
|
+
// Ensure dimensions are even (required by MediaCodec)
|
|
38
|
+
width = width and 0x7FFFFFFE.toInt()
|
|
39
|
+
height = height and 0x7FFFFFFE.toInt()
|
|
40
|
+
|
|
41
|
+
val format = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, width, height).apply {
|
|
42
|
+
setInteger(MediaFormat.KEY_BIT_RATE, bitrate)
|
|
43
|
+
setInteger(MediaFormat.KEY_FRAME_RATE, 3)
|
|
44
|
+
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 5)
|
|
45
|
+
setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)
|
|
46
|
+
setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
codec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC)
|
|
50
|
+
codec?.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
|
51
|
+
|
|
52
|
+
codec?.setCallback(object : MediaCodec.Callback() {
|
|
53
|
+
override fun onInputBufferAvailable(codec: MediaCodec, index: Int) {
|
|
54
|
+
// Using surface input mode — this won't be called
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
override fun onOutputBufferAvailable(codec: MediaCodec, index: Int, info: MediaCodec.BufferInfo) {
|
|
58
|
+
val buffer = codec.getOutputBuffer(index) ?: return
|
|
59
|
+
if (info.size > 0 && info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG == 0) {
|
|
60
|
+
val data = ByteArray(info.size)
|
|
61
|
+
buffer.get(data)
|
|
62
|
+
val isKeyframe = info.flags and MediaCodec.BUFFER_FLAG_KEY_FRAME != 0
|
|
63
|
+
val timestampMs = System.currentTimeMillis()
|
|
64
|
+
|
|
65
|
+
val entry = NALEntry(data, isKeyframe, timestampMs)
|
|
66
|
+
nalBuffer.add(entry)
|
|
67
|
+
trimBuffer()
|
|
68
|
+
|
|
69
|
+
onEncodedChunk(data, isKeyframe)
|
|
70
|
+
}
|
|
71
|
+
codec.releaseOutputBuffer(index, false)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
override fun onError(codec: MediaCodec, e: MediaCodec.CodecException) {
|
|
75
|
+
// Log but don't crash
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) {
|
|
79
|
+
// SPS/PPS available in format — extract for first chunk
|
|
80
|
+
val sps = format.getByteBuffer("csd-0")
|
|
81
|
+
val pps = format.getByteBuffer("csd-1")
|
|
82
|
+
if (sps != null && pps != null) {
|
|
83
|
+
val spsData = ByteArray(sps.remaining())
|
|
84
|
+
sps.get(spsData)
|
|
85
|
+
val ppsData = ByteArray(pps.remaining())
|
|
86
|
+
pps.get(ppsData)
|
|
87
|
+
val combined = spsData + ppsData
|
|
88
|
+
nalBuffer.add(NALEntry(combined, true, System.currentTimeMillis()))
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
inputSurface = codec?.createInputSurface()
|
|
94
|
+
codec?.start()
|
|
95
|
+
started = true
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
fun encode(bitmap: Bitmap) {
|
|
99
|
+
if (!started) return
|
|
100
|
+
val surface = inputSurface ?: return
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
val canvas = surface.lockHardwareCanvas()
|
|
104
|
+
canvas.drawBitmap(bitmap, 0f, 0f, null)
|
|
105
|
+
surface.unlockCanvasAndPost(canvas)
|
|
106
|
+
} catch (e: Exception) {
|
|
107
|
+
// Skip frame on error
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
fun flushPendingFrames() {
|
|
112
|
+
// Request a sync frame to ensure clean output
|
|
113
|
+
if (started) {
|
|
114
|
+
try {
|
|
115
|
+
val params = Bundle()
|
|
116
|
+
params.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0)
|
|
117
|
+
codec?.setParameters(params)
|
|
118
|
+
} catch (e: Exception) {
|
|
119
|
+
// Ignore
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
fun stop() {
|
|
125
|
+
started = false
|
|
126
|
+
try {
|
|
127
|
+
codec?.stop()
|
|
128
|
+
codec?.release()
|
|
129
|
+
} catch (e: Exception) {
|
|
130
|
+
// Ignore
|
|
131
|
+
}
|
|
132
|
+
codec = null
|
|
133
|
+
inputSurface?.release()
|
|
134
|
+
inputSurface = null
|
|
135
|
+
nalBuffer.clear()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
fun drainBuffer(): ByteArray {
|
|
139
|
+
bufferLock.lock()
|
|
140
|
+
try {
|
|
141
|
+
var combined = ByteArray(0)
|
|
142
|
+
while (nalBuffer.isNotEmpty()) {
|
|
143
|
+
val entry = nalBuffer.poll() ?: break
|
|
144
|
+
combined += entry.data
|
|
145
|
+
}
|
|
146
|
+
return combined
|
|
147
|
+
} finally {
|
|
148
|
+
bufferLock.unlock()
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private fun trimBuffer() {
|
|
153
|
+
val cutoffMs = System.currentTimeMillis() - bufferDurationMs
|
|
154
|
+
while (nalBuffer.isNotEmpty()) {
|
|
155
|
+
val head = nalBuffer.peek() ?: break
|
|
156
|
+
if (head.timestampMs < cutoffMs) {
|
|
157
|
+
nalBuffer.poll()
|
|
158
|
+
} else {
|
|
159
|
+
break
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
package com.sitepong.screenrecorder
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.graphics.Bitmap
|
|
5
|
+
import android.graphics.Canvas
|
|
6
|
+
import android.graphics.Color
|
|
7
|
+
import android.graphics.Rect
|
|
8
|
+
import android.os.Handler
|
|
9
|
+
import android.os.Looper
|
|
10
|
+
import android.view.PixelCopy
|
|
11
|
+
import android.view.View
|
|
12
|
+
|
|
13
|
+
class ScreenCapture(
|
|
14
|
+
private val activity: Activity,
|
|
15
|
+
private val fps: Int,
|
|
16
|
+
private val sensitiveViewManager: SensitiveViewManager,
|
|
17
|
+
private val onFrame: (Bitmap) -> Unit
|
|
18
|
+
) {
|
|
19
|
+
private val handler = Handler(Looper.getMainLooper())
|
|
20
|
+
private var running = false
|
|
21
|
+
private val intervalMs = (1000L / fps.coerceIn(1, 10))
|
|
22
|
+
|
|
23
|
+
private val captureRunnable = object : Runnable {
|
|
24
|
+
override fun run() {
|
|
25
|
+
if (!running) return
|
|
26
|
+
captureFrame()
|
|
27
|
+
handler.postDelayed(this, intervalMs)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
fun start() {
|
|
32
|
+
if (running) return
|
|
33
|
+
running = true
|
|
34
|
+
handler.postDelayed(captureRunnable, intervalMs)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
fun stop() {
|
|
38
|
+
running = false
|
|
39
|
+
handler.removeCallbacks(captureRunnable)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private fun captureFrame() {
|
|
43
|
+
val window = activity.window ?: return
|
|
44
|
+
val decorView = window.decorView
|
|
45
|
+
val width = decorView.width
|
|
46
|
+
val height = decorView.height
|
|
47
|
+
if (width <= 0 || height <= 0) return
|
|
48
|
+
|
|
49
|
+
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
|
50
|
+
|
|
51
|
+
// Try PixelCopy (API 26+)
|
|
52
|
+
try {
|
|
53
|
+
val copyHandler = Handler(Looper.getMainLooper())
|
|
54
|
+
PixelCopy.request(window, bitmap, { result ->
|
|
55
|
+
if (result == PixelCopy.SUCCESS) {
|
|
56
|
+
// Apply sensitive view masks
|
|
57
|
+
applySensitiveMasks(bitmap, decorView)
|
|
58
|
+
onFrame(bitmap)
|
|
59
|
+
} else {
|
|
60
|
+
// Fallback to canvas draw
|
|
61
|
+
captureViaCanvas(decorView, bitmap)
|
|
62
|
+
}
|
|
63
|
+
}, copyHandler)
|
|
64
|
+
} catch (e: Exception) {
|
|
65
|
+
captureViaCanvas(decorView, bitmap)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private fun captureViaCanvas(view: View, bitmap: Bitmap) {
|
|
70
|
+
try {
|
|
71
|
+
val canvas = Canvas(bitmap)
|
|
72
|
+
view.draw(canvas)
|
|
73
|
+
applySensitiveMasks(bitmap, view)
|
|
74
|
+
onFrame(bitmap)
|
|
75
|
+
} catch (e: Exception) {
|
|
76
|
+
// Skip this frame
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private fun applySensitiveMasks(bitmap: Bitmap, rootView: View) {
|
|
81
|
+
val tags = sensitiveViewManager.getViewTags()
|
|
82
|
+
if (tags.isEmpty()) return
|
|
83
|
+
|
|
84
|
+
val canvas = Canvas(bitmap)
|
|
85
|
+
val paint = android.graphics.Paint().apply {
|
|
86
|
+
color = Color.BLACK
|
|
87
|
+
style = android.graphics.Paint.Style.FILL
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (tag in tags) {
|
|
91
|
+
val sensitiveView = rootView.findViewWithTag<View>(tag) ?: continue
|
|
92
|
+
val location = IntArray(2)
|
|
93
|
+
sensitiveView.getLocationInWindow(location)
|
|
94
|
+
val rect = Rect(
|
|
95
|
+
location[0],
|
|
96
|
+
location[1],
|
|
97
|
+
location[0] + sensitiveView.width,
|
|
98
|
+
location[1] + sensitiveView.height
|
|
99
|
+
)
|
|
100
|
+
canvas.drawRect(rect, paint)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
package com.sitepong.screenrecorder
|
|
2
|
+
|
|
3
|
+
import expo.modules.kotlin.modules.Module
|
|
4
|
+
import expo.modules.kotlin.modules.ModuleDefinition
|
|
5
|
+
import expo.modules.kotlin.Promise
|
|
6
|
+
|
|
7
|
+
class ScreenRecorderModule : Module() {
|
|
8
|
+
private var screenCapture: ScreenCapture? = null
|
|
9
|
+
private var encoder: H264Encoder? = null
|
|
10
|
+
private var uploader: ChunkUploader? = null
|
|
11
|
+
private val sensitiveViewManager = SensitiveViewManager()
|
|
12
|
+
private var recording = false
|
|
13
|
+
|
|
14
|
+
override fun definition() = ModuleDefinition {
|
|
15
|
+
Name("SitePongScreenRecorder")
|
|
16
|
+
|
|
17
|
+
Function("isRecording") {
|
|
18
|
+
recording
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
AsyncFunction("startRecording") { options: Map<String, Any?> ->
|
|
22
|
+
if (recording) return@AsyncFunction
|
|
23
|
+
|
|
24
|
+
val apiKey = options["apiKey"] as? String ?: ""
|
|
25
|
+
val endpoint = options["endpoint"] as? String ?: "https://ingest.sitepong.com"
|
|
26
|
+
val sessionId = options["sessionId"] as? String ?: ""
|
|
27
|
+
val fps = (options["fps"] as? Double)?.toInt() ?: 3
|
|
28
|
+
val quality = options["quality"] as? String ?: "standard"
|
|
29
|
+
val bufferDuration = (options["bufferDuration"] as? Double)?.toInt() ?: 20_000
|
|
30
|
+
|
|
31
|
+
val bitrate = when (quality) {
|
|
32
|
+
"low" -> 200_000
|
|
33
|
+
"high" -> 800_000
|
|
34
|
+
else -> 400_000
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
uploader = ChunkUploader(endpoint, apiKey, sessionId)
|
|
38
|
+
|
|
39
|
+
encoder = H264Encoder(bitrate, bufferDuration) { data, isKeyframe ->
|
|
40
|
+
uploader?.bufferNALUnit(data, isKeyframe)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
val activity = appContext.currentActivity ?: return@AsyncFunction
|
|
44
|
+
screenCapture = ScreenCapture(activity, fps, sensitiveViewManager) { bitmap ->
|
|
45
|
+
encoder?.encode(bitmap)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
encoder?.start(activity)
|
|
49
|
+
screenCapture?.start()
|
|
50
|
+
recording = true
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
AsyncFunction("stopRecording") {
|
|
54
|
+
stopInternal()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
AsyncFunction("flushBuffer") {
|
|
58
|
+
if (!recording) return@AsyncFunction
|
|
59
|
+
encoder?.flushPendingFrames()
|
|
60
|
+
uploader?.flushBuffer()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
Function("addSensitiveView") { viewTag: Int ->
|
|
64
|
+
sensitiveViewManager.addView(viewTag)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
Function("removeSensitiveView") { viewTag: Int ->
|
|
68
|
+
sensitiveViewManager.removeView(viewTag)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private fun stopInternal() {
|
|
73
|
+
screenCapture?.stop()
|
|
74
|
+
encoder?.stop()
|
|
75
|
+
screenCapture = null
|
|
76
|
+
encoder = null
|
|
77
|
+
uploader = null
|
|
78
|
+
recording = false
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
package com.sitepong.screenrecorder
|
|
2
|
+
|
|
3
|
+
import java.util.concurrent.CopyOnWriteArraySet
|
|
4
|
+
|
|
5
|
+
class SensitiveViewManager {
|
|
6
|
+
private val viewTags = CopyOnWriteArraySet<Int>()
|
|
7
|
+
|
|
8
|
+
fun addView(tag: Int) {
|
|
9
|
+
viewTags.add(tag)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
fun removeView(tag: Int) {
|
|
13
|
+
viewTags.remove(tag)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
fun getViewTags(): Set<Int> {
|
|
17
|
+
return viewTags.toSet()
|
|
18
|
+
}
|
|
19
|
+
}
|